@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.
- package/lib/api-gallery.js +25 -1
- package/lib/create.js +35 -0
- package/lib/doctor/codes.js +66 -0
- package/lib/doctor/manifest.js +161 -0
- package/lib/doctor/policy.js +124 -0
- package/lib/doctor/probes/elision.js +111 -0
- package/lib/doctor/probes/env.js +53 -0
- package/lib/doctor/probes/framework-resolves.js +84 -0
- package/lib/doctor/probes/git-hook.js +58 -0
- package/lib/doctor/probes/importmap-coherence.js +158 -0
- package/lib/doctor/probes/node.js +37 -0
- package/lib/doctor/probes/static-asset-freshness.js +58 -0
- package/lib/doctor/probes/tsconfig.js +55 -0
- package/lib/doctor/probes/unmarked-asset-links.js +199 -0
- package/lib/doctor/probes/vendor-gitignore.js +84 -0
- package/lib/doctor/probes/vendor-pin.js +77 -0
- package/lib/doctor/probes/webjs-versions.js +85 -0
- package/lib/doctor/route-modules.js +100 -0
- package/lib/doctor/runner.js +71 -0
- package/lib/doctor/util.js +160 -0
- package/lib/doctor.js +5 -1634
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/SKILL.md +41 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +5 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +20 -2
- package/templates/.agents/skills/webjs/references/components.md +41 -1
- package/templates/.agents/skills/webjs/references/module-structure.md +229 -0
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +1 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +36 -2
- package/templates/.agents/skills/webjs/references/runtime.md +5 -0
- package/templates/gallery/app/features/boundaries/page.ts +11 -0
- package/templates/gallery/app/features/client-router/page.ts +8 -1
- package/templates/gallery/app/features/rate-limit/ping/middleware.ts +36 -4
- package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
- package/templates/gallery/modules/client-router/components/router-controls.ts +16 -2
- package/templates/gallery/modules/gallery/nav.ts +35 -26
- package/templates/gallery/test/rate-limit/rate-limit.test.ts +91 -0
- package/templates/scripts/clear-gallery.mjs +6 -3
package/lib/api-gallery.js
CHANGED
|
@@ -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({
|
|
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
|
+
}
|