@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/doctor.js
CHANGED
|
@@ -50,1637 +50,8 @@
|
|
|
50
50
|
* the blunt "every warning is fatal" switch, layered on top.
|
|
51
51
|
*/
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* `status` is what the CHECK found and never depends on config. `severity` is
|
|
61
|
-
* the EFFECTIVE level the result contributes after the app's gate is applied,
|
|
62
|
-
* attached by `applyDoctorPolicy` (the checks never set it). `bestEffort` marks
|
|
63
|
-
* a result that reports "could not check" rather than a real finding, which is
|
|
64
|
-
* the one thing a gate can never escalate.
|
|
65
|
-
* @typedef {'pass' | 'warn' | 'fail'} DoctorStatus
|
|
66
|
-
* @typedef {'off' | 'warn' | 'error'} DoctorSeverity a level a gate entry may DECLARE
|
|
67
|
-
* @typedef {'pass' | DoctorSeverity} DoctorLevel the EFFECTIVE level of a result
|
|
68
|
-
* @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string, bestEffort?: boolean, severity?: DoctorLevel }} DoctorResult
|
|
69
|
-
*/
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The severity levels a `webjs.doctor.gate` entry may name, mirroring ESLint's
|
|
73
|
-
* three-level scale (its `off` / `warn` / `error`, which Next.js's
|
|
74
|
-
* `eslint-plugin-next` uses verbatim as a rule-id-keyed map). `off` is uniform:
|
|
75
|
-
* it silences ANY code, the two hard-fail checks included, exactly as ESLint
|
|
76
|
-
* lets any rule be turned off.
|
|
77
|
-
* @type {DoctorSeverity[]}
|
|
78
|
-
*/
|
|
79
|
-
export const DOCTOR_SEVERITIES = ['off', 'warn', 'error'];
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Stable machine-readable code per check (#975), so an agent consuming
|
|
83
|
-
* `webjs doctor --json` branches on the failure KIND, not the human message
|
|
84
|
-
* text (which is free to change). The `name` stays the display identity (some
|
|
85
|
-
* are kebab-case, two are prose); the `code` is the durable contract, a
|
|
86
|
-
* SCREAMING_SNAKE_CASE constant that never changes for a given check. Attached
|
|
87
|
-
* centrally in `runDoctorChecks` so every check function stays focused on its
|
|
88
|
-
* own logic. Mirrors Remix's `DoctorFindingCode` enum (its `doctor/types.ts`).
|
|
89
|
-
*
|
|
90
|
-
* Keyed by each check's `name`. A missing entry falls back to a name-derived
|
|
91
|
-
* code (see `codeForName`), but every shipped check is listed here explicitly
|
|
92
|
-
* and a drift test asserts each result carries one of these codes.
|
|
93
|
-
* @type {Record<string, string>}
|
|
94
|
-
*/
|
|
95
|
-
export const DOCTOR_CODES = {
|
|
96
|
-
'node-version': 'NODE_VERSION',
|
|
97
|
-
'tsconfig-erasable': 'TSCONFIG_ERASABLE',
|
|
98
|
-
'env-drift': 'ENV_DRIFT',
|
|
99
|
-
'vendor-pin': 'VENDOR_PIN',
|
|
100
|
-
'vendor-gitignore': 'VENDOR_GITIGNORE',
|
|
101
|
-
'webjs-versions': 'WEBJS_VERSIONS',
|
|
102
|
-
'framework-resolve': 'FRAMEWORK_RESOLVE',
|
|
103
|
-
'importmap-coherence': 'IMPORTMAP_COHERENCE',
|
|
104
|
-
'git-hook': 'GIT_HOOK',
|
|
105
|
-
'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS',
|
|
106
|
-
'Component elision (what the browser drops)': 'ELISION_COMPONENTS',
|
|
107
|
-
'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
|
|
108
|
-
'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS',
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The stable code for a check name: the explicit `DOCTOR_CODES` entry, else a
|
|
113
|
-
* best-effort derivation (uppercased, non-alphanumerics collapsed to `_`) so a
|
|
114
|
-
* newly-added check that forgets its map entry still gets a non-empty code.
|
|
115
|
-
* @param {string} name
|
|
116
|
-
* @returns {string}
|
|
117
|
-
*/
|
|
118
|
-
export function codeForName(name) {
|
|
119
|
-
return DOCTOR_CODES[name] || name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* @typedef {{ gate: Record<string, DoctorSeverity>, unknownCodes: string[], badSeverities: Array<{ code: string, value: unknown }>, malformed: Array<{ path: string, value: unknown }>, unknownKeys: string[] }} DoctorPolicy
|
|
124
|
-
*/
|
|
125
|
-
|
|
126
|
-
/** A plain JSON object (not null, not an array), the only shape the gate accepts. */
|
|
127
|
-
function isPlainObject(v) {
|
|
128
|
-
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Read the app's per-check severity policy out of `package.json`
|
|
133
|
-
* `webjs.doctor.gate` (#1257). PURE: it reads one file and returns data, and
|
|
134
|
-
* the caller (the CLI) decides what to do about a problem.
|
|
135
|
-
*
|
|
136
|
-
* `gate` keeps only WELL-FORMED entries, so a caller can fold it over the
|
|
137
|
-
* results without re-validating. Everything rejected is reported separately:
|
|
138
|
-
* a key that is not a value of `DOCTOR_CODES` lands in `unknownCodes`, a value
|
|
139
|
-
* outside `DOCTOR_SEVERITIES` in `badSeverities`, a wrong SHAPE (a non-object
|
|
140
|
-
* `doctor` or `gate`) in `malformed`, and a misspelled sibling of `gate` such
|
|
141
|
-
* as `gates` in `unknownKeys`. All four are surfaced as a hard error by the
|
|
142
|
-
* CLI rather than skipped.
|
|
143
|
-
*
|
|
144
|
-
* The shape check matters as much as the per-entry one, and is the easier half
|
|
145
|
-
* to leave out. A gate that FAILS OPEN is the one outcome this mechanism cannot
|
|
146
|
-
* afford: `"gate": "error"` or a misspelled `"gates": {...}` would leave CI
|
|
147
|
-
* un-gated while the package.json looks gated, which is strictly worse than
|
|
148
|
-
* having no gate at all, since nobody goes looking. The JSON Schema catches
|
|
149
|
-
* these in an editor, but it is editor-only, so it can never be the enforcement.
|
|
150
|
-
*
|
|
151
|
-
* A missing package.json, a missing block, or unparseable JSON is an EMPTY
|
|
152
|
-
* policy with no problems: an app that declares nothing behaves exactly as it
|
|
153
|
-
* did before the gate existed. Unparseable JSON in particular is deliberately
|
|
154
|
-
* not an error here, since `checkWebjsVersions` already reports that condition
|
|
155
|
-
* and doctor must never crash on a broken app file.
|
|
156
|
-
*
|
|
157
|
-
* @param {string} appDir
|
|
158
|
-
* @returns {DoctorPolicy}
|
|
159
|
-
*/
|
|
160
|
-
export function readDoctorPolicy(appDir) {
|
|
161
|
-
/** @type {DoctorPolicy} */
|
|
162
|
-
const empty = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] };
|
|
163
|
-
let raw;
|
|
164
|
-
try {
|
|
165
|
-
raw = readFileSync(join(appDir, 'package.json'), 'utf8');
|
|
166
|
-
} catch {
|
|
167
|
-
return empty;
|
|
168
|
-
}
|
|
169
|
-
let pkg;
|
|
170
|
-
try {
|
|
171
|
-
pkg = JSON.parse(raw);
|
|
172
|
-
} catch {
|
|
173
|
-
return empty;
|
|
174
|
-
}
|
|
175
|
-
const doctor = pkg?.webjs?.doctor;
|
|
176
|
-
if (doctor === undefined) return empty;
|
|
177
|
-
if (!isPlainObject(doctor)) return { ...empty, malformed: [{ path: 'webjs.doctor', value: doctor }] };
|
|
178
|
-
|
|
179
|
-
/** @type {DoctorPolicy} */
|
|
180
|
-
const policy = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] };
|
|
181
|
-
// A misspelled sibling (`gates`) would otherwise be dropped in silence, which
|
|
182
|
-
// is the fail-open case. `gate` is the only key this block accepts.
|
|
183
|
-
for (const key of Object.keys(doctor)) {
|
|
184
|
-
if (key !== 'gate') policy.unknownKeys.push(`webjs.doctor.${key}`);
|
|
185
|
-
}
|
|
186
|
-
const declared = doctor.gate;
|
|
187
|
-
if (declared !== undefined && !isPlainObject(declared)) {
|
|
188
|
-
policy.malformed.push({ path: 'webjs.doctor.gate', value: declared });
|
|
189
|
-
}
|
|
190
|
-
if (!isPlainObject(declared)) return policy;
|
|
191
|
-
|
|
192
|
-
const known = new Set(Object.values(DOCTOR_CODES));
|
|
193
|
-
for (const [code, value] of Object.entries(declared)) {
|
|
194
|
-
if (!known.has(code)) {
|
|
195
|
-
policy.unknownCodes.push(code);
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
if (typeof value !== 'string' || !DOCTOR_SEVERITIES.includes(/** @type {DoctorSeverity} */ (value))) {
|
|
199
|
-
policy.badSeverities.push({ code, value });
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
policy.gate[code] = /** @type {DoctorSeverity} */ (value);
|
|
203
|
-
}
|
|
204
|
-
return policy;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Fold a severity `gate` over check results, returning a NEW array whose
|
|
209
|
-
* results each carry the EFFECTIVE level they contribute (#1257). PURE: the
|
|
210
|
-
* input array and its results are never mutated.
|
|
211
|
-
*
|
|
212
|
-
* `severity` is the effective level, not the declared one, which is why a
|
|
213
|
-
* PASSING check reports `'pass'` even when its code is gated `error`. A rule
|
|
214
|
-
* that did not fire contributes nothing, the same way ESLint puts severity on a
|
|
215
|
-
* message rather than on a rule that stayed quiet. It also keeps the obvious
|
|
216
|
-
* one-liner honest: `results.some((r) => r.severity === 'error')` is exactly
|
|
217
|
-
* "something fatal was found", with no passing-check false positive.
|
|
218
|
-
*
|
|
219
|
-
* The gate's one hard limit is `bestEffort`: a result that could not check
|
|
220
|
-
* (a toolchain that would not load, a network that was unreachable) is CLAMPED
|
|
221
|
-
* to `warn` however loudly the gate declares its code. That is what lets this
|
|
222
|
-
* repo's required CI job run a check whose live resolve touches jspm without
|
|
223
|
-
* an outage there ever redding an unrelated pull request.
|
|
224
|
-
*
|
|
225
|
-
* @param {DoctorResult[]} results
|
|
226
|
-
* @param {Record<string, DoctorSeverity>} [gate] well-formed entries only (see readDoctorPolicy)
|
|
227
|
-
* @returns {DoctorResult[]}
|
|
228
|
-
*/
|
|
229
|
-
export function applyDoctorPolicy(results, gate = {}) {
|
|
230
|
-
return results.map((r) => {
|
|
231
|
-
if (r.status === 'pass') return { ...r, severity: /** @type {DoctorLevel} */ ('pass') };
|
|
232
|
-
const declared = gate[r.code];
|
|
233
|
-
const fallback = r.status === 'fail' ? 'error' : 'warn';
|
|
234
|
-
let severity = /** @type {DoctorSeverity} */ (declared || fallback);
|
|
235
|
-
if (r.bestEffort && severity === 'error') severity = 'warn';
|
|
236
|
-
return { ...r, severity };
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Read the CLI package's own `engines.node` so the required Node major lives in
|
|
242
|
-
* one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`.
|
|
243
|
-
* @param {string} cliDir directory of THIS file's package (lib/ -> package root)
|
|
244
|
-
* @returns {Promise<string>}
|
|
245
|
-
*/
|
|
246
|
-
async function readEngines(cliDir) {
|
|
247
|
-
try {
|
|
248
|
-
const pkg = JSON.parse(await readFile(join(cliDir, '..', 'package.json'), 'utf8'));
|
|
249
|
-
return pkg?.engines?.node || '>=24.0.0';
|
|
250
|
-
} catch {
|
|
251
|
-
return '>=24.0.0';
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Strip `//` line comments, block comments, and trailing commas from a JSONC
|
|
257
|
-
* string so a tsconfig (which permits all three) parses with `JSON.parse`.
|
|
258
|
-
* Deliberately simple: it does not honor comment-looking sequences inside
|
|
259
|
-
* string values, which is acceptable for a tsconfig (paths rarely contain `//`
|
|
260
|
-
* or block-comment markers, and the worst case is a parse failure the caller
|
|
261
|
-
* already degrades to a WARN).
|
|
262
|
-
* @param {string} text
|
|
263
|
-
* @returns {string}
|
|
264
|
-
*/
|
|
265
|
-
function stripJsonc(text) {
|
|
266
|
-
let out = '';
|
|
267
|
-
let inString = false;
|
|
268
|
-
let stringQuote = '';
|
|
269
|
-
for (let i = 0; i < text.length; i++) {
|
|
270
|
-
const ch = text[i];
|
|
271
|
-
const next = text[i + 1];
|
|
272
|
-
if (inString) {
|
|
273
|
-
out += ch;
|
|
274
|
-
if (ch === '\\') {
|
|
275
|
-
// Copy the escaped char verbatim so an escaped quote does not end the string.
|
|
276
|
-
out += text[i + 1] || '';
|
|
277
|
-
i++;
|
|
278
|
-
} else if (ch === stringQuote) {
|
|
279
|
-
inString = false;
|
|
280
|
-
}
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
if (ch === '"' || ch === "'") {
|
|
284
|
-
inString = true;
|
|
285
|
-
stringQuote = ch;
|
|
286
|
-
out += ch;
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
if (ch === '/' && next === '/') {
|
|
290
|
-
while (i < text.length && text[i] !== '\n') i++;
|
|
291
|
-
out += '\n';
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
if (ch === '/' && next === '*') {
|
|
295
|
-
i += 2;
|
|
296
|
-
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
|
297
|
-
i++; // land on the '/'
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
out += ch;
|
|
301
|
-
}
|
|
302
|
-
// Drop trailing commas before } or ].
|
|
303
|
-
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Parse a `.env`-style file into the SET of KEY names it declares. A simple
|
|
308
|
-
* `KEY=value` line parse: comments (`#`) and blank lines are skipped, and only
|
|
309
|
-
* the key before the first `=` is taken (the value is irrelevant for drift).
|
|
310
|
-
* @param {string} text
|
|
311
|
-
* @returns {Set<string>}
|
|
312
|
-
*/
|
|
313
|
-
function parseEnvKeys(text) {
|
|
314
|
-
const keys = new Set();
|
|
315
|
-
for (const raw of text.split(/\r?\n/)) {
|
|
316
|
-
const line = raw.trim();
|
|
317
|
-
if (!line || line.startsWith('#')) continue;
|
|
318
|
-
const eq = line.indexOf('=');
|
|
319
|
-
if (eq <= 0) continue;
|
|
320
|
-
let key = line.slice(0, eq).trim();
|
|
321
|
-
// Tolerate a leading `export ` (a common .env.example convention).
|
|
322
|
-
if (key.startsWith('export ')) key = key.slice('export '.length).trim();
|
|
323
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) keys.add(key);
|
|
324
|
-
}
|
|
325
|
-
return keys;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* CHECK 1, Node version. HARD-FAIL when the running major is below the required
|
|
330
|
-
* major (the strip-types + recursive fs.watch floor). `opts.nodeVersion` lets a
|
|
331
|
-
* test inject the running version so the fail case is assertable without being
|
|
332
|
-
* on old Node.
|
|
333
|
-
* @param {string} cliDir
|
|
334
|
-
* @param {{ nodeVersion?: string }} opts
|
|
335
|
-
* @returns {Promise<DoctorResult>}
|
|
336
|
-
*/
|
|
337
|
-
async function checkNode(cliDir, opts) {
|
|
338
|
-
const engines = await readEngines(cliDir);
|
|
339
|
-
const current = opts.nodeVersion || process.versions.node;
|
|
340
|
-
const r = checkNodeInline(current, engines);
|
|
341
|
-
if (r.ok) {
|
|
342
|
-
return {
|
|
343
|
-
name: 'node-version',
|
|
344
|
-
status: 'pass',
|
|
345
|
-
message: `Node ${r.current} satisfies the required Node ${r.requiredMajor}+.`,
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
return {
|
|
349
|
-
name: 'node-version',
|
|
350
|
-
status: 'fail',
|
|
351
|
-
message:
|
|
352
|
-
`Node ${r.current} is below the required Node ${r.requiredMajor}+. ` +
|
|
353
|
-
`webjs is buildless and relies on Node ${r.requiredMajor}'s built-in TypeScript ` +
|
|
354
|
-
`strip and recursive fs.watch.`,
|
|
355
|
-
fix: `Upgrade to Node ${r.requiredMajor}+ (see https://nodejs.org).`,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* CHECK 2, tsconfig erasableSyntaxOnly. PASS when `true`; WARN when no tsconfig
|
|
361
|
-
* (a JS-only app legitimately has none) or the file is unparseable; HARD-FAIL
|
|
362
|
-
* when the file EXISTS but the flag is missing/false (non-erasable TS 500s at
|
|
363
|
-
* strip time).
|
|
364
|
-
* @param {string} appDir
|
|
365
|
-
* @returns {Promise<DoctorResult>}
|
|
366
|
-
*/
|
|
367
|
-
async function checkTsconfig(appDir) {
|
|
368
|
-
const path = join(appDir, 'tsconfig.json');
|
|
369
|
-
if (!existsSync(path)) {
|
|
370
|
-
return {
|
|
371
|
-
name: 'tsconfig-erasable',
|
|
372
|
-
status: 'warn',
|
|
373
|
-
message: 'No tsconfig.json found. A JS-only app needs none; a TypeScript app requires one.',
|
|
374
|
-
fix: 'If this app uses TypeScript, add a tsconfig.json with "erasableSyntaxOnly": true.',
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
let parsed;
|
|
378
|
-
try {
|
|
379
|
-
parsed = JSON.parse(stripJsonc(await readFile(path, 'utf8')));
|
|
380
|
-
} catch {
|
|
381
|
-
return {
|
|
382
|
-
name: 'tsconfig-erasable',
|
|
383
|
-
status: 'warn',
|
|
384
|
-
message: 'tsconfig.json could not be parsed (even after stripping comments + trailing commas).',
|
|
385
|
-
fix: 'Fix the tsconfig.json syntax, then ensure "compilerOptions.erasableSyntaxOnly": true.',
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
const flag = parsed?.compilerOptions?.erasableSyntaxOnly;
|
|
389
|
-
if (flag === true) {
|
|
390
|
-
return {
|
|
391
|
-
name: 'tsconfig-erasable',
|
|
392
|
-
status: 'pass',
|
|
393
|
-
message: 'tsconfig.json sets "erasableSyntaxOnly": true.',
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
return {
|
|
397
|
-
name: 'tsconfig-erasable',
|
|
398
|
-
status: 'fail',
|
|
399
|
-
message:
|
|
400
|
-
'tsconfig.json is missing "compilerOptions.erasableSyntaxOnly": true. ' +
|
|
401
|
-
'Non-erasable TypeScript (enum, namespace, parameter properties, ...) 500s at strip time.',
|
|
402
|
-
fix: 'Set "compilerOptions": { "erasableSyntaxOnly": true } in tsconfig.json.',
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* CHECK 3, .env presence + drift vs .env.example. WARN-level only (a missing
|
|
408
|
-
* env var is the app's runtime problem, not a toolchain crash). When no
|
|
409
|
-
* `.env.example`, PASS (nothing to compare). When `.env.example` exists but
|
|
410
|
-
* `.env` is absent, WARN to copy it. Otherwise WARN listing any example key
|
|
411
|
-
* missing from `.env`, else PASS.
|
|
412
|
-
* @param {string} appDir
|
|
413
|
-
* @returns {Promise<DoctorResult>}
|
|
414
|
-
*/
|
|
415
|
-
async function checkEnv(appDir) {
|
|
416
|
-
const examplePath = join(appDir, '.env.example');
|
|
417
|
-
if (!existsSync(examplePath)) {
|
|
418
|
-
return {
|
|
419
|
-
name: 'env-drift',
|
|
420
|
-
status: 'pass',
|
|
421
|
-
message: 'No .env.example to compare against.',
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
const exampleKeys = parseEnvKeys(await readFile(examplePath, 'utf8'));
|
|
425
|
-
const envPath = join(appDir, '.env');
|
|
426
|
-
if (!existsSync(envPath)) {
|
|
427
|
-
return {
|
|
428
|
-
name: 'env-drift',
|
|
429
|
-
status: 'warn',
|
|
430
|
-
message: '.env.example exists but .env does not.',
|
|
431
|
-
fix: 'Copy it: cp .env.example .env (then fill in the values).',
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
const envKeys = parseEnvKeys(await readFile(envPath, 'utf8'));
|
|
435
|
-
const missing = [...exampleKeys].filter((k) => !envKeys.has(k));
|
|
436
|
-
if (missing.length === 0) {
|
|
437
|
-
return {
|
|
438
|
-
name: 'env-drift',
|
|
439
|
-
status: 'pass',
|
|
440
|
-
message: `.env has all ${exampleKeys.size} key(s) declared in .env.example.`,
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
return {
|
|
444
|
-
name: 'env-drift',
|
|
445
|
-
status: 'warn',
|
|
446
|
-
message: `.env is missing ${missing.length} key(s) from .env.example: ${missing.join(', ')}.`,
|
|
447
|
-
fix: 'Add the missing key(s) to .env (see .env.example for the expected names).',
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* CHECK 4, vendor pin freshness. Applies ONLY when a pin file exists. PASS/skip
|
|
453
|
-
* for an unpinned app (it resolves live, which is fine in dev). BEST-EFFORT +
|
|
454
|
-
* NETWORK-TOLERANT: any error (network, timeout) is a WARN "could not check",
|
|
455
|
-
* never a hard fail and never a throw. PASS when all pins current, WARN listing
|
|
456
|
-
* outdated packages otherwise.
|
|
457
|
-
*
|
|
458
|
-
* The vendor functions are injected via `opts.vendor` so a test can supply a
|
|
459
|
-
* stub without a real network call; absent the override, they are dynamically
|
|
460
|
-
* imported from `@webjsdev/server`.
|
|
461
|
-
* @param {string} appDir
|
|
462
|
-
* @param {{ vendor?: { hasVendorPin: (d: string) => boolean, findOutdated: (d: string) => Promise<Array<{ pkg: string, current: string, latest: string }>> } }} opts
|
|
463
|
-
* @returns {Promise<DoctorResult>}
|
|
464
|
-
*/
|
|
465
|
-
async function checkVendorPin(appDir, opts) {
|
|
466
|
-
let vendor = opts.vendor;
|
|
467
|
-
if (!vendor) {
|
|
468
|
-
try {
|
|
469
|
-
const mod = await import('@webjsdev/server');
|
|
470
|
-
vendor = { hasVendorPin: mod.hasVendorPin, findOutdated: mod.findOutdated };
|
|
471
|
-
} catch {
|
|
472
|
-
return {
|
|
473
|
-
name: 'vendor-pin',
|
|
474
|
-
status: 'warn',
|
|
475
|
-
// "Could not check", not a finding: never escalatable by a gate.
|
|
476
|
-
bestEffort: true,
|
|
477
|
-
message: 'Could not load the vendor toolchain to check pin freshness.',
|
|
478
|
-
fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
let pinned = false;
|
|
483
|
-
try {
|
|
484
|
-
pinned = vendor.hasVendorPin(appDir);
|
|
485
|
-
} catch {
|
|
486
|
-
pinned = false;
|
|
487
|
-
}
|
|
488
|
-
if (!pinned) {
|
|
489
|
-
return {
|
|
490
|
-
name: 'vendor-pin',
|
|
491
|
-
status: 'pass',
|
|
492
|
-
message: 'No vendor pin file; the app resolves vendor imports live (fine in dev).',
|
|
493
|
-
};
|
|
494
|
-
}
|
|
495
|
-
let outdated;
|
|
496
|
-
try {
|
|
497
|
-
outdated = await vendor.findOutdated(appDir);
|
|
498
|
-
} catch {
|
|
499
|
-
// findOutdated is built to swallow fetch errors and return [], but guard
|
|
500
|
-
// anyway: a network check must NEVER throw out of doctor.
|
|
501
|
-
return {
|
|
502
|
-
name: 'vendor-pin',
|
|
503
|
-
status: 'warn',
|
|
504
|
-
bestEffort: true,
|
|
505
|
-
message: 'Could not check pin freshness (network unreachable or registry error).',
|
|
506
|
-
fix: 'Re-run `webjs doctor` when connectivity is back, or run `webjs vendor outdated`.',
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
if (!Array.isArray(outdated) || outdated.length === 0) {
|
|
510
|
-
return {
|
|
511
|
-
name: 'vendor-pin',
|
|
512
|
-
status: 'pass',
|
|
513
|
-
message: 'All vendor pins are current.',
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
const list = outdated.map((o) => `${o.pkg} (${o.current} -> ${o.latest})`).join(', ');
|
|
517
|
-
return {
|
|
518
|
-
name: 'vendor-pin',
|
|
519
|
-
status: 'warn',
|
|
520
|
-
message: `${outdated.length} pinned package(s) are outdated: ${list}.`,
|
|
521
|
-
fix: 'Run `webjs vendor update` to re-pin to the latest versions.',
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* CHECK: the `.gitignore` does not swallow the committed vendor pin. The pattern
|
|
527
|
-
* for `.webjs/vendor/` is subtle: a bare `.webjs/` line excludes the directory
|
|
528
|
-
* entirely and git cannot re-include children of an excluded parent, so a
|
|
529
|
-
* `!.webjs/vendor/` exception silently does nothing and `webjs vendor pin`
|
|
530
|
-
* output never gets committed. The correct pattern is the depth-robust
|
|
531
|
-
* contents-glob form (see the fix text below / VENDOR_GITIGNORE_LINES in
|
|
532
|
-
* vendor.js): a globstar-prefixed `.webjs/*` plus the matching vendor
|
|
533
|
-
* negations, which ignores transient `.webjs` output at any depth while
|
|
534
|
-
* keeping the committed vendor pin tracked.
|
|
535
|
-
*
|
|
536
|
-
* This was a `webjs check` rule, but inspecting `.gitignore` is a project-config
|
|
537
|
-
* concern (like `tsconfig-erasable`), not source-code correctness, and vendoring
|
|
538
|
-
* is optional, so a doctor WARN fits the domain and severity better than a CI
|
|
539
|
-
* hard-fail (#461). It lives next to `vendor-pin` (same family).
|
|
540
|
-
*
|
|
541
|
-
* PASS/skip when the dir is not a git repo or has no `.gitignore` (the user has
|
|
542
|
-
* not opted into version control yet). Probes two representative paths via
|
|
543
|
-
* `git check-ignore` with the inherited GIT_* env stripped so `cwd` is the sole
|
|
544
|
-
* authority on which repo + .gitignore stack is consulted (a pre-commit hook
|
|
545
|
-
* from a linked worktree exports GIT_WORK_TREE, which would otherwise override
|
|
546
|
-
* cwd-based discovery).
|
|
547
|
-
*
|
|
548
|
-
* @param {string} appDir
|
|
549
|
-
* @returns {Promise<DoctorResult>}
|
|
550
|
-
*/
|
|
551
|
-
async function checkVendorGitignore(appDir) {
|
|
552
|
-
const hasGit = existsSync(join(appDir, '.git'));
|
|
553
|
-
const hasGitignore = existsSync(join(appDir, '.gitignore'));
|
|
554
|
-
if (!hasGit || !hasGitignore) {
|
|
555
|
-
return {
|
|
556
|
-
name: 'vendor-gitignore',
|
|
557
|
-
status: 'pass',
|
|
558
|
-
message: 'Not a git checkout with a .gitignore; nothing to verify.',
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
const { spawnSync } = await import('node:child_process');
|
|
562
|
-
const {
|
|
563
|
-
GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
|
|
564
|
-
...gitEnv
|
|
565
|
-
} = process.env;
|
|
566
|
-
// Check two representative paths: the pin manifest AND a sample downloaded
|
|
567
|
-
// bundle. A `.gitignore` that allows the manifest but blocks bundles (e.g.
|
|
568
|
-
// `*.js` higher up) would still break `webjs vendor pin --download`.
|
|
569
|
-
// `git check-ignore -q` exits 0 when the path is ignored, 1 when not.
|
|
570
|
-
const probes = [
|
|
571
|
-
'.webjs/vendor/importmap.json',
|
|
572
|
-
'.webjs/vendor/sample-pkg@1.0.0.js',
|
|
573
|
-
];
|
|
574
|
-
for (const probe of probes) {
|
|
575
|
-
const result = spawnSync('git', ['check-ignore', '-q', probe], {
|
|
576
|
-
cwd: appDir,
|
|
577
|
-
stdio: 'pipe',
|
|
578
|
-
env: gitEnv,
|
|
579
|
-
});
|
|
580
|
-
if (result.status === 0) {
|
|
581
|
-
return {
|
|
582
|
-
name: 'vendor-gitignore',
|
|
583
|
-
status: 'warn',
|
|
584
|
-
message:
|
|
585
|
-
`${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ that MUST be committed for a production deploy to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second cause is a broader rule (e.g. \`*.js\` at root) hiding bundle files added by \`webjs vendor pin --download\`.`,
|
|
586
|
-
fix:
|
|
587
|
-
'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' +
|
|
588
|
-
' **/.webjs/*\n' +
|
|
589
|
-
' !**/.webjs/vendor/\n' +
|
|
590
|
-
' !**/.webjs/vendor/**\n' +
|
|
591
|
-
'The `**/` prefix ignores `.webjs/` at any depth (so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`) while still re-including the committed vendor pin. ' +
|
|
592
|
-
'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).',
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
return {
|
|
597
|
-
name: 'vendor-gitignore',
|
|
598
|
-
status: 'pass',
|
|
599
|
-
message: 'The .gitignore keeps .webjs/vendor/ committable.',
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Compare an installed version against a semver range PRAGMATICALLY (no semver
|
|
605
|
-
* dependency). Supports the common scaffold shapes: `latest` / `*` / `workspace:*`
|
|
606
|
-
* (any installed version satisfies), an exact `1.2.3`, and a caret `^1.2.3`
|
|
607
|
-
* (installed must be >= the floor AND share the same major, with major 0 also
|
|
608
|
-
* pinning the minor, matching npm caret semantics). An unrecognized range is
|
|
609
|
-
* treated as "cannot statically verify" (returns null), so the caller does not
|
|
610
|
-
* warn on a shape it does not understand.
|
|
611
|
-
* @param {string} installed
|
|
612
|
-
* @param {string} range
|
|
613
|
-
* @returns {boolean | null}
|
|
614
|
-
*/
|
|
615
|
-
function satisfiesRange(installed, range) {
|
|
616
|
-
if (!installed) return null;
|
|
617
|
-
const r = String(range).trim();
|
|
618
|
-
if (r === 'latest' || r === '*' || r === '' || r.startsWith('workspace:')) return true;
|
|
619
|
-
const parse = (v) => {
|
|
620
|
-
const m = String(v).match(/(\d+)\.(\d+)\.(\d+)/);
|
|
621
|
-
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
622
|
-
};
|
|
623
|
-
const inst = parse(installed);
|
|
624
|
-
if (!inst) return null;
|
|
625
|
-
if (/^\d+\.\d+\.\d+$/.test(r)) {
|
|
626
|
-
const exact = parse(r);
|
|
627
|
-
return exact ? inst[0] === exact[0] && inst[1] === exact[1] && inst[2] === exact[2] : null;
|
|
628
|
-
}
|
|
629
|
-
if (r.startsWith('^')) {
|
|
630
|
-
const floor = parse(r);
|
|
631
|
-
if (!floor) return null;
|
|
632
|
-
if (inst[0] !== floor[0]) return false;
|
|
633
|
-
// For 0.x, caret pins the minor too (^0.7.0 allows 0.7.x, not 0.8.0).
|
|
634
|
-
if (floor[0] === 0 && inst[1] !== floor[1]) return false;
|
|
635
|
-
const cmp =
|
|
636
|
-
inst[0] !== floor[0] ? inst[0] - floor[0] :
|
|
637
|
-
inst[1] !== floor[1] ? inst[1] - floor[1] :
|
|
638
|
-
inst[2] - floor[2];
|
|
639
|
-
return cmp >= 0;
|
|
640
|
-
}
|
|
641
|
-
return null;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* Read the declared dependency ranges of an INSTALLED package from
|
|
646
|
-
* `node_modules/<pkg>/package.json`, for the importmap-coherence check. This
|
|
647
|
-
* is the "already-resolved metadata, no network" path the issue calls for: the
|
|
648
|
-
* package is on disk (it was installed for the importmap to pin it), so its
|
|
649
|
-
* manifest is a local read. Returns null on any failure (not installed,
|
|
650
|
-
* unreadable, unparseable), which the coherence check treats as "could not
|
|
651
|
-
* verify" rather than a conflict.
|
|
652
|
-
*
|
|
653
|
-
* @param {string} appDir
|
|
654
|
-
* @returns {(pkg: string) => Promise<{ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null>}
|
|
655
|
-
*/
|
|
656
|
-
function makeInstalledManifestReader(appDir) {
|
|
657
|
-
return async (pkg) => {
|
|
658
|
-
const manifestPath = join(appDir, 'node_modules', pkg, 'package.json');
|
|
659
|
-
if (!existsSync(manifestPath)) return null;
|
|
660
|
-
try {
|
|
661
|
-
const parsed = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
662
|
-
return {
|
|
663
|
-
dependencies: parsed.dependencies || {},
|
|
664
|
-
peerDependencies: parsed.peerDependencies || {},
|
|
665
|
-
};
|
|
666
|
-
} catch {
|
|
667
|
-
return null;
|
|
668
|
-
}
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/**
|
|
673
|
-
* Format a coherence conflict list into a single human-readable warning line
|
|
674
|
-
* naming each conflicting pair, the required range, and the pinned version.
|
|
675
|
-
* @param {Array<{ pkg: string, version: string, dependsOn: string, kind: string, requiredRange: string, pinnedVersion: string }>} conflicts
|
|
676
|
-
* @returns {string}
|
|
677
|
-
*/
|
|
678
|
-
function formatConflicts(conflicts) {
|
|
679
|
-
return conflicts
|
|
680
|
-
.map(
|
|
681
|
-
(c) =>
|
|
682
|
-
`${c.pkg}@${c.version} needs ${c.dependsOn} ${c.kind === 'peerDependency' ? '(peer) ' : ''}${c.requiredRange} but the importmap pins ${c.dependsOn}@${c.pinnedVersion}`,
|
|
683
|
-
)
|
|
684
|
-
.join('; ');
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* CHECK 7, importmap coherence (issue #450). Defense-in-depth that catches an
|
|
689
|
-
* INCOHERENT client dependency graph in the produced importmap, regardless of
|
|
690
|
-
* how the incoherence arose (a hand-edited pin file, a partial vendor pin, or
|
|
691
|
-
* the #446 resolution skew). For each resolved package, it checks that the
|
|
692
|
-
* version actually pinned for every OTHER resolved package it depends on
|
|
693
|
-
* satisfies the declared range; a miss warns naming both packages, the range,
|
|
694
|
-
* and the pinned version.
|
|
695
|
-
*
|
|
696
|
-
* Runs the SAME check over BOTH inputs and produces the same verdict for the
|
|
697
|
-
* same dep set (the parity invariant): the live importmap (resolved the way the
|
|
698
|
-
* server resolves it at runtime) AND the vendored `.webjs/vendor/importmap.json`.
|
|
699
|
-
* A vendored importmap is a freeze of the runtime-resolved graph, so a coherent
|
|
700
|
-
* runtime graph that gets vendored stays coherent.
|
|
701
|
-
*
|
|
702
|
-
* WARN-only and BEST-EFFORT: it never hard-fails (a runtime incoherence is the
|
|
703
|
-
* app's concern, not a broken toolchain), and it degrades to a soft
|
|
704
|
-
* "could not verify" whenever metadata or a live resolve is unavailable rather
|
|
705
|
-
* than failing closed. Dependency metadata is read from the already-installed
|
|
706
|
-
* `node_modules` manifests, no network call of its own; the only network touch
|
|
707
|
-
* is the live importmap resolve, which is wrapped so any failure degrades.
|
|
708
|
-
*
|
|
709
|
-
* The vendor functions + manifest reader are injectable via `opts.coherence`
|
|
710
|
-
* so a test can drive every branch without a network call.
|
|
711
|
-
*
|
|
712
|
-
* @param {string} appDir
|
|
713
|
-
* @param {{ coherence?: {
|
|
714
|
-
* liveImports?: () => Promise<Record<string,string> | null>,
|
|
715
|
-
* vendoredImports?: () => Promise<Record<string,string> | null>,
|
|
716
|
-
* getManifest?: (pkg: string, version: string) => Promise<any>,
|
|
717
|
-
* check?: (imports: Record<string,string>, o: { getManifest: any }) => Promise<{ conflicts: any[], unverified: any[], checked: number }>,
|
|
718
|
-
* } }} opts
|
|
719
|
-
* @returns {Promise<DoctorResult>}
|
|
720
|
-
*/
|
|
721
|
-
async function checkImportmapCoherence(appDir, opts) {
|
|
722
|
-
let inj = opts.coherence;
|
|
723
|
-
// Resolve the real vendor toolchain unless a test injected stubs. Both the
|
|
724
|
-
// importmap sources and the coherence-check function come from
|
|
725
|
-
// @webjsdev/server, so a missing install degrades to a WARN, never a throw.
|
|
726
|
-
if (!inj || !inj.check || !inj.liveImports || !inj.vendoredImports || !inj.getManifest) {
|
|
727
|
-
let mod;
|
|
728
|
-
try {
|
|
729
|
-
mod = await import('@webjsdev/server');
|
|
730
|
-
} catch {
|
|
731
|
-
return {
|
|
732
|
-
name: 'importmap-coherence',
|
|
733
|
-
status: 'warn',
|
|
734
|
-
bestEffort: true,
|
|
735
|
-
message: 'Could not load the vendor toolchain to check importmap coherence.',
|
|
736
|
-
fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
const real = {
|
|
740
|
-
check: mod.checkImportmapCoherence,
|
|
741
|
-
// Hoist-aware manifest read from the already-installed node_modules (no
|
|
742
|
-
// network of its own), so a monorepo-hoisted dep still resolves. Falls
|
|
743
|
-
// back to the local app/node_modules read if the server build predates
|
|
744
|
-
// getPackageManifest.
|
|
745
|
-
getManifest: typeof mod.getPackageManifest === 'function'
|
|
746
|
-
? (pkg) => mod.getPackageManifest(pkg, appDir)
|
|
747
|
-
: makeInstalledManifestReader(appDir),
|
|
748
|
-
// Live importmap: resolve vendor imports the way the server does on the
|
|
749
|
-
// first request (prefers the pin file, else a live jspm.io resolve).
|
|
750
|
-
liveImports: async () => {
|
|
751
|
-
try {
|
|
752
|
-
const resolved = await mod.resolveVendorImports(appDir, () => mod.scanBareImports(appDir));
|
|
753
|
-
return resolved && resolved.imports ? resolved.imports : {};
|
|
754
|
-
} catch {
|
|
755
|
-
return null;
|
|
756
|
-
}
|
|
757
|
-
},
|
|
758
|
-
// Vendored importmap: the committed pin file, no network.
|
|
759
|
-
vendoredImports: async () => {
|
|
760
|
-
try {
|
|
761
|
-
const pin = await mod.readPinFile(appDir);
|
|
762
|
-
return pin && pin.imports ? pin.imports : null;
|
|
763
|
-
} catch {
|
|
764
|
-
return null;
|
|
765
|
-
}
|
|
766
|
-
},
|
|
767
|
-
};
|
|
768
|
-
inj = { ...real, ...(inj || {}) };
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// Gather both importmaps. Either may be absent (no pin file, or a live
|
|
772
|
-
// resolve that failed / found no vendor imports); the check runs over
|
|
773
|
-
// whichever exist, identically.
|
|
774
|
-
let live = null;
|
|
775
|
-
let vendored = null;
|
|
776
|
-
try { live = await inj.liveImports(); } catch { live = null; }
|
|
777
|
-
try { vendored = await inj.vendoredImports(); } catch { vendored = null; }
|
|
778
|
-
|
|
779
|
-
const liveHas = live && Object.keys(live).length > 0;
|
|
780
|
-
const vendoredHas = vendored && Object.keys(vendored).length > 0;
|
|
781
|
-
if (!liveHas && !vendoredHas) {
|
|
782
|
-
return {
|
|
783
|
-
name: 'importmap-coherence',
|
|
784
|
-
status: 'pass',
|
|
785
|
-
message: 'No vendor importmap to check (the app imports no npm packages on the client).',
|
|
786
|
-
};
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// Run the IDENTICAL check over each available importmap. The function is
|
|
790
|
-
// pure in (imports, getManifest), so the same pinned dep set produces the
|
|
791
|
-
// same verdict whichever input it came from (the runtime-vs-vendored parity
|
|
792
|
-
// invariant). Aggregate the conflicts; dedupe identical ones so a package
|
|
793
|
-
// pinned the same way in both maps is reported once.
|
|
794
|
-
/** @type {Map<string, any>} */
|
|
795
|
-
const conflictsByKey = new Map();
|
|
796
|
-
let anyChecked = 0;
|
|
797
|
-
let anyUnverified = 0;
|
|
798
|
-
for (const imports of [liveHas ? live : null, vendoredHas ? vendored : null]) {
|
|
799
|
-
if (!imports) continue;
|
|
800
|
-
let report;
|
|
801
|
-
try {
|
|
802
|
-
report = await inj.check(imports, { getManifest: inj.getManifest });
|
|
803
|
-
} catch {
|
|
804
|
-
// A check that threw is a "could not verify", never a doctor crash.
|
|
805
|
-
anyUnverified++;
|
|
806
|
-
continue;
|
|
807
|
-
}
|
|
808
|
-
anyChecked += report.checked || 0;
|
|
809
|
-
anyUnverified += (report.unverified || []).length;
|
|
810
|
-
for (const c of report.conflicts || []) {
|
|
811
|
-
conflictsByKey.set(`${c.pkg}@${c.version}->${c.dependsOn}@${c.pinnedVersion}`, c);
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
const conflicts = [...conflictsByKey.values()];
|
|
816
|
-
if (conflicts.length > 0) {
|
|
817
|
-
return {
|
|
818
|
-
name: 'importmap-coherence',
|
|
819
|
-
status: 'warn',
|
|
820
|
-
message: `Incoherent client dependency graph in the importmap: ${formatConflicts(conflicts)}.`,
|
|
821
|
-
fix: 'Align the pinned versions: re-run `webjs vendor pin` to re-resolve a coherent set, or bump the lagging package in package.json and reinstall so the importmap pins a version satisfying every dependent.',
|
|
822
|
-
};
|
|
823
|
-
}
|
|
824
|
-
if (anyChecked === 0 && anyUnverified > 0) {
|
|
825
|
-
return {
|
|
826
|
-
name: 'importmap-coherence',
|
|
827
|
-
status: 'warn',
|
|
828
|
-
bestEffort: true,
|
|
829
|
-
message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).',
|
|
830
|
-
fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.',
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
return {
|
|
834
|
-
name: 'importmap-coherence',
|
|
835
|
-
status: 'pass',
|
|
836
|
-
message: 'The importmap dependency graph is coherent (every pinned package satisfies its dependents\' declared ranges).',
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
/**
|
|
841
|
-
* Read a dependency's INSTALLED version as resolved FROM `appDir`, or null when
|
|
842
|
-
* it does not resolve there at all.
|
|
843
|
-
*
|
|
844
|
-
* Node's own resolver is the ground truth here, not a directory read. The check
|
|
845
|
-
* this serves asks "would this app resolve this dependency at runtime, and at
|
|
846
|
-
* what version", and Node's resolution algorithm IS that question's definition,
|
|
847
|
-
* so anything re-implementing it can only be a worse approximation. Asking Node
|
|
848
|
-
* handles workspace hoisting (the bug this fixes: under npm workspaces the
|
|
849
|
-
* `@webjsdev/*` deps hoist to the ROOT node_modules, so an app subdirectory has
|
|
850
|
-
* no local copy and a per-app `node_modules/<dep>/package.json` read reported
|
|
851
|
-
* every declared dep missing on a healthy install), symlinked workspace links,
|
|
852
|
-
* nested non-hoisted trees, and `package.json` `imports`, for free and for ever.
|
|
853
|
-
*
|
|
854
|
-
* The direct `<dep>/package.json` resolve is attempted FIRST because a package
|
|
855
|
-
* may declare no main entry at all: `@webjsdev/cli` is bin-only (no `main`, no
|
|
856
|
-
* `exports`), so `require.resolve('@webjsdev/cli')` throws MODULE_NOT_FOUND.
|
|
857
|
-
* The ERR_PACKAGE_PATH_NOT_EXPORTED fallback exists because a package may lock
|
|
858
|
-
* its manifest out of its `exports` map: `@webjsdev/server` exports only `.`,
|
|
859
|
-
* `./check`, `./testing`, and `./webjs-config.schema.json`, so the direct
|
|
860
|
-
* manifest resolve is refused and the main entry plus a bounded walk up to the
|
|
861
|
-
* package root is the way in. Neither strategy alone resolves all four
|
|
862
|
-
* `@webjsdev/*` packages; both halves are required.
|
|
863
|
-
*
|
|
864
|
-
* Local rather than `getPackageVersion` from `@webjsdev/server` for two reasons.
|
|
865
|
-
* Doctor must stay usable when the framework does not resolve from the app dir
|
|
866
|
-
* at all, which is the #954 fresh-worktree case doctor exists to diagnose, so
|
|
867
|
-
* this check cannot import the server (the same argument `frameworkResolves`
|
|
868
|
-
* below already follows). And `getPackageVersion` resolves the main entry only,
|
|
869
|
-
* so it returns null for a bin-only package, which would leave `@webjsdev/cli`
|
|
870
|
-
* reported missing: the same false positive with more machinery.
|
|
871
|
-
*
|
|
872
|
-
* Pinned by the workspace, bin-only, and exports-locked fixtures in
|
|
873
|
-
* `test/cli/doctor.test.mjs`.
|
|
874
|
-
* @param {string} dep package name, e.g. `@webjsdev/server`
|
|
875
|
-
* @param {string} appDir directory to anchor resolution at
|
|
876
|
-
* @returns {Promise<string|null>} the installed version, or null when unresolvable
|
|
877
|
-
*/
|
|
878
|
-
async function readInstalledVersion(dep, appDir) {
|
|
879
|
-
// The base file need not exist; createRequire only uses it to anchor the
|
|
880
|
-
// node_modules lookup at appDir.
|
|
881
|
-
const require = createRequire(join(appDir, '__webjs_resolve_probe__.js'));
|
|
882
|
-
let manifestPath = null;
|
|
883
|
-
try {
|
|
884
|
-
manifestPath = require.resolve(dep + '/package.json');
|
|
885
|
-
} catch (err) {
|
|
886
|
-
if (err?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') return null;
|
|
887
|
-
let entry;
|
|
888
|
-
try {
|
|
889
|
-
entry = require.resolve(dep);
|
|
890
|
-
} catch {
|
|
891
|
-
return null;
|
|
892
|
-
}
|
|
893
|
-
let dir = dirname(entry);
|
|
894
|
-
for (let i = 0; i < 12; i++) {
|
|
895
|
-
const candidate = join(dir, 'package.json');
|
|
896
|
-
if (existsSync(candidate)) {
|
|
897
|
-
manifestPath = candidate;
|
|
898
|
-
break;
|
|
899
|
-
}
|
|
900
|
-
const parent = dirname(dir);
|
|
901
|
-
if (parent === dir) break;
|
|
902
|
-
dir = parent;
|
|
903
|
-
}
|
|
904
|
-
if (!manifestPath) return null;
|
|
905
|
-
}
|
|
906
|
-
try {
|
|
907
|
-
return JSON.parse(await readFile(manifestPath, 'utf8')).version || null;
|
|
908
|
-
} catch {
|
|
909
|
-
return null;
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
/**
|
|
914
|
-
* CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is
|
|
915
|
-
* not a crash). Reads the app package.json `@webjsdev/*` ranges across
|
|
916
|
-
* dependencies + devDependencies, then for each resolves the INSTALLED version
|
|
917
|
-
* through Node's own resolver anchored at the app dir (see
|
|
918
|
-
* `readInstalledVersion`, which is why a workspace-hoisted install resolves)
|
|
919
|
-
* and checks it satisfies the declared range. PASS when every @webjsdev dep is
|
|
920
|
-
* present + satisfied; WARN on a missing install or a range drift.
|
|
921
|
-
* @param {string} appDir
|
|
922
|
-
* @returns {Promise<DoctorResult>}
|
|
923
|
-
*/
|
|
924
|
-
async function checkWebjsVersions(appDir) {
|
|
925
|
-
const pkgPath = join(appDir, 'package.json');
|
|
926
|
-
if (!existsSync(pkgPath)) {
|
|
927
|
-
return {
|
|
928
|
-
name: 'webjs-versions',
|
|
929
|
-
status: 'warn',
|
|
930
|
-
message: 'No package.json found in this directory.',
|
|
931
|
-
fix: 'Run `webjs doctor` from the app root (where package.json lives).',
|
|
932
|
-
};
|
|
933
|
-
}
|
|
934
|
-
let pkg;
|
|
935
|
-
try {
|
|
936
|
-
pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
937
|
-
} catch {
|
|
938
|
-
return {
|
|
939
|
-
name: 'webjs-versions',
|
|
940
|
-
status: 'warn',
|
|
941
|
-
message: 'package.json could not be parsed.',
|
|
942
|
-
fix: 'Fix the package.json syntax.',
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
const ranges = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
946
|
-
const webjsDeps = Object.keys(ranges).filter((n) => n.startsWith('@webjsdev/'));
|
|
947
|
-
if (webjsDeps.length === 0) {
|
|
948
|
-
return {
|
|
949
|
-
name: 'webjs-versions',
|
|
950
|
-
status: 'warn',
|
|
951
|
-
message: 'No @webjsdev/* dependencies declared in package.json.',
|
|
952
|
-
fix: 'A webjs app depends on @webjsdev/core + @webjsdev/server (+ @webjsdev/cli).',
|
|
953
|
-
};
|
|
954
|
-
}
|
|
955
|
-
const missing = [];
|
|
956
|
-
const drift = [];
|
|
957
|
-
for (const dep of webjsDeps) {
|
|
958
|
-
const installedVersion = await readInstalledVersion(dep, appDir);
|
|
959
|
-
if (!installedVersion) {
|
|
960
|
-
missing.push(dep);
|
|
961
|
-
continue;
|
|
962
|
-
}
|
|
963
|
-
const ok = satisfiesRange(installedVersion, ranges[dep]);
|
|
964
|
-
// null = a range shape we cannot statically verify; do not warn on it.
|
|
965
|
-
if (ok === false) drift.push(`${dep}@${installedVersion} does not satisfy "${ranges[dep]}"`);
|
|
966
|
-
}
|
|
967
|
-
if (missing.length > 0) {
|
|
968
|
-
return {
|
|
969
|
-
name: 'webjs-versions',
|
|
970
|
-
status: 'warn',
|
|
971
|
-
message: `${missing.length} @webjsdev/* dependency not installed: ${missing.join(', ')}.`,
|
|
972
|
-
fix: 'Run `npm install` to install the declared dependencies.',
|
|
973
|
-
};
|
|
974
|
-
}
|
|
975
|
-
if (drift.length > 0) {
|
|
976
|
-
return {
|
|
977
|
-
name: 'webjs-versions',
|
|
978
|
-
status: 'warn',
|
|
979
|
-
message: `@webjsdev version drift: ${drift.join('; ')}.`,
|
|
980
|
-
fix: 'Run `npm install` to reconcile node_modules with the declared ranges.',
|
|
981
|
-
};
|
|
982
|
-
}
|
|
983
|
-
return {
|
|
984
|
-
name: 'webjs-versions',
|
|
985
|
-
status: 'pass',
|
|
986
|
-
message: `All ${webjsDeps.length} @webjsdev/* dependency satisfy their declared ranges.`,
|
|
987
|
-
};
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
/**
|
|
991
|
-
* CHECK 6 (optional), git pre-commit hook installed + executable. WARN when the
|
|
992
|
-
* repo is a git checkout but `.git/hooks/pre-commit` is absent or
|
|
993
|
-
* non-executable, since the test-gate / changelog hook would not fire. PASS when
|
|
994
|
-
* present + executable, or skip (PASS) when this is not a git checkout at all
|
|
995
|
-
* (an exported tarball, a non-repo dir). Respects a configured `core.hooksPath`
|
|
996
|
-
* is OUT of scope here: the common scaffold installs into `.git/hooks`, so this
|
|
997
|
-
* checks the default location and a configured path is the user's own concern.
|
|
998
|
-
* @param {string} appDir
|
|
999
|
-
* @returns {DoctorResult}
|
|
1000
|
-
*/
|
|
1001
|
-
function checkGitHook(appDir) {
|
|
1002
|
-
const gitDir = join(appDir, '.git');
|
|
1003
|
-
if (!existsSync(gitDir)) {
|
|
1004
|
-
return {
|
|
1005
|
-
name: 'git-hook',
|
|
1006
|
-
status: 'pass',
|
|
1007
|
-
message: 'Not a git checkout; no pre-commit hook expected.',
|
|
1008
|
-
};
|
|
1009
|
-
}
|
|
1010
|
-
const hook = join(gitDir, 'hooks', 'pre-commit');
|
|
1011
|
-
if (!existsSync(hook)) {
|
|
1012
|
-
return {
|
|
1013
|
-
name: 'git-hook',
|
|
1014
|
-
status: 'warn',
|
|
1015
|
-
message: 'No .git/hooks/pre-commit hook installed.',
|
|
1016
|
-
fix: 'Install the project hooks (e.g. `npm install` runs the prepare step that wires them).',
|
|
1017
|
-
};
|
|
1018
|
-
}
|
|
1019
|
-
let executable = false;
|
|
1020
|
-
try {
|
|
1021
|
-
// Owner-execute bit. On a checkout without exec bits (some Windows / CI
|
|
1022
|
-
// setups) the hook will not run, so flag it.
|
|
1023
|
-
executable = (statSync(hook).mode & 0o100) !== 0;
|
|
1024
|
-
} catch {
|
|
1025
|
-
executable = false;
|
|
1026
|
-
}
|
|
1027
|
-
if (!executable) {
|
|
1028
|
-
return {
|
|
1029
|
-
name: 'git-hook',
|
|
1030
|
-
status: 'warn',
|
|
1031
|
-
message: '.git/hooks/pre-commit exists but is not executable.',
|
|
1032
|
-
fix: 'chmod +x .git/hooks/pre-commit',
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
|
-
return {
|
|
1036
|
-
name: 'git-hook',
|
|
1037
|
-
status: 'pass',
|
|
1038
|
-
message: '.git/hooks/pre-commit is installed and executable.',
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
/**
|
|
1043
|
-
* Run every doctor check against `appDir` and return the results. PURE: no
|
|
1044
|
-
* printing, no `process.exit`; the CLI renders + decides the exit code.
|
|
1045
|
-
*
|
|
1046
|
-
* @param {string} appDir the app directory to check (usually `process.cwd()`)
|
|
1047
|
-
* @param {{
|
|
1048
|
-
* nodeVersion?: string,
|
|
1049
|
-
* cliDir?: string,
|
|
1050
|
-
* vendor?: { hasVendorPin: (d: string) => boolean, findOutdated: (d: string) => Promise<Array<{ pkg: string, current: string, latest: string }>> },
|
|
1051
|
-
* }} [opts] test-injection seams:
|
|
1052
|
-
* - `nodeVersion`: override the running Node version (asserts the fail case
|
|
1053
|
-
* without being on old Node);
|
|
1054
|
-
* - `cliDir`: directory of the CLI package whose `engines.node` sources the
|
|
1055
|
-
* required major (defaults to THIS module's package);
|
|
1056
|
-
* - `vendor`: inject the `{ hasVendorPin, findOutdated }` pair so the pin check
|
|
1057
|
-
* runs against a stub instead of a real network call.
|
|
1058
|
-
* - `coherence`: inject `{ liveImports, vendoredImports, getManifest, check }`
|
|
1059
|
-
* so the importmap-coherence check runs against stub importmaps + metadata
|
|
1060
|
-
* instead of a real live resolve / node_modules read.
|
|
1061
|
-
* @returns {Promise<DoctorResult[]>}
|
|
1062
|
-
*/
|
|
1063
|
-
/**
|
|
1064
|
-
* Advisory (#646): name why a page/layout SHIPS its module to the browser
|
|
1065
|
-
* instead of being elided. A page/layout that is a pure carrier (import-only
|
|
1066
|
-
* #605 / inert #179) stays out of the browser; one that ships whole is pinned
|
|
1067
|
-
* by a specific client-effecting NON-component on a component-free path from it, #963 (a util touching
|
|
1068
|
-
* a client global, a module-scope side effect, a bare side-effect import) or by
|
|
1069
|
-
* its own client work. This turns that invisible #605/#179 regression into a
|
|
1070
|
-
* named line. WARN only: a page legitimately MAY ship, and the analyser is
|
|
1071
|
-
* biased toward shipping by design (server AGENTS invariant 7), so this is a
|
|
1072
|
-
* "you may not have intended this" hint, never a hard fail.
|
|
1073
|
-
* @param {Promise<any|null>} elisionPromise the ONE shared report (#1308)
|
|
1074
|
-
* @returns {Promise<DoctorResult>}
|
|
1075
|
-
*/
|
|
1076
|
-
async function checkElisionCarriers(elisionPromise) {
|
|
1077
|
-
const name = 'Page/layout elision (carrier hygiene)';
|
|
1078
|
-
const report = await elisionPromise;
|
|
1079
|
-
if (!report) {
|
|
1080
|
-
// Analysis unavailable (no app, malformed, server import failed): no advice.
|
|
1081
|
-
return { name, status: 'pass', message: 'not analysed (no routable app or analysis unavailable)' };
|
|
1082
|
-
}
|
|
1083
|
-
if (!report.analysed) {
|
|
1084
|
-
return { name, status: 'pass', message: 'not analysed (no routable app, or elision is disabled)' };
|
|
1085
|
-
}
|
|
1086
|
-
// Paths and reasons arrive app-relative from `analyzeAppElision` (#1308).
|
|
1087
|
-
const shipped = report.routeModules.filter((r) => r.verdict === 'shipped');
|
|
1088
|
-
if (shipped.length === 0) {
|
|
1089
|
-
return { name, status: 'pass', message: 'every page/layout is elided (a pure import-only or inert carrier)' };
|
|
1090
|
-
}
|
|
1091
|
-
// Name the FIRST client-effecting blocker (there may be more than one; the
|
|
1092
|
-
// module stays shipped until every such blocker is moved out).
|
|
1093
|
-
const lines = shipped.map(({ file, blocker, reason }) =>
|
|
1094
|
-
blocker
|
|
1095
|
-
? `${file} ships whole. Its first client-effecting blocker is ${blocker}, which ${reason} and is not a component`
|
|
1096
|
-
: `${file} ships whole because it ${reason}`,
|
|
1097
|
-
);
|
|
1098
|
-
return {
|
|
1099
|
-
name,
|
|
1100
|
-
status: 'warn',
|
|
1101
|
-
message:
|
|
1102
|
-
`${shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` +
|
|
1103
|
-
lines.map((l) => ` ${l}`).join('\n'),
|
|
1104
|
-
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.',
|
|
1105
|
-
};
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
/**
|
|
1109
|
-
* The OTHER direction of the elision verdict (#1308): which COMPONENT modules
|
|
1110
|
-
* the browser never downloads. `checkElisionCarriers` above reports the benign
|
|
1111
|
-
* over-ship direction; this one reports what was DROPPED, which is where a
|
|
1112
|
-
* wrong verdict silently costs an app its interactivity.
|
|
1113
|
-
*
|
|
1114
|
-
* Pass-only except for orphans, deliberately. An elided component is the
|
|
1115
|
-
* DESIRED outcome, so warning on one would fire on every healthy app and train
|
|
1116
|
-
* the reader to skip doctor output. The passing message carries the elided
|
|
1117
|
-
* inventory instead, which makes it the discovery surface, while `webjs
|
|
1118
|
-
* elision` is the detail surface. The one always-wrong condition is an ORPHAN:
|
|
1119
|
-
* a `class X extends WebComponent` with no literal-tag registration is
|
|
1120
|
-
* invisible to the scanner, so it gets no verdict at all and `static
|
|
1121
|
-
* interactive = true` cannot rescue it (nothing consults the component
|
|
1122
|
-
* analyser for a component the scanner never saw). Never `fail`:
|
|
1123
|
-
* an app that wants an orphan to break CI gates `ELISION_COMPONENTS` to
|
|
1124
|
-
* `error` via `webjs.doctor.gate`.
|
|
1125
|
-
*
|
|
1126
|
-
* @param {Promise<any|null>} elisionPromise the ONE shared report
|
|
1127
|
-
* @returns {Promise<DoctorResult>}
|
|
1128
|
-
*/
|
|
1129
|
-
async function checkElisionComponents(elisionPromise) {
|
|
1130
|
-
const name = 'Component elision (what the browser drops)';
|
|
1131
|
-
const report = await elisionPromise;
|
|
1132
|
-
const notAnalysed = { name, status: /** @type {const} */ ('pass'), message: 'not analysed (no routable app or analysis unavailable)' };
|
|
1133
|
-
if (!report) return notAnalysed;
|
|
1134
|
-
if (!report.analysed) {
|
|
1135
|
-
return report.skipped === 'elide-off'
|
|
1136
|
-
? { name, status: 'pass', message: 'elision is disabled (webjs.elide false or WEBJS_ELIDE), so every component module ships' }
|
|
1137
|
-
: notAnalysed;
|
|
1138
|
-
}
|
|
1139
|
-
if (report.orphans.length > 0) {
|
|
1140
|
-
const lines = report.orphans.map(({ file, className }) =>
|
|
1141
|
-
`${className} in ${file} is never registered with a literal tag`,
|
|
1142
|
-
);
|
|
1143
|
-
return {
|
|
1144
|
-
name,
|
|
1145
|
-
status: 'warn',
|
|
1146
|
-
message:
|
|
1147
|
-
`${report.orphans.length} component class(es) get NO elision verdict:\n` +
|
|
1148
|
-
lines.map((l) => ` ${l}`).join('\n') +
|
|
1149
|
-
'\n Either it has no registration call at all, or it registers a computed tag. The component '
|
|
1150
|
-
+ 'scanner matches only a literal tag, so either way it never sees the class: no elision verdict, no '
|
|
1151
|
-
+ 'registry entry, no preload hint, and `static interactive = true` cannot rescue it. With no '
|
|
1152
|
-
+ 'registration call the element never upgrades at all; with a computed tag it upgrades only while '
|
|
1153
|
-
+ 'its module still reaches the browser through an importer that ships.',
|
|
1154
|
-
fix: 'Register it with a literal tag, Class.register(\'my-tag\') (invariant 3 already requires one), or delete the class if nothing uses it.',
|
|
1155
|
-
};
|
|
1156
|
-
}
|
|
1157
|
-
const elided = report.components.filter((c) => c.verdict === 'elided');
|
|
1158
|
-
const tags = elided.flatMap((c) => c.tags);
|
|
1159
|
-
const shown = tags.slice(0, 8).join(', ');
|
|
1160
|
-
const tail = tags.length > 8 ? `, +${tags.length - 8} more` : '';
|
|
1161
|
-
return {
|
|
1162
|
-
name,
|
|
1163
|
-
status: 'pass',
|
|
1164
|
-
message:
|
|
1165
|
-
`${report.summary.elided} of ${report.summary.components} component module(s) are elided (never downloaded)` +
|
|
1166
|
-
(tags.length ? `: ${shown}${tail}` : '') +
|
|
1167
|
-
'. Run `webjs elision` for the full verdict.',
|
|
1168
|
-
};
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
// Directories never worth walking for the CSS-freshness advisory (mirrors
|
|
1172
|
-
// dev-regenerate's IGNORE_DIRS): build output, deps, VCS + framework caches.
|
|
1173
|
-
const FRESHNESS_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
|
|
1174
|
-
|
|
1175
|
-
/**
|
|
1176
|
-
* Newest mtime (ms) of any FILE under a path (a file's own, or the max over the
|
|
1177
|
-
* files in a directory tree, skipping dependencies / dotfiles). Directory-node
|
|
1178
|
-
* mtimes are NOT counted, matching dev-regenerate's walker: a content edit only
|
|
1179
|
-
* shows through the file mtime, and a directory mtime is a flaky moving target.
|
|
1180
|
-
* A missing path is 0. Best-effort: never throws.
|
|
1181
|
-
* @param {string} abs
|
|
1182
|
-
* @returns {number}
|
|
1183
|
-
*/
|
|
1184
|
-
function newestMtimeMs(abs) {
|
|
1185
|
-
let st;
|
|
1186
|
-
try { st = statSync(abs); } catch { return 0; }
|
|
1187
|
-
if (!st.isDirectory()) return st.mtimeMs;
|
|
1188
|
-
let newest = 0;
|
|
1189
|
-
let entries;
|
|
1190
|
-
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return newest; }
|
|
1191
|
-
for (const e of entries) {
|
|
1192
|
-
if (e.name.startsWith('.') || FRESHNESS_IGNORE.has(e.name)) continue;
|
|
1193
|
-
// Skip symlinks: following one can cycle into unbounded recursion (a stack
|
|
1194
|
-
// overflow here) or escape into node_modules. Same tradeoff as the server
|
|
1195
|
-
// walker in dev-regenerate.js.
|
|
1196
|
-
if (e.isSymbolicLink()) continue;
|
|
1197
|
-
const m = newestMtimeMs(join(abs, e.name));
|
|
1198
|
-
if (m > newest) newest = m;
|
|
1199
|
-
}
|
|
1200
|
-
return newest;
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
/**
|
|
1204
|
-
* ADVISORY: a declared `webjs.dev.regenerate` output is STALE on disk (a source
|
|
1205
|
-
* is newer than the committed/built output). In DEV the framework recompiles it
|
|
1206
|
-
* on request (#967), so this never bites locally, but the check is the explicit
|
|
1207
|
-
* dev/prod PARITY backstop: it catches a stale `public/tailwind.css` that would
|
|
1208
|
-
* be served as-is by `webjs start` (prod does NOT recompile on request) or
|
|
1209
|
-
* committed into the repo. WARN-level: the fix is a one-line rebuild, and a
|
|
1210
|
-
* missing output (a fresh clone before the first `css:build`) is not this app's
|
|
1211
|
-
* bug to hard-fail on.
|
|
1212
|
-
* @param {string} appDir
|
|
1213
|
-
* @returns {Promise<DoctorResult>}
|
|
1214
|
-
*/
|
|
1215
|
-
async function checkStaticAssetFreshness(appDir) {
|
|
1216
|
-
const name = 'Static build outputs (dev.regenerate freshness)';
|
|
1217
|
-
let pkg;
|
|
1218
|
-
try {
|
|
1219
|
-
pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
1220
|
-
} catch {
|
|
1221
|
-
return { name, status: 'pass', message: 'no package.json to analyse' };
|
|
1222
|
-
}
|
|
1223
|
-
const rules = pkg && pkg.webjs && pkg.webjs.dev ? pkg.webjs.dev.regenerate : null;
|
|
1224
|
-
if (!Array.isArray(rules) || rules.length === 0) {
|
|
1225
|
-
return { name, status: 'pass', message: 'no webjs.dev.regenerate rules declared' };
|
|
1226
|
-
}
|
|
1227
|
-
const stale = [];
|
|
1228
|
-
for (const rule of rules) {
|
|
1229
|
-
if (!rule || typeof rule.output !== 'string') continue;
|
|
1230
|
-
const output = rule.output.replace(/^\/+/, '');
|
|
1231
|
-
const outMtime = newestMtimeMs(join(appDir, output));
|
|
1232
|
-
if (outMtime === 0) continue; // missing output: not a staleness fail (built on first boot)
|
|
1233
|
-
let newestSrc = 0;
|
|
1234
|
-
for (const inp of Array.isArray(rule.inputs) ? rule.inputs : []) {
|
|
1235
|
-
const m = newestMtimeMs(join(appDir, inp));
|
|
1236
|
-
if (m > newestSrc) newestSrc = m;
|
|
1237
|
-
}
|
|
1238
|
-
if (newestSrc > outMtime) stale.push({ output, command: rule.command });
|
|
1239
|
-
}
|
|
1240
|
-
if (stale.length === 0) {
|
|
1241
|
-
return { name, status: 'pass', message: 'every declared build output is up to date with its sources' };
|
|
1242
|
-
}
|
|
1243
|
-
return {
|
|
1244
|
-
name,
|
|
1245
|
-
status: 'warn',
|
|
1246
|
-
message:
|
|
1247
|
-
`${stale.length} static build output(s) are older than a source file:\n` +
|
|
1248
|
-
stale.map((s) => ` ${s.output} (rebuild: ${s.command})`).join('\n') +
|
|
1249
|
-
'\n In dev the framework recompiles these on request, so this only bites a `webjs start` (prod) or a committed stale file.',
|
|
1250
|
-
fix: 'Rebuild the output(s) with the command shown (e.g. `npm run css:build`) before deploying or committing. `webjs dev` regenerates them on request automatically.',
|
|
1251
|
-
};
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// Directories the route-module walk never descends into (deps, VCS, framework
|
|
1255
|
-
// and build caches). Mirrors FRESHNESS_IGNORE; kept separate so either walk can
|
|
1256
|
-
// change its exclusions without silently moving the other.
|
|
1257
|
-
const ROUTE_WALK_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
|
|
1258
|
-
|
|
1259
|
-
/**
|
|
1260
|
-
* A route module that renders markup on the server, which is where `asset()`
|
|
1261
|
-
* belongs. Page and layout are the common case, but the BOUNDARY modules matter
|
|
1262
|
-
* too and are easy to miss: `error` / `not-found` / `forbidden` / `unauthorized`
|
|
1263
|
-
* / `loading` are always shipped and never elided, and `global-error` renders
|
|
1264
|
-
* its OWN `<!doctype><html><head>` and is returned verbatim with no framework
|
|
1265
|
-
* head splice, which makes it the likeliest place outside the root layout for
|
|
1266
|
-
* an author to hand-write a stylesheet link.
|
|
1267
|
-
* @type {RegExp}
|
|
1268
|
-
*/
|
|
1269
|
-
const ROUTE_MODULE_RE =
|
|
1270
|
-
/^(?:page|layout|error|not-found|forbidden|unauthorized|loading)\.(?:js|ts|mjs|mts)$/;
|
|
1271
|
-
|
|
1272
|
-
/**
|
|
1273
|
-
* The two boundary stems `router.js` registers ONLY at the app root (both are
|
|
1274
|
-
* guarded by `dir === '.'` there). A nested `app/admin/global-error.ts` is never
|
|
1275
|
-
* in the route table and never renders, so scanning one would advise on dead
|
|
1276
|
-
* code, the same defect the `_private` skip exists to avoid.
|
|
1277
|
-
* @type {RegExp}
|
|
1278
|
-
*/
|
|
1279
|
-
const ROOT_ONLY_MODULE_RE = /^(?:global-error|global-not-found)\.(?:js|ts|mjs|mts)$/;
|
|
1280
|
-
|
|
1281
|
-
/**
|
|
1282
|
-
* One whole `<link …>` tag. QUOTE-AWARE (`(?:[^>"']|"[^"]*"|'[^']*')*`), the
|
|
1283
|
-
* same shape `ssr.js`'s hoist scanner uses, so a `>` inside a quoted attribute
|
|
1284
|
-
* value cannot terminate the tag early.
|
|
1285
|
-
* @type {RegExp}
|
|
1286
|
-
*/
|
|
1287
|
-
const LINK_TAG_RE = /<link\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
1288
|
-
|
|
1289
|
-
/**
|
|
1290
|
-
* One attribute inside a tag: a name, then optionally `=` and a double-quoted,
|
|
1291
|
-
* single-quoted, or unquoted value. Matching attributes as WHOLE units is what
|
|
1292
|
-
* makes the scan correct, because each quoted value is consumed in one step and
|
|
1293
|
-
* can therefore never be re-scanned as if it contained an attribute of its own.
|
|
1294
|
-
* @type {RegExp}
|
|
1295
|
-
*/
|
|
1296
|
-
const ATTR_RE = /([a-zA-Z_:][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
1297
|
-
|
|
1298
|
-
/**
|
|
1299
|
-
* Parse a tag's attributes into a lowercased-name map. The value is `null` for a
|
|
1300
|
-
* valueless attribute and carries a `quoted` flag, since this check treats an
|
|
1301
|
-
* UNQUOTED href (a template hole) as undecidable rather than as a path.
|
|
1302
|
-
* @param {string} tag
|
|
1303
|
-
* @returns {Map<string, { value: string | null, quoted: boolean }>}
|
|
1304
|
-
*/
|
|
1305
|
-
function parseTagAttrs(tag) {
|
|
1306
|
-
/** @type {Map<string, { value: string | null, quoted: boolean }>} */
|
|
1307
|
-
const attrs = new Map();
|
|
1308
|
-
// Skip the tag name itself so `link` is not read as an attribute.
|
|
1309
|
-
const body = tag.replace(/^<[a-zA-Z_:][-\w:.]*/, '');
|
|
1310
|
-
ATTR_RE.lastIndex = 0;
|
|
1311
|
-
for (const m of body.matchAll(ATTR_RE)) {
|
|
1312
|
-
const name = m[1].toLowerCase();
|
|
1313
|
-
if (attrs.has(name)) continue; // first wins, as in HTML parsing
|
|
1314
|
-
const quoted = m[2] !== undefined || m[3] !== undefined;
|
|
1315
|
-
const value = m[2] ?? m[3] ?? m[4] ?? null;
|
|
1316
|
-
attrs.set(name, { value, quoted });
|
|
1317
|
-
}
|
|
1318
|
-
return attrs;
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
/**
|
|
1322
|
-
* Whether a parsed `<link>` is an unmarked stylesheet, and if so its href.
|
|
1323
|
-
*
|
|
1324
|
-
* Attribute PARSING rather than a lookahead over the raw tag is load-bearing,
|
|
1325
|
-
* not tidiness. A scan that merely looks ahead for `rel=…stylesheet` anywhere in
|
|
1326
|
-
* the tag matches the string inside ANOTHER attribute's value, which flags the
|
|
1327
|
-
* two shapes this check most needs to leave alone: the canonical async-CSS
|
|
1328
|
-
* `<link rel="preload" as="style" href="/public/app.css" onload="this.rel='stylesheet'">`
|
|
1329
|
-
* (where the advised `asset()` fix would actively BREAK the preload, since the
|
|
1330
|
-
* versioned hint could then never match the unversioned request), and a
|
|
1331
|
-
* `data-rel="stylesheet"` sitting on a `rel="icon"`. Reading real attributes
|
|
1332
|
-
* makes `rel` mean the `rel` attribute and nothing else.
|
|
1333
|
-
*
|
|
1334
|
-
* Returns the href only when every condition holds:
|
|
1335
|
-
* - `rel` is a token list CONTAINING `stylesheet` (so `rel="preload"` with an
|
|
1336
|
-
* onload swap, and `rel="icon"`, are both out).
|
|
1337
|
-
* - `href` is QUOTED. An unquoted value is a template hole
|
|
1338
|
-
* (`href=${asset('/public/app.css')}`), undecidable from source, and is
|
|
1339
|
-
* exactly the shape the marked form uses.
|
|
1340
|
-
* - the path is under `/public/` (after the app's `webjs.basePath` is
|
|
1341
|
-
* stripped, since under a sub-path deploy the author writes the prefix
|
|
1342
|
-
* themselves and `resolveAssetUrl` strips it before its own `public/` gate).
|
|
1343
|
-
* - `resolveAssetUrl` would actually fingerprint it. It returns a path
|
|
1344
|
-
* carrying a QUERY or a `..` unchanged, so wrapping one in `asset()` is a
|
|
1345
|
-
* runtime NO-OP: the author does the work and the url they ship is
|
|
1346
|
-
* byte-identical. Advising it would be advising a change that buys nothing.
|
|
1347
|
-
* A hand-rolled `?v=` cache-buster is exactly what an author who has not
|
|
1348
|
-
* adopted `asset()` is most likely to have written, so this is the common
|
|
1349
|
-
* case, not a corner. (The warning itself would clear, since this check
|
|
1350
|
-
* reads the SOURCE shape and a wrapped href is an unquoted hole. Clearing a
|
|
1351
|
-
* warning without improving the caching is the outcome to avoid.)
|
|
1352
|
-
*
|
|
1353
|
-
* @param {string} tag
|
|
1354
|
-
* @param {string} basePath the app's normalized `webjs.basePath` (`''` at root)
|
|
1355
|
-
* @returns {string | null}
|
|
1356
|
-
*/
|
|
1357
|
-
function unmarkedStylesheetHref(tag, basePath = '') {
|
|
1358
|
-
const attrs = parseTagAttrs(tag);
|
|
1359
|
-
const rel = attrs.get('rel');
|
|
1360
|
-
if (!rel || !rel.value) return null;
|
|
1361
|
-
if (!rel.value.toLowerCase().split(/\s+/).includes('stylesheet')) return null;
|
|
1362
|
-
const href = attrs.get('href');
|
|
1363
|
-
if (!href || !href.quoted || !href.value) return null;
|
|
1364
|
-
const url = href.value;
|
|
1365
|
-
if (url[0] !== '/' || url[1] === '/') return null;
|
|
1366
|
-
// Mirror `resolveAssetUrl`'s refusals IN ITS ORDER, so every flagged href is
|
|
1367
|
-
// one `asset()` can actually fingerprint. It strips the base path, cuts at
|
|
1368
|
-
// `?` / `#`, DECODES, and only then tests `..` and the `public/` prefix.
|
|
1369
|
-
// Testing the raw value instead disagrees at both ends: `/public/%2e%2e/x`
|
|
1370
|
-
// would be flagged although wrapping it changes nothing, and
|
|
1371
|
-
// `/%70ublic/app.css` would be skipped although `asset()` fingerprints it.
|
|
1372
|
-
let probe = url;
|
|
1373
|
-
if (basePath && probe.startsWith(basePath + '/')) probe = probe.slice(basePath.length);
|
|
1374
|
-
const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1);
|
|
1375
|
-
let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length);
|
|
1376
|
-
try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ }
|
|
1377
|
-
if (decoded.includes('..') || !decoded.startsWith('/public/')) return null;
|
|
1378
|
-
// A query is refused outright (an author query may carry meaning we do not
|
|
1379
|
-
// own, so `resolveAssetUrl` returns the url untouched); a `#fragment` is not,
|
|
1380
|
-
// since it is split off and preserved.
|
|
1381
|
-
const beforeFragment = url.indexOf('#') === -1 ? url : url.slice(0, url.indexOf('#'));
|
|
1382
|
-
if (beforeFragment.includes('?')) return null;
|
|
1383
|
-
return url;
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
/**
|
|
1387
|
-
* The app's `webjs.basePath`, normalized to `''` (root mount) or `/segment…`.
|
|
1388
|
-
*
|
|
1389
|
-
* A faithful port of `normalizeBasePath` (`packages/server/src/base-path.js`),
|
|
1390
|
-
* which is the source of truth: it trims, PREPENDS the leading slash (so the
|
|
1391
|
-
* documented `"myapp"`, `"/myapp"` and `"/myapp/"` all normalize alike), and
|
|
1392
|
-
* fails safe to `''` on a value that is not a plain same-origin prefix. Reading
|
|
1393
|
-
* only `startsWith('/')` would leave this check inert for an app configured
|
|
1394
|
-
* `"myapp"`, which is exactly the silently-inert case it exists to close.
|
|
1395
|
-
*
|
|
1396
|
-
* Ported rather than imported because that helper is not on `@webjsdev/server`'s
|
|
1397
|
-
* public surface, and because doctor must stay usable when the framework does
|
|
1398
|
-
* not resolve from the app dir at all (the #954 fresh-worktree case this same
|
|
1399
|
-
* command exists to diagnose). The port is intentional and stays. What makes it
|
|
1400
|
-
* safe is that the drift is tested rather than trusted.
|
|
1401
|
-
*
|
|
1402
|
-
* `test/cli/base-path-parity.test.mjs` feeds one input table through BOTH this
|
|
1403
|
-
* function and the server's `readBasePath`, asserting they agree with each other
|
|
1404
|
-
* and with the expected value. Change either side without the other and it reds.
|
|
1405
|
-
* So edit this body only alongside `packages/server/src/base-path.js`, and run
|
|
1406
|
-
* that test. (`test/cli/doctor.test.mjs` covers the check that consumes this,
|
|
1407
|
-
* not the normalization forms themselves.)
|
|
1408
|
-
* @param {string} appDir
|
|
1409
|
-
* @returns {Promise<string>}
|
|
1410
|
-
*/
|
|
1411
|
-
export async function readAppBasePath(appDir) {
|
|
1412
|
-
let raw;
|
|
1413
|
-
try {
|
|
1414
|
-
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
1415
|
-
raw = pkg?.webjs?.basePath;
|
|
1416
|
-
} catch {
|
|
1417
|
-
return '';
|
|
1418
|
-
}
|
|
1419
|
-
if (typeof raw !== 'string') return '';
|
|
1420
|
-
let v = raw.trim();
|
|
1421
|
-
if (v === '' || v === '/') return '';
|
|
1422
|
-
// Not a plain same-origin path prefix: fail safe to no base path.
|
|
1423
|
-
if (v.includes('..') || v.includes('://') || v.includes('\\') || /\s/.test(v)) return '';
|
|
1424
|
-
// A network-path reference (`//host`) is rejected BEFORE leading slashes are
|
|
1425
|
-
// collapsed, since collapsing would turn an origin escape into `/host`.
|
|
1426
|
-
if (v.startsWith('//')) return '';
|
|
1427
|
-
v = ('/' + v.replace(/^\/+/, '')).replace(/\/+$/, '');
|
|
1428
|
-
return v === '' || v === '/' ? '' : v;
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
/**
|
|
1432
|
-
* Whether the `<link>` tag at `idx` is commented out, so dead markup is never
|
|
1433
|
-
* reported as a live finding.
|
|
1434
|
-
*
|
|
1435
|
-
* A DELIMITED comment is decided by an unclosed opener behind the tag. Neither
|
|
1436
|
-
* `<!--` nor `/*` nests, so "nearest opener beats nearest closer" is exact, and
|
|
1437
|
-
* it covers a multi-line block whose interior lines carry no marker of their
|
|
1438
|
-
* own (what an editor's toggle-block-comment writes). A `//` has no closer, so
|
|
1439
|
-
* it is decided from the tag's own line: a `//` inside an href later in the
|
|
1440
|
-
* line cannot match, because the line does not START with it.
|
|
1441
|
-
*
|
|
1442
|
-
* Do NOT replace this with a lexer. Two attempts did, and both shipped bugs a
|
|
1443
|
-
* stateless test cannot have: a line-blanking regex killed any line holding a
|
|
1444
|
-
* protocol-relative url, and a quote-tracking walk inverted string/code
|
|
1445
|
-
* polarity on a nested ``html`...` `` inside a `${}` hole (one quote char
|
|
1446
|
-
* cannot model nesting), so an unbalanced apostrophe in template text
|
|
1447
|
-
* desynchronized the rest of the file. This check does not need to lex
|
|
1448
|
-
* JavaScript. If it ever genuinely does, export `redactStringsAndTemplates`
|
|
1449
|
-
* from `@webjsdev/server` (`src/js-scan.js`, fuzz-tested differentially against
|
|
1450
|
-
* a real TypeScript parse) rather than growing a third one here.
|
|
1451
|
-
*
|
|
1452
|
-
* Residual gap: a tag behind a `//` that trails real code on the same line
|
|
1453
|
-
* stays reported. Rare, and it fails toward reporting rather than toward the
|
|
1454
|
-
* silent inertness both lexers produced.
|
|
1455
|
-
*
|
|
1456
|
-
* @param {string} src
|
|
1457
|
-
* @param {number} idx index of the tag's `<`
|
|
1458
|
-
* @returns {boolean}
|
|
1459
|
-
*/
|
|
1460
|
-
function isCommentedOut(src, idx) {
|
|
1461
|
-
const before = src.slice(0, idx);
|
|
1462
|
-
if (before.lastIndexOf('<!--') > before.lastIndexOf('-->')) return true;
|
|
1463
|
-
if (before.lastIndexOf('/*') > before.lastIndexOf('*/')) return true;
|
|
1464
|
-
const lineStart = before.lastIndexOf('\n') + 1;
|
|
1465
|
-
return before.slice(lineStart).trimStart().startsWith('//');
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
/**
|
|
1469
|
-
* Collect every `app/**` route module that renders markup, depth-first.
|
|
1470
|
-
* Best-effort: an unreadable directory contributes nothing rather than throwing.
|
|
1471
|
-
* @param {string} dir
|
|
1472
|
-
* @param {string[]} [out]
|
|
1473
|
-
* @returns {string[]}
|
|
1474
|
-
*/
|
|
1475
|
-
function collectRouteModules(dir, root = dir, out = []) {
|
|
1476
|
-
let entries;
|
|
1477
|
-
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
1478
|
-
for (const e of entries) {
|
|
1479
|
-
if (e.name.startsWith('.') || ROUTE_WALK_IGNORE.has(e.name)) continue;
|
|
1480
|
-
if (e.isSymbolicLink()) continue; // never follow: can cycle or escape into deps
|
|
1481
|
-
// `_`-prefixed folders are PRIVATE: `router.js` drops any route whose
|
|
1482
|
-
// directory has such a segment, so markup under one is never routed and
|
|
1483
|
-
// never rendered. Advising on it would be advice about dead code.
|
|
1484
|
-
if (e.isDirectory() && e.name.startsWith('_')) continue;
|
|
1485
|
-
const abs = join(dir, e.name);
|
|
1486
|
-
if (e.isDirectory()) collectRouteModules(abs, root, out);
|
|
1487
|
-
else if (ROUTE_MODULE_RE.test(e.name)) out.push(abs);
|
|
1488
|
-
else if (dir === root && ROOT_ONLY_MODULE_RE.test(e.name)) out.push(abs);
|
|
1489
|
-
}
|
|
1490
|
-
return out;
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
/**
|
|
1494
|
-
* ADVISORY (#1095): a route module hand-writes a `<link rel="stylesheet"
|
|
1495
|
-
* href="/public/…">` without `asset()`, so the url is un-versioned and a deploy
|
|
1496
|
-
* cannot bust a CDN's copy of it.
|
|
1497
|
-
*
|
|
1498
|
-
* The failure this names was caught in production on webjs.dev: the edge served
|
|
1499
|
-
* a `public/tailwind.css` built BEFORE the deploy (`cf-cache-status: HIT`,
|
|
1500
|
-
* `max-age=14400`) against post-deploy HTML, so the new page rendered with its
|
|
1501
|
-
* content edge to edge and its grid collapsed, because the cached css was
|
|
1502
|
-
* missing the arbitrary-value utilities that page introduced. It is invisible
|
|
1503
|
-
* while a deploy only restyles existing classes and maximally visible the moment
|
|
1504
|
-
* one adds a page using new utilities.
|
|
1505
|
-
*
|
|
1506
|
-
* Why this is an ADVISORY over the author's SOURCE rather than a rewrite of the
|
|
1507
|
-
* framework's OUTPUT. The first attempt at the automatic form (#1196) matched
|
|
1508
|
-
* urls in the assembled HTML, and two deep-review rounds found six major
|
|
1509
|
-
* defects, five of them one bug: at that layer framework output and author data
|
|
1510
|
-
* are indistinguishable, so the matcher kept editing things it did not own. That
|
|
1511
|
-
* is why `asset()` (#1194) is opt-in, and it is what Rails (a
|
|
1512
|
-
* `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url
|
|
1513
|
-
* from the build graph, surfaced through `links()`) both do: take the
|
|
1514
|
-
* fingerprint from an authoritative source at the point the url is PRODUCED, and
|
|
1515
|
-
* never rewrite a rendered document. The gap `asset()` leaves is purely
|
|
1516
|
-
* ergonomic. In Rails the helper is the only idiomatic way to write the tag, so
|
|
1517
|
-
* forgetting it is nearly impossible; in WebJs the `<link>` is hand-written HTML,
|
|
1518
|
-
* so it is easy to omit. This check closes exactly that gap, at authoring time,
|
|
1519
|
-
* where the author's meaning is unambiguous and nothing is rewritten.
|
|
1520
|
-
*
|
|
1521
|
-
* Scoped to `rel="stylesheet"` on purpose. An icon is a legitimate deliberate
|
|
1522
|
-
* NON-mark (the website leaves its favicons bare so the SEO repo-health tests
|
|
1523
|
-
* can parse the hrefs literally), and a `rel="preload"` must NOT be marked at
|
|
1524
|
-
* all, since its versioned hint could never match the unversioned request a CSS
|
|
1525
|
-
* `url()` actually makes. Flagging either would nag about a correct choice.
|
|
1526
|
-
*
|
|
1527
|
-
* WARN only: an un-versioned stylesheet still SERVES correctly, it just caches
|
|
1528
|
-
* badly, and an app fronted by no CDN may not care.
|
|
1529
|
-
* @param {string} appDir
|
|
1530
|
-
* @returns {Promise<DoctorResult>}
|
|
1531
|
-
*/
|
|
1532
|
-
async function checkUnmarkedAssetLinks(appDir) {
|
|
1533
|
-
const name = 'Asset urls (unmarked stylesheet links)';
|
|
1534
|
-
const routeDir = join(appDir, 'app');
|
|
1535
|
-
if (!existsSync(routeDir)) {
|
|
1536
|
-
return { name, status: 'pass', message: 'no app/ directory to analyse' };
|
|
1537
|
-
}
|
|
1538
|
-
const basePath = await readAppBasePath(appDir);
|
|
1539
|
-
const findings = [];
|
|
1540
|
-
for (const file of collectRouteModules(routeDir)) {
|
|
1541
|
-
let src;
|
|
1542
|
-
try { src = await readFile(file, 'utf8'); } catch { continue; }
|
|
1543
|
-
// Cheap bail before any tag scanning. Case-INSENSITIVE to match the tag
|
|
1544
|
-
// regex: a file whose only link tag is written `<LINK …>` must still be
|
|
1545
|
-
// scanned, or the scanner's own case-insensitivity is unreachable exactly
|
|
1546
|
-
// where it is needed.
|
|
1547
|
-
if (!/<link/i.test(src)) continue;
|
|
1548
|
-
LINK_TAG_RE.lastIndex = 0;
|
|
1549
|
-
for (const m of src.matchAll(LINK_TAG_RE)) {
|
|
1550
|
-
const href = unmarkedStylesheetHref(m[0], basePath);
|
|
1551
|
-
if (!href) continue;
|
|
1552
|
-
// A commented-out tag emits nothing, so advising on it is advice about
|
|
1553
|
-
// dead markup.
|
|
1554
|
-
if (isCommentedOut(src, /** @type {number} */ (m.index))) continue;
|
|
1555
|
-
// 1-indexed line of the match, for a jump-to reference.
|
|
1556
|
-
const line = src.slice(0, m.index).split('\n').length;
|
|
1557
|
-
findings.push({ file, line, href });
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
if (findings.length === 0) {
|
|
1561
|
-
return { name, status: 'pass', message: 'every route-module stylesheet link is content-hashed (or has none)' };
|
|
1562
|
-
}
|
|
1563
|
-
const rel = (f) => relative(appDir, f) || f;
|
|
1564
|
-
return {
|
|
1565
|
-
name,
|
|
1566
|
-
status: 'warn',
|
|
1567
|
-
message:
|
|
1568
|
-
`${findings.length} stylesheet link(s) are served at an un-versioned url, so a deploy cannot bust a cached copy:\n` +
|
|
1569
|
-
findings.map((f) => ` ${rel(f.file)}:${f.line} href="${f.href}"`).join('\n'),
|
|
1570
|
-
fix:
|
|
1571
|
-
"Wrap the path in asset(): `import { asset } from '@webjsdev/core'` then "
|
|
1572
|
-
+ '`<link rel="stylesheet" href=${asset(\'/public/app.css\')}>`. It appends a content hash in prod '
|
|
1573
|
-
+ '(the framework then serves that url immutable for a year) and is a no-op in dev and in the browser. '
|
|
1574
|
-
+ 'Call it inside the render function, not at module scope.',
|
|
1575
|
-
};
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
/**
|
|
1579
|
-
* Probe whether `@webjsdev/core` resolves from `appDir`. Node resolution is
|
|
1580
|
-
* directory-relative, so this must probe FROM the app (not the CLI's own
|
|
1581
|
-
* location, which resolves the framework fine from a global install even when
|
|
1582
|
-
* the app cannot). A no-op-cheap resolve, no I/O beyond what Node's resolver
|
|
1583
|
-
* does, no network. Returns true when the framework resolves, false otherwise.
|
|
1584
|
-
* @param {string} appDir
|
|
1585
|
-
* @returns {boolean}
|
|
1586
|
-
*/
|
|
1587
|
-
export function frameworkResolves(appDir) {
|
|
1588
|
-
try {
|
|
1589
|
-
// The base file need not exist; createRequire only uses it to anchor the
|
|
1590
|
-
// node_modules lookup at appDir.
|
|
1591
|
-
const require = createRequire(join(appDir, '__webjs_resolve_probe__.js'));
|
|
1592
|
-
require.resolve('@webjsdev/core');
|
|
1593
|
-
return true;
|
|
1594
|
-
} catch {
|
|
1595
|
-
return false;
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
/**
|
|
1600
|
-
* CHECK 8, framework resolvability (#954). WARN when `@webjsdev/core` cannot be
|
|
1601
|
-
* resolved FROM the app directory, which is the fresh-git-worktree trap: a
|
|
1602
|
-
* worktree does not copy `node_modules`, so a plain `webjs dev` there dies at
|
|
1603
|
-
* SSR with a raw `ERR_MODULE_NOT_FOUND: Cannot find package '@webjsdev/core'`
|
|
1604
|
-
* whose remedy is not obvious. Silent PASS when the framework resolves (the
|
|
1605
|
-
* common case), so this never slows a healthy app. WARN (not a hard fail): it
|
|
1606
|
-
* is a setup/environment concern, the same tier as the version-coherence check.
|
|
1607
|
-
* @param {string} appDir
|
|
1608
|
-
* @returns {DoctorResult}
|
|
1609
|
-
*/
|
|
1610
|
-
export function checkFrameworkResolves(appDir) {
|
|
1611
|
-
const name = 'framework-resolve';
|
|
1612
|
-
if (frameworkResolves(appDir)) {
|
|
1613
|
-
return { name, status: 'pass', message: '@webjsdev/core resolves from the app directory.' };
|
|
1614
|
-
}
|
|
1615
|
-
const hasNodeModules = existsSync(join(appDir, 'node_modules'));
|
|
1616
|
-
// A git worktree checks out `.git` as a FILE (a gitdir pointer), not a
|
|
1617
|
-
// directory. That, plus a missing node_modules, is the exact #954 cause.
|
|
1618
|
-
let isWorktree = false;
|
|
1619
|
-
try {
|
|
1620
|
-
isWorktree = statSync(join(appDir, '.git')).isFile();
|
|
1621
|
-
} catch {
|
|
1622
|
-
isWorktree = false;
|
|
1623
|
-
}
|
|
1624
|
-
if (isWorktree && !hasNodeModules) {
|
|
1625
|
-
return {
|
|
1626
|
-
name,
|
|
1627
|
-
status: 'warn',
|
|
1628
|
-
message:
|
|
1629
|
-
'@webjsdev/core cannot be resolved from this directory, and this is a git worktree with no ' +
|
|
1630
|
-
'node_modules. Git worktrees do not copy node_modules, so the framework is unresolvable here ' +
|
|
1631
|
-
'and `webjs dev` / `webjs start` would fail at SSR with a raw ERR_MODULE_NOT_FOUND.',
|
|
1632
|
-
fix:
|
|
1633
|
-
'Install dependencies in this worktree (`npm install`), or symlink node_modules from the ' +
|
|
1634
|
-
'primary checkout (`ln -s ../<primary-checkout>/node_modules node_modules`).',
|
|
1635
|
-
};
|
|
1636
|
-
}
|
|
1637
|
-
if (!hasNodeModules) {
|
|
1638
|
-
return {
|
|
1639
|
-
name,
|
|
1640
|
-
status: 'warn',
|
|
1641
|
-
message: '@webjsdev/core cannot be resolved from this directory (no node_modules present).',
|
|
1642
|
-
fix: 'Run `npm install` in the app directory so the framework resolves.',
|
|
1643
|
-
};
|
|
1644
|
-
}
|
|
1645
|
-
return {
|
|
1646
|
-
name,
|
|
1647
|
-
status: 'warn',
|
|
1648
|
-
message:
|
|
1649
|
-
'@webjsdev/core cannot be resolved from this directory even though node_modules exists ' +
|
|
1650
|
-
'(a partial or corrupted install).',
|
|
1651
|
-
fix: 'Reinstall dependencies (`npm install`, or remove node_modules and reinstall).',
|
|
1652
|
-
};
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
export async function runDoctorChecks(appDir, opts = {}) {
|
|
1656
|
-
const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname;
|
|
1657
|
-
// ONE elision report for BOTH elision checks (#1308). Started before the
|
|
1658
|
-
// batch and awaited inside each check, so the module graph is built once per
|
|
1659
|
-
// doctor run and the two checks still run in parallel with everything else.
|
|
1660
|
-
// Fails soft to null, exactly as the carrier check's own try/catch did.
|
|
1661
|
-
const elision = (async () => {
|
|
1662
|
-
try {
|
|
1663
|
-
const { analyzeAppElision } = await import('@webjsdev/server');
|
|
1664
|
-
return await analyzeAppElision(appDir);
|
|
1665
|
-
} catch { return null; }
|
|
1666
|
-
})();
|
|
1667
|
-
const results = await Promise.all([
|
|
1668
|
-
checkNode(cliDir, opts),
|
|
1669
|
-
checkTsconfig(appDir),
|
|
1670
|
-
checkEnv(appDir),
|
|
1671
|
-
checkVendorPin(appDir, opts),
|
|
1672
|
-
checkVendorGitignore(appDir),
|
|
1673
|
-
checkWebjsVersions(appDir),
|
|
1674
|
-
Promise.resolve(checkFrameworkResolves(appDir)),
|
|
1675
|
-
checkImportmapCoherence(appDir, opts),
|
|
1676
|
-
Promise.resolve(checkGitHook(appDir)),
|
|
1677
|
-
checkElisionCarriers(elision),
|
|
1678
|
-
checkElisionComponents(elision),
|
|
1679
|
-
checkStaticAssetFreshness(appDir),
|
|
1680
|
-
checkUnmarkedAssetLinks(appDir),
|
|
1681
|
-
]);
|
|
1682
|
-
// Attach the stable machine code to every result (#975). Centralized here so
|
|
1683
|
-
// each check function stays free of the code-contract concern.
|
|
1684
|
-
for (const r of results) r.code = codeForName(r.name);
|
|
1685
|
-
return results;
|
|
1686
|
-
}
|
|
53
|
+
export { DOCTOR_SEVERITIES, DOCTOR_CODES, codeForName } from './doctor/codes.js';
|
|
54
|
+
export { readDoctorPolicy, applyDoctorPolicy } from './doctor/policy.js';
|
|
55
|
+
export { readAppBasePath } from './doctor/route-modules.js';
|
|
56
|
+
export { frameworkResolves, checkFrameworkResolves } from './doctor/probes/framework-resolves.js';
|
|
57
|
+
export { runDoctorChecks } from './doctor/runner.js';
|