@webjsdev/cli 0.10.54 → 0.10.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/lib/api-gallery.js +25 -1
  2. package/lib/create.js +35 -0
  3. package/lib/doctor/codes.js +66 -0
  4. package/lib/doctor/manifest.js +161 -0
  5. package/lib/doctor/policy.js +124 -0
  6. package/lib/doctor/probes/elision.js +111 -0
  7. package/lib/doctor/probes/env.js +53 -0
  8. package/lib/doctor/probes/framework-resolves.js +84 -0
  9. package/lib/doctor/probes/git-hook.js +58 -0
  10. package/lib/doctor/probes/importmap-coherence.js +158 -0
  11. package/lib/doctor/probes/node.js +37 -0
  12. package/lib/doctor/probes/static-asset-freshness.js +58 -0
  13. package/lib/doctor/probes/tsconfig.js +55 -0
  14. package/lib/doctor/probes/unmarked-asset-links.js +199 -0
  15. package/lib/doctor/probes/vendor-gitignore.js +84 -0
  16. package/lib/doctor/probes/vendor-pin.js +77 -0
  17. package/lib/doctor/probes/webjs-versions.js +85 -0
  18. package/lib/doctor/route-modules.js +100 -0
  19. package/lib/doctor/runner.js +71 -0
  20. package/lib/doctor/util.js +160 -0
  21. package/lib/doctor.js +5 -1634
  22. package/package.json +1 -1
  23. package/templates/.agents/skills/webjs/SKILL.md +41 -2
  24. package/templates/.agents/skills/webjs/references/built-ins.md +5 -1
  25. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +20 -2
  26. package/templates/.agents/skills/webjs/references/components.md +41 -1
  27. package/templates/.agents/skills/webjs/references/module-structure.md +229 -0
  28. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +1 -1
  29. package/templates/.agents/skills/webjs/references/routing-and-pages.md +36 -2
  30. package/templates/.agents/skills/webjs/references/runtime.md +5 -0
  31. package/templates/gallery/app/features/boundaries/page.ts +11 -0
  32. package/templates/gallery/app/features/client-router/page.ts +8 -1
  33. package/templates/gallery/app/features/rate-limit/ping/middleware.ts +36 -4
  34. package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
  35. package/templates/gallery/modules/client-router/components/router-controls.ts +16 -2
  36. package/templates/gallery/modules/gallery/nav.ts +35 -26
  37. package/templates/gallery/test/rate-limit/rate-limit.test.ts +91 -0
  38. package/templates/scripts/clear-gallery.mjs +6 -3
@@ -69,9 +69,33 @@ export async function writeApiGallery(appDir) {
69
69
  "// Per-segment middleware: it sits beside this route, so it rate-limits ONLY",
70
70
  "// /api/features/rate-limit. rateLimit() is backed by the pluggable cache store",
71
71
  "// (in-memory by default; point it at Redis to share the window across nodes).",
72
+ "//",
73
+ "// `trustProxy: true` decides WHAT gets counted. Without it the bucket key is",
74
+ "// the socket peer, which is the visitor only when the browser connects to you",
75
+ "// directly. Behind a CDN or a platform router the peer is that proxy, so a",
76
+ "// proxy POOL hands out one bucket per proxy and multiplies your real limit by",
77
+ "// the pool size. With it the key is the forwarded client address instead.",
78
+ "//",
79
+ "// It has a precondition: the proxy in front MUST strip an inbound",
80
+ "// X-Forwarded-For before adding its own, or a client can forge the header and",
81
+ "// pick its own bucket. Serving with nothing in front? Drop the option, since",
82
+ "// then the socket peer IS the visitor. WEBJS_NO_TRUST_PROXY=1 outranks it either",
83
+ "// way. https://webjs.dev/docs/rate-limiting has the full threat model.",
84
+ "//",
85
+ "// Behind a CDN, add `clientIpHeader` to name the header carrying the visitor,",
86
+ "// e.g. `clientIpHeader: 'cf-connecting-ip'` behind Cloudflare. The default",
87
+ "// chain reads the leftmost X-Forwarded-For entry, which behind a CDN is the",
88
+ "// CDN's egress address; those are pinned per connection, so the limiter ends up",
89
+ "// handing out a bucket per connection and refusing nobody. It is left unset",
90
+ "// here because the right header depends on what you deploy behind.",
72
91
  "import { rateLimit } from '@webjsdev/server';",
73
92
  "",
74
- "export default rateLimit({ window: '10s', max: 5, message: 'Slow down: five requests per ten seconds.' });",
93
+ "export default rateLimit({",
94
+ " window: '10s',",
95
+ " max: 5,",
96
+ " trustProxy: true,",
97
+ " message: 'Slow down: five requests per ten seconds.',",
98
+ "});",
75
99
  "",
76
100
  ].join('\n'));
77
101
  await writeFile(feat('rate-limit', 'route.ts'), [
package/lib/create.js CHANGED
@@ -476,6 +476,41 @@ export async function scaffoldApp(name, cwd, opts = {}) {
476
476
  // @webjsdev/core, not the kit), and the
477
477
  // CLI resolves @webjsdev/ui from its own install.
478
478
  },
479
+ // Transitive-version floor for the browser test runner (#1418). Without
480
+ // this, `npm audit` in a freshly created app reports 5 high-severity
481
+ // advisories the moment scaffolding finishes, all of them one advisory
482
+ // (GHSA-jmr9-qjv8-65gv, an unvalidated symlink path traversal in
483
+ // extract-zip) reached through one chain:
484
+ //
485
+ // @web/test-runner -> @web/test-runner-chrome -> puppeteer-core@24
486
+ // -> @puppeteer/browsers@2.13.2 -> extract-zip@2.0.1
487
+ //
488
+ // extract-zip has NO patched version (the advisory covers `*`, and 2.0.1
489
+ // is the newest release), so an override on extract-zip itself cannot fix
490
+ // anything. The fix sits one level up: @puppeteer/browsers@3 dropped
491
+ // extract-zip entirely (it unpacks with modern-tar), and puppeteer-core@25
492
+ // depends on that 3 line. puppeteer-core@24.43.1 pins @puppeteer/browsers
493
+ // EXACTLY ("2.13.2", no caret), so npm cannot dedupe its way out and an
494
+ // explicit override is the only lever.
495
+ //
496
+ // Nothing in a scaffolded app executes this code. The runner is a
497
+ // devDependency, and web-test-runner.config.js launches browsers through
498
+ // playwrightLauncher, so the puppeteer chrome launcher is dead weight that
499
+ // @web/test-runner pulls in unconditionally via its own hard dependency on
500
+ // @web/test-runner-chrome (there is no supported install without it).
501
+ //
502
+ // Caret, not an exact pin: this is a security FLOOR, so a generated app
503
+ // should pick up later 25.x patches. That is the opposite of the
504
+ // drizzle-orm reasoning above, where the exact pin is deliberate because
505
+ // the scaffold source is written against one rc API.
506
+ //
507
+ // Do NOT "fix" this by taking @web/test-runner@1, which is what
508
+ // `npm audit fix --force` proposes: its @web/test-runner-chrome@1 still
509
+ // declares puppeteer-core ^24, so the same vulnerable chain resolves and
510
+ // the audit stays red after a breaking major.
511
+ overrides: {
512
+ 'puppeteer-core': '^25.7.0',
513
+ },
479
514
  // Dev + start task orchestration (#550). `webjs dev` / `webjs start` read
480
515
  // `before` and run it in-process, so `npm run dev` / `start` (thin aliases
481
516
  // above) behave identically. Both apply pending migrations via `webjs db
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `status` is what the CHECK found and never depends on config. `severity` is
3
+ * the EFFECTIVE level the result contributes after the app's gate is applied,
4
+ * attached by `applyDoctorPolicy` (the checks never set it). `bestEffort` marks
5
+ * a result that reports "could not check" rather than a real finding, which is
6
+ * the one thing a gate can never escalate.
7
+ * @typedef {'pass' | 'warn' | 'fail'} DoctorStatus
8
+ * @typedef {'off' | 'warn' | 'error'} DoctorSeverity a level a gate entry may DECLARE
9
+ * @typedef {'pass' | DoctorSeverity} DoctorLevel the EFFECTIVE level of a result
10
+ * @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string, bestEffort?: boolean, severity?: DoctorLevel }} DoctorResult
11
+ */
12
+
13
+ /**
14
+ * The severity levels a `webjs.doctor.gate` entry may name, mirroring ESLint's
15
+ * three-level scale (its `off` / `warn` / `error`, which Next.js's
16
+ * `eslint-plugin-next` uses verbatim as a rule-id-keyed map). `off` is uniform:
17
+ * it silences ANY code, the two hard-fail checks included, exactly as ESLint
18
+ * lets any rule be turned off.
19
+ * @type {DoctorSeverity[]}
20
+ */
21
+ export const DOCTOR_SEVERITIES = ['off', 'warn', 'error'];
22
+
23
+ /**
24
+ * Stable machine-readable code per check (#975), so an agent consuming
25
+ * `webjs doctor --json` branches on the failure KIND, not the human message
26
+ * text (which is free to change). The `name` stays the display identity (some
27
+ * are kebab-case, two are prose); the `code` is the durable contract, a
28
+ * SCREAMING_SNAKE_CASE constant that never changes for a given check. Attached
29
+ * centrally in `runDoctorChecks` so every check function stays focused on its
30
+ * own logic. Mirrors Remix's `DoctorFindingCode` enum (its `doctor/types.ts`).
31
+ *
32
+ * Keyed by each check's `name`. A missing entry falls back to a name-derived
33
+ * code (see `codeForName`), but every shipped check is listed here explicitly
34
+ * and a drift test asserts each result carries one of these codes.
35
+ * @type {Record<string, string>}
36
+ */
37
+ export const DOCTOR_CODES = {
38
+ 'node-version': 'NODE_VERSION',
39
+ 'tsconfig-erasable': 'TSCONFIG_ERASABLE',
40
+ 'env-drift': 'ENV_DRIFT',
41
+ 'vendor-pin': 'VENDOR_PIN',
42
+ 'vendor-gitignore': 'VENDOR_GITIGNORE',
43
+ 'webjs-versions': 'WEBJS_VERSIONS',
44
+ 'framework-resolve': 'FRAMEWORK_RESOLVE',
45
+ 'importmap-coherence': 'IMPORTMAP_COHERENCE',
46
+ 'git-hook': 'GIT_HOOK',
47
+ 'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS',
48
+ 'Component elision (what the browser drops)': 'ELISION_COMPONENTS',
49
+ 'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
50
+ 'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS',
51
+ };
52
+
53
+ /**
54
+ * The stable code for a check name: the explicit `DOCTOR_CODES` entry, else a
55
+ * best-effort derivation (uppercased, non-alphanumerics collapsed to `_`) so a
56
+ * newly-added check that forgets its map entry still gets a non-empty code.
57
+ * @param {string} name
58
+ * @returns {string}
59
+ */
60
+ export function codeForName(name) {
61
+ return DOCTOR_CODES[name] || name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
62
+ }
63
+
64
+ /**
65
+ * @typedef {{ gate: Record<string, DoctorSeverity>, unknownCodes: string[], badSeverities: Array<{ code: string, value: unknown }>, malformed: Array<{ path: string, value: unknown }>, unknownKeys: string[] }} DoctorPolicy
66
+ */
@@ -0,0 +1,161 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+
6
+ /**
7
+ * Compare an installed version against a semver range PRAGMATICALLY (no semver
8
+ * dependency). Supports the common scaffold shapes: `latest` / `*` / `workspace:*`
9
+ * (any installed version satisfies), an exact `1.2.3`, and a caret `^1.2.3`
10
+ * (installed must be >= the floor AND share the same major, with major 0 also
11
+ * pinning the minor, matching npm caret semantics). An unrecognized range is
12
+ * treated as "cannot statically verify" (returns null), so the caller does not
13
+ * warn on a shape it does not understand.
14
+ * @param {string} installed
15
+ * @param {string} range
16
+ * @returns {boolean | null}
17
+ */
18
+ export function satisfiesRange(installed, range) {
19
+ if (!installed) return null;
20
+ const r = String(range).trim();
21
+ if (r === 'latest' || r === '*' || r === '' || r.startsWith('workspace:')) return true;
22
+ const parse = (v) => {
23
+ const m = String(v).match(/(\d+)\.(\d+)\.(\d+)/);
24
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
25
+ };
26
+ const inst = parse(installed);
27
+ if (!inst) return null;
28
+ if (/^\d+\.\d+\.\d+$/.test(r)) {
29
+ const exact = parse(r);
30
+ return exact ? inst[0] === exact[0] && inst[1] === exact[1] && inst[2] === exact[2] : null;
31
+ }
32
+ if (r.startsWith('^')) {
33
+ const floor = parse(r);
34
+ if (!floor) return null;
35
+ if (inst[0] !== floor[0]) return false;
36
+ // For 0.x, caret pins the minor too (^0.7.0 allows 0.7.x, not 0.8.0).
37
+ if (floor[0] === 0 && inst[1] !== floor[1]) return false;
38
+ const cmp =
39
+ inst[0] !== floor[0] ? inst[0] - floor[0] :
40
+ inst[1] !== floor[1] ? inst[1] - floor[1] :
41
+ inst[2] - floor[2];
42
+ return cmp >= 0;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Read the declared dependency ranges of an INSTALLED package from
49
+ * `node_modules/<pkg>/package.json`, for the importmap-coherence check. This
50
+ * is the "already-resolved metadata, no network" path the issue calls for: the
51
+ * package is on disk (it was installed for the importmap to pin it), so its
52
+ * manifest is a local read. Returns null on any failure (not installed,
53
+ * unreadable, unparseable), which the coherence check treats as "could not
54
+ * verify" rather than a conflict.
55
+ *
56
+ * @param {string} appDir
57
+ * @returns {(pkg: string) => Promise<{ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null>}
58
+ */
59
+ export function makeInstalledManifestReader(appDir) {
60
+ return async (pkg) => {
61
+ const manifestPath = join(appDir, 'node_modules', pkg, 'package.json');
62
+ if (!existsSync(manifestPath)) return null;
63
+ try {
64
+ const parsed = JSON.parse(await readFile(manifestPath, 'utf8'));
65
+ return {
66
+ dependencies: parsed.dependencies || {},
67
+ peerDependencies: parsed.peerDependencies || {},
68
+ };
69
+ } catch {
70
+ return null;
71
+ }
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Format a coherence conflict list into a single human-readable warning line
77
+ * naming each conflicting pair, the required range, and the pinned version.
78
+ * @param {Array<{ pkg: string, version: string, dependsOn: string, kind: string, requiredRange: string, pinnedVersion: string }>} conflicts
79
+ * @returns {string}
80
+ */
81
+ export function formatConflicts(conflicts) {
82
+ return conflicts
83
+ .map(
84
+ (c) =>
85
+ `${c.pkg}@${c.version} needs ${c.dependsOn} ${c.kind === 'peerDependency' ? '(peer) ' : ''}${c.requiredRange} but the importmap pins ${c.dependsOn}@${c.pinnedVersion}`,
86
+ )
87
+ .join('; ');
88
+ }
89
+
90
+ /**
91
+ * Read a dependency's INSTALLED version as resolved FROM `appDir`, or null when
92
+ * it does not resolve there at all.
93
+ *
94
+ * Node's own resolver is the ground truth here, not a directory read. The check
95
+ * this serves asks "would this app resolve this dependency at runtime, and at
96
+ * what version", and Node's resolution algorithm IS that question's definition,
97
+ * so anything re-implementing it can only be a worse approximation. Asking Node
98
+ * handles workspace hoisting (the bug this fixes: under npm workspaces the
99
+ * `@webjsdev/*` deps hoist to the ROOT node_modules, so an app subdirectory has
100
+ * no local copy and a per-app `node_modules/<dep>/package.json` read reported
101
+ * every declared dep missing on a healthy install), symlinked workspace links,
102
+ * nested non-hoisted trees, and `package.json` `imports`, for free and for ever.
103
+ *
104
+ * The direct `<dep>/package.json` resolve is attempted FIRST because a package
105
+ * may declare no main entry at all: `@webjsdev/cli` is bin-only (no `main`, no
106
+ * `exports`), so `require.resolve('@webjsdev/cli')` throws MODULE_NOT_FOUND.
107
+ * The ERR_PACKAGE_PATH_NOT_EXPORTED fallback exists because a package may lock
108
+ * its manifest out of its `exports` map: `@webjsdev/server` exports only `.`,
109
+ * `./check`, `./testing`, and `./webjs-config.schema.json`, so the direct
110
+ * manifest resolve is refused and the main entry plus a bounded walk up to the
111
+ * package root is the way in. Neither strategy alone resolves all four
112
+ * `@webjsdev/*` packages; both halves are required.
113
+ *
114
+ * Local rather than `getPackageVersion` from `@webjsdev/server` for two reasons.
115
+ * Doctor must stay usable when the framework does not resolve from the app dir
116
+ * at all, which is the #954 fresh-worktree case doctor exists to diagnose, so
117
+ * this check cannot import the server (the same argument `frameworkResolves`
118
+ * below already follows). And `getPackageVersion` resolves the main entry only,
119
+ * so it returns null for a bin-only package, which would leave `@webjsdev/cli`
120
+ * reported missing: the same false positive with more machinery.
121
+ *
122
+ * Pinned by the workspace, bin-only, and exports-locked fixtures in
123
+ * `test/cli/doctor.test.mjs`.
124
+ * @param {string} dep package name, e.g. `@webjsdev/server`
125
+ * @param {string} appDir directory to anchor resolution at
126
+ * @returns {Promise<string|null>} the installed version, or null when unresolvable
127
+ */
128
+ export async function readInstalledVersion(dep, appDir) {
129
+ // The base file need not exist; createRequire only uses it to anchor the
130
+ // node_modules lookup at appDir.
131
+ const require = createRequire(join(appDir, '__webjs_resolve_probe__.js'));
132
+ let manifestPath = null;
133
+ try {
134
+ manifestPath = require.resolve(dep + '/package.json');
135
+ } catch (err) {
136
+ if (err?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') return null;
137
+ let entry;
138
+ try {
139
+ entry = require.resolve(dep);
140
+ } catch {
141
+ return null;
142
+ }
143
+ let dir = dirname(entry);
144
+ for (let i = 0; i < 12; i++) {
145
+ const candidate = join(dir, 'package.json');
146
+ if (existsSync(candidate)) {
147
+ manifestPath = candidate;
148
+ break;
149
+ }
150
+ const parent = dirname(dir);
151
+ if (parent === dir) break;
152
+ dir = parent;
153
+ }
154
+ if (!manifestPath) return null;
155
+ }
156
+ try {
157
+ return JSON.parse(await readFile(manifestPath, 'utf8')).version || null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
@@ -0,0 +1,124 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { DOCTOR_CODES, DOCTOR_SEVERITIES } from './codes.js';
4
+
5
+ /**
6
+ * @typedef {import('./codes.js').DoctorSeverity} DoctorSeverity
7
+ * @typedef {import('./codes.js').DoctorLevel} DoctorLevel
8
+ * @typedef {import('./codes.js').DoctorResult} DoctorResult
9
+ * @typedef {{ gate: Record<string, DoctorSeverity>, unknownCodes: string[], badSeverities: Array<{ code: string, value: unknown }>, malformed: Array<{ path: string, value: unknown }>, unknownKeys: string[] }} DoctorPolicy
10
+ */
11
+
12
+ /** A plain JSON object (not null, not an array), the only shape the gate accepts. */
13
+ function isPlainObject(v) {
14
+ return !!v && typeof v === 'object' && !Array.isArray(v);
15
+ }
16
+
17
+ /**
18
+ * Read the app's per-check severity policy out of `package.json`
19
+ * `webjs.doctor.gate` (#1257). PURE: it reads one file and returns data, and
20
+ * the caller (the CLI) decides what to do about a problem.
21
+ *
22
+ * `gate` keeps only WELL-FORMED entries, so a caller can fold it over the
23
+ * results without re-validating. Everything rejected is reported separately:
24
+ * a key that is not a value of `DOCTOR_CODES` lands in `unknownCodes`, a value
25
+ * outside `DOCTOR_SEVERITIES` in `badSeverities`, a wrong SHAPE (a non-object
26
+ * `doctor` or `gate`) in `malformed`, and a misspelled sibling of `gate` such
27
+ * as `gates` in `unknownKeys`. All four are surfaced as a hard error by the
28
+ * CLI rather than skipped.
29
+ *
30
+ * The shape check matters as much as the per-entry one, and is the easier half
31
+ * to leave out. A gate that FAILS OPEN is the one outcome this mechanism cannot
32
+ * afford: `"gate": "error"` or a misspelled `"gates": {...}` would leave CI
33
+ * un-gated while the package.json looks gated, which is strictly worse than
34
+ * having no gate at all, since nobody goes looking. The JSON Schema catches
35
+ * these in an editor, but it is editor-only, so it can never be the enforcement.
36
+ *
37
+ * A missing package.json, a missing block, or unparseable JSON is an EMPTY
38
+ * policy with no problems: an app that declares nothing behaves exactly as it
39
+ * did before the gate existed. Unparseable JSON in particular is deliberately
40
+ * not an error here, since `checkWebjsVersions` already reports that condition
41
+ * and doctor must never crash on a broken app file.
42
+ *
43
+ * @param {string} appDir
44
+ * @returns {DoctorPolicy}
45
+ */
46
+ export function readDoctorPolicy(appDir) {
47
+ /** @type {DoctorPolicy} */
48
+ const empty = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] };
49
+ let raw;
50
+ try {
51
+ raw = readFileSync(join(appDir, 'package.json'), 'utf8');
52
+ } catch {
53
+ return empty;
54
+ }
55
+ let pkg;
56
+ try {
57
+ pkg = JSON.parse(raw);
58
+ } catch {
59
+ return empty;
60
+ }
61
+ const doctor = pkg?.webjs?.doctor;
62
+ if (doctor === undefined) return empty;
63
+ if (!isPlainObject(doctor)) return { ...empty, malformed: [{ path: 'webjs.doctor', value: doctor }] };
64
+
65
+ /** @type {DoctorPolicy} */
66
+ const policy = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] };
67
+ // A misspelled sibling (`gates`) would otherwise be dropped in silence, which
68
+ // is the fail-open case. `gate` is the only key this block accepts.
69
+ for (const key of Object.keys(doctor)) {
70
+ if (key !== 'gate') policy.unknownKeys.push(`webjs.doctor.${key}`);
71
+ }
72
+ const declared = doctor.gate;
73
+ if (declared !== undefined && !isPlainObject(declared)) {
74
+ policy.malformed.push({ path: 'webjs.doctor.gate', value: declared });
75
+ }
76
+ if (!isPlainObject(declared)) return policy;
77
+
78
+ const known = new Set(Object.values(DOCTOR_CODES));
79
+ for (const [code, value] of Object.entries(declared)) {
80
+ if (!known.has(code)) {
81
+ policy.unknownCodes.push(code);
82
+ continue;
83
+ }
84
+ if (typeof value !== 'string' || !DOCTOR_SEVERITIES.includes(/** @type {DoctorSeverity} */ (value))) {
85
+ policy.badSeverities.push({ code, value });
86
+ continue;
87
+ }
88
+ policy.gate[code] = /** @type {DoctorSeverity} */ (value);
89
+ }
90
+ return policy;
91
+ }
92
+
93
+ /**
94
+ * Fold a severity `gate` over check results, returning a NEW array whose
95
+ * results each carry the EFFECTIVE level they contribute (#1257). PURE: the
96
+ * input array and its results are never mutated.
97
+ *
98
+ * `severity` is the effective level, not the declared one, which is why a
99
+ * PASSING check reports `'pass'` even when its code is gated `error`. A rule
100
+ * that did not fire contributes nothing, the same way ESLint puts severity on a
101
+ * message rather than on a rule that stayed quiet. It also keeps the obvious
102
+ * one-liner honest: `results.some((r) => r.severity === 'error')` is exactly
103
+ * "something fatal was found", with no passing-check false positive.
104
+ *
105
+ * The gate's one hard limit is `bestEffort`: a result that could not check
106
+ * (a toolchain that would not load, a network that was unreachable) is CLAMPED
107
+ * to `warn` however loudly the gate declares its code. That is what lets this
108
+ * repo's required CI job run a check whose live resolve touches jspm without
109
+ * an outage there ever redding an unrelated pull request.
110
+ *
111
+ * @param {DoctorResult[]} results
112
+ * @param {Record<string, DoctorSeverity>} [gate] well-formed entries only (see readDoctorPolicy)
113
+ * @returns {DoctorResult[]}
114
+ */
115
+ export function applyDoctorPolicy(results, gate = {}) {
116
+ return results.map((r) => {
117
+ if (r.status === 'pass') return { ...r, severity: /** @type {DoctorLevel} */ ('pass') };
118
+ const declared = gate[r.code];
119
+ const fallback = r.status === 'fail' ? 'error' : 'warn';
120
+ let severity = /** @type {DoctorSeverity} */ (declared || fallback);
121
+ if (r.bestEffort && severity === 'error') severity = 'warn';
122
+ return { ...r, severity };
123
+ });
124
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
3
+ */
4
+
5
+ /**
6
+ * Advisory (#646): name why a page/layout SHIPS its module to the browser
7
+ * instead of being elided. A page/layout that is a pure carrier (import-only
8
+ * #605 / inert #179) stays out of the browser; one that ships whole is pinned
9
+ * by a specific client-effecting NON-component on a component-free path from it, #963 (a util touching
10
+ * a client global, a module-scope side effect, a bare side-effect import) or by
11
+ * its own client work. This turns that invisible #605/#179 regression into a
12
+ * named line. WARN only: a page legitimately MAY ship, and the analyser is
13
+ * biased toward shipping by design (server AGENTS invariant 7), so this is a
14
+ * "you may not have intended this" hint, never a hard fail.
15
+ * @param {Promise<any|null>} elisionPromise the ONE shared report (#1308)
16
+ * @returns {Promise<DoctorResult>}
17
+ */
18
+ export async function checkElisionCarriers(elisionPromise) {
19
+ const name = 'Page/layout elision (carrier hygiene)';
20
+ const report = await elisionPromise;
21
+ if (!report) {
22
+ // Analysis unavailable (no app, malformed, server import failed): no advice.
23
+ return { name, status: 'pass', message: 'not analysed (no routable app or analysis unavailable)' };
24
+ }
25
+ if (!report.analysed) {
26
+ return { name, status: 'pass', message: 'not analysed (no routable app, or elision is disabled)' };
27
+ }
28
+ // Paths and reasons arrive app-relative from `analyzeAppElision` (#1308).
29
+ const shipped = report.routeModules.filter((r) => r.verdict === 'shipped');
30
+ if (shipped.length === 0) {
31
+ return { name, status: 'pass', message: 'every page/layout is elided (a pure import-only or inert carrier)' };
32
+ }
33
+ // Name the FIRST client-effecting blocker (there may be more than one; the
34
+ // module stays shipped until every such blocker is moved out).
35
+ const lines = shipped.map(({ file, blocker, reason }) =>
36
+ blocker
37
+ ? `${file} ships whole. Its first client-effecting blocker is ${blocker}, which ${reason} and is not a component`
38
+ : `${file} ships whole because it ${reason}`,
39
+ );
40
+ return {
41
+ name,
42
+ status: 'warn',
43
+ message:
44
+ `${shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` +
45
+ lines.map((l) => ` ${l}`).join('\n'),
46
+ fix: 'Move the client work out of the page/layout closure (into a component, or a .server module reached through an action) so the carrier can be elided, or accept that it ships. See references/components.md in the skill.',
47
+ };
48
+ }
49
+
50
+ /**
51
+ * The OTHER direction of the elision verdict (#1308): which COMPONENT modules
52
+ * the browser never downloads. `checkElisionCarriers` above reports the benign
53
+ * over-ship direction; this one reports what was DROPPED, which is where a
54
+ * wrong verdict silently costs an app its interactivity.
55
+ *
56
+ * Pass-only except for orphans, deliberately. An elided component is the
57
+ * DESIRED outcome, so warning on one would fire on every healthy app and train
58
+ * the reader to skip doctor output. The passing message carries the elided
59
+ * inventory instead, which makes it the discovery surface, while `webjs
60
+ * elision` is the detail surface. The one always-wrong condition is an ORPHAN:
61
+ * a `class X extends WebComponent` with no literal-tag registration is
62
+ * invisible to the scanner, so it gets no verdict at all and `static
63
+ * interactive = true` cannot rescue it (nothing consults the component
64
+ * analyser for a component the scanner never saw). Never `fail`:
65
+ * an app that wants an orphan to break CI gates `ELISION_COMPONENTS` to
66
+ * `error` via `webjs.doctor.gate`.
67
+ *
68
+ * @param {Promise<any|null>} elisionPromise the ONE shared report
69
+ * @returns {Promise<DoctorResult>}
70
+ */
71
+ export async function checkElisionComponents(elisionPromise) {
72
+ const name = 'Component elision (what the browser drops)';
73
+ const report = await elisionPromise;
74
+ const notAnalysed = { name, status: /** @type {const} */ ('pass'), message: 'not analysed (no routable app or analysis unavailable)' };
75
+ if (!report) return notAnalysed;
76
+ if (!report.analysed) {
77
+ return report.skipped === 'elide-off'
78
+ ? { name, status: 'pass', message: 'elision is disabled (webjs.elide false or WEBJS_ELIDE), so every component module ships' }
79
+ : notAnalysed;
80
+ }
81
+ if (report.orphans.length > 0) {
82
+ const lines = report.orphans.map(({ file, className }) =>
83
+ `${className} in ${file} is never registered with a literal tag`,
84
+ );
85
+ return {
86
+ name,
87
+ status: 'warn',
88
+ message:
89
+ `${report.orphans.length} component class(es) get NO elision verdict:\n` +
90
+ lines.map((l) => ` ${l}`).join('\n') +
91
+ '\n Either it has no registration call at all, or it registers a computed tag. The component '
92
+ + 'scanner matches only a literal tag, so either way it never sees the class: no elision verdict, no '
93
+ + 'registry entry, no preload hint, and `static interactive = true` cannot rescue it. With no '
94
+ + 'registration call the element never upgrades at all; with a computed tag it upgrades only while '
95
+ + 'its module still reaches the browser through an importer that ships.',
96
+ fix: 'Register it with a literal tag, Class.register(\'my-tag\') (invariant 3 already requires one), or delete the class if nothing uses it.',
97
+ };
98
+ }
99
+ const elided = report.components.filter((c) => c.verdict === 'elided');
100
+ const tags = elided.flatMap((c) => c.tags);
101
+ const shown = tags.slice(0, 8).join(', ');
102
+ const tail = tags.length > 8 ? `, +${tags.length - 8} more` : '';
103
+ return {
104
+ name,
105
+ status: 'pass',
106
+ message:
107
+ `${report.summary.elided} of ${report.summary.components} component module(s) are elided (never downloaded)` +
108
+ (tags.length ? `: ${shown}${tail}` : '') +
109
+ '. Run `webjs elision` for the full verdict.',
110
+ };
111
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { parseEnvKeys } from '../util.js';
5
+
6
+ /**
7
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
8
+ */
9
+
10
+ /**
11
+ * CHECK 3, .env presence + drift vs .env.example. WARN-level only (a missing
12
+ * env var is the app's runtime problem, not a toolchain crash). When no
13
+ * `.env.example`, PASS (nothing to compare). When `.env.example` exists but
14
+ * `.env` is absent, WARN to copy it. Otherwise WARN listing any example key
15
+ * missing from `.env`, else PASS.
16
+ * @param {string} appDir
17
+ * @returns {Promise<DoctorResult>}
18
+ */
19
+ export async function checkEnv(appDir) {
20
+ const examplePath = join(appDir, '.env.example');
21
+ if (!existsSync(examplePath)) {
22
+ return {
23
+ name: 'env-drift',
24
+ status: 'pass',
25
+ message: 'No .env.example to compare against.',
26
+ };
27
+ }
28
+ const exampleKeys = parseEnvKeys(await readFile(examplePath, 'utf8'));
29
+ const envPath = join(appDir, '.env');
30
+ if (!existsSync(envPath)) {
31
+ return {
32
+ name: 'env-drift',
33
+ status: 'warn',
34
+ message: '.env.example exists but .env does not.',
35
+ fix: 'Copy it: cp .env.example .env (then fill in the values).',
36
+ };
37
+ }
38
+ const envKeys = parseEnvKeys(await readFile(envPath, 'utf8'));
39
+ const missing = [...exampleKeys].filter((k) => !envKeys.has(k));
40
+ if (missing.length === 0) {
41
+ return {
42
+ name: 'env-drift',
43
+ status: 'pass',
44
+ message: `.env has all ${exampleKeys.size} key(s) declared in .env.example.`,
45
+ };
46
+ }
47
+ return {
48
+ name: 'env-drift',
49
+ status: 'warn',
50
+ message: `.env is missing ${missing.length} key(s) from .env.example: ${missing.join(', ')}.`,
51
+ fix: 'Add the missing key(s) to .env (see .env.example for the expected names).',
52
+ };
53
+ }