From adc84e3d8cd0bb79ba7a7f49dc19ee1ee90c6df4 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 22:52:54 +0530 Subject: [PATCH 01/12] feat: declare per-check doctor severity in webjs.doctor.gate `webjs doctor` could only be all-or-nothing: the default exit fails on a broken toolchain, and `--strict` makes every warning fatal, including four that are environment-shaped and would red a clean CI run (the git hook, env drift, vendor-pin freshness, and framework resolvability). So no CI ran it, and every advisory it carries was findable but not unmissable. Per-check severity is now config rather than a flag, on the three-level scale ESLint uses and `eslint-plugin-next` ships as a rule-id-keyed map. An app declares it once in package.json, so its workflow, its `npm run doctor`, and an agent's `--json` loop read the same policy instead of three copies of a code list. Four rules keep it safe. A code with no entry keeps its default, so an app with no block produces byte-identical counts and the exit formula is untouched. A result's `severity` is the effective level, so a passing check reports `pass` even when its code is gated `error`. A best-effort result (a network or toolchain outage) is capped at `warn` and can never be escalated, which is what makes doctor safe inside a required job. And an unknown code or severity exits 1 naming it, because a silently-ignored typo would leave CI un-gated while looking gated. Refs #1257 --- .agents/skills/webjs/references/built-ins.md | 19 +- AGENTS.md | 2 +- packages/cli/AGENTS.md | 4 +- packages/cli/README.md | 2 +- packages/cli/bin/webjs.js | 103 ++++++-- packages/cli/lib/doctor.js | 141 ++++++++++- packages/core/index.d.ts | 2 + packages/core/src/webjs-config.d.ts | 31 +++ .../test/config/webjs-config-schema.test.js | 1 + packages/server/webjs-config.schema.json | 16 ++ test/cli/doctor.test.mjs | 221 ++++++++++++++++++ test/cli/help.test.mjs | 13 ++ test/types/webjs-config.test-d.ts | 23 ++ website/app/docs/configuration/page.ts | 12 + 14 files changed, 558 insertions(+), 32 deletions(-) diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index 4c04b6b08..29388b795 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -93,7 +93,7 @@ html`` That emits `/public/app.css?v=` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, so this is a convention rather than a `webjs check` rule (`webjs doctor` does flag the plain form, see below). Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched. -Forgetting it is the one real cost of opt-in, so `webjs doctor` catches it: a page, layout, or error boundary writing a plain `` gets a WARN naming the `file:line` and the fix (#1095). It reads your source and rewrites nothing, and it stays quiet about the non-marks that are deliberate: a cross-origin sheet, a `rel="icon"`, a `rel="preload"`, and any `href=${expr}` hole. Same posture as Rails (a `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url from the build graph, surfaced through `links()`): take the fingerprint at the point the url is PRODUCED, never by rewriting a rendered document. +Forgetting it is the one real cost of opt-in, so `webjs doctor` catches it: a page, layout, or error boundary writing a plain `` gets a WARN naming the `file:line` and the fix (#1095). It reads your source and rewrites nothing, and it stays quiet about the non-marks that are deliberate: a cross-origin sheet, a `rel="icon"`, a `rel="preload"`, and any `href=${expr}` hole. Same posture as Rails (a `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url from the build graph, surfaced through `links()`): take the fingerprint at the point the url is PRODUCED, never by rewriting a rendered document. A warning is easy to miss, so make it fatal in the app that cares: gate `UNMARKED_ASSET_LINKS` to `error` (see the doctor severity gate below) and one `npm run doctor` step in CI stops the un-versioned url reaching a deploy. The scaffold ships exactly that. It is opt-in rather than automatic because only the author knows which urls are the REQUEST. Do NOT mark a `rel="preload"` hint whose asset is actually fetched by CSS `url()`: the preload cache is keyed on the full url, so a versioned hint can never satisfy the unversioned request the stylesheet makes, and the file is fetched twice. Mark the thing that fetches, not the hint. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing). @@ -210,6 +210,23 @@ An over-limit body responds `413` without buffering the whole payload. `before` runs to completion first (a non-zero exit aborts the boot). `parallel` (dev only) runs long-lived watchers alongside the server and tears them down on exit. `watch` (dev only) adds extra live-reload directories outside the app tree. +### Doctor severity gate + +`webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE` depends on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries. + +```jsonc +{ "webjs": { + "doctor": { "gate": { + "UNMARKED_ASSET_LINKS": "error", // fail the exit on this one + "ELISION_CARRIERS": "off" // silence it entirely, even under --strict + } } +} } +``` + +Three levels, the same scale ESLint uses: `error` fails the exit, `warn` reports without failing, `off` silences the check. A code with no entry keeps its default (`error` for a hard toolchain failure, `warn` otherwise), so an app that declares nothing behaves exactly as before. Read the codes off `webjs doctor --json`, where every result carries its `code` and its effective `severity`. + +Two guarantees worth knowing. A result that could not check (a network or toolchain outage) is capped at `warn` and can never be escalated, so a jspm or npm outage cannot red your CI. And an unknown code or severity exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. Wire it up with one workflow step, `npm run doctor`, and change what is fatal in `package.json` rather than in the workflow. + ## Observability Wired at the single response funnel, covering pages, routes, actions, and assets uniformly. diff --git a/AGENTS.md b/AGENTS.md index 795c4a198..f42d847aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -506,7 +506,7 @@ webjs test [--server] [--browser] [--watch] webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check, ui kit -webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory; a warning when a route module writes a `` without `asset()`, #1095); non-zero exit on a hard fail. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code); --strict also fails the exit on warnings (#975) +webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory; a warning when a route module writes a `` without `asset()`, #1095); non-zero exit on a hard fail. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code + its effective severity); --strict also fails the exit on warnings (#975). Per-check severity is CONFIG, not a flag: `webjs.doctor.gate` maps a code to `off` / `warn` / `error` so CI gates a chosen subset (#1257) webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975) webjs help [command] # full usage banner, or per-command usage + Options + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs --help / -h (that command). typecheck/db/ui --help forward to their wrapped tool; an unknown topic exits 1 diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index a25170500..48c5e4297 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -153,7 +153,7 @@ README.md npm-facing package readme. | `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 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 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` / `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) | -| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js`. A project-health checklist over existing signals (Node major, tsconfig `erasableSyntaxOnly`, `.env` drift vs `.env.example`, vendor-pin freshness, the `.gitignore` keeping `.webjs/vendor/` committable (`vendor-gitignore`, moved here from `webjs check` in #461 as a warn since it is a project-config concern, not source correctness), `@webjsdev/*` version coherence, a framework-resolve probe (#954: `checkFrameworkResolves` + the exported `frameworkResolves` helper WARN when `@webjsdev/core` cannot be resolved FROM the app dir via a directory-relative `createRequire` probe, naming the fresh-git-worktree-without-node_modules cause and the fix; silent PASS when it resolves, so a healthy app is untouched), importmap coherence, git pre-commit hook, and a page/layout elision advisory (#646: `checkElisionCarriers` runs `@webjsdev/server`'s `analyzeAppElision` and WARNS, naming the first client-effecting blocker, for each page/layout that ships whole instead of being elided as a carrier; advisory-only, skipped when elision is off or there is no `app/`), and an unmarked-stylesheet-link advisory (#1095: `checkUnmarkedAssetLinks` scans every `app/**` route module that renders markup (`page` / `layout`, plus the always-shipped `error` / `not-found` / `forbidden` / `unauthorized` / `loading` boundaries and `global-error`, which writes its own ``), skipping `_private` folders the router never routes, for a `` whose href is a STATIC, quoted, root-absolute `/public/...` literal, i.e. one not wrapped in `asset()`, and WARNs with `file:line`, since that url is un-versioned and a deploy cannot bust a CDN's copy. Scoped to `rel="stylesheet"`: a cross-origin sheet must keep its exact url, `rel="icon"` is a legitimate deliberate non-mark (the website leaves its favicons bare so the SEO repo-health tests parse the hrefs literally), `rel="preload"` MUST stay unversioned or its hint could never match the request the CSS `url()` makes, and an `href=${expr}` hole is undecidable from source and is exactly the marked shape. It reads the author's SOURCE and rewrites nothing, deliberately: the automatic form was built and rejected in #1196, and this is the same authoring-time posture Rails and Remix take)). PURE checks render with a `[pass]` / `[warn]` / `[fail]` marker; non-zero exit iff a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig), so CI can gate. Warns (drift / staleness) never fail the exit. Each `DoctorResult` carries a stable SCREAMING_SNAKE `code` (#975, the `DOCTOR_CODES` map; e.g. `NODE_VERSION`, `TSCONFIG_ERASABLE`, `IMPORTMAP_COHERENCE`) so an agent branches on the failure KIND, not the message text. `--json` emits `{ results, summary }` where `results` is the raw `DoctorResult[]` (each carrying a `code`) and `summary` is `{ pass, warn, fail, strict, ok }` (the same array-under-a-key convention as `check --json`); `--strict` also fails the exit on warnings (not just hard failures), so doctor can gate a fully-clean fix loop. The only network touch (pin freshness, plus the importmap-coherence live resolve) is best-effort: a fetch failure is a warn, never a hard fail. The importmap-coherence check (#450) runs `@webjsdev/server`'s `checkImportmapCoherence` IDENTICALLY over the live importmap AND the vendored `.webjs/vendor/importmap.json`, warning when a pinned package needs a newer version of another pinned package than is pinned (the #446 skew class); it reads dependency metadata from the already-installed node_modules manifests (no network of its own) and degrades to "could not verify" when a manifest is unavailable. An onboarding/setup-verify tool, NOT a scaffold-CI hard gate. Tests: `test/cli/doctor.test.mjs` | +| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js`. A project-health checklist over existing signals (Node major, tsconfig `erasableSyntaxOnly`, `.env` drift vs `.env.example`, vendor-pin freshness, the `.gitignore` keeping `.webjs/vendor/` committable (`vendor-gitignore`, moved here from `webjs check` in #461 as a warn since it is a project-config concern, not source correctness), `@webjsdev/*` version coherence, a framework-resolve probe (#954: `checkFrameworkResolves` + the exported `frameworkResolves` helper WARN when `@webjsdev/core` cannot be resolved FROM the app dir via a directory-relative `createRequire` probe, naming the fresh-git-worktree-without-node_modules cause and the fix; silent PASS when it resolves, so a healthy app is untouched), importmap coherence, git pre-commit hook, and a page/layout elision advisory (#646: `checkElisionCarriers` runs `@webjsdev/server`'s `analyzeAppElision` and WARNS, naming the first client-effecting blocker, for each page/layout that ships whole instead of being elided as a carrier; advisory-only, skipped when elision is off or there is no `app/`), and an unmarked-stylesheet-link advisory (#1095: `checkUnmarkedAssetLinks` scans every `app/**` route module that renders markup (`page` / `layout`, plus the always-shipped `error` / `not-found` / `forbidden` / `unauthorized` / `loading` boundaries and `global-error`, which writes its own ``), skipping `_private` folders the router never routes, for a `` whose href is a STATIC, quoted, root-absolute `/public/...` literal, i.e. one not wrapped in `asset()`, and WARNs with `file:line`, since that url is un-versioned and a deploy cannot bust a CDN's copy. Scoped to `rel="stylesheet"`: a cross-origin sheet must keep its exact url, `rel="icon"` is a legitimate deliberate non-mark (the website leaves its favicons bare so the SEO repo-health tests parse the hrefs literally), `rel="preload"` MUST stay unversioned or its hint could never match the request the CSS `url()` makes, and an `href=${expr}` hole is undecidable from source and is exactly the marked shape. It reads the author's SOURCE and rewrites nothing, deliberately: the automatic form was built and rejected in #1196, and this is the same authoring-time posture Rails and Remix take)). PURE checks render with a `[pass]` / `[off]` / `[warn]` / `[fail]` marker; by default the exit is non-zero iff a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig), and warns (drift / staleness) never fail the exit. Each `DoctorResult` carries a stable SCREAMING_SNAKE `code` (#975, the `DOCTOR_CODES` map; e.g. `NODE_VERSION`, `TSCONFIG_ERASABLE`, `IMPORTMAP_COHERENCE`) so an agent branches on the failure KIND, not the message text. `--json` emits `{ results, summary }` where `results` is the raw `DoctorResult[]` (each carrying a `code` + its effective `severity`) and `summary` is `{ pass, warn, fail, off, strict, ok }` (the same array-under-a-key convention as `check --json`); `--strict` also fails the exit on warnings (not just hard failures), so doctor can gate a fully-clean fix loop. The only network touch (pin freshness, plus the importmap-coherence live resolve) is best-effort: a fetch failure is a warn, never a hard fail. The importmap-coherence check (#450) runs `@webjsdev/server`'s `checkImportmapCoherence` IDENTICALLY over the live importmap AND the vendored `.webjs/vendor/importmap.json`, warning when a pinned package needs a newer version of another pinned package than is pinned (the #446 skew class); it reads dependency metadata from the already-installed node_modules manifests (no network of its own) and degrades to "could not verify" when a manifest is unavailable. Tests: `test/cli/doctor.test.mjs` | | `webjs types` | `generateRouteTypes()` from `@webjsdev/server`, writes `.webjs/routes.d.ts` (typed `Route` union + per-route params, #258). Also auto-emitted at `webjs dev` startup | | `webjs typecheck [tsc args]` | Resolves the project's own `typescript/bin/tsc` (via `createRequire` from the app cwd) and spawns it with `--noEmit`, passing extra args through. Exits non-zero on a type error (a CI gate). A clear message + non-zero exit when typescript is not installed (#265). The framework runs the standard compiler, it does not embed one | | `webjs create [--template …] [--db …] [--runtime node\|bun]` | `scaffoldApp()` from `lib/create.js`. `` is validated by `lib/app-name.js` BEFORE any file is written (#1066; npm package-name rules minus the lowercase-only clause, which never protected anything and which `webjs create MyApp` relied on), at all three entries (this bin, `scaffoldApp()`, and the `create-webjs` wrapper). `--runtime bun` (or `bun create webjs`, auto-detected) emits a Bun-flavored app (#541): `dev`/`start` scripts force `bun --bun`, `bun.lock`, a pure `oven/bun:1` Dockerfile + bun-install CI, and bun-command agent docs. Orthogonal to `--template` (invariant 1 stays exactly 3 templates). | @@ -162,6 +162,8 @@ README.md npm-facing package readme. | `webjs version` | Prints the installed `@webjsdev/cli` version (#975, `readCliVersion()` reads the package's own package.json). Also reachable as the top-level `webjs --version` / `-v` flag, handled at the top of `main()` before the Node preflight so it works on an old Node. Tests: `test/cli/help.test.mjs` | | `webjs help [command]` | Bare: the full USAGE banner. `webjs help ` prints that command's usage line, a one-line summary, an **Options** table (each flag + a universal `-h, --help` row, matching the Remix CLI's per-command Options section), and an Examples block from the `HELP` map in `bin/webjs.js` (#975), so an agent reads the exact invocation instead of guessing flags. An unknown help topic prints an error + the banner and **exits 1** (`printCommandHelp` returns false). The `--help` / `-h` FLAG forms are equivalent and handled at the top of `main()` (before the Node preflight, so they work on an old Node): `webjs --help` / `-h` prints the banner; `webjs --help` / `-h` prints that command's help and short-circuits the body. Commands that forward args to an external CLI (`HELP_FLAG_PASSTHROUGH` = `typecheck` to tsc, `db` to drizzle-kit, `ui` to `@webjsdev/ui`) are excluded so the wrapped tool's own `--help` reaches it; an unrecognised command is not intercepted either, so it hits the Unknown-command error (exit 1). Tests: `test/cli/help.test.mjs` | +**Per-check severity is CONFIG, not a flag (#1257), which is what lets CI gate doctor.** `--strict` is unusable in CI on its own, because four checks are environment-shaped and fatal under it (`GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, `FRAMEWORK_RESOLVE` is environment-dependent). So an app declares its policy in `package.json` under `"webjs": { "doctor": { "gate": { "": "off" | "warn" | "error" } } }`, the same three-level scale ESLint uses and `eslint-plugin-next` ships as a rule-id-keyed map. `readDoctorPolicy(appDir)` reads it (PURE, returns `{ gate, unknownCodes, badSeverities }`) and `applyDoctorPolicy(results, gate)` folds it over the results (PURE, returns a new array), both in `lib/doctor.js`; the CHECKS stay policy-unaware and the bin composes the two. Four rules make it safe. **A code with no entry keeps its default** (`error` for a hard fail, `warn` otherwise), so an app with no `webjs.doctor` block produces byte-identical counts and the `failing = fail > 0 || (strict && warn > 0)` formula is untouched. **`severity` on a result is the EFFECTIVE level, not the declared one**, so a PASSING check reports `pass` even when its code is gated `error` and `results.some((r) => r.severity === 'error')` has no false positive. **A `bestEffort` result is CAPPED at `warn`**, so the four could-not-check branches (the two vendor-pin ones, the two importmap-coherence ones) can never be escalated and a jspm or npm outage cannot red the required job. **An unknown code or severity exits 1 naming it, without running the checks**, because a silently-ignored typo would leave CI un-gated while looking gated. `off` is uniform and silences any code, the two hard-fail checks included, matching ESLint, where any rule can be turned off. The repo's own `conventions` CI job runs doctor over the four in-repo apps, and `website` + `examples/blog` gate `UNMARKED_ASSET_LINKS` to `error`; the scaffold emits the same gate and its `ci.yml` runs `npm run doctor`. The config key rides the three-surface lockstep in `packages/server/AGENTS.md` (JSON Schema + `WebjsConfig` type + the `KNOWN_KEYS` drift test), like `dev` / `start`. + ## UI subcommand: proxies to `@webjsdev/ui` `@webjsdev/ui` is a **hard dependency** of `@webjsdev/cli` (listed in diff --git a/packages/cli/README.md b/packages/cli/README.md index 99d5b81ad..d9671e8b9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -46,7 +46,7 @@ webjs create --template api # backend-only API app (routes + modules + webjs dev # dev server with live reload (runs webjs.dev.before, e.g. webjs db migrate, then serves; npm run dev is a thin alias) webjs start # production server (no build step, serves source directly) webjs check # validate source-code conventions (CI gate) -webjs doctor # verify the project/toolchain setup (local onboarding, not CI) +webjs doctor # verify the project/toolchain setup (per-check severity via webjs.doctor.gate, so CI can gate a subset) webjs test # run server + browser tests webjs vendor pin [--download] # pin client deps to a committable importmap (offline/reproducible) webjs db # drizzle-kit passthrough (+ seed) diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index a5d451de7..eb341885f 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -58,7 +58,9 @@ const USAGE = `webjs commands: webjs routes [--json|--table] [--no-headers] Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape; --no-headers drops the --table header webjs mcp Start the read-only MCP server (routes / actions / components / check) webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision, un-versioned stylesheet links). - --json emits the structured results (with stable codes). --strict also fails the exit on warnings + --json emits the structured results (with stable codes). --strict also fails the exit on warnings. + Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } } + in package.json, so CI gates on a chosen subset without every warning becoming fatal webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params) webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors) webjs create [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app @@ -146,9 +148,16 @@ const HELP = { usage: 'webjs doctor [--json] [--strict]', summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.', options: [ - { flag: '--json', description: 'Emit the DoctorResult[] (with stable codes) + a summary as JSON.' }, + { flag: '--json', description: 'Emit the DoctorResult[] (with stable codes + severities) + a summary as JSON.' }, { flag: '--strict', description: 'Also fail the exit on warnings, not just hard failures.' }, ], + notes: [ + 'Per-check severity is CONFIG, not a flag. Declare it in package.json under', + '"webjs": { "doctor": { "gate": { "": "off" | "warn" | "error" } } }, so CI', + 'gates on a chosen subset without --strict making every warning fatal. An unknown', + 'code or severity exits 1 naming it. A "could not check" result (a network or', + 'toolchain outage) is capped at warn and can never be escalated to error.', + ], examples: ['webjs doctor', 'webjs doctor --json', 'webjs doctor --strict', 'webjs doctor --json --strict'], }, types: { @@ -243,6 +252,12 @@ function printCommandHelp(name) { const width = Math.max(...options.map((o) => o.flag.length)); console.log('Options:'); for (const o of options) console.log(` ${o.flag.padEnd(width)} ${o.description}`); + // Optional per-command prose for surface a flag table cannot carry (doctor's + // package.json severity gate is the one that needs it). + if (h.notes) { + console.log('\nConfig:'); + for (const line of h.notes) console.log(` ${line}`); + } console.log('\nExamples:'); for (const ex of h.examples) console.log(` ${ex}`); return true; @@ -679,51 +694,95 @@ async function main() { } case 'doctor': { // Project-health checklist (#266). The checks are PURE (in lib/doctor.js); - // this branch only renders them and owns the exit code: non-zero iff any - // HARD check FAILS, so CI can gate on it. Warns are informational and do - // NOT fail the exit (env drift / pin staleness / version drift are the - // app's concern, not a broken toolchain). - const { runDoctorChecks } = await import('../lib/doctor.js'); - const results = await runDoctorChecks(process.cwd()); + // this branch only renders them and owns the exit code. By default the + // exit is non-zero iff a HARD check FAILS; warns are informational (env + // drift / pin staleness / version drift are the app's concern, not a + // broken toolchain). On top of that, an app declares per-check severity + // in its package.json `webjs.doctor.gate` (#1257), which is what lets CI + // gate on a chosen subset without `--strict` making every warning fatal. + const { runDoctorChecks, readDoctorPolicy, applyDoctorPolicy, DOCTOR_CODES, DOCTOR_SEVERITIES } = + await import('../lib/doctor.js'); + const appDir = process.cwd(); + const strict = rest.includes('--strict'); + const asJson = rest.includes('--json'); + + // Read the policy FIRST. A key that is not a known code, or a value that + // is not a severity, exits 1 without running the checks: a typo silently + // ignored would leave CI un-gated while looking gated, which is the worst + // failure a mechanism like this can have. + const policy = readDoctorPolicy(appDir); + const configErrors = [ + ...policy.unknownCodes.map((code) => ({ kind: 'unknown-code', code })), + ...policy.badSeverities.map(({ code, value }) => ({ kind: 'bad-severity', code, value })), + ]; + if (configErrors.length > 0) { + if (asJson) { + console.log(JSON.stringify({ + results: [], + summary: { pass: 0, warn: 0, fail: 0, off: 0, strict, ok: false }, + configErrors, + })); + process.exit(1); + } + console.error('webjs doctor: invalid "webjs.doctor.gate" in package.json\n'); + for (const e of configErrors) { + if (e.kind === 'unknown-code') console.error(` Unknown check code: ${e.code}`); + else console.error(` Invalid severity for ${e.code}: ${JSON.stringify(e.value)}`); + } + console.error(`\n Valid severities: ${DOCTOR_SEVERITIES.join(' / ')}`); + console.error(` Valid codes: ${Object.values(DOCTOR_CODES).join(', ')}`); + process.exit(1); + } + + const results = applyDoctorPolicy(await runDoctorChecks(appDir), policy.gate); + // Counts come off the EFFECTIVE severity, not the raw status, so a gated + // code lands in the bucket the app asked for. With no gate the two are + // identical (a `fail` defaults to `error`, a `warn` to `warn`), which is + // what keeps an un-configured app byte-identical to before. const counts = results.reduce((acc, r) => { - acc[r.status] = (acc[r.status] || 0) + 1; + acc[r.severity] = (acc[r.severity] || 0) + 1; return acc; }, /** @type {Record} */ ({})); const pass = counts.pass || 0; const warn = counts.warn || 0; - const fail = counts.fail || 0; + const fail = counts.error || 0; + const off = counts.off || 0; // `--strict` also fails the exit on warnings, so an agent can gate on a // fully-clean toolchain (drift / staleness / pin freshness) in a fix loop, // not just on a hard toolchain break. Default keeps warnings non-fatal. - const strict = rest.includes('--strict'); const failing = fail > 0 || (strict && warn > 0); - // --json emits the raw DoctorResult[] (each carries a stable `code`) plus - // a summary, so an agent consumes structured data instead of scraping the - // text. Shape mirrors `check --json`: a top-level array-bearing object - // with a `summary` count. The non-zero exit is preserved (an agent gates - // on the exit code AND parses the report). - if (rest.includes('--json')) { + // --json emits the raw DoctorResult[] (each carries a stable `code` and + // its effective `severity`) plus a summary, so an agent consumes + // structured data instead of scraping the text. Shape mirrors `check + // --json`: a top-level array-bearing object with a `summary` count. The + // non-zero exit is preserved (an agent gates on the exit code AND parses + // the report). + if (asJson) { console.log(JSON.stringify({ results, - summary: { pass, warn, fail, strict, ok: !failing }, + summary: { pass, warn, fail, off, strict, ok: !failing }, })); if (failing) process.exit(1); break; } - const marker = { pass: '[pass]', warn: '[warn]', fail: '[fail]' }; + const marker = { pass: '[pass]', off: '[off]', warn: '[warn]', error: '[fail]' }; console.log('webjs doctor: project-health checklist\n'); for (const r of results) { - console.log(` ${marker[r.status]} ${r.name} (${r.code})`); + // Name the gate whenever it moved a result off its default, so the + // reason a warning is fatal (or silenced) is on the line itself. + const dflt = r.status === 'fail' ? 'error' : 'warn'; + const gated = r.status !== 'pass' && r.severity !== dflt ? `, gated: ${r.severity}` : ''; + console.log(` ${marker[r.severity]} ${r.name} (${r.code}${gated})`); console.log(` ${r.message}`); if (r.fix && r.status !== 'pass') console.log(` Fix: ${r.fix}`); console.log(); } - console.log(` ${pass} passed, ${warn} warning(s), ${fail} failed.`); + console.log(` ${pass} passed, ${warn} warning(s), ${fail} failed${off > 0 ? `, ${off} silenced` : ''}.`); if (failing) { const reason = fail > 0 - ? `${fail} hard check(s) failed. Fix the toolchain issue(s) above.` + ? `${fail} check(s) failed. Fix the issue(s) above, or adjust "webjs.doctor.gate" in package.json.` : `${warn} warning(s) found and --strict was set.`; console.error(`\nwebjs doctor: ${reason}`); process.exit(1); diff --git a/packages/cli/lib/doctor.js b/packages/cli/lib/doctor.js index 6b2dd8bd4..eb5e46ddf 100644 --- a/packages/cli/lib/doctor.js +++ b/packages/cli/lib/doctor.js @@ -31,23 +31,53 @@ * missing/non-executable git hook. * - 'pass' is the green path. * - * Every NETWORK touch (only the vendor-pin freshness check) is BEST-EFFORT: a - * fetch failure is a WARN ("could not check, network"), never a hard fail and - * never a throw that crashes the command. Network is flaky, and a doctor that - * fails CI because npm was briefly unreachable is worse than useless. + * Every NETWORK touch (the vendor-pin freshness check, plus the live resolve in + * the importmap-coherence check) is BEST-EFFORT: a fetch failure is a WARN + * ("could not check, network"), never a hard fail and never a throw that + * crashes the command. Network is flaky, and a doctor that fails CI because npm + * was briefly unreachable is worse than useless. A result that reports "could + * not check" rather than a real finding carries `bestEffort: true`, and that + * flag is what the severity gate below reads to CLAMP it: an app may declare a + * code fatal, but an outage still cannot red its CI. + * + * SEVERITY POLICY (#1257) is CONFIG, not a flag, and lives one layer up. The + * checks below stay policy-unaware; `readDoctorPolicy(appDir)` reads the app's + * `webjs.doctor.gate` map out of package.json and `applyDoctorPolicy` folds it + * over the results, attaching the EFFECTIVE severity each one contributes. So a + * project declares which health signals it treats as fatal in ONE place that + * travels with the repo, and its CI workflow, its `npm run doctor`, and an + * agent's `--json` loop all read that one policy. `--strict` stays what it is: + * the blunt "every warning is fatal" switch, layered on top. */ -import { existsSync, statSync, readdirSync } from 'node:fs'; +import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { createRequire } from 'node:module'; import { checkNodeInline } from './node-preflight.js'; /** + * `status` is what the CHECK found and never depends on config. `severity` is + * the EFFECTIVE level the result contributes after the app's gate is applied, + * attached by `applyDoctorPolicy` (the checks never set it). `bestEffort` marks + * a result that reports "could not check" rather than a real finding, which is + * the one thing a gate can never escalate. * @typedef {'pass' | 'warn' | 'fail'} DoctorStatus - * @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string }} DoctorResult + * @typedef {'off' | 'warn' | 'error'} DoctorSeverity a level a gate entry may DECLARE + * @typedef {'pass' | DoctorSeverity} DoctorLevel the EFFECTIVE level of a result + * @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string, bestEffort?: boolean, severity?: DoctorLevel }} DoctorResult */ +/** + * The severity levels a `webjs.doctor.gate` entry may name, mirroring ESLint's + * three-level scale (its `off` / `warn` / `error`, which Next.js's + * `eslint-plugin-next` uses verbatim as a rule-id-keyed map). `off` is uniform: + * it silences ANY code, the two hard-fail checks included, exactly as ESLint + * lets any rule be turned off. + * @type {DoctorSeverity[]} + */ +export const DOCTOR_SEVERITIES = ['off', 'warn', 'error']; + /** * Stable machine-readable code per check (#975), so an agent consuming * `webjs doctor --json` branches on the failure KIND, not the human message @@ -88,6 +118,100 @@ export function codeForName(name) { return DOCTOR_CODES[name] || name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, ''); } +/** + * @typedef {{ gate: Record, unknownCodes: string[], badSeverities: Array<{ code: string, value: unknown }> }} DoctorPolicy + */ + +/** + * Read the app's per-check severity policy out of `package.json` + * `webjs.doctor.gate` (#1257). PURE: it reads one file and returns data, and + * the caller (the CLI) decides what to do about a problem. + * + * `gate` keeps only WELL-FORMED entries, so a caller can fold it over the + * results without re-validating. Everything rejected is reported separately: + * a key that is not a value of `DOCTOR_CODES` lands in `unknownCodes`, a value + * outside `DOCTOR_SEVERITIES` in `badSeverities`. Both are surfaced as a hard + * error by the CLI rather than skipped, because silently ignoring a typo in a + * map whose whole job is to make a check fatal would leave CI un-gated while + * looking gated, which is the worst outcome this mechanism has. + * + * A missing package.json, a missing block, or unparseable JSON is an EMPTY + * policy with no problems: an app that declares nothing behaves exactly as it + * did before the gate existed. Unparseable JSON in particular is deliberately + * not an error here, since `checkWebjsVersions` already reports that condition + * and doctor must never crash on a broken app file. + * + * @param {string} appDir + * @returns {DoctorPolicy} + */ +export function readDoctorPolicy(appDir) { + /** @type {DoctorPolicy} */ + const empty = { gate: {}, unknownCodes: [], badSeverities: [] }; + let raw; + try { + raw = readFileSync(join(appDir, 'package.json'), 'utf8'); + } catch { + return empty; + } + let pkg; + try { + pkg = JSON.parse(raw); + } catch { + return empty; + } + const declared = pkg?.webjs?.doctor?.gate; + if (!declared || typeof declared !== 'object' || Array.isArray(declared)) return empty; + + const known = new Set(Object.values(DOCTOR_CODES)); + /** @type {DoctorPolicy} */ + const policy = { gate: {}, unknownCodes: [], badSeverities: [] }; + for (const [code, value] of Object.entries(declared)) { + if (!known.has(code)) { + policy.unknownCodes.push(code); + continue; + } + if (typeof value !== 'string' || !DOCTOR_SEVERITIES.includes(/** @type {DoctorSeverity} */ (value))) { + policy.badSeverities.push({ code, value }); + continue; + } + policy.gate[code] = /** @type {DoctorSeverity} */ (value); + } + return policy; +} + +/** + * Fold a severity `gate` over check results, returning a NEW array whose + * results each carry the EFFECTIVE level they contribute (#1257). PURE: the + * input array and its results are never mutated. + * + * `severity` is the effective level, not the declared one, which is why a + * PASSING check reports `'pass'` even when its code is gated `error`. A rule + * that did not fire contributes nothing, the same way ESLint puts severity on a + * message rather than on a rule that stayed quiet. It also keeps the obvious + * one-liner honest: `results.some((r) => r.severity === 'error')` is exactly + * "something fatal was found", with no passing-check false positive. + * + * The gate's one hard limit is `bestEffort`: a result that could not check + * (a toolchain that would not load, a network that was unreachable) is CLAMPED + * to `warn` however loudly the gate declares its code. That is what lets this + * repo's required CI job run a check whose live resolve touches jspm without + * an outage there ever redding an unrelated pull request. + * + * @param {DoctorResult[]} results + * @param {Record} [gate] well-formed entries only (see readDoctorPolicy) + * @returns {DoctorResult[]} + */ +export function applyDoctorPolicy(results, gate = {}) { + return results.map((r) => { + if (r.status === 'pass') return { ...r, severity: /** @type {DoctorLevel} */ ('pass') }; + const declared = gate[r.code]; + const fallback = r.status === 'fail' ? 'error' : 'warn'; + let severity = /** @type {DoctorSeverity} */ (declared || fallback); + if (r.bestEffort && severity === 'error') severity = 'warn'; + return { ...r, severity }; + }); +} + /** * Read the CLI package's own `engines.node` so the required Node major lives in * one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`. @@ -323,6 +447,8 @@ async function checkVendorPin(appDir, opts) { return { name: 'vendor-pin', status: 'warn', + // "Could not check", not a finding: never escalatable by a gate. + bestEffort: true, message: 'Could not load the vendor toolchain to check pin freshness.', fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.', }; @@ -350,6 +476,7 @@ async function checkVendorPin(appDir, opts) { return { name: 'vendor-pin', status: 'warn', + bestEffort: true, message: 'Could not check pin freshness (network unreachable or registry error).', fix: 'Re-run `webjs doctor` when connectivity is back, or run `webjs vendor outdated`.', }; @@ -579,6 +706,7 @@ async function checkImportmapCoherence(appDir, opts) { return { name: 'importmap-coherence', status: 'warn', + bestEffort: true, message: 'Could not load the vendor toolchain to check importmap coherence.', fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.', }; @@ -672,6 +800,7 @@ async function checkImportmapCoherence(appDir, opts) { return { name: 'importmap-coherence', status: 'warn', + bestEffort: true, message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).', fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.', }; diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 4cdd49c96..5a3d79445 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -52,6 +52,8 @@ export type { WebjsRedirectRule, WebjsTrailingSlash, WebjsCspConfig, + WebjsDoctorConfig, + WebjsDoctorSeverity, } from './src/webjs-config.d.ts'; // Compile-time serializability typing for server actions (#488): the opt-in diff --git a/packages/core/src/webjs-config.d.ts b/packages/core/src/webjs-config.d.ts index cfae96f12..5fd2f7202 100644 --- a/packages/core/src/webjs-config.d.ts +++ b/packages/core/src/webjs-config.d.ts @@ -136,6 +136,30 @@ export interface WebjsStartTasks { before?: string[]; } +/** + * A severity a `webjs.doctor.gate` entry may declare, mirroring ESLint's + * three-level scale. `error` fails the `webjs doctor` exit, `warn` reports + * without failing, and `off` silences the check entirely (including under + * `--strict`). + */ +export type WebjsDoctorSeverity = 'off' | 'warn' | 'error'; + +/** The object form of `webjs.doctor` (#1257). */ +export interface WebjsDoctorConfig { + /** + * Per-check severity, keyed by the stable doctor code (`NODE_VERSION`, + * `UNMARKED_ASSET_LINKS`, `ELISION_CARRIERS`, and so on; `webjs doctor --json` + * carries the code on every result). A code with no entry keeps its default: + * `error` for a hard toolchain failure, `warn` otherwise. This is how CI gates + * on a chosen subset without `--strict` making every warning fatal. + * + * An unknown code or severity is a hard error, so a typo cannot silently + * un-gate CI. A result that could not check (a network or toolchain outage) is + * capped at `warn` and can never be escalated to `error`. + */ + gate?: Record; +} + /** The object form of `webjs.csp` (the non-boolean shape). */ export interface WebjsCspConfig { /** @@ -191,6 +215,13 @@ export interface WebjsConfig { dev?: WebjsDevTasks; start?: WebjsStartTasks; + /** + * `webjs doctor` policy (#1257): which project-health checks the project + * treats as fatal. Read by the CLI (`packages/cli/lib/doctor.js`), NOT the + * server readers. + */ + doctor?: WebjsDoctorConfig; + /** Per-path response-header rules, shaped like Next's. */ headers?: WebjsHeaderRule[]; diff --git a/packages/server/test/config/webjs-config-schema.test.js b/packages/server/test/config/webjs-config-schema.test.js index a434729ab..aba7d4942 100644 --- a/packages/server/test/config/webjs-config-schema.test.js +++ b/packages/server/test/config/webjs-config-schema.test.js @@ -54,6 +54,7 @@ const KNOWN_KEYS = [ 'keepAliveTimeoutMs', // computeServerTimeouts (body-limit.js) 'dev', // readAppTasks (cli/lib/app-tasks.js), CLI-read not server (#550) 'start', // readAppTasks (cli/lib/app-tasks.js), CLI-read not server (#550) + 'doctor', // readDoctorPolicy (cli/lib/doctor.js), CLI-read not server (#1257) ]; test('schema file is valid JSON and parses', () => { diff --git a/packages/server/webjs-config.schema.json b/packages/server/webjs-config.schema.json index e7ee9ac17..6c317cba4 100644 --- a/packages/server/webjs-config.schema.json +++ b/packages/server/webjs-config.schema.json @@ -216,6 +216,22 @@ "items": { "type": "string" } } } + }, + "doctor": { + "description": "`webjs doctor` policy (#1257). Read by the CLI (readDoctorPolicy in packages/cli/lib/doctor.js), NOT the server.", + "type": "object", + "additionalProperties": false, + "properties": { + "gate": { + "description": "Per-check severity, keyed by the stable DOCTOR_CODES code (NODE_VERSION, UNMARKED_ASSET_LINKS, ELISION_CARRIERS, ...). `error` fails the exit, `warn` reports without failing, `off` silences the check entirely (including under --strict). A code with no entry keeps its default: error for a hard toolchain failure, warn otherwise. This is how CI gates on a chosen subset without --strict making every warning fatal. An unknown code or severity is a hard error, so a typo cannot silently un-gate CI. A result that could not check (a network or toolchain outage) is capped at warn and can never be escalated to error.", + "type": "object", + "propertyNames": { "pattern": "^[A-Z][A-Z0-9_]*$" }, + "additionalProperties": { + "type": "string", + "enum": ["off", "warn", "error"] + } + } + } } } } diff --git a/test/cli/doctor.test.mjs b/test/cli/doctor.test.mjs index fba58bede..cf9fcaf0b 100644 --- a/test/cli/doctor.test.mjs +++ b/test/cli/doctor.test.mjs @@ -287,6 +287,9 @@ test('vendor-pin WARNS (never fails) when the freshness check throws (network)', const pin = byName(results, 'vendor-pin'); assert.equal(pin.status, 'warn', 'a network failure must be a warn, never a fail'); assert.match(pin.message, /network|registry/i); + // And it is flagged best-effort, which is what stops a gate from escalating + // it (#1257). Without this the required CI job would red on an npm outage. + assert.equal(pin.bestEffort, true, 'a could-not-check result is bestEffort'); // And critically, it did not throw out of runDoctorChecks. }); @@ -397,6 +400,20 @@ test('coherence degrades to could-not-verify when metadata is unavailable (no cr assert.equal(c.status, 'warn'); assert.match(c.message, /[Cc]ould not verify/); assert.doesNotMatch(c.message, /Incoherent/, 'missing metadata must not be reported as a conflict'); + // Best-effort, so a jspm outage cannot be escalated to a fatal by a gate + // (#1257). This is what makes it safe to run doctor in the required job. + assert.equal(c.bestEffort, true, 'a could-not-verify result is bestEffort'); +}); + +test('a REAL coherence conflict is NOT bestEffort (it is a finding, so it is gateable)', async () => { + const dir = tmpDir(); + const coherence = await coherenceInjection({ + live: CM_LIVE, vendored: CM_VENDORED, getManifest: CM_SKEW_MANIFEST, + }); + const results = await runDoctorChecks(dir, baseOpts({ nodeVersion: '24.0.0', coherence })); + const c = byName(results, 'importmap-coherence'); + assert.equal(c.status, 'warn'); + assert.ok(!c.bestEffort, 'a real conflict is a finding, not a could-not-check'); }); test('coherence never throws out of runDoctorChecks even if the check itself throws', async () => { @@ -637,6 +654,210 @@ test('--strict with --json reports ok:false and exits 1 on a warning', () => { assert.equal(out.summary.fail, 0, 'and it was a warn, not a hard fail'); }); +// --------------------------------------------------------------------------- +// Per-check severity gate (#1257): `webjs.doctor.gate` in package.json. +// --------------------------------------------------------------------------- +const { readDoctorPolicy, applyDoctorPolicy, DOCTOR_SEVERITIES } = await import( + resolve(CLI_LIB_DIR, 'doctor.js') +); + +/** Shorthand for a fake result, so the policy tests stay readable. */ +function res(code, status, extra = {}) { + return { name: code.toLowerCase(), code, status, message: '', ...extra }; +} + +test('readDoctorPolicy returns an empty policy when the app declares nothing', () => { + const missingPkg = tmpDir(); + assert.deepEqual(readDoctorPolicy(missingPkg), { gate: {}, unknownCodes: [], badSeverities: [] }); + + const noBlock = tmpDir(); + write(noBlock, 'package.json', JSON.stringify({ name: 'x', webjs: { dev: { before: [] } } })); + assert.deepEqual(readDoctorPolicy(noBlock).gate, {}); + + // Unparseable JSON is NOT a policy error: checkWebjsVersions already reports + // that condition, and doctor must never crash on a broken app file. + const broken = tmpDir(); + write(broken, 'package.json', '{ not json'); + assert.deepEqual(readDoctorPolicy(broken), { gate: {}, unknownCodes: [], badSeverities: [] }); +}); + +test('readDoctorPolicy keeps well-formed entries and reports the rest separately', () => { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ + name: 'x', + webjs: { + doctor: { + gate: { + UNMARKED_ASSET_LINKS: 'error', + ELISION_CARRIERS: 'off', + NOT_A_REAL_CODE: 'error', + ENV_DRIFT: 'fatal', + NODE_VERSION: 3, + }, + }, + }, + })); + const p = readDoctorPolicy(dir); + assert.deepEqual(p.gate, { UNMARKED_ASSET_LINKS: 'error', ELISION_CARRIERS: 'off' }); + assert.deepEqual(p.unknownCodes, ['NOT_A_REAL_CODE']); + assert.deepEqual( + p.badSeverities.map((b) => b.code).sort(), + ['ENV_DRIFT', 'NODE_VERSION'], + 'a non-severity string and a non-string both land in badSeverities', + ); + // DOCTOR_SEVERITIES is the vocabulary the reader validates against. + assert.deepEqual(DOCTOR_SEVERITIES, ['off', 'warn', 'error']); +}); + +test('applyDoctorPolicy defaults severity from status when nothing is gated', () => { + const out = applyDoctorPolicy([ + res('NODE_VERSION', 'fail'), + res('ENV_DRIFT', 'warn'), + res('GIT_HOOK', 'pass'), + ]); + assert.deepEqual(out.map((r) => r.severity), ['error', 'warn', 'pass']); +}); + +test('applyDoctorPolicy honours a gate entry in BOTH directions', () => { + const out = applyDoctorPolicy( + [res('ENV_DRIFT', 'warn'), res('NODE_VERSION', 'fail'), res('GIT_HOOK', 'warn')], + { ENV_DRIFT: 'error', NODE_VERSION: 'off', GIT_HOOK: 'off' }, + ); + assert.deepEqual(out.map((r) => r.severity), ['error', 'off', 'off']); +}); + +test('applyDoctorPolicy reports a PASSING check as pass even when its code is gated error', () => { + // The severity is the EFFECTIVE level, not the declared one, so the obvious + // `results.some(r => r.severity === 'error')` has no false positive. + const [r] = applyDoctorPolicy([res('UNMARKED_ASSET_LINKS', 'pass')], { UNMARKED_ASSET_LINKS: 'error' }); + assert.equal(r.severity, 'pass'); +}); + +test('applyDoctorPolicy CLAMPS a bestEffort result to warn under an error gate', () => { + const [clamped] = applyDoctorPolicy( + [res('VENDOR_PIN', 'warn', { bestEffort: true })], + { VENDOR_PIN: 'error' }, + ); + assert.equal(clamped.severity, 'warn', 'a could-not-check result can never be escalated'); + + // But `off` still applies: silencing is not an escalation. + const [silenced] = applyDoctorPolicy( + [res('VENDOR_PIN', 'warn', { bestEffort: true })], + { VENDOR_PIN: 'off' }, + ); + assert.equal(silenced.severity, 'off'); +}); + +test('applyDoctorPolicy never mutates its input', () => { + const input = [res('ENV_DRIFT', 'warn')]; + const out = applyDoctorPolicy(input, { ENV_DRIFT: 'error' }); + assert.equal(input[0].severity, undefined, 'the caller\'s results are untouched'); + assert.notEqual(out[0], input[0], 'each result is a fresh object'); +}); + +/** + * A fixture whose ONLY non-pass finding is the unmarked stylesheet link, so a + * gate on UNMARKED_ASSET_LINKS is the sole thing that can flip the exit. `.env` + * matching `.env.example` keeps env-drift quiet, and the tsconfig flag keeps the + * hard check green. + */ +function assetLinkFixture(gate) { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ + name: 'x', + ...(gate ? { webjs: { doctor: { gate } } } : {}), + })); + write(dir, 'tsconfig.json', JSON.stringify({ compilerOptions: { erasableSyntaxOnly: true } })); + write(dir, 'app/layout.ts', [ + "import { html } from '@webjsdev/core';", + 'export default function Layout({ children }) {', + ' return html`${children}`;', + '}', + ].join('\n')); + return dir; +} + +// The counterfactual PAIR, mirroring the --strict pair above: the SAME fixture +// exits 0 ungated and 1 gated, which is what proves the gate itself flips the +// exit rather than some unrelated hard fail. +test('a gated warning fails the exit; the same warning ungated does not', () => { + const ungated = runCliArgs(assetLinkFixture(null), []); + assert.equal(ungated.status, 0, `ungated, a warn does NOT fail: got ${ungated.status}\n${ungated.stdout}`); + assert.match(ungated.stdout, /\[warn\] .*UNMARKED_ASSET_LINKS/); + + const gated = runCliArgs(assetLinkFixture({ UNMARKED_ASSET_LINKS: 'error' }), []); + assert.equal(gated.status, 1, 'gated to error, the same warn fails the exit'); + assert.match(gated.stdout, /\[fail\] .*\(UNMARKED_ASSET_LINKS, gated: error\)/); + assert.match(gated.stderr, /webjs\.doctor\.gate/); +}); + +test('a gated `off` silences a warning even under --strict', () => { + // A tmp fixture also warns on the two environment-shaped checks (it has no + // node_modules), so silence those too and `--strict` has nothing left to fail + // on. That is exactly the CI shape this feature exists for. + const env = { FRAMEWORK_RESOLVE: 'off', WEBJS_VERSIONS: 'off' }; + const strict = runCliArgs(assetLinkFixture({ ...env, UNMARKED_ASSET_LINKS: 'off' }), ['--strict']); + assert.equal(strict.status, 0, `every warn silenced, so --strict passes\n${strict.stdout}\n${strict.stderr}`); + assert.match(strict.stdout, /\[off\] .*\(UNMARKED_ASSET_LINKS, gated: off\)/); + assert.match(strict.stdout, /3 silenced/); + + // The counterfactual: leave the asset-link warn ungated and --strict fails on + // it, so `off` is what silenced it and not the other two entries. + const stillWarns = runCliArgs(assetLinkFixture(env), ['--strict']); + assert.equal(stillWarns.status, 1, 'the un-silenced warn still fails under --strict'); +}); + +test('an unknown gate code exits 1 naming it, WITHOUT running the checks', () => { + const dir = assetLinkFixture({ UNMARKD_ASSET_LINKS: 'error' }); + const r = runCliArgs(dir, []); + assert.equal(r.status, 1, 'a typo must be loud, never silently un-gating CI'); + assert.match(r.stderr, /Unknown check code: UNMARKD_ASSET_LINKS/); + assert.match(r.stderr, /Valid codes:.*UNMARKED_ASSET_LINKS/); + assert.doesNotMatch(r.stdout, /project-health checklist/, 'the checks did not run'); +}); + +test('a bad gate severity exits 1 naming it, and --json carries configErrors', () => { + const dir = assetLinkFixture({ ENV_DRIFT: 'fatal' }); + const plain = runCliArgs(dir, []); + assert.equal(plain.status, 1); + assert.match(plain.stderr, /Invalid severity for ENV_DRIFT: "fatal"/); + assert.match(plain.stderr, /Valid severities: off \/ warn \/ error/); + + const json = runCliArgs(dir, ['--json']); + assert.equal(json.status, 1); + const out = JSON.parse(json.stdout); + assert.deepEqual(out.results, [], 'no checks ran'); + assert.equal(out.summary.ok, false); + assert.deepEqual(out.configErrors, [{ kind: 'bad-severity', code: 'ENV_DRIFT', value: 'fatal' }]); +}); + +test('--json carries severity on every result plus off in the summary', () => { + const dir = assetLinkFixture({ UNMARKED_ASSET_LINKS: 'off' }); + const r = runCliArgs(dir, ['--json']); + assert.equal(r.status, 0); + const out = JSON.parse(r.stdout); + assert.ok(out.results.every((x) => x.severity), 'every result carries a severity'); + assert.ok( + out.results.every((x) => ['pass', 'off', 'warn', 'error'].includes(x.severity)), + 'severity is one of the four effective levels', + ); + assert.equal(out.results.find((x) => x.code === 'UNMARKED_ASSET_LINKS').severity, 'off'); + assert.equal(out.summary.off, 1); +}); + +// An app with NO gate block must produce exactly today's numbers, which is the +// whole back-compat promise of folding policy in at the summary layer. +test('an app with no gate block gets status-derived counts, unchanged', () => { + const dir = assetLinkFixture(null); + const out = JSON.parse(runCliArgs(dir, ['--json']).stdout); + const byStatus = { pass: 0, warn: 0, fail: 0 }; + for (const r of out.results) byStatus[r.status]++; + assert.equal(out.summary.pass, byStatus.pass); + assert.equal(out.summary.warn, byStatus.warn); + assert.equal(out.summary.fail, byStatus.fail); + assert.equal(out.summary.off, 0); +}); + // Regression: the doctor pin check imports hasVendorPin from @webjsdev/server on // the REAL (un-stubbed) path. If it is not re-exported, the check silently // reports "no pin file" for a pinned app and the freshness check is inert. The diff --git a/test/cli/help.test.mjs b/test/cli/help.test.mjs index 3e5583fd2..6ec79fd10 100644 --- a/test/cli/help.test.mjs +++ b/test/cli/help.test.mjs @@ -133,6 +133,19 @@ test('`webjs help doctor` documents --json and --strict', () => { assert.match(r.stdout, /webjs doctor --json/); }); +// Per-check severity is CONFIG, not a flag (#1257), so the usage line stays +// exactly as it was and the gate is documented in its own Config section. An +// agent reading only this help must be able to write a valid gate block. +test('`webjs help doctor` documents the package.json severity gate', () => { + const r = help('doctor'); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^Config:/m); + assert.match(r.stdout, /"webjs": \{ "doctor": \{ "gate"/); + assert.match(r.stdout, /"off" \| "warn" \| "error"/); + // The gate adds no flag, so the usage line is unchanged. + assert.match(r.stdout, /^Usage: webjs doctor \[--json\] \[--strict\]$/m); +}); + test('`webjs help ` exits non-zero with an error (Remix CLI parity)', () => { const r = help('bogus'); assert.equal(r.status, 1, 'an unknown help topic is an error, not a silent success'); diff --git a/test/types/webjs-config.test-d.ts b/test/types/webjs-config.test-d.ts index 32c287f21..caac2be0a 100644 --- a/test/types/webjs-config.test-d.ts +++ b/test/types/webjs-config.test-d.ts @@ -16,6 +16,8 @@ import type { WebjsRedirectRule, WebjsCspConfig, WebjsTrailingSlash, + WebjsDoctorConfig, + WebjsDoctorSeverity, } from '@webjsdev/core'; /* ------------- A fully-populated, valid config ------------- */ @@ -40,9 +42,22 @@ const full: WebjsConfig = { requestTimeoutMs: 30000, headersTimeoutMs: 20000, keepAliveTimeoutMs: 5000, + doctor: { gate: { UNMARKED_ASSET_LINKS: 'error', ELISION_CARRIERS: 'off', ENV_DRIFT: 'warn' } }, }; void full; +/* ------------- The doctor gate (#1257) ------------- */ + +const doctorConfig: WebjsDoctorConfig = { gate: { NODE_VERSION: 'off' } }; +void doctorConfig; + +const severity: WebjsDoctorSeverity = 'error'; +void severity; + +// An empty doctor block is valid: `gate` is optional. +const emptyDoctor: WebjsConfig = { doctor: {} }; +void emptyDoctor; + /* ------------- The minimal / boolean-csp forms ------------- */ const minimal: WebjsConfig = {}; @@ -105,3 +120,11 @@ void badBasePath; // @ts-expect-error a header value of true is rejected (only string, null, or false). const badHeaderValue: WebjsConfig = { headers: [{ source: '/a', headers: [{ key: 'X-Test', value: true }] }] }; void badHeaderValue; + +// @ts-expect-error a doctor gate severity is a fixed union; 'fatal' is not a member. +const badSeverity: WebjsConfig = { doctor: { gate: { NODE_VERSION: 'fatal' } } }; +void badSeverity; + +// @ts-expect-error the doctor block seals its keys; `rules` is not one. +const badDoctorKey: WebjsConfig = { doctor: { rules: {} } }; +void badDoctorKey; diff --git a/website/app/docs/configuration/page.ts b/website/app/docs/configuration/page.ts index 0a70ff81f..0698844a5 100644 --- a/website/app/docs/configuration/page.ts +++ b/website/app/docs/configuration/page.ts @@ -47,6 +47,18 @@ webjs routes --json # structured JSON (matches the MCP list_routes t webjs doctor # human-readable project-health checklist webjs doctor --json # structured results (each with a stable code) + a summary webjs doctor --strict # also fail the exit on warnings, not just hard failures +

Per-check severity is configuration, not a flag. Declare it in package.json under webjs.doctor.gate, keyed by the stable code every result carries, on the same three-level scale ESLint uses. That is what lets CI gate on a chosen subset without --strict making every warning fatal, which is unusable on a runner (the git-hook, env-drift, vendor-pin, and framework-resolve checks are all environment-shaped and would fail a perfectly healthy build).

+ { + "webjs": { + "doctor": { + "gate": { + "UNMARKED_ASSET_LINKS": "error", + "ELISION_CARRIERS": "off" + } + } + } +} +

error fails the exit, warn reports without failing, and off silences the check entirely, including under --strict. A code with no entry keeps its default (error for a hard toolchain failure, warn otherwise), so an app that declares nothing behaves exactly as it did before. Two guarantees make it safe to put in a required CI job: a result that could not check, such as a network or toolchain outage, is capped at warn and can never be escalated, and an unknown code or severity exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build.

Verifies project health: the Node version floor, erasableSyntaxOnly, .env drift, vendor-pin freshness, importmap coherence, @webjsdev/* version coherence, framework resolvability, the git hook, a page/layout elision advisory, and a warning when a route module writes a <link rel="stylesheet"> without asset() (so its url is un-versioned and a deploy cannot bust a cached copy). Each result carries a stable machine code (for example NODE_VERSION, TSCONFIG_ERASABLE, IMPORTMAP_COHERENCE) so an agent branches on the failure kind, not the message text. The --json payload is an object { results, summary } (the results array holds the per-check objects, each with its code). By default the exit is non-zero only on a hard toolchain failure; --strict also fails on warnings, so it can gate a fully-clean fix loop the way webjs check --json does.

webjs version

From 98116bc5034b755ebac93132db619ef5485a17b2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 22:56:43 +0530 Subject: [PATCH 02/12] feat: run the gated doctor in CI, here and in every scaffolded app Nothing ran `webjs doctor`, so the advisory that exists because an un-versioned stylesheet url shipped a visible regression on webjs.dev could not have caught that regression. Now the repo's own `conventions` job runs doctor over the same four in-repo apps as its `webjs check` loop, and the scaffold's CI template runs `npm run doctor`. Both `website` and `examples/blog` gate `UNMARKED_ASSET_LINKS` to error, and the scaffold emits the same gate. Every other code stays at its default warn, so the environment-shaped checks (git hook, env drift, vendor-pin freshness over the network, framework resolvability) cannot red a clean run. Measured before choosing: all four apps report zero fails today and pass `UNMARKED_ASSET_LINKS`, while `ELISION_CARRIERS`, `WEBJS_VERSIONS`, and `ENV_DRIFT` warn here, so gating any of those would have redded main. The step goes INSIDE the existing `conventions` job rather than a new one. Branch protection matches the required context by display name, so a new job would gate nothing and a rename would silently un-require the job. Refs #1257 --- .github/workflows/ci.yml | 14 ++++++++++++ examples/blog/package.json | 5 +++++ packages/cli/lib/create.js | 19 ++++++++++++---- .../cli/templates/.github/workflows/ci.yml | 11 ++++++++++ .../scaffold-template-validation.test.js | 22 +++++++++++++++++++ website/package.json | 5 +++++ 6 files changed, 72 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5cb50d5a..2f813eb3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,20 @@ jobs: ( cd "$app" && node "$GITHUB_WORKSPACE/packages/cli/bin/webjs.js" check ) echo "::endgroup::" done + # Project health on the same four apps (#1257). WHICH findings are fatal + # is each app's own call, declared in its package.json `webjs.doctor.gate` + # rather than here, so a local `webjs doctor` and this step agree. Today + # website + examples/blog gate UNMARKED_ASSET_LINKS to error; everything + # else stays a warn and cannot red this job. Deliberately NOT --strict: + # the git-hook, env-drift, vendor-pin, and framework-resolve checks are + # environment-shaped and would fail a perfectly healthy runner. + - name: webjs doctor (blog, website, docs host, ui host) + run: | + for app in examples/blog website docs packages/ui/packages/website; do + echo "::group::webjs doctor $app" + ( cd "$app" && node "$GITHUB_WORKSPACE/packages/cli/bin/webjs.js" doctor ) + echo "::endgroup::" + done - name: Framework runtime packages are buildless (no .ts source) run: | # Invariant: packages/{core,server,cli} and packages/editors/* are diff --git a/examples/blog/package.json b/examples/blog/package.json index fce674fd2..dfc5dec97 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -34,6 +34,11 @@ "webjs db migrate", "npm run css:build" ] + }, + "doctor": { + "gate": { + "UNMARKED_ASSET_LINKS": "error" + } } }, "dependencies": { diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index 8baafdc5c..2913f122b 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -398,10 +398,12 @@ export async function scaffoldApp(name, cwd, opts = {}) { 'test:browser': 'webjs test --browser', check: 'webjs check', typecheck: 'webjs typecheck', - // Onboarding/setup-verify: a contributor runs `npm run doctor` after - // cloning to assert the toolchain (Node floor, tsconfig flag, env drift, - // vendor pins, @webjsdev versions, git hook). Local tool, NOT a CI gate - // (its env-drift + network pin-freshness checks would make CI flaky). + // Project health: a contributor runs `npm run doctor` after cloning to + // assert the toolchain (Node floor, tsconfig flag, env drift, vendor + // pins, @webjsdev versions, git hook), and CI runs the same script. Which + // findings are FATAL comes from the `webjs.doctor.gate` block below, so + // the environment-shaped checks (env drift, pin freshness over the + // network, the git hook) stay warns and cannot make CI flaky. doctor: 'webjs doctor', 'db:generate': 'webjs db generate', 'db:migrate': 'webjs db migrate', @@ -491,6 +493,15 @@ export async function scaffoldApp(name, cwd, opts = {}) { }), }, start: { before: isApi ? ['webjs db migrate'] : ['webjs db migrate', cssBuildCmd] }, + // Which doctor findings are FATAL is the app's own call (#1257), declared + // here rather than in the CI workflow so `npm run doctor` locally and the + // workflow step agree about what fails. UNMARKED_ASSET_LINKS starts at + // error because an un-versioned /public url is a real deploy-staleness + // bug (it shipped a visible regression on webjs.dev) and the generated + // layout already writes asset(), so a fresh app is green on day one. + // Everything else keeps its default warn. Add a code with "off" to + // silence it, or "error" to make it fatal too. + doctor: { gate: { UNMARKED_ASSET_LINKS: 'error' } }, }, }, null, 2) + '\n'); diff --git a/packages/cli/templates/.github/workflows/ci.yml b/packages/cli/templates/.github/workflows/ci.yml index aa7633524..8f83512c0 100644 --- a/packages/cli/templates/.github/workflows/ci.yml +++ b/packages/cli/templates/.github/workflows/ci.yml @@ -34,6 +34,17 @@ jobs: cache: npm - run: npm ci - run: npm run check + # Project health, on top of the correctness checks. WHICH findings are + # fatal is your call, declared in package.json under + # "webjs": { "doctor": { "gate": { "": "off" | "warn" | "error" } } }, + # so this step and a local `npm run doctor` always agree. The scaffold + # starts with UNMARKED_ASSET_LINKS at error (an un-versioned /public url + # is a real deploy-staleness bug); everything else stays a warn and + # cannot fail this job. Widen or narrow the gate in package.json, not + # here. Deliberately not --strict: the git-hook, env-drift, vendor-pin, + # and framework-resolve checks are environment-shaped and would fail a + # perfectly healthy runner. + - run: npm run doctor unit: name: Unit + integration (node --test) diff --git a/test/scaffolds/scaffold-template-validation.test.js b/test/scaffolds/scaffold-template-validation.test.js index b36043150..4e6dcea56 100644 --- a/test/scaffolds/scaffold-template-validation.test.js +++ b/test/scaffolds/scaffold-template-validation.test.js @@ -124,6 +124,28 @@ test('a valid kebab name still scaffolds, and its title survives verbatim', asyn } }); +// The generators emit STRINGS, so a malformed doctor-gate block only shows in a +// freshly generated app. Both templates get the gate, and the generated CI +// workflow runs the script that reads it, which is the whole loop (#1257). +for (const template of ['full-stack', 'api']) { + test(`the ${template} scaffold gates UNMARKED_ASSET_LINKS and runs doctor in CI`, async () => { + const cwd = await tempCwd(); + const restoreLog = console.log; + console.log = () => {}; + try { + await scaffoldApp('my-app', cwd, { template, install: false }); + const pkg = JSON.parse(await readFile(join(cwd, 'my-app', 'package.json'), 'utf8')); + assert.equal(pkg.webjs.doctor.gate.UNMARKED_ASSET_LINKS, 'error'); + assert.equal(pkg.scripts.doctor, 'webjs doctor'); + const ci = await readFile(join(cwd, 'my-app', '.github', 'workflows', 'ci.yml'), 'utf8'); + assert.match(ci, /^\s+- run: npm run doctor$/m, 'the conventions job runs doctor'); + } finally { + console.log = restoreLog; + await rm(cwd, { recursive: true, force: true }); + } + }); +} + test('an uppercase name scaffolds a working app end to end', async () => { // The rule deliberately allows uppercase, and the claim that goes with it is // that a capital letter is safe as the DIRECTORY, in the package.json diff --git a/website/package.json b/website/package.json index 41ddcea5b..4caa6b038 100644 --- a/website/package.json +++ b/website/package.json @@ -59,6 +59,11 @@ "node scripts/copy-registry.mjs", "npm run css:build" ] + }, + "doctor": { + "gate": { + "UNMARKED_ASSET_LINKS": "error" + } } }, "dependencies": { From ce6ca83428113d0dea698eb8ff92bd5b51686f28 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 23:02:49 +0530 Subject: [PATCH 03/12] test: assert the doctor gate outcome, not a runtime-dependent count The `off` test hard-coded "3 silenced", which is the number of checks that happen to warn in a tmp fixture on Node. Under Bun the count is 2, because `FRAMEWORK_RESOLVE` resolves `@webjsdev/core` from a tmp dir there and passes. So the assertion redded the Bun matrix over a difference the test is not about. Assert the outcome instead: nothing left to warn about, and the summary reports some checks silenced. That is what "off silences a warn even under --strict" actually claims, and it holds on both runtimes. Refs #1257 --- test/cli/doctor.test.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/cli/doctor.test.mjs b/test/cli/doctor.test.mjs index cf9fcaf0b..6454a3b2b 100644 --- a/test/cli/doctor.test.mjs +++ b/test/cli/doctor.test.mjs @@ -792,14 +792,21 @@ test('a gated warning fails the exit; the same warning ungated does not', () => }); test('a gated `off` silences a warning even under --strict', () => { - // A tmp fixture also warns on the two environment-shaped checks (it has no + // A tmp fixture also warns on the environment-shaped checks (it has no // node_modules), so silence those too and `--strict` has nothing left to fail // on. That is exactly the CI shape this feature exists for. const env = { FRAMEWORK_RESOLVE: 'off', WEBJS_VERSIONS: 'off' }; const strict = runCliArgs(assetLinkFixture({ ...env, UNMARKED_ASSET_LINKS: 'off' }), ['--strict']); assert.equal(strict.status, 0, `every warn silenced, so --strict passes\n${strict.stdout}\n${strict.stderr}`); assert.match(strict.stdout, /\[off\] .*\(UNMARKED_ASSET_LINKS, gated: off\)/); - assert.match(strict.stdout, /3 silenced/); + // Assert the OUTCOME (nothing left to warn about, and the summary says some + // were silenced), not an exact silenced count. How many of the + // environment-shaped checks warn in the first place is runtime-dependent: + // FRAMEWORK_RESOLVE passes under Bun from a tmp dir and warns under Node, so + // a hard-coded count reds the Bun matrix on a change that has nothing to do + // with it. + assert.match(strict.stdout, /0 warning\(s\)/); + assert.match(strict.stdout, /\d+ silenced/); // The counterfactual: leave the asset-link warn ungated and --strict fails on // it, so `off` is what silenced it and not the other two entries. From 1f99ddda5a420494af8bac3cfd9455e1e5f74070 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 23:10:27 +0530 Subject: [PATCH 04/12] fix: make `off` silence the doctor finding, not just the exit Four doc surfaces said a gated `off` "silences the check entirely", and it did not: the renderer still printed the finding and its `Fix:` line, because that guard keys off `status`, which an off result still fails. Only the counts and the exit were suppressed. An app that turns a code off has asked not to hear about it, so printing the message and a remedy on every run is exactly the noise it just silenced. ESLint's `off` drops the message too, and the whole gate is modelled on that scale. So the render now prints the `[off]` line alone. The check stays on the checklist and in the summary's silenced count, and `--json` still carries the whole result, so a silenced check is never invisible to a person or to tooling. Also corrects two config-inventory surfaces the key was added to without their prose being updated. `packages/server/AGENTS.md` called the dev/start task keys "the one exception" for a CLI-read key in the `webjs` block, which `doctor` makes false, and that is the exact file `packages/cli/AGENTS.md` points the reader at. Root `AGENTS.md`'s config-block bullet enumerates every `webjs.*` key and did not list `doctor`, so an agent reading only that bullet could not learn it exists. Refs #1257 --- .agents/skills/webjs/references/built-ins.md | 2 +- AGENTS.md | 2 +- packages/cli/bin/webjs.js | 12 ++++++++++-- packages/core/src/webjs-config.d.ts | 6 ++++-- packages/server/AGENTS.md | 13 ++++++++----- packages/server/webjs-config.schema.json | 2 +- test/cli/doctor.test.mjs | 15 +++++++++++++++ website/app/docs/configuration/page.ts | 2 +- 8 files changed, 41 insertions(+), 13 deletions(-) diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index 29388b795..0e5abd334 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -223,7 +223,7 @@ An over-limit body responds `413` without buffering the whole payload. } } ``` -Three levels, the same scale ESLint uses: `error` fails the exit, `warn` reports without failing, `off` silences the check. A code with no entry keeps its default (`error` for a hard toolchain failure, `warn` otherwise), so an app that declares nothing behaves exactly as before. Read the codes off `webjs doctor --json`, where every result carries its `code` and its effective `severity`. +Three levels, the same scale ESLint uses: `error` fails the exit, `warn` reports without failing, `off` silences the check, meaning its finding is not printed and it cannot fail the exit (it still appears on the checklist as `[off]` and in the summary's silenced count, so a silenced check is never invisible, and `--json` still carries the whole result). A code with no entry keeps its default (`error` for a hard toolchain failure, `warn` otherwise), so an app that declares nothing behaves exactly as before. Read the codes off `webjs doctor --json`, where every result carries its `code` and its effective `severity`. Two guarantees worth knowing. A result that could not check (a network or toolchain outage) is capped at `warn` and can never be escalated, so a jspm or npm outage cannot red your CI. And an unknown code or severity exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. Wire it up with one workflow step, `npm run doctor`, and change what is fatal in `package.json` rather than in the workflow. diff --git a/AGENTS.md b/AGENTS.md index f42d847aa..ce0f96d0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -524,7 +524,7 @@ webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importma ## Environment, server config, caching, observability - **Env vars.** `process.env.X` reads are server-only; `WEBJS_PUBLIC_`-prefixed names are exposed in the browser via an inline `