diff --git a/AGENTS.md b/AGENTS.md index 4bed15400..44a7023e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ Every code change MUST include, automatically: 1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** WebJs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `references/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`). 2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/app/docs/`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`). 3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,api-gallery}.js`), a gallery demo (`packages/cli/templates/gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`. -4. **Convention validation.** Run `webjs check` and fix violations. Run `webjs doctor` too when you touched an in-repo app (`examples/blog`, `website`): the required `conventions` CI job runs it over both, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257). +4. **Convention validation.** Run `webjs check` and fix violations. Run it from INSIDE an app, never from the repo root: the root is a workspace, not an app, so the command refuses there with exit 1 rather than reporting the cross-app collisions no single runtime ever sees (#1301). In this repo that means `( cd examples/blog && npx webjs check )` and `( cd website && npx webjs check )`. Run `webjs doctor` too when you touched an in-repo app (`examples/blog`, `website`): the required `conventions` CI job runs it over both, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257). ### Git workflow (mandatory) diff --git a/framework-dev.md b/framework-dev.md index c43804fc4..859fb8c2a 100644 --- a/framework-dev.md +++ b/framework-dev.md @@ -105,6 +105,23 @@ It costs about two and a half seconds on a cold worktree and nothing once there It runs below the primary-checkout guard, so `worktree:link` in the primary stays a no-op. That guard is not what keeps seeding out of the test suite, though. The `defaultPrimary()` repo-health test runs the script bare against its own cwd, and from a linked worktree (the mandated workflow) the guard does not fire, so the script would seed that worktree's blog database as a side effect of `npm test`, racing `test/integration/blog-http.test.mjs` reading the same file in parallel. That test therefore sets `WEBJS_NO_WORKTREE_SEED=1` explicitly, and the helpers in that file strip the variable from the ambient env so an exported opt-out cannot invert the seed assertions. +### `webjs check` runs per app, and the repo root refuses (#1301) + +`webjs check` is an APP-level tool: every rule assumes one application, meaning one module graph, one custom-element registry, one runtime. This repo's root is none of those, it is a workspace holding two apps plus every package's test suite plus editor fixtures plus the scaffold templates, so a root-level run used to walk all of it and report 67 collisions that no single runtime ever sees. `my-counter`, for instance, was reported as duplicated across a blog component, an editor-plugin fixture, two unit tests, and a type fixture, five files that never load together. + +So the command now refuses in any directory with no `app/`, exits 1, and names the member apps to run instead. The two in-repo apps are `examples/blog` and `website`: + +```sh +( cd examples/blog && npx webjs check ) +( cd website && npx webjs check ) +``` + +Under `--json` the refusal is emitted as JSON rather than prose, `{ error: { code: 'NOT_AN_APP', message, cwd, apps } }`, so an agent's parser does not choke. It carries neither `violations` nor `summary` on purpose: a consumer that ignores the exit code and reads `report.violations.length` should throw rather than be told a workspace is clean. + +That is what `.github/workflows/ci.yml` has always done (it `cd`s per app), which is why CI was green while the root-level run looked catastrophic. `test/cli/check-target.test.mjs` pins the two together: it parses the app list out of the `for app in ...; do` loop in the `webjs check` step and asserts set equality with the list the refusal derives from the root `package.json` `workspaces` globs. A third app added to CI is picked up automatically; one dropped from CI reds the test. That drift guard is what a `--workspaces` flag was rejected in favour of. + +`webjs doctor` is deliberately NOT gated the same way. It already degrades correctly at the root (the elision and asset checks report "no app to analyse") and its toolchain checks are meaningful in a workspace. + ### Merged worktrees are auto-removed (`cleanup-merged-worktree.sh`) Per-task worktrees pile up when a session merges its PR but never runs `git worktree remove` (a skipped step, or a crash mid-task). The `.claude/hooks/cleanup-merged-worktree.sh` PostToolUse hook (matcher `Bash`, wired in `.claude/settings.json`) closes that gap: after any `gh pr merge`, it sweeps every linked worktree and removes the ones that are safe to drop, so cleanup is deterministic rather than a thing an agent has to remember. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index f46bdbfd1..30cd5a8f5 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -114,6 +114,15 @@ lib/ scaffold-template-validation.test.js` + `test/scaffolds/scaffold-integration.test.js` (the emitted `.env.example` line). + check-target.js PURE invocation-target guard for `webjs check` (#1301). + `findCheckTarget(cwd)` returns `{ isApp, workspaceApps }`, + `notAnAppMessage()` renders the stderr refusal and + `notAnAppJson()` the `--json` one. The predicate is the + presence of an `app/` DIRECTORY and nothing else, the + same test `check.js` already applies per-rule; a + `workspaces` key is not part of it, it only enriches the + message with the member directories that ARE apps. + Tests: `test/cli/check-target.test.mjs`. create.js `webjs create ` scaffold logic. Copies `templates/` into the new app, writes package.json + tsconfig + Drizzle db layer, @@ -168,7 +177,7 @@ README.md npm-facing package readme. | `webjs dev` | Re-execs itself under the host runtime's hot-reload supervisor, then `startServer({ dev: true })` in the child. The supervisor is runtime-specific (#514, `lib/dev-supervisor.js`): `node --watch` on Node (restart-on-change, fresh ESM cache, plus the dev re-import's `?t=` query); `bun --hot` on Bun (in-place module invalidation, since Bun keys its cache by path and ignores `?t=`, so `node --watch` would leave a server-module edit stale). `--no-hot` opts out and runs the server in-process on either runtime. In the parent (pre-spawn) it runs the configured dev orchestration (#550, `lib/run-tasks.js`): the `webjs.dev.before` steps (one-shot) to completion, then the `webjs.dev.parallel` watchers (e.g. the Tailwind CLI) alongside the server, torn down on exit. So a bare `webjs dev` runs the same before-steps and watchers as `npm run dev`. The scaffold ships `webjs db migrate` as both a `dev.before` and a `start.before` step (#725), so a `db:generate`'d migration is applied on the next boot in dev and prod alike with no manual `db:migrate`; `.env` is loaded before the dev before-steps (same as start), so a Postgres dev migrate sees `DATABASE_URL`. Local binaries (`drizzle-kit`, `tailwindcss`) resolve because the spawn PATH is prepended with the ancestor `node_modules/.bin` dirs, npm-style. **Before dispatching either dev or start**, a directory-relative resolve probe (`checkFrameworkResolves` from `lib/doctor.js`) checks that `@webjsdev/core` resolves from `process.cwd()`; if not (the fresh-git-worktree-without-node_modules trap, #954), it prints the cause + remedy and exits 1 instead of letting a raw `ERR_MODULE_NOT_FOUND` bubble from deep in SSR. A no-op single resolve on the happy path. | | `webjs start` | `startServer({ dev: false })`, plain HTTP/1.1 (front a reverse proxy for TLS + HTTP/2). Shares the dev framework-resolve preflight above (#954). | | `webjs test [--server\|--browser]` | Runtime-native test runner (#570): server tests run under `node --test` on Node and `bun test` on Bun (`bun --test` is invalid), dispatched on `process.versions.bun`; browser tests run the app's resolved `@web/test-runner` (`wtr`) bin via `process.execPath` (no `npx`). | -| `webjs check [--rules] [--json]` | `checkConventions()` from `@webjsdev/server/check`. `--rules` lists the checks. `--json` emits the structured violations + a summary count as JSON (via `projectCheck` from `@webjsdev/mcp/check-report`, the same projector the MCP `check` tool uses, #415), so an agent in a loop consumes structured data instead of regex-scraping stdout; the non-zero exit on violations is preserved. Report-only: each violation carries a prose `fix` hint, but there is no `--fix` autofix flag (the rules either rewrite code or rename files, so an automatic codemod is not safe) | +| `webjs check [--rules] [--json]` | `checkConventions()` from `@webjsdev/server/check`. `--rules` lists the checks. `--json` emits the structured violations + a summary count as JSON (via `projectCheck` from `@webjsdev/mcp/check-report`, the same projector the MCP `check` tool uses, #415), so an agent in a loop consumes structured data instead of regex-scraping stdout; the non-zero exit on violations is preserved. Report-only: each violation carries a prose `fix` hint, but there is no `--fix` autofix flag (the rules either rewrite code or rename files, so an automatic codemod is not safe). Refuses with exit 1 in a directory that has no `app/`, naming the workspace member apps to run it in when the directory declares `workspaces` (#1301): every rule assumes ONE application, so a workspace root reports collisions no single runtime ever sees (67 false findings at this repo's root). The predicate is the `app/` directory alone, which both scaffold templates create, and `--rules` is exempt and works anywhere. Under `--json` the refusal is the SECOND shape this flag can emit, `{ error: { code: 'NOT_AN_APP', message, cwd, apps } }`, and it deliberately carries neither `violations` nor `summary`, so a consumer that ignores the exit code and reads `report.violations.length` throws instead of being told a workspace is clean. Branch on `error` before reading `violations`. Guard in `lib/check-target.js` | | `webjs routes [--json\|--table] [--no-headers]` | Prints the route table to stdout (#975): every page (path, owner file, dynamic params) and every `route.{js,ts}` handler (path, owner file, HTTP methods). Reuses `buildRouteTable` from `@webjsdev/server` (the ONE walker, shared with `webjs types` + the dev server) and the shared `projectRoutes` projector from `@webjsdev/mcp/routes-report`, so `--json` is byte-identical to the MCP `list_routes` tool (the same split as `check --json` / `check-report.js`). Default is a grouped tree; `--table` is aligned KIND/PATH/METHODS/FILE columns and `--no-headers` drops the header row for piping. Read-only. Tests: `test/cli/routes.test.mjs` | | `webjs elision [--json] [--verify] [--routes ]` | `analyzeAppElision()` from `@webjsdev/server` (#1308). Prints the display-only elision verdict: every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it, `own` / `observed` / `closure` / `render` / `import` / `unreadable`, and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every ORPHAN class, one with no registration call or a computed tag, which the scanner cannot see either way. `--json` is byte-identical to the MCP `list_elision` tool (drift-tested). `--verify` boots two `createRequestHandler` instances with `WEBJS_ELIDE` flipped, renders the app's static page corpus through both, and diffs the masked SSR bytes via the shared `maskJsSet` / `staticPageRoutes` leaf: the framework's own differential guard pointed at an arbitrary app. It FORCES the ON side on (the override wins over `webjs.elide`, so an opted-out app still gets a real pair), skips dynamic routes by name (`--routes` adds real paths), skips a nondeterministic route rather than failing it, and exits non-zero on a divergence OR on a corpus where nothing was compared. It reports how many modules elision dropped, so a pass over a corpus with nothing elidable is visibly trivial rather than mistaken for proof | | `webjs mcp` | Delegates to `runMcpServer()` from the standalone `@webjsdev/mcp` package (#415; full surface in `packages/mcp/AGENTS.md`). A read-only MCP stdio server: INTROSPECTION (`list_routes` / `list_actions` / `list_components` / `list_elision` / `check`), KNOWLEDGE (`init` primer, `docs`, `resources`, `prompts`), and a `source` tool. The scaffold's `.claude.json` registers the server directly as `{ "command": "npx", "args": ["@webjsdev/mcp"] }` (mountable in any MCP host, e.g. Cursor `.cursor/mcp.json`); `webjs mcp` stays as a back-compat alias. STDOUT is the JSON-RPC channel (diagnostics go to stderr) | diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index ad1d7eaed..3e50c168e 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -9,6 +9,7 @@ import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js'; import { loadAppEnv, resolvePort } from '../lib/port.js'; import { planDevSupervisor } from '../lib/dev-supervisor.js'; import { checkAppName, appNameErrorMessage } from '../lib/app-name.js'; +import { findCheckTarget, notAnAppMessage, notAnAppJson } from '../lib/check-target.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const [cmd, ...rest] = process.argv.slice(2); @@ -722,6 +723,22 @@ async function main() { break; } + // #1301: `webjs check` is an APP-level tool. At a workspace root it + // walks every package's tests and every app at once and reports + // cross-app collisions that no single runtime ever sees (67 false + // findings at this repo's root). Refuse instead, naming the member + // apps to run it in. Exit 1: an agent gates on the exit status, and + // 0 would read as "clean", the exact false signal this fixes. + const target = await findCheckTarget(process.cwd()); + if (!target.isApp) { + if (rest.includes('--json')) { + console.log(JSON.stringify(notAnAppJson(process.cwd(), target.workspaceApps))); + } else { + console.error(notAnAppMessage(process.cwd(), target.workspaceApps)); + } + process.exit(1); + } + const violations = await checkConventions(process.cwd()); // --json emits the raw structured violations + a summary count as JSON, diff --git a/packages/cli/lib/check-target.js b/packages/cli/lib/check-target.js new file mode 100644 index 000000000..ea6d25514 --- /dev/null +++ b/packages/cli/lib/check-target.js @@ -0,0 +1,145 @@ +/** + * `webjs check` invocation-target guard (#1301). + * + * `webjs check` is an APP-level tool: every rule assumes one application, + * meaning one module graph, one custom-element registry, one runtime. Run at a + * workspace root it walks whatever JS/TS happens to live under that path (two + * apps, every package's test suite, editor fixtures, the scaffold templates) + * and reports collisions no single runtime ever sees. At this repo's root that + * was 67 findings, all false. + * + * The predicate is the presence of an `app/` directory, nothing else. That is + * the same test `check.js` already applies per-rule, `app/` cannot be renamed + * (AGENTS.md "App layout"), and BOTH scaffold templates create it. Next.js + * refuses on the identical predicate in + * `packages/next/src/lib/find-pages-dir.ts`. A `workspaces` key is NOT part of + * the predicate (a directory with no `app/` is not an app either way); it only + * enriches the MESSAGE with the member apps to run instead. + * + * PURE apart from directory reads: it never prints and never exits. The bin + * owns rendering and the exit code. + * + * @module check-target + */ + +import { statSync } from 'node:fs'; +import { readFile, glob } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * @typedef {{ isApp: boolean, workspaceApps: string[] }} CheckTarget + */ + +/** + * Whether `dir` holds an `app/` DIRECTORY. A plain file named `app` is not one, + * and `existsSync` alone would call it one, so the type is checked. A broken + * symlink or an unreadable parent throws out of `statSync` rather than + * returning false, so it is caught: an unreadable path is not an app either. + * + * @param {string} dir + * @returns {boolean} + */ +function hasAppDir(dir) { + try { + return statSync(join(dir, 'app')).isDirectory(); + } catch { + return false; + } +} + +/** + * Classify `cwd` as a checkable app or not, and (when it declares npm + * workspaces) list the member directories that ARE apps, sorted, as + * cwd-relative POSIX paths. + * + * @param {string} cwd + * @returns {Promise} + */ +export async function findCheckTarget(cwd) { + if (hasAppDir(cwd)) return { isApp: true, workspaceApps: [] }; + return { isApp: false, workspaceApps: await workspaceApps(cwd) }; +} + +/** + * Expand `package.json` `workspaces` (the array form and yarn's + * `{ packages: [...] }` form) and keep the members that have an `app/` + * directory. Any read / parse failure yields an empty list: the message + * degrades to the generic form and the refusal still stands. + * + * @param {string} cwd + * @returns {Promise} + */ +async function workspaceApps(cwd) { + let patterns; + try { + const pkg = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')); + const ws = pkg.workspaces; + patterns = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : null; + } catch { + return []; + } + if (!patterns) return []; + /** @type {Set} */ + const apps = new Set(); + for (const pattern of patterns) { + if (typeof pattern !== 'string') continue; + try { + for await (const match of glob(pattern, { cwd })) { + // `glob` yields whatever matched, files included, so the app test is + // what filters a stray same-named file out too. + if (hasAppDir(join(cwd, match))) apps.add(match.split('\\').join('/')); + } + } catch { + // A malformed pattern drops out of the listing, never out of the refusal. + } + } + return [...apps].sort(); +} + +/** + * The human refusal, for stderr. + * + * @param {string} cwd + * @param {string[]} apps + * @returns {string} + */ +export function notAnAppMessage(cwd, apps) { + const lines = [ + 'webjs check: this directory is not a WebJs app, so nothing was checked.', + '', + ` ${cwd}`, + '', + 'There is no `app/` directory here. Every check assumes ONE application', + '(one module graph, one custom-element registry, one runtime), so running', + 'them over a workspace root reports collisions no single runtime ever sees.', + '', + ]; + if (apps.length) { + lines.push('This is a workspace root. Run the check inside each app:', ''); + for (const app of apps) lines.push(` ( cd ${app} && npx webjs check )`); + } else { + lines.push('Change into your app directory (the one holding `app/`) and re-run.'); + } + lines.push('', '`webjs check --rules` lists the rules and works from anywhere.'); + return lines.join('\n'); +} + +/** + * The `--json` refusal. It carries NO `violations` key on purpose: a consumer + * that ignores the exit code and reads `report.violations.length` must throw + * rather than be told the workspace is clean. + * + * @param {string} cwd + * @param {string[]} apps + * @returns {{ error: { code: string, message: string, cwd: string, apps: string[] } }} + */ +export function notAnAppJson(cwd, apps) { + return { + error: { + code: 'NOT_AN_APP', + message: 'No `app/` directory here, so webjs check has no application to check.', + cwd, + apps, + }, + }; +} diff --git a/packages/mcp/test/check-report.test.mjs b/packages/mcp/test/check-report.test.mjs index b53004b6e..b92f7bacd 100644 --- a/packages/mcp/test/check-report.test.mjs +++ b/packages/mcp/test/check-report.test.mjs @@ -77,6 +77,9 @@ test('check --json: clean app emits parseable JSON and exits 0', async () => { test('check --json: app with a violation emits the violation and exits non-zero', async () => { const dir = tmpDir(); + // The fixture needs an `app/` directory to BE an app: `webjs check` refuses + // outside one (#1301), the same way its clean-app sibling above is shaped. + write(dir, 'app/page.ts', `import { html } from '@webjsdev/core';\nexport default function Home() { return html\`

Hi

\`; }\n`); // A component that defines a WebComponent subclass but never registers it // trips `components-have-register`. write( diff --git a/test/cli/check-target.test.mjs b/test/cli/check-target.test.mjs new file mode 100644 index 000000000..279377aef --- /dev/null +++ b/test/cli/check-target.test.mjs @@ -0,0 +1,207 @@ +/** + * Tests for the `webjs check` invocation-target guard (#1301). + * + * `webjs check` is an APP-level tool, so running it at a workspace root walked + * two apps plus every package's test suite at once and reported 67 cross-app + * collisions no single runtime ever sees. The guard refuses instead, naming the + * member apps to run it in. + * + * Two things these tests pin beyond the refusal itself. The guard must NOT + * swallow a real finding inside an app (the fixture with a genuine duplicate + * tag still exits 1 with the violation), and the app list the refusal derives + * must stay equal to the one `.github/workflows/ci.yml` loops over, which is + * what replaces a `--workspaces` flag. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + findCheckTarget, + notAnAppMessage, + notAnAppJson, +} from '../../packages/cli/lib/check-target.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(__dirname, '..', '..'); +const CLI = resolve(REPO, 'packages', 'cli', 'bin', 'webjs.js'); + +/** Run `webjs check` in `cwd` with the given flags. */ +function check(cwd, ...args) { + return spawnSync(process.execPath, [CLI, 'check', ...args], { cwd, encoding: 'utf8' }); +} + +/** A fresh temp directory, removed when `t` finishes. */ +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), 'webjs-check-target-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +test('a directory holding app/ is an app', async (t) => { + const dir = await fixture(t); + await mkdir(join(dir, 'app')); + + const target = await findCheckTarget(dir); + assert.equal(target.isApp, true); + assert.deepEqual(target.workspaceApps, []); +}); + +test('a workspace root lists only the members that are apps', async (t) => { + const dir = await fixture(t); + await writeFile( + join(dir, 'package.json'), + JSON.stringify({ name: 'root', private: true, workspaces: ['pkgs/*'] }), + ); + await mkdir(join(dir, 'pkgs', 'withapp', 'app'), { recursive: true }); + await mkdir(join(dir, 'pkgs', 'noapp'), { recursive: true }); + + const target = await findCheckTarget(dir); + assert.equal(target.isApp, false); + assert.deepEqual(target.workspaceApps, ['pkgs/withapp']); + + const message = notAnAppMessage(dir, target.workspaceApps); + assert.match(message, /\( cd pkgs\/withapp && npx webjs check \)/); + assert.doesNotMatch(message, /noapp/); +}); + +test('yarn\'s { packages: [...] } workspaces form is expanded too', async (t) => { + const dir = await fixture(t); + await writeFile( + join(dir, 'package.json'), + JSON.stringify({ name: 'root', private: true, workspaces: { packages: ['apps/*'] } }), + ); + await mkdir(join(dir, 'apps', 'shop', 'app'), { recursive: true }); + + const target = await findCheckTarget(dir); + assert.deepEqual(target.workspaceApps, ['apps/shop']); +}); + +test('a plain directory with no app/ and no workspaces gets the generic advice', async (t) => { + const dir = await fixture(t); + + const target = await findCheckTarget(dir); + assert.equal(target.isApp, false); + assert.deepEqual(target.workspaceApps, []); + + const message = notAnAppMessage(dir, target.workspaceApps); + assert.match(message, /Change into your app directory/); + assert.doesNotMatch(message, /Run the check inside each app/); +}); + +test('a file named app is not an app directory', async (t) => { + const dir = await fixture(t); + await writeFile(join(dir, 'app'), 'not a directory'); + + assert.equal((await findCheckTarget(dir)).isApp, false); +}); + +test('a missing or malformed package.json degrades the message, never the refusal', async (t) => { + const missing = await fixture(t); + const malformed = await fixture(t); + await writeFile(join(malformed, 'package.json'), '{ not json'); + + for (const dir of [missing, malformed]) { + const target = await findCheckTarget(dir); + assert.equal(target.isApp, false, dir); + assert.deepEqual(target.workspaceApps, [], dir); + } +}); + +test('the JSON refusal carries no violations key', () => { + const json = notAnAppJson('/somewhere', ['examples/blog']); + assert.equal(json.error.code, 'NOT_AN_APP'); + assert.equal(json.error.cwd, '/somewhere'); + assert.deepEqual(json.error.apps, ['examples/blog']); + // A consumer that ignores the exit code and reads `report.violations.length` + // must throw rather than be told the workspace is clean. + assert.ok(!('violations' in json)); +}); + +test('the guard does not swallow a real violation inside an app', async (t) => { + const dir = await fixture(t); + await mkdir(join(dir, 'app'), { recursive: true }); + await mkdir(join(dir, 'components'), { recursive: true }); + await writeFile( + join(dir, 'app', 'page.ts'), + "import { html } from '@webjsdev/core';\nexport default function Page() {\n return html`

Hi

`;\n}\n", + ); + for (const name of ['a', 'b']) { + await writeFile( + join(dir, 'components', `${name}.ts`), + `import { WebComponent, html } from '@webjsdev/core';\n` + + `class ${name.toUpperCase()} extends WebComponent({}) {\n` + + ' render() { return html``; }\n' + + '}\n' + + `${name.toUpperCase()}.register('dup-tag');\n`, + ); + } + + const r = check(dir); + assert.equal(r.status, 1, r.stdout + r.stderr); + assert.match(r.stdout, /no-duplicate-tag/); + assert.match(r.stdout, /dup-tag/); +}); + +test('a directory that is not an app refuses with exit 1 and reports nothing', async (t) => { + const dir = await fixture(t); + await writeFile(join(dir, 'stray.ts'), 'export const x = 1;\n'); + + const r = check(dir); + assert.equal(r.status, 1); + assert.match(r.stderr, /not a WebJs app/); + assert.doesNotMatch(r.stdout + r.stderr, /violation\(s\) found/); +}); + +test('--json refuses as parseable JSON', async (t) => { + const dir = await fixture(t); + + const r = check(dir, '--json'); + assert.equal(r.status, 1); + const json = JSON.parse(r.stdout); + assert.equal(json.error.code, 'NOT_AN_APP'); + assert.ok(!('violations' in json)); +}); + +test('--rules is exempt and still works outside an app', async (t) => { + const dir = await fixture(t); + + const r = check(dir, '--rules'); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /webjs check, correctness rules:/); + assert.match(r.stdout, /no-duplicate-tag/); +}); + +test('the monorepo root itself refuses instead of reporting cross-app findings', () => { + const r = check(REPO); + assert.equal(r.status, 1); + assert.match(r.stderr, /not a WebJs app/); + assert.match(r.stderr, /\( cd examples\/blog && npx webjs check \)/); + assert.match(r.stderr, /\( cd website && npx webjs check \)/); + // Asserted on the refusal rather than on a count, so the test stays stable + // as the (false) finding count drifts. Reverting the guard reds this. + assert.doesNotMatch(r.stdout, /no-duplicate-tag/); + assert.doesNotMatch(r.stdout, /violation\(s\) found/); +}); + +test('the derived app list matches the app list ci.yml loops over', async () => { + const workflow = await readFile(join(REPO, '.github', 'workflows', 'ci.yml'), 'utf8'); + // The `webjs check` step's loop, isolated from the sibling `webjs doctor` + // loop over the same apps, by anchoring on the step name above it. + const step = workflow.split('webjs check (')[1]; + assert.ok(step, 'ci.yml has a `webjs check` step'); + const loop = step.match(/for app in ([^;]+); do/); + assert.ok(loop, 'that step loops over an app list'); + const fromCi = loop[1].trim().split(/\s+/).sort(); + + const { workspaceApps } = await findCheckTarget(REPO); + assert.deepEqual( + workspaceApps, + fromCi, + 'the apps the refusal names must be the apps CI checks', + ); +}); diff --git a/website/app/docs/conventions/page.ts b/website/app/docs/conventions/page.ts index 8be160305..eac3f63ae 100644 --- a/website/app/docs/conventions/page.ts +++ b/website/app/docs/conventions/page.ts @@ -42,6 +42,8 @@ webjs check # List the correctness checks and their descriptions webjs check --rules +

Run it from the app root, the directory holding app/. Every rule assumes one application, meaning one module graph, one custom-element registry, one runtime, so in a monorepo the workspace root has no application to check. Run there, the command refuses with a non-zero exit and names the member apps to run it in, rather than reporting cross-app collisions that no single runtime ever sees. webjs check --rules is documentation, so it works from anywhere.

+

Workflow for AI agents

  1. Read the skill (.agents/skills/webjs/SKILL.md) and AGENTS.md for the project conventions and follow them by judgment.