Skip to content

webjs check at the monorepo root reports 61 false violations #1301

Description

@vivek7405

Line anchors in this body were re-derived at HEAD 79fc28fc. The original report was written against 5ac991ce and several of its numbers and anchors had drifted; every correction is called out inline below.

Problem

Running webjs check from the monorepo ROOT reports 67 violations, and effectively all of them are false positives. That matters more than the count, because AGENTS.md "Code workflow (mandatory)" item 4 tells every agent to run webjs check and fix violations. An agent following that from the repo root today is handed 67 findings, none of which are real, with no signal telling it so. The likely outcomes are a wasted investigation or, worse, renames in test fixtures to satisfy a checker that should never have looked at them.

Breakdown re-measured at 79fc28fc via npx webjs check --json:

Rule Count
no-duplicate-tag 53
no-static-properties 7
no-interpolation-in-raw-text-element 6
no-browser-globals-in-render 1
total 67

Correction to the original table. It read 61 total with no-duplicate-tag at 47. The rule mix is unchanged; only the counts moved, so the report's substance holds.

The mechanism is that the monorepo root is not an app, and webjs check is an app-level tool. Every rule assumes one application, meaning one module graph, one custom-element registry, one runtime. The root is a workspace holding two separate apps plus every package's test suite plus editor fixtures plus the scaffold templates, none of which ever coexist in a single runtime.

no-duplicate-tag shows it most clearly. The tag my-counter is still reported as duplicated across exactly these five files at HEAD:

  • examples/blog/components/counter.ts (a real app component)
  • packages/editors/intellisense/test/plugin/intellisense.test.mjs (an editor-plugin fixture)
  • packages/server/test/instrumentation/instrumentation.test.js (a unit test)
  • packages/server/test/scanner/component-scanner.test.js (a unit test)
  • test/types/component-types.test-d.ts (a type fixture)

Those five never load together. The rule's stated hazard, that SSR keeps the last registration while the browser keeps the first, cannot occur, because no single browser or SSR pass sees more than one of them. The rule is correct; the scope it was pointed at is not.

CI is unaffected and green, which is why this has gone unnoticed. .github/workflows/ci.yml runs the check PER APP, each from its own directory:

35      - name: webjs check (blog, website)
36        run: |
37          for app in examples/blog website; do
38            echo "::group::webjs check $app"
39            ( cd "$app" && node "$GITHUB_WORKSPACE/packages/cli/bin/webjs.js" check )
40            echo "::endgroup::"
41          done

That is the correct invocation and it reports clean. Only the root-level invocation is misleading, and the root-level invocation is exactly the one the workflow instruction implies.

Three anchor corrections, all verified at 79fc28fc.

  1. CI loops TWO apps, not four. The original body said the loop covers examples/blog, website, docs, and packages/ui/packages/website. Commit ddfc5547 ("chore: delete the docs and ui-website redirect-only apps", chore: delete the docs and ui-website redirect-only apps #1305) removed the last two. git ls-files docs and git ls-files packages/ui/packages/website both return zero files today; any local directories of those names are untracked leftovers, and packages/ui/packages/website is empty. There are exactly two in-repo apps. Both were re-verified clean at HEAD by hand (( cd "$app" && node packages/cli/bin/webjs.js check ) exits 0 with webjs check: all checks pass ✓ for each).
  2. ci.yml:35 is the step NAME, not the loop. The loop is line 37. A sibling webjs doctor loop over the same two apps sits at line 53.
  3. The walker the original body pointed at is the wrong one. It described "the checker's own walker (packages/server/src/check.js, around L1038-L1046)" as skipping node_modules, dist, build, .git, .next, _private. That block is real but it is a RULE-LOCAL walker used only by no-non-erasable-typescript, and it walks .ts / .mts only. The MAIN file collection that feeds every other rule, no-duplicate-tag included, is checkConventions at packages/server/src/check.js:561-570:
547  export async function checkConventions(appDir) {
...
560    const files = [];
561    for await (const abs of walk(appDir, (p) => /\.m?[jt]sx?$/.test(p))) {
562      const rel = relative(appDir, abs);
563      let content;
564      try {
565        content = await readFile(abs, 'utf8');
566      } catch {
567        continue;
568      }
569      files.push({ abs, rel, content, scan: redactStringsAndTemplates(content) });
570    }

It applies NO skip list of its own. The only exclusions come from packages/server/src/fs-walk.js:19-20, which skips dot-prefixed entries and node_modules and nothing else. Any plan written against the L1038 block would have edited a walker that cannot affect 66 of the 67 findings.

Verified anchors that did NOT move: the no-duplicate-tag rule description at packages/server/src/check.js:74-77, the rule body at packages/server/src/check.js:1243-1297, the check subcommand dispatch at packages/cli/bin/webjs.js:709-754, and scanBareImports's test-directory skip at packages/server/src/vendor.js:176-196 (the e.name === 'test' || e.name === 'tests' arms are lines 185-186, inside a local walk at line 176, not the exported scanBareImports at line 100).

Design / approach

Settled: refuse to run when the current directory is not an app, and name the correct invocation. No --workspaces mode. No test-directory exclusion.

Decision 1: refusal only, not a --workspaces loop

The harm is an agent at the repo root being handed 67 findings with no signal that the scope is wrong. A refusal removes that harm completely, in one place, with zero behavioural change for any real app.

A --workspaces mode was considered and rejected on three counts.

  • It would have to discover which workspace members are apps, and the only workable predicate for that is "the member has an app/ directory", which is the same predicate the refusal needs. So the flag buys no new capability, only a second way to spell a loop.
  • It creates exactly the drift surface the report warns about. CI would either keep its own loop (two definitions of the app set) or switch to the flag (a required job now depending on a fresh CLI feature). Neither is worth it for a two-app repo.
  • The refusal can carry the whole payload the flag would. It DERIVES the app list from the root package.json workspaces globs and prints the exact per-app commands, so the agent gets the right invocation and a per-app exit code, which a single folded exit code would lose.

Instead of a flag, drift is handled by a test: test/cli/check-target.test.mjs parses the for app in ...; do list out of the webjs check step in .github/workflows/ci.yml and asserts set equality with the app list the refusal derives. Measured at HEAD, both sides are exactly ['examples/blog', 'website']. If a third app lands and CI is updated, the derived list picks it up automatically; if CI is edited to drop one, the test reds.

Exit code is 1, and the message goes to stderr with the usual webjs check: prefix. An agent gates on exit status, and 0 would read as "clean", which is the precise false signal this issue is about. Under --json the refusal is emitted as JSON so an agent's parser does not choke on prose, and it deliberately carries no violations key, so a consumer that ignores the exit code and reads report.violations.length throws rather than being told it is clean.

Prior art: Next.js refuses on exactly this predicate. ~/Documents/Projects/frameworks/next.js/packages/next/src/lib/find-pages-dir.ts:14-26 throws "> Couldn't find any 'pages' or 'app' directory. Please create one under the project root" when neither routing directory exists, and next-dev, next-typegen, and next-test all route through it. It is directory existence alone, with no workspace awareness and no partial-work fallback, and the message names what to do. This plan is that shape.

Rejected alternative, for the record: make checkConventions itself throw. That would also cover the MCP check tool, but the MCP tool's contract is { violations, summary } over JSON-RPC, so a thrown refusal surfaces as a transport error rather than guidance, and every existing rule-engine test would have to plant an app/ directory. It is also not a live path here: the monorepo root registers no MCP server (there is no .mcp.json and no .claude.json at the repo root), and the scaffold registers @webjsdev/mcp with an app cwd. So the guard stays a CLI-usage guard.

Decision 2: the detection predicate

notAnApp(cwd)  ===  !existsSync(join(cwd, 'app'))

The app/ directory alone. The workspaces key is NOT part of the predicate.

Why the app/ directory is the right and only test:

  • It is what the checker itself already uses. packages/server/src/check.js:1543 reads if (!(await pathExists(join(appDir, 'app')))) return; to skip no-server-import-in-browser-module. The notion of "this is an app" already exists in the file; the guard promotes it from a per-rule skip to a command-level one.
  • AGENTS.md "App layout (cannot be renamed)" makes app/ mandatory and unrenameable, so there is no src/app variant to also probe (unlike Next, which must check both).
  • The api template has an app/ directory, verified in code rather than assumed. packages/cli/lib/create.js:334-342 creates ['app', 'components', 'modules', 'lib', 'public', 'db', 'test/unit', 'test/e2e', ...] for EVERY template, packages/cli/lib/create.js:943-1012 writes app/api/health/route.ts, app/route.ts, and app/api/users/route.ts for the api template, and packages/cli/lib/api-gallery.js:20 writes the whole backend gallery under join(appDir, 'app', 'api', 'features', ...). So the predicate never refuses inside a legitimate api app.
  • Both in-repo apps pass it (examples/blog/app and website/app both exist).

Why workspaces must NOT be ANDed in: a directory with no app/ is not an app whether or not it declares workspaces. packages/ui has neither, and running the check there today scans the registry components and can report cross-package duplicate tags. Requiring workspaces would leave that case, and a mistyped cd, and a bare clone, still emitting garbage. The workspaces key keeps a job, but it is a message job, not a predicate job: when it is present, the refusal enumerates the workspace members that DO have an app/ and prints their commands.

Measured at the repo root, expanding the six workspaces globs (packages/*, packages/editors/*, packages/wrappers/*, packages/ui/packages/*, examples/*, website) and keeping members with an app/ directory yields exactly examples/blog and website.

Decision 3: test and fixture exclusion is OUT

Excluding test/ and tests/ from the checker's walk is rejected, and not deferred. Three reasons, in order of weight.

It does not fix the reported problem. Measured over the actual 67 findings, filtering every violation whose path has a test/ or tests/ segment (and re-deriving each no-duplicate-tag group, since a pair collapses when one side leaves) still leaves a substantial root residue:

Tag or rule Surviving non-test sites
theme-toggle examples/blog/components/theme-toggle.ts and website/components/theme-toggle.ts (two different apps)
ui-dialog, ui-dialog-trigger, ui-dialog-content, ui-dialog-close, ui-dialog-footer examples/blog/components/ui/dialog.ts and packages/ui/packages/registry/components/dialog.ts
slow-fact examples/blog/components/slow-fact.ts and packages/cli/templates/gallery/modules/suspense/components/slow-fact.ts (app versus scaffold template)
token-stream examples/blog/components/token-stream.ts and packages/cli/templates/gallery/modules/streaming/components/token-stream.ts
like-button packages/cli/templates/gallery/modules/optimistic-ui/components/like-button.ts and website/components/like-button.ts
no-static-properties, no-interpolation-in-raw-text-element, no-duplicate-tag packages/server/src/check.js (its own rule prose and examples)
no-duplicate-tag packages/mcp/src/mcp-docs.js

That is roughly 15 surviving findings across app-versus-app, app-versus-template, and app-versus-registry pairs, none of which a test exclusion can touch, because the cause is cross-app scope rather than test code. The refusal removes all 67; the exclusion removes about three quarters of them and leaves the agent in the same position.

It would break a documented lockstep. The rule's own comment at packages/server/src/check.js:1246-1252 states that it scans every source file "to keep the rule in lockstep with the editor's 9004 diagnostic, which is likewise project-wide". That diagnostic, webjsDuplicateTagDiagnostics at packages/editors/intellisense/src/index.js:1233-1273, runs over the whole TypeScript program and is explicitly "NOT gated on the import graph". A normal tsconfig includes test files, so excluding them from webjs check would make the editor underline a tag the CLI calls clean.

A duplicate tag inside a test file is a real hazard, not a false positive. The elision precedent in packages/server/src/vendor.js:185-186 skips test/ because a test's npm imports must never reach the browser importmap, which is a shipping concern. Registration is not: browser tests (*/test/**/browser/*, run through npm run test:browser) load in one realm alongside the components under test, and a second customElements.define for the same tag there throws NotSupportedError. The scaffold also creates test/unit and test/e2e in every app (packages/cli/lib/create.js:341-342), so an exclusion would silence correctness rules in a directory every app has. no-static-properties in an app's test helper is a real runtime throw whether or not the file ships.

The upside the report imagined, that a component registered only inside a test file is not in the shipped registry, is real but narrow, and the rule already has the right escape valve for the case that actually bites: it skips gitignored files (packages/server/src/check.js:1256-1261), which is how a webjs ui add regenerated copy avoids colliding with its hand-written original.

Where this sits relative to the correctness-only line

The AGENTS.md section headed "Conventions and webjs check" states the rules "run unconditionally with no per-project disabling". This change adds no opt-out. It does not even scope WHICH FILES a rule sees, which would already be the safer side of that line. It scopes WHERE the command may be invoked, and the rule set inside an app is byte-identical before and after. checkConventions is not modified, so no rule-engine test changes.

Implementation plan

Every step is decided. Two files change and one is added.

Step 1: add packages/cli/lib/check-target.js (new file)

A pure guard plus message renderer, mirroring the two established siblings in that directory, lib/node-preflight.js (checkNodeInline plus nodeInlineMessage) and lib/app-name.js (checkAppName plus appNameErrorMessage). Both are "pure predicate, pure message, called from the bin before the real work", and this is the same shape.

It lives in the CLI rather than in packages/server/src/check.js for two concrete reasons. The refusal is a CLI-usage concern (an exit status plus a directive), and adding exports to check.js would require matching declarations in packages/server/src/check.d.ts, which types the ./check subpath, for a helper no library consumer calls.

Plain .js with JSDoc, per the buildless invariant. Node 24 is the floor, so fs.promises.glob is available (verified present on the repo's runtime).

/**
 * `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 { existsSync } from 'node:fs';
import { readFile, glob } from 'node:fs/promises';
import { join } from 'node:path';

/**
 * @typedef {{ isApp: boolean, workspaceApps: string[] }} CheckTarget
 */

/**
 * 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<CheckTarget>}
 */
export async function findCheckTarget(cwd) {
  if (existsSync(join(cwd, 'app'))) 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<string[]>}
 */
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<string>} */
  const apps = new Set();
  for (const pattern of patterns) {
    try {
      for await (const match of glob(pattern, { cwd })) {
        if (existsSync(join(cwd, match, 'app'))) 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,
    },
  };
}

Step 2: wire the guard into packages/cli/bin/webjs.js

Today, case 'check': reads (lines 709 through 726, the --rules branch elided for brevity):

709      case 'check': {
710        const { checkConventions, RULES } = await import('@webjsdev/server/check');
711
712        if (rest.includes('--rules')) {
...
722          break;
723        }
724
725        const violations = await checkConventions(process.cwd());
726

Insert the guard between line 723 and line 725, so it sits AFTER the --rules branch (that branch is pure documentation and must keep working anywhere) and BEFORE checkConventions runs. After the change:

722          break;
723        }
724
725        // #1301: `webjs check` is an APP-level tool. At a workspace root it
726        // walks every package's tests and every app at once and reports
727        // cross-app collisions that no single runtime ever sees (67 false
728        // findings at this repo's root). Refuse instead, naming the member
729        // apps to run it in. Exit 1: an agent gates on the exit status, and
730        // 0 would read as "clean", the exact false signal this fixes.
731        const target = await findCheckTarget(process.cwd());
732        if (!target.isApp) {
733          if (rest.includes('--json')) {
734            console.log(JSON.stringify(notAnAppJson(process.cwd(), target.workspaceApps)));
735          } else {
736            console.error(notAnAppMessage(process.cwd(), target.workspaceApps));
737          }
738          process.exit(1);
739        }
740
741        const violations = await checkConventions(process.cwd());
742

Add the import beside the existing lib/ imports at the top of the file (currently node-preflight.js at line 8 and app-name.js at line 11):

import { findCheckTarget, notAnAppMessage, notAnAppJson } from '../lib/check-target.js';

A static import is correct here rather than a dynamic one: the module has no heavy dependencies (node:fs, node:fs/promises, node:path) and both existing lib/ guards are statically imported.

Step 3: no change to packages/server/src/check.js

Stated as a step so it is not skipped by accident. The rule engine, the walker at line 561, the no-duplicate-tag rule at line 1243, and fs-walk.js are all untouched. This is what keeps the change on the "where the command runs" side of the correctness-only line, and it is why no rule-engine test needs editing.

Step 4: no change to webjs doctor

Verified by running it at the repo root at HEAD. It already degrades correctly and exits 0: ELISION_CARRIERS reports "not analysed (no routable app, or elision is disabled)", ELISION_COMPONENTS reports "not analysed (no routable app or analysis unavailable)", and UNMARKED_ASSET_LINKS reports "no app/ directory to analyse". It emits two warnings (WEBJS_VERSIONS and IMPORTMAP_COHERENCE) and no false correctness violations, so it is not the reported harm. Extending the refusal to doctor would also remove a legitimate use, since the toolchain checks (NODE_VERSION, TSCONFIG_ERASABLE, GIT_HOOK, VENDOR_GITIGNORE) are meaningful at a repo root. Leave it alone.

Step 5: no CI change

.github/workflows/ci.yml:37 and :53 already cd into each app, so both jobs are unaffected. The new drift test in Step 6 pins that agreement rather than moving CI.

Tests

New: test/cli/check-target.test.mjs

Naming follows the siblings in test/cli/ (doctor.test.mjs, routes.test.mjs, elision.test.mjs, help.test.mjs), which are node:test files that both import the pure lib/ helper and spawn the real bin. Fixtures are mkdtemp temp directories, matching packages/server/test/check/check.test.js:10-13.

Unit cases over findCheckTarget / notAnAppMessage / notAnAppJson:

  1. A fixture with an app/ directory yields { isApp: true }.
  2. A fixture with no app/, a package.json declaring workspaces: ['pkgs/*'], one member with app/ and one without, yields isApp: false and workspaceApps: ['pkgs/withapp'] only. Proves the workspace filter keeps non-app members out.
  3. A fixture with no app/ and no workspaces yields isApp: false with an empty list, and notAnAppMessage renders the "change into your app directory" branch rather than an empty command block.
  4. notAnAppJson(...) has error.code === 'NOT_AN_APP' and no violations key (assert.ok(!('violations' in json))).
  5. A package.json that is missing or malformed still yields isApp: false with an empty list, so a bad manifest degrades the message and never the refusal.

Spawned-CLI cases (node packages/cli/bin/webjs.js check, the same invocation CI uses):

  1. In an app-shaped fixture containing a genuine duplicate tag in two components, exit is 1 and stdout carries the no-duplicate-tag violation. This is the acceptance-criteria counterfactual proving the guard does not swallow real findings inside an app. Verified by hand at HEAD against a scratch fixture with app/page.ts plus components/a.ts and components/b.ts both calling A.register('dup-tag'), which reports two violations.
  2. In a no-app/ fixture, exit is 1, stderr carries "not a WebJs app", and the output does NOT contain "violation(s) found".
  3. Same fixture with --json, exit 1, JSON.parse(stdout).error.code === 'NOT_AN_APP'.
  4. Same fixture with --rules, exit 0 and the rule list prints. Pins the ordering requirement that the guard sits after the --rules branch.

The counterfactual that fails when the change is reverted (10). Spawn the CLI at the REPO ROOT itself (resolved from import.meta.url) and assert exit 1 plus the refusal, and that stdout contains no violation lines. With the guard removed this run prints 67 violations and the assertion on the refusal text fails. It is stable against the count drifting further because it asserts on the refusal, not the number.

The CI drift guard (11). Read .github/workflows/ci.yml, extract the app list from the for app in <list>; do line inside the webjs check step, and assert set equality with (await findCheckTarget(repoRoot)).workspaceApps. Both sides are ['examples/blog', 'website'] at HEAD. This is the mechanism that keeps the refusal's advice and the CI loop from diverging, in place of a --workspaces flag.

Existing, extended: none required

packages/server/test/check/check.test.js already covers no-duplicate-tag at lines 186, 221, 257, and 294, including a clean-when-unique counterfactual. Because check.js is untouched, those tests are the regression guard for "the rule still fires inside one app" and need no edit. Do not modify them.

Layers that do NOT apply, and why

  • Browser (*/test/**/browser/*): the change touches no DOM, hydration, slot, client-router, or custom-element-upgrade surface. It is a CLI argv branch.
  • e2e (test/e2e/*.test.mjs): no request path, no server, no navigation or streaming behaviour changes. The spawned-CLI cases above already exercise the real binary end to end.
  • Smoke (test/examples/*/smoke/*): those boot in-repo apps over HTTP. Both apps have app/, so the guard never fires for them, and the per-app CI loop already proves they still check clean.
  • Bun parity (test/bun/**): not required, judged by reading .claude/hooks/require-bun-parity-with-runtime-src.sh rather than by guess. Its gate (line 61) is grep -E '^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/' followed by a filename filter (line 63) for serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr\.js|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev\.js|stream. packages/cli/lib/check-target.js passes the first grep and matches none of the second, and packages/cli/bin/webjs.js is not under a covered prefix at all (bin/, not lib/). Confirmed by running both paths through the two greps: no match. The surface is also genuinely runtime-neutral (node:fs, node:path, argv), so there is nothing for a cross-runtime assertion to prove.

Hook gates this commit will trip, and what satisfies them

  • .claude/hooks/require-tests-with-src.sh matches packages/cli/lib/, and test/cli/check-target.test.mjs satisfies it.
  • .claude/hooks/require-docs-with-src.sh matches the same prefix and accepts (^|/)(AGENTS|CLAUDE|CONVENTIONS|README)\.md$|^\.agents/skills/webjs/|^website/|^packages/cli/templates/. The AGENTS.md and website/ edits below satisfy it. Note that framework-dev.md alone would NOT.
  • test/knowledge/knowledge-coverage.test.js reconciles the live RULES array against the troubleshooting surfaces. No rule is added or renamed, so it is unaffected.

Docs

Four surfaces change. The escape hatch is not needed and must not be used.

  1. AGENTS.md, "Code workflow (mandatory)" item 4. This is the root cause of the misleading instruction and must state WHERE. Current text opens "Convention validation. Run webjs check and fix violations. Run webjs doctor too when you touched an in-repo app (examples/blog, website)...". Add, immediately after the first sentence: run it from INSIDE the app, not from the repo root, which is a workspace and refuses with exit 1 (webjs check at the monorepo root reports 61 false violations #1301), and give the two commands ( cd examples/blog && npx webjs check ) and ( cd website && npx webjs check ). Keep the existing doctor sentence as is.

  2. packages/cli/AGENTS.md, the webjs check row of the "Public commands" table. It currently ends "Report-only: each violation carries a prose fix hint, but there is no --fix autofix flag". Append that the command refuses with exit 1 in a directory with no app/ (webjs check at the monorepo root reports 61 false violations #1301), naming the workspace member apps when the directory declares workspaces, that the predicate is the app/ directory alone (both templates create it), and that --rules is exempt and works anywhere. Also add check-target.js to the lib/ module map in that file, beside node-preflight.js and app-name.js, with a one-line summary and its test path.

  3. framework-dev.md. Add a short subsection under the existing top-level structure recording that webjs check is per app in this monorepo, that the root refuses, that the two apps are examples/blog and website, and that test/cli/check-target.test.mjs pins the derived app list against the ci.yml loop so the two cannot drift.

  4. The docs site, website/app/docs/conventions/page.ts. This is the page that documents the command (its <h2> at line 33 reads "webjs check, correctness, not conventions", with the invocation block at lines 40-43 and the workflow list at lines 48-50). Add one sentence after the invocation block: run it from the app root, the directory containing app/, because in a monorepo the workspace root has no application to check and the command refuses rather than reporting cross-app findings. This is the surface a user outside this repo (a Turborepo or npm-workspaces user) will hit.

Surfaces deliberately NOT changed, stated so an implementer does not go looking:

  • The AGENTS.md CLI reference block and the HELP.check entry in packages/cli/bin/webjs.js:164-172. No flag or option is added, so the usage line, the Options table, and the examples are all still accurate. webjs help check output is unchanged.
  • packages/server/AGENTS.md. check.js is untouched.
  • The scaffold templates (packages/cli/templates/). Every scaffolded app has app/ at its root and its CI runs npm run check from there (packages/cli/templates/.github/workflows/ci.yml:36), so no scaffolded app can hit the refusal and no template rule file needs a word.
  • WEBJS_NO_DOC_GATE=1 is not applicable here. This is a user-visible behaviour change on a public CLI command, so the gate is doing its job and the four edits above are the answer.

Acceptance criteria

  • npx webjs check at the monorepo root exits 1, prints a message naming this directory as not an app, and lists ( cd examples/blog && npx webjs check ) and ( cd website && npx webjs check )
  • That run reports zero violations, so no [no-duplicate-tag] line, no "violation(s) found" line
  • npx webjs check --json at the root exits 1 and emits parseable JSON with error.code === 'NOT_AN_APP' and no violations key
  • npx webjs check --rules still exits 0 and lists the rules at the monorepo root
  • Both in-repo apps still check clean via the CI loop, for app in examples/blog website; do ( cd "$app" && node packages/cli/bin/webjs.js check ); done, each exiting 0 with webjs check: all checks pass ✓
  • webjs doctor at the monorepo root is byte-identical to before and still exits 0
  • no-duplicate-tag still fires for a genuine duplicate WITHIN one app: an app-shaped fixture with the same tag registered in two components exits 1 with the violation
  • packages/server/src/check.js is unchanged in the diff
  • AGENTS.md item 4 says where to run the check in this repo
  • No test fixture was renamed, and no tag string anywhere under packages/*/test/, test/, or packages/editors/nvim/vendor/ was edited
  • test/cli/check-target.test.mjs passes, including the repo-root counterfactual and the ci.yml drift guard
  • npm test is green from the repo root

Out of scope

  • Do NOT fix the reported files. Renaming tags in test fixtures would churn dozens of files and fix nothing real. Verified as fact on one file: packages/server/test/scanner/component-scanner.test.js registers my-counter at lines 40 and 82 and then asserts on it BY NAME at line 45 (assert.equal(comps[0].tag, 'my-counter')) and line 90 (assert.equal(byTag['my-counter'], '/components/counter.ts')). A rename breaks the test that owns the tag.
  • packages/editors/nvim/vendor/** must never be hand-edited. It is a generated verbatim copy of the intellisense source, enforced by a drift test, so it can legitimately carry a second registration of a tag.
  • Do not add a per-project opt-out for any rule, no webjs.check config key, no inline suppression comment, no rule allowlist. The AGENTS.md section headed "Conventions and webjs check" states the rules run unconditionally.
  • Do not exclude test or fixture directories from the walker, and do not touch the no-duplicate-tag scope. Settled OUT above with measurements.
  • Do not add a --workspaces flag or any other new CLI flag or option. Settled above; the drift test replaces it.
  • Do not change .github/workflows/ci.yml. Both loops already cd per app.
  • Do not change webjs doctor or anything in packages/cli/lib/doctor.js.
  • Do not change the MCP check tool (packages/mcp/src/mcp.js, packages/mcp/src/check-report.js). Its contract is { violations, summary } over JSON-RPC with no exit code, it is registered with an app cwd by the scaffold, and the monorepo root registers no MCP server at all.
  • Do not clean up the stale untracked docs/ and packages/ui/packages/website/ directories. They are local leftovers from chore: delete the docs and ui-website redirect-only apps #1305 and are not in git.
  • No cross-issue file overlap exists. This issue owns packages/server/src/check.js (which it ends up not modifying) and the CLI. Noted for the record: dogfood: ui kit cn.ts and native-select.ts do module-scope work, pinning every page #1320, planned in parallel, must NOT touch packages/server/src/component-elision.js, a different file, so there is no collision.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions