@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CHECK: the `.gitignore` does not swallow the committed vendor pin. The pattern
|
|
10
|
+
* for `.webjs/vendor/` is subtle: a bare `.webjs/` line excludes the directory
|
|
11
|
+
* entirely and git cannot re-include children of an excluded parent, so a
|
|
12
|
+
* `!.webjs/vendor/` exception silently does nothing and `webjs vendor pin`
|
|
13
|
+
* output never gets committed. The correct pattern is the depth-robust
|
|
14
|
+
* contents-glob form (see the fix text below / VENDOR_GITIGNORE_LINES in
|
|
15
|
+
* vendor.js): a globstar-prefixed `.webjs/*` plus the matching vendor
|
|
16
|
+
* negations, which ignores transient `.webjs` output at any depth while
|
|
17
|
+
* keeping the committed vendor pin tracked.
|
|
18
|
+
*
|
|
19
|
+
* This was a `webjs check` rule, but inspecting `.gitignore` is a project-config
|
|
20
|
+
* concern (like `tsconfig-erasable`), not source-code correctness, and vendoring
|
|
21
|
+
* is optional, so a doctor WARN fits the domain and severity better than a CI
|
|
22
|
+
* hard-fail (#461). It lives next to `vendor-pin` (same family).
|
|
23
|
+
*
|
|
24
|
+
* PASS/skip when the dir is not a git repo or has no `.gitignore` (the user has
|
|
25
|
+
* not opted into version control yet). Probes two representative paths via
|
|
26
|
+
* `git check-ignore` with the inherited GIT_* env stripped so `cwd` is the sole
|
|
27
|
+
* authority on which repo + .gitignore stack is consulted (a pre-commit hook
|
|
28
|
+
* from a linked worktree exports GIT_WORK_TREE, which would otherwise override
|
|
29
|
+
* cwd-based discovery).
|
|
30
|
+
*
|
|
31
|
+
* @param {string} appDir
|
|
32
|
+
* @returns {Promise<DoctorResult>}
|
|
33
|
+
*/
|
|
34
|
+
export async function checkVendorGitignore(appDir) {
|
|
35
|
+
const hasGit = existsSync(join(appDir, '.git'));
|
|
36
|
+
const hasGitignore = existsSync(join(appDir, '.gitignore'));
|
|
37
|
+
if (!hasGit || !hasGitignore) {
|
|
38
|
+
return {
|
|
39
|
+
name: 'vendor-gitignore',
|
|
40
|
+
status: 'pass',
|
|
41
|
+
message: 'Not a git checkout with a .gitignore; nothing to verify.',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const { spawnSync } = await import('node:child_process');
|
|
45
|
+
const {
|
|
46
|
+
GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
|
|
47
|
+
...gitEnv
|
|
48
|
+
} = process.env;
|
|
49
|
+
// Check two representative paths: the pin manifest AND a sample downloaded
|
|
50
|
+
// bundle. A `.gitignore` that allows the manifest but blocks bundles (e.g.
|
|
51
|
+
// `*.js` higher up) would still break `webjs vendor pin --download`.
|
|
52
|
+
// `git check-ignore -q` exits 0 when the path is ignored, 1 when not.
|
|
53
|
+
const probes = [
|
|
54
|
+
'.webjs/vendor/importmap.json',
|
|
55
|
+
'.webjs/vendor/sample-pkg@1.0.0.js',
|
|
56
|
+
];
|
|
57
|
+
for (const probe of probes) {
|
|
58
|
+
const result = spawnSync('git', ['check-ignore', '-q', probe], {
|
|
59
|
+
cwd: appDir,
|
|
60
|
+
stdio: 'pipe',
|
|
61
|
+
env: gitEnv,
|
|
62
|
+
});
|
|
63
|
+
if (result.status === 0) {
|
|
64
|
+
return {
|
|
65
|
+
name: 'vendor-gitignore',
|
|
66
|
+
status: 'warn',
|
|
67
|
+
message:
|
|
68
|
+
`${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\`.`,
|
|
69
|
+
fix:
|
|
70
|
+
'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' +
|
|
71
|
+
' **/.webjs/*\n' +
|
|
72
|
+
' !**/.webjs/vendor/\n' +
|
|
73
|
+
' !**/.webjs/vendor/**\n' +
|
|
74
|
+
'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. ' +
|
|
75
|
+
'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
name: 'vendor-gitignore',
|
|
81
|
+
status: 'pass',
|
|
82
|
+
message: 'The .gitignore keeps .webjs/vendor/ committable.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CHECK 4, vendor pin freshness. Applies ONLY when a pin file exists. PASS/skip
|
|
7
|
+
* for an unpinned app (it resolves live, which is fine in dev). BEST-EFFORT +
|
|
8
|
+
* NETWORK-TOLERANT: any error (network, timeout) is a WARN "could not check",
|
|
9
|
+
* never a hard fail and never a throw. PASS when all pins current, WARN listing
|
|
10
|
+
* outdated packages otherwise.
|
|
11
|
+
*
|
|
12
|
+
* The vendor functions are injected via `opts.vendor` so a test can supply a
|
|
13
|
+
* stub without a real network call; absent the override, they are dynamically
|
|
14
|
+
* imported from `@webjsdev/server`.
|
|
15
|
+
* @param {string} appDir
|
|
16
|
+
* @param {{ vendor?: { hasVendorPin: (d: string) => boolean, findOutdated: (d: string) => Promise<Array<{ pkg: string, current: string, latest: string }>> } }} opts
|
|
17
|
+
* @returns {Promise<DoctorResult>}
|
|
18
|
+
*/
|
|
19
|
+
export async function checkVendorPin(appDir, opts) {
|
|
20
|
+
let vendor = opts.vendor;
|
|
21
|
+
if (!vendor) {
|
|
22
|
+
try {
|
|
23
|
+
const mod = await import('@webjsdev/server');
|
|
24
|
+
vendor = { hasVendorPin: mod.hasVendorPin, findOutdated: mod.findOutdated };
|
|
25
|
+
} catch {
|
|
26
|
+
return {
|
|
27
|
+
name: 'vendor-pin',
|
|
28
|
+
status: 'warn',
|
|
29
|
+
// "Could not check", not a finding: never escalatable by a gate.
|
|
30
|
+
bestEffort: true,
|
|
31
|
+
message: 'Could not load the vendor toolchain to check pin freshness.',
|
|
32
|
+
fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
let pinned = false;
|
|
37
|
+
try {
|
|
38
|
+
pinned = vendor.hasVendorPin(appDir);
|
|
39
|
+
} catch {
|
|
40
|
+
pinned = false;
|
|
41
|
+
}
|
|
42
|
+
if (!pinned) {
|
|
43
|
+
return {
|
|
44
|
+
name: 'vendor-pin',
|
|
45
|
+
status: 'pass',
|
|
46
|
+
message: 'No vendor pin file; the app resolves vendor imports live (fine in dev).',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
let outdated;
|
|
50
|
+
try {
|
|
51
|
+
outdated = await vendor.findOutdated(appDir);
|
|
52
|
+
} catch {
|
|
53
|
+
// findOutdated is built to swallow fetch errors and return [], but guard
|
|
54
|
+
// anyway: a network check must NEVER throw out of doctor.
|
|
55
|
+
return {
|
|
56
|
+
name: 'vendor-pin',
|
|
57
|
+
status: 'warn',
|
|
58
|
+
bestEffort: true,
|
|
59
|
+
message: 'Could not check pin freshness (network unreachable or registry error).',
|
|
60
|
+
fix: 'Re-run `webjs doctor` when connectivity is back, or run `webjs vendor outdated`.',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(outdated) || outdated.length === 0) {
|
|
64
|
+
return {
|
|
65
|
+
name: 'vendor-pin',
|
|
66
|
+
status: 'pass',
|
|
67
|
+
message: 'All vendor pins are current.',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const list = outdated.map((o) => `${o.pkg} (${o.current} -> ${o.latest})`).join(', ');
|
|
71
|
+
return {
|
|
72
|
+
name: 'vendor-pin',
|
|
73
|
+
status: 'warn',
|
|
74
|
+
message: `${outdated.length} pinned package(s) are outdated: ${list}.`,
|
|
75
|
+
fix: 'Run `webjs vendor update` to re-pin to the latest versions.',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { satisfiesRange, readInstalledVersion } from '../manifest.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is
|
|
12
|
+
* not a crash). Reads the app package.json `@webjsdev/*` ranges across
|
|
13
|
+
* dependencies + devDependencies, then for each resolves the INSTALLED version
|
|
14
|
+
* through Node's own resolver anchored at the app dir (see
|
|
15
|
+
* `readInstalledVersion`, which is why a workspace-hoisted install resolves)
|
|
16
|
+
* and checks it satisfies the declared range. PASS when every @webjsdev dep is
|
|
17
|
+
* present + satisfied; WARN on a missing install or a range drift.
|
|
18
|
+
* @param {string} appDir
|
|
19
|
+
* @returns {Promise<DoctorResult>}
|
|
20
|
+
*/
|
|
21
|
+
export async function checkWebjsVersions(appDir) {
|
|
22
|
+
const pkgPath = join(appDir, 'package.json');
|
|
23
|
+
if (!existsSync(pkgPath)) {
|
|
24
|
+
return {
|
|
25
|
+
name: 'webjs-versions',
|
|
26
|
+
status: 'warn',
|
|
27
|
+
message: 'No package.json found in this directory.',
|
|
28
|
+
fix: 'Run `webjs doctor` from the app root (where package.json lives).',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
let pkg;
|
|
32
|
+
try {
|
|
33
|
+
pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
34
|
+
} catch {
|
|
35
|
+
return {
|
|
36
|
+
name: 'webjs-versions',
|
|
37
|
+
status: 'warn',
|
|
38
|
+
message: 'package.json could not be parsed.',
|
|
39
|
+
fix: 'Fix the package.json syntax.',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const ranges = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
43
|
+
const webjsDeps = Object.keys(ranges).filter((n) => n.startsWith('@webjsdev/'));
|
|
44
|
+
if (webjsDeps.length === 0) {
|
|
45
|
+
return {
|
|
46
|
+
name: 'webjs-versions',
|
|
47
|
+
status: 'warn',
|
|
48
|
+
message: 'No @webjsdev/* dependencies declared in package.json.',
|
|
49
|
+
fix: 'A webjs app depends on @webjsdev/core + @webjsdev/server (+ @webjsdev/cli).',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const missing = [];
|
|
53
|
+
const drift = [];
|
|
54
|
+
for (const dep of webjsDeps) {
|
|
55
|
+
const installedVersion = await readInstalledVersion(dep, appDir);
|
|
56
|
+
if (!installedVersion) {
|
|
57
|
+
missing.push(dep);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const ok = satisfiesRange(installedVersion, ranges[dep]);
|
|
61
|
+
// null = a range shape we cannot statically verify; do not warn on it.
|
|
62
|
+
if (ok === false) drift.push(`${dep}@${installedVersion} does not satisfy "${ranges[dep]}"`);
|
|
63
|
+
}
|
|
64
|
+
if (missing.length > 0) {
|
|
65
|
+
return {
|
|
66
|
+
name: 'webjs-versions',
|
|
67
|
+
status: 'warn',
|
|
68
|
+
message: `${missing.length} @webjsdev/* dependency not installed: ${missing.join(', ')}.`,
|
|
69
|
+
fix: 'Run `npm install` to install the declared dependencies.',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (drift.length > 0) {
|
|
73
|
+
return {
|
|
74
|
+
name: 'webjs-versions',
|
|
75
|
+
status: 'warn',
|
|
76
|
+
message: `@webjsdev version drift: ${drift.join('; ')}.`,
|
|
77
|
+
fix: 'Run `npm install` to reconcile node_modules with the declared ranges.',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
name: 'webjs-versions',
|
|
82
|
+
status: 'pass',
|
|
83
|
+
message: `All ${webjsDeps.length} @webjsdev/* dependency satisfy their declared ranges.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Directories the route-module walk never descends into (deps, VCS, framework
|
|
6
|
+
// and build caches). Mirrors FRESHNESS_IGNORE; kept separate so either walk can
|
|
7
|
+
// change its exclusions without silently moving the other.
|
|
8
|
+
export const ROUTE_WALK_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A route module that renders markup on the server, which is where `asset()`
|
|
12
|
+
* belongs. Page and layout are the common case, but the BOUNDARY modules matter
|
|
13
|
+
* too and are easy to miss: `error` / `not-found` / `forbidden` / `unauthorized`
|
|
14
|
+
* / `loading` are always shipped and never elided, and `global-error` renders
|
|
15
|
+
* its OWN `<!doctype><html><head>` and is returned verbatim with no framework
|
|
16
|
+
* head splice, which makes it the likeliest place outside the root layout for
|
|
17
|
+
* an author to hand-write a stylesheet link.
|
|
18
|
+
* @type {RegExp}
|
|
19
|
+
*/
|
|
20
|
+
export const ROUTE_MODULE_RE =
|
|
21
|
+
/^(?:page|layout|error|not-found|forbidden|unauthorized|loading)\.(?:js|ts|mjs|mts)$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The two boundary stems `router.js` registers ONLY at the app root (both are
|
|
25
|
+
* guarded by `dir === '.'` there). A nested `app/admin/global-error.ts` is never
|
|
26
|
+
* in the route table and never renders, so scanning one would advise on dead
|
|
27
|
+
* code, the same defect the `_private` skip exists to avoid.
|
|
28
|
+
* @type {RegExp}
|
|
29
|
+
*/
|
|
30
|
+
export const ROOT_ONLY_MODULE_RE = /^(?:global-error|global-not-found)\.(?:js|ts|mjs|mts)$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The app's `webjs.basePath`, normalized to `''` (root mount) or `/segment…`.
|
|
34
|
+
*
|
|
35
|
+
* A faithful port of `normalizeBasePath` (`packages/server/src/base-path.js`),
|
|
36
|
+
* which is the source of truth: it trims, PREPENDS the leading slash (so the
|
|
37
|
+
* documented `"myapp"`, `"/myapp"` and `"/myapp/"` all normalize alike), and
|
|
38
|
+
* fails safe to `''` on a value that is not a plain same-origin prefix. Reading
|
|
39
|
+
* only `startsWith('/')` would leave this check inert for an app configured
|
|
40
|
+
* `"myapp"`, which is exactly the silently-inert case it exists to close.
|
|
41
|
+
*
|
|
42
|
+
* Ported rather than imported because that helper is not on `@webjsdev/server`'s
|
|
43
|
+
* public surface, and because doctor must stay usable when the framework does
|
|
44
|
+
* not resolve from the app dir at all (the #954 fresh-worktree case this same
|
|
45
|
+
* command exists to diagnose). The port is intentional and stays. What makes it
|
|
46
|
+
* safe is that the drift is tested rather than trusted.
|
|
47
|
+
*
|
|
48
|
+
* `test/cli/base-path-parity.test.mjs` feeds one input table through BOTH this
|
|
49
|
+
* function and the server's `readBasePath`, asserting they agree with each other
|
|
50
|
+
* and with the expected value. Change either side without the other and it reds.
|
|
51
|
+
* So edit this body only alongside `packages/server/src/base-path.js`, and run
|
|
52
|
+
* that test. (`test/cli/doctor.test.mjs` covers the check that consumes this,
|
|
53
|
+
* not the normalization forms themselves.)
|
|
54
|
+
* @param {string} appDir
|
|
55
|
+
* @returns {Promise<string>}
|
|
56
|
+
*/
|
|
57
|
+
export async function readAppBasePath(appDir) {
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
61
|
+
raw = pkg?.webjs?.basePath;
|
|
62
|
+
} catch {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
if (typeof raw !== 'string') return '';
|
|
66
|
+
let v = raw.trim();
|
|
67
|
+
if (v === '' || v === '/') return '';
|
|
68
|
+
// Not a plain same-origin path prefix: fail safe to no base path.
|
|
69
|
+
if (v.includes('..') || v.includes('://') || v.includes('\\') || /\s/.test(v)) return '';
|
|
70
|
+
// A network-path reference (`//host`) is rejected BEFORE leading slashes are
|
|
71
|
+
// collapsed, since collapsing would turn an origin escape into `/host`.
|
|
72
|
+
if (v.startsWith('//')) return '';
|
|
73
|
+
v = ('/' + v.replace(/^\/+/, '')).replace(/\/+$/, '');
|
|
74
|
+
return v === '' || v === '/' ? '' : v;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Collect every `app/**` route module that renders markup, depth-first.
|
|
79
|
+
* Best-effort: an unreadable directory contributes nothing rather than throwing.
|
|
80
|
+
* @param {string} dir
|
|
81
|
+
* @param {string[]} [out]
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
export function collectRouteModules(dir, root = dir, out = []) {
|
|
85
|
+
let entries;
|
|
86
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
87
|
+
for (const e of entries) {
|
|
88
|
+
if (e.name.startsWith('.') || ROUTE_WALK_IGNORE.has(e.name)) continue;
|
|
89
|
+
if (e.isSymbolicLink()) continue; // never follow: can cycle or escape into deps
|
|
90
|
+
// `_`-prefixed folders are PRIVATE: `router.js` drops any route whose
|
|
91
|
+
// directory has such a segment, so markup under one is never routed and
|
|
92
|
+
// never rendered. Advising on it would be advice about dead code.
|
|
93
|
+
if (e.isDirectory() && e.name.startsWith('_')) continue;
|
|
94
|
+
const abs = join(dir, e.name);
|
|
95
|
+
if (e.isDirectory()) collectRouteModules(abs, root, out);
|
|
96
|
+
else if (ROUTE_MODULE_RE.test(e.name)) out.push(abs);
|
|
97
|
+
else if (dir === root && ROOT_ONLY_MODULE_RE.test(e.name)) out.push(abs);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { codeForName } from './codes.js';
|
|
2
|
+
import { checkNode } from './probes/node.js';
|
|
3
|
+
import { checkTsconfig } from './probes/tsconfig.js';
|
|
4
|
+
import { checkEnv } from './probes/env.js';
|
|
5
|
+
import { checkVendorPin } from './probes/vendor-pin.js';
|
|
6
|
+
import { checkVendorGitignore } from './probes/vendor-gitignore.js';
|
|
7
|
+
import { checkImportmapCoherence } from './probes/importmap-coherence.js';
|
|
8
|
+
import { checkWebjsVersions } from './probes/webjs-versions.js';
|
|
9
|
+
import { checkGitHook } from './probes/git-hook.js';
|
|
10
|
+
import { checkElisionCarriers, checkElisionComponents } from './probes/elision.js';
|
|
11
|
+
import { checkStaticAssetFreshness } from './probes/static-asset-freshness.js';
|
|
12
|
+
import { checkUnmarkedAssetLinks } from './probes/unmarked-asset-links.js';
|
|
13
|
+
import { checkFrameworkResolves } from './probes/framework-resolves.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {import('./codes.js').DoctorResult} DoctorResult
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run every doctor check against `appDir` and return the results. PURE: no
|
|
21
|
+
* printing, no `process.exit`; the CLI renders + decides the exit code.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} appDir the app directory to check (usually `process.cwd()`)
|
|
24
|
+
* @param {{
|
|
25
|
+
* nodeVersion?: string,
|
|
26
|
+
* cliDir?: string,
|
|
27
|
+
* vendor?: { hasVendorPin: (d: string) => boolean, findOutdated: (d: string) => Promise<Array<{ pkg: string, current: string, latest: string }>> },
|
|
28
|
+
* }} [opts] test-injection seams:
|
|
29
|
+
* - `nodeVersion`: override the running Node version (asserts the fail case
|
|
30
|
+
* without being on old Node);
|
|
31
|
+
* - `cliDir`: directory of the CLI package whose `engines.node` sources the
|
|
32
|
+
* required major (defaults to THIS module's package);
|
|
33
|
+
* - `vendor`: inject the `{ hasVendorPin, findOutdated }` pair so the pin check
|
|
34
|
+
* runs against a stub instead of a real network call.
|
|
35
|
+
* - `coherence`: inject `{ liveImports, vendoredImports, getManifest, check }`
|
|
36
|
+
* so the importmap-coherence check runs against stub importmaps + metadata
|
|
37
|
+
* instead of a real live resolve / node_modules read.
|
|
38
|
+
* @returns {Promise<DoctorResult[]>}
|
|
39
|
+
*/
|
|
40
|
+
export async function runDoctorChecks(appDir, opts = {}) {
|
|
41
|
+
const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname;
|
|
42
|
+
// ONE elision report for BOTH elision checks (#1308). Started before the
|
|
43
|
+
// batch and awaited inside each check, so the module graph is built once per
|
|
44
|
+
// doctor run and the two checks still run in parallel with everything else.
|
|
45
|
+
// Fails soft to null, exactly as the carrier check's own try/catch did.
|
|
46
|
+
const elision = (async () => {
|
|
47
|
+
try {
|
|
48
|
+
const { analyzeAppElision } = await import('@webjsdev/server');
|
|
49
|
+
return await analyzeAppElision(appDir);
|
|
50
|
+
} catch { return null; }
|
|
51
|
+
})();
|
|
52
|
+
const results = await Promise.all([
|
|
53
|
+
checkNode(cliDir, opts),
|
|
54
|
+
checkTsconfig(appDir),
|
|
55
|
+
checkEnv(appDir),
|
|
56
|
+
checkVendorPin(appDir, opts),
|
|
57
|
+
checkVendorGitignore(appDir),
|
|
58
|
+
checkWebjsVersions(appDir),
|
|
59
|
+
Promise.resolve(checkFrameworkResolves(appDir)),
|
|
60
|
+
checkImportmapCoherence(appDir, opts),
|
|
61
|
+
Promise.resolve(checkGitHook(appDir)),
|
|
62
|
+
checkElisionCarriers(elision),
|
|
63
|
+
checkElisionComponents(elision),
|
|
64
|
+
checkStaticAssetFreshness(appDir),
|
|
65
|
+
checkUnmarkedAssetLinks(appDir),
|
|
66
|
+
]);
|
|
67
|
+
// Attach the stable machine code to every result (#975). Centralized here so
|
|
68
|
+
// each check function stays free of the code-contract concern.
|
|
69
|
+
for (const r of results) r.code = codeForName(r.name);
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { statSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read the CLI package's own `engines.node` so the required Node major lives in
|
|
7
|
+
* one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`.
|
|
8
|
+
* @param {string} cliDir directory of THIS file's package (lib/ -> package root)
|
|
9
|
+
* @returns {Promise<string>}
|
|
10
|
+
*/
|
|
11
|
+
export async function readEngines(cliDir) {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(await readFile(join(cliDir, '..', 'package.json'), 'utf8'));
|
|
14
|
+
return pkg?.engines?.node || '>=24.0.0';
|
|
15
|
+
} catch {
|
|
16
|
+
return '>=24.0.0';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Strip `//` line comments, block comments, and trailing commas from a JSONC
|
|
22
|
+
* string so a tsconfig (which permits all three) parses with `JSON.parse`.
|
|
23
|
+
* Deliberately simple: it does not honor comment-looking sequences inside
|
|
24
|
+
* string values, which is acceptable for a tsconfig (paths rarely contain `//`
|
|
25
|
+
* or block-comment markers, and the worst case is a parse failure the caller
|
|
26
|
+
* already degrades to a WARN).
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function stripJsonc(text) {
|
|
31
|
+
let out = '';
|
|
32
|
+
let inString = false;
|
|
33
|
+
let stringQuote = '';
|
|
34
|
+
for (let i = 0; i < text.length; i++) {
|
|
35
|
+
const ch = text[i];
|
|
36
|
+
const next = text[i + 1];
|
|
37
|
+
if (inString) {
|
|
38
|
+
out += ch;
|
|
39
|
+
if (ch === '\\') {
|
|
40
|
+
// Copy the escaped char verbatim so an escaped quote does not end the string.
|
|
41
|
+
out += text[i + 1] || '';
|
|
42
|
+
i++;
|
|
43
|
+
} else if (ch === stringQuote) {
|
|
44
|
+
inString = false;
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (ch === '"' || ch === "'") {
|
|
49
|
+
inString = true;
|
|
50
|
+
stringQuote = ch;
|
|
51
|
+
out += ch;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ch === '/' && next === '/') {
|
|
55
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
56
|
+
out += '\n';
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === '/' && next === '*') {
|
|
60
|
+
i += 2;
|
|
61
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
|
62
|
+
i++; // land on the '/'
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
out += ch;
|
|
66
|
+
}
|
|
67
|
+
// Drop trailing commas before } or ].
|
|
68
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse a `.env`-style file into the SET of KEY names it declares. A simple
|
|
73
|
+
* `KEY=value` line parse: comments (`#`) and blank lines are skipped, and only
|
|
74
|
+
* the key before the first `=` is taken (the value is irrelevant for drift).
|
|
75
|
+
* @param {string} text
|
|
76
|
+
* @returns {Set<string>}
|
|
77
|
+
*/
|
|
78
|
+
export function parseEnvKeys(text) {
|
|
79
|
+
const keys = new Set();
|
|
80
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
81
|
+
const line = raw.trim();
|
|
82
|
+
if (!line || line.startsWith('#')) continue;
|
|
83
|
+
const eq = line.indexOf('=');
|
|
84
|
+
if (eq <= 0) continue;
|
|
85
|
+
let key = line.slice(0, eq).trim();
|
|
86
|
+
// Tolerate a leading `export ` (a common .env.example convention).
|
|
87
|
+
if (key.startsWith('export ')) key = key.slice('export '.length).trim();
|
|
88
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) keys.add(key);
|
|
89
|
+
}
|
|
90
|
+
return keys;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Directories never worth walking for the CSS-freshness advisory (mirrors
|
|
94
|
+
// dev-regenerate's IGNORE_DIRS): build output, deps, VCS + framework caches.
|
|
95
|
+
const FRESHNESS_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Newest mtime (ms) of any FILE under a path (a file's own, or the max over the
|
|
99
|
+
* files in a directory tree, skipping dependencies / dotfiles). Directory-node
|
|
100
|
+
* mtimes are NOT counted, matching dev-regenerate's walker: a content edit only
|
|
101
|
+
* shows through the file mtime, and a directory mtime is a flaky moving target.
|
|
102
|
+
* A missing path is 0. Best-effort: never throws.
|
|
103
|
+
* @param {string} abs
|
|
104
|
+
* @returns {number}
|
|
105
|
+
*/
|
|
106
|
+
export function newestMtimeMs(abs) {
|
|
107
|
+
let st;
|
|
108
|
+
try { st = statSync(abs); } catch { return 0; }
|
|
109
|
+
if (!st.isDirectory()) return st.mtimeMs;
|
|
110
|
+
let newest = 0;
|
|
111
|
+
let entries;
|
|
112
|
+
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return newest; }
|
|
113
|
+
for (const e of entries) {
|
|
114
|
+
if (e.name.startsWith('.') || FRESHNESS_IGNORE.has(e.name)) continue;
|
|
115
|
+
// Skip symlinks: following one can cycle into unbounded recursion (a stack
|
|
116
|
+
// overflow here) or escape into node_modules. Same tradeoff as the server
|
|
117
|
+
// walker in dev-regenerate.js.
|
|
118
|
+
if (e.isSymbolicLink()) continue;
|
|
119
|
+
const m = newestMtimeMs(join(abs, e.name));
|
|
120
|
+
if (m > newest) newest = m;
|
|
121
|
+
}
|
|
122
|
+
return newest;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether the `<link>` tag at `idx` is commented out, so dead markup is never
|
|
127
|
+
* reported as a live finding.
|
|
128
|
+
*
|
|
129
|
+
* A DELIMITED comment is decided by an unclosed opener behind the tag. Neither
|
|
130
|
+
* `<!--` nor `/*` nests, so "nearest opener beats nearest closer" is exact, and
|
|
131
|
+
* it covers a multi-line block whose interior lines carry no marker of their
|
|
132
|
+
* own (what an editor's toggle-block-comment writes). A `//` has no closer, so
|
|
133
|
+
* it is decided from the tag's own line: a `//` inside an href later in the
|
|
134
|
+
* line cannot match, because the line does not START with it.
|
|
135
|
+
*
|
|
136
|
+
* Do NOT replace this with a lexer. Two attempts did, and both shipped bugs a
|
|
137
|
+
* stateless test cannot have: a line-blanking regex killed any line holding a
|
|
138
|
+
* protocol-relative url, and a quote-tracking walk inverted string/code
|
|
139
|
+
* polarity on a nested ``html`...` `` inside a `${}` hole (one quote char
|
|
140
|
+
* cannot model nesting), so an unbalanced apostrophe in template text
|
|
141
|
+
* desynchronized the rest of the file. This check does not need to lex
|
|
142
|
+
* JavaScript. If it ever genuinely does, export `redactStringsAndTemplates`
|
|
143
|
+
* from `@webjsdev/server` (`src/js-scan.js`, fuzz-tested differentially against
|
|
144
|
+
* a real TypeScript parse) rather than growing a third one here.
|
|
145
|
+
*
|
|
146
|
+
* Residual gap: a tag behind a `//` that trails real code on the same line
|
|
147
|
+
* stays reported. Rare, and it fails toward reporting rather than toward the
|
|
148
|
+
* silent inertness both lexers produced.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} src
|
|
151
|
+
* @param {number} idx index of the tag's `<`
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
export function isCommentedOut(src, idx) {
|
|
155
|
+
const before = src.slice(0, idx);
|
|
156
|
+
if (before.lastIndexOf('<!--') > before.lastIndexOf('-->')) return true;
|
|
157
|
+
if (before.lastIndexOf('/*') > before.lastIndexOf('*/')) return true;
|
|
158
|
+
const lineStart = before.lastIndexOf('\n') + 1;
|
|
159
|
+
return before.slice(lineStart).trimStart().startsWith('//');
|
|
160
|
+
}
|