@webjsdev/cli 0.10.55 → 0.10.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/lib/create.js +43 -1
  2. package/lib/doctor/codes.js +67 -0
  3. package/lib/doctor/manifest.js +161 -0
  4. package/lib/doctor/policy.js +124 -0
  5. package/lib/doctor/probes/elision.js +111 -0
  6. package/lib/doctor/probes/env.js +53 -0
  7. package/lib/doctor/probes/framework-resolves.js +260 -0
  8. package/lib/doctor/probes/git-hook.js +58 -0
  9. package/lib/doctor/probes/importmap-coherence.js +158 -0
  10. package/lib/doctor/probes/node.js +37 -0
  11. package/lib/doctor/probes/static-asset-freshness.js +58 -0
  12. package/lib/doctor/probes/tsconfig.js +55 -0
  13. package/lib/doctor/probes/unmarked-asset-links.js +199 -0
  14. package/lib/doctor/probes/vendor-gitignore.js +84 -0
  15. package/lib/doctor/probes/vendor-pin.js +77 -0
  16. package/lib/doctor/probes/webjs-versions.js +85 -0
  17. package/lib/doctor/route-modules.js +100 -0
  18. package/lib/doctor/runner.js +72 -0
  19. package/lib/doctor/util.js +160 -0
  20. package/lib/doctor.js +5 -1634
  21. package/package.json +1 -1
  22. package/templates/.agents/rules/workflow.md +4 -3
  23. package/templates/.agents/skills/webjs/SKILL.md +46 -4
  24. package/templates/.agents/skills/webjs/references/built-ins.md +3 -1
  25. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +52 -5
  26. package/templates/.agents/skills/webjs/references/components.md +50 -2
  27. package/templates/.agents/skills/webjs/references/module-structure.md +229 -0
  28. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +27 -3
  29. package/templates/.agents/skills/webjs/references/optimistic-ui.md +23 -11
  30. package/templates/.agents/skills/webjs/references/routing-and-pages.md +36 -2
  31. package/templates/.agents/skills/webjs/references/runtime.md +6 -1
  32. package/templates/.agents/skills/webjs/references/styling.md +47 -2
  33. package/templates/.github/pull_request_template.md +0 -4
  34. package/templates/gallery/app/features/boundaries/page.ts +11 -0
  35. package/templates/gallery/app/features/client-router/page.ts +13 -2
  36. package/templates/gallery/app/features/metadata/page.ts +7 -1
  37. package/templates/gallery/modules/client-router/components/router-controls.ts +23 -2
  38. package/templates/gallery/modules/gallery/nav.ts +35 -26
  39. package/templates/gallery/modules/stream/components/browser/stream-demo.test.js +64 -0
  40. package/templates/gallery/modules/stream/components/stream-demo.ts +14 -9
  41. package/templates/gallery/modules/stream/utils/ui/row.ts +41 -0
  42. package/templates/gallery/test/rate-limit/rate-limit.test.ts +2 -1
@@ -0,0 +1,199 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join, relative } from 'node:path';
4
+ import { isCommentedOut } from '../util.js';
5
+ import { collectRouteModules, readAppBasePath } from '../route-modules.js';
6
+
7
+ /**
8
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
9
+ */
10
+
11
+ /**
12
+ * One whole `<link …>` tag. QUOTE-AWARE (`(?:[^>"']|"[^"]*"|'[^']*')*`), the
13
+ * same shape `ssr.js`'s hoist scanner uses, so a `>` inside a quoted attribute
14
+ * value cannot terminate the tag early.
15
+ * @type {RegExp}
16
+ */
17
+ const LINK_TAG_RE = /<link\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
18
+
19
+ /**
20
+ * One attribute inside a tag: a name, then optionally `=` and a double-quoted,
21
+ * single-quoted, or unquoted value. Matching attributes as WHOLE units is what
22
+ * makes the scan correct, because each quoted value is consumed in one step and
23
+ * can therefore never be re-scanned as if it contained an attribute of its own.
24
+ * @type {RegExp}
25
+ */
26
+ const ATTR_RE = /([a-zA-Z_:][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
27
+
28
+ /**
29
+ * Parse a tag's attributes into a lowercased-name map. The value is `null` for a
30
+ * valueless attribute and carries a `quoted` flag, since this check treats an
31
+ * UNQUOTED href (a template hole) as undecidable rather than as a path.
32
+ * @param {string} tag
33
+ * @returns {Map<string, { value: string | null, quoted: boolean }>}
34
+ */
35
+ function parseTagAttrs(tag) {
36
+ /** @type {Map<string, { value: string | null, quoted: boolean }>} */
37
+ const attrs = new Map();
38
+ // Skip the tag name itself so `link` is not read as an attribute.
39
+ const body = tag.replace(/^<[a-zA-Z_:][-\w:.]*/, '');
40
+ ATTR_RE.lastIndex = 0;
41
+ for (const m of body.matchAll(ATTR_RE)) {
42
+ const name = m[1].toLowerCase();
43
+ if (attrs.has(name)) continue; // first wins, as in HTML parsing
44
+ const quoted = m[2] !== undefined || m[3] !== undefined;
45
+ const value = m[2] ?? m[3] ?? m[4] ?? null;
46
+ attrs.set(name, { value, quoted });
47
+ }
48
+ return attrs;
49
+ }
50
+
51
+ /**
52
+ * Whether a parsed `<link>` is an unmarked stylesheet, and if so its href.
53
+ *
54
+ * Attribute PARSING rather than a lookahead over the raw tag is load-bearing,
55
+ * not tidiness. A scan that merely looks ahead for `rel=…stylesheet` anywhere in
56
+ * the tag matches the string inside ANOTHER attribute's value, which flags the
57
+ * two shapes this check most needs to leave alone: the canonical async-CSS
58
+ * `<link rel="preload" as="style" href="/public/app.css" onload="this.rel='stylesheet'">`
59
+ * (where the advised `asset()` fix would actively BREAK the preload, since the
60
+ * versioned hint could then never match the unversioned request), and a
61
+ * `data-rel="stylesheet"` sitting on a `rel="icon"`. Reading real attributes
62
+ * makes `rel` mean the `rel` attribute and nothing else.
63
+ *
64
+ * Returns the href only when every condition holds:
65
+ * - `rel` is a token list CONTAINING `stylesheet` (so `rel="preload"` with an
66
+ * onload swap, and `rel="icon"`, are both out).
67
+ * - `href` is QUOTED. An unquoted value is a template hole
68
+ * (`href=${asset('/public/app.css')}`), undecidable from source, and is
69
+ * exactly the shape the marked form uses.
70
+ * - the path is under `/public/` (after the app's `webjs.basePath` is
71
+ * stripped, since under a sub-path deploy the author writes the prefix
72
+ * themselves and `resolveAssetUrl` strips it before its own `public/` gate).
73
+ * - `resolveAssetUrl` would actually fingerprint it. It returns a path
74
+ * carrying a QUERY or a `..` unchanged, so wrapping one in `asset()` is a
75
+ * runtime NO-OP: the author does the work and the url they ship is
76
+ * byte-identical. Advising it would be advising a change that buys nothing.
77
+ * A hand-rolled `?v=` cache-buster is exactly what an author who has not
78
+ * adopted `asset()` is most likely to have written, so this is the common
79
+ * case, not a corner. (The warning itself would clear, since this check
80
+ * reads the SOURCE shape and a wrapped href is an unquoted hole. Clearing a
81
+ * warning without improving the caching is the outcome to avoid.)
82
+ *
83
+ * @param {string} tag
84
+ * @param {string} basePath the app's normalized `webjs.basePath` (`''` at root)
85
+ * @returns {string | null}
86
+ */
87
+ function unmarkedStylesheetHref(tag, basePath = '') {
88
+ const attrs = parseTagAttrs(tag);
89
+ const rel = attrs.get('rel');
90
+ if (!rel || !rel.value) return null;
91
+ if (!rel.value.toLowerCase().split(/\s+/).includes('stylesheet')) return null;
92
+ const href = attrs.get('href');
93
+ if (!href || !href.quoted || !href.value) return null;
94
+ const url = href.value;
95
+ if (url[0] !== '/' || url[1] === '/') return null;
96
+ // Mirror `resolveAssetUrl`'s refusals IN ITS ORDER, so every flagged href is
97
+ // one `asset()` can actually fingerprint. It strips the base path, cuts at
98
+ // `?` / `#`, DECODES, and only then tests `..` and the `public/` prefix.
99
+ // Testing the raw value instead disagrees at both ends: `/public/%2e%2e/x`
100
+ // would be flagged although wrapping it changes nothing, and
101
+ // `/%70ublic/app.css` would be skipped although `asset()` fingerprints it.
102
+ let probe = url;
103
+ if (basePath && probe.startsWith(basePath + '/')) probe = probe.slice(basePath.length);
104
+ const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1);
105
+ let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length);
106
+ try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ }
107
+ if (decoded.includes('..') || !decoded.startsWith('/public/')) return null;
108
+ // A query is refused outright (an author query may carry meaning we do not
109
+ // own, so `resolveAssetUrl` returns the url untouched); a `#fragment` is not,
110
+ // since it is split off and preserved.
111
+ const beforeFragment = url.indexOf('#') === -1 ? url : url.slice(0, url.indexOf('#'));
112
+ if (beforeFragment.includes('?')) return null;
113
+ return url;
114
+ }
115
+
116
+ /**
117
+ * ADVISORY (#1095): a route module hand-writes a `<link rel="stylesheet"
118
+ * href="/public/…">` without `asset()`, so the url is un-versioned and a deploy
119
+ * cannot bust a CDN's copy of it.
120
+ *
121
+ * The failure this names was caught in production on webjs.dev: the edge served
122
+ * a `public/tailwind.css` built BEFORE the deploy (`cf-cache-status: HIT`,
123
+ * `max-age=14400`) against post-deploy HTML, so the new page rendered with its
124
+ * content edge to edge and its grid collapsed, because the cached css was
125
+ * missing the arbitrary-value utilities that page introduced. It is invisible
126
+ * while a deploy only restyles existing classes and maximally visible the moment
127
+ * one adds a page using new utilities.
128
+ *
129
+ * Why this is an ADVISORY over the author's SOURCE rather than a rewrite of the
130
+ * framework's OUTPUT. The first attempt at the automatic form (#1196) matched
131
+ * urls in the assembled HTML, and two deep-review rounds found six major
132
+ * defects, five of them one bug: at that layer framework output and author data
133
+ * are indistinguishable, so the matcher kept editing things it did not own. That
134
+ * is why `asset()` (#1194) is opt-in, and it is what Rails (a
135
+ * `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url
136
+ * from the build graph, surfaced through `links()`) both do: take the
137
+ * fingerprint from an authoritative source at the point the url is PRODUCED, and
138
+ * never rewrite a rendered document. The gap `asset()` leaves is purely
139
+ * ergonomic. In Rails the helper is the only idiomatic way to write the tag, so
140
+ * forgetting it is nearly impossible; in WebJs the `<link>` is hand-written HTML,
141
+ * so it is easy to omit. This check closes exactly that gap, at authoring time,
142
+ * where the author's meaning is unambiguous and nothing is rewritten.
143
+ *
144
+ * Scoped to `rel="stylesheet"` on purpose. An icon is a legitimate deliberate
145
+ * NON-mark (the website leaves its favicons bare so the SEO repo-health tests
146
+ * can parse the hrefs literally), and a `rel="preload"` must NOT be marked at
147
+ * all, since its versioned hint could never match the unversioned request a CSS
148
+ * `url()` actually makes. Flagging either would nag about a correct choice.
149
+ *
150
+ * WARN only: an un-versioned stylesheet still SERVES correctly, it just caches
151
+ * badly, and an app fronted by no CDN may not care.
152
+ * @param {string} appDir
153
+ * @returns {Promise<DoctorResult>}
154
+ */
155
+ export async function checkUnmarkedAssetLinks(appDir) {
156
+ const name = 'Asset urls (unmarked stylesheet links)';
157
+ const routeDir = join(appDir, 'app');
158
+ if (!existsSync(routeDir)) {
159
+ return { name, status: 'pass', message: 'no app/ directory to analyse' };
160
+ }
161
+ const basePath = await readAppBasePath(appDir);
162
+ const findings = [];
163
+ for (const file of collectRouteModules(routeDir)) {
164
+ let src;
165
+ try { src = await readFile(file, 'utf8'); } catch { continue; }
166
+ // Cheap bail before any tag scanning. Case-INSENSITIVE to match the tag
167
+ // regex: a file whose only link tag is written `<LINK …>` must still be
168
+ // scanned, or the scanner's own case-insensitivity is unreachable exactly
169
+ // where it is needed.
170
+ if (!/<link/i.test(src)) continue;
171
+ LINK_TAG_RE.lastIndex = 0;
172
+ for (const m of src.matchAll(LINK_TAG_RE)) {
173
+ const href = unmarkedStylesheetHref(m[0], basePath);
174
+ if (!href) continue;
175
+ // A commented-out tag emits nothing, so advising on it is advice about
176
+ // dead markup.
177
+ if (isCommentedOut(src, /** @type {number} */ (m.index))) continue;
178
+ // 1-indexed line of the match, for a jump-to reference.
179
+ const line = src.slice(0, m.index).split('\n').length;
180
+ findings.push({ file, line, href });
181
+ }
182
+ }
183
+ if (findings.length === 0) {
184
+ return { name, status: 'pass', message: 'every route-module stylesheet link is content-hashed (or has none)' };
185
+ }
186
+ const rel = (f) => relative(appDir, f) || f;
187
+ return {
188
+ name,
189
+ status: 'warn',
190
+ message:
191
+ `${findings.length} stylesheet link(s) are served at an un-versioned url, so a deploy cannot bust a cached copy:\n` +
192
+ findings.map((f) => ` ${rel(f.file)}:${f.line} href="${f.href}"`).join('\n'),
193
+ fix:
194
+ "Wrap the path in asset(): `import { asset } from '@webjsdev/core'` then "
195
+ + '`<link rel="stylesheet" href=${asset(\'/public/app.css\')}>`. It appends a content hash in prod '
196
+ + '(the framework then serves that url immutable for a year) and is a no-op in dev and in the browser. '
197
+ + 'Call it inside the render function, not at module scope.',
198
+ };
199
+ }
@@ -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,72 @@
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, checkFrameworkLinks } 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
+ Promise.resolve(checkFrameworkLinks(appDir)),
61
+ checkImportmapCoherence(appDir, opts),
62
+ Promise.resolve(checkGitHook(appDir)),
63
+ checkElisionCarriers(elision),
64
+ checkElisionComponents(elision),
65
+ checkStaticAssetFreshness(appDir),
66
+ checkUnmarkedAssetLinks(appDir),
67
+ ]);
68
+ // Attach the stable machine code to every result (#975). Centralized here so
69
+ // each check function stays free of the code-contract concern.
70
+ for (const r of results) r.code = codeForName(r.name);
71
+ return results;
72
+ }