@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,260 @@
1
+ import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs';
2
+ import { dirname, join, resolve, sep } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+
5
+ /**
6
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
7
+ */
8
+
9
+ /**
10
+ * Probe whether `@webjsdev/core` resolves from `appDir`. Node resolution is
11
+ * directory-relative, so this must probe FROM the app (not the CLI's own
12
+ * location, which resolves the framework fine from a global install even when
13
+ * the app cannot). A no-op-cheap resolve, no I/O beyond what Node's resolver
14
+ * does, no network. Returns true when the framework resolves, false otherwise.
15
+ * @param {string} appDir
16
+ * @returns {boolean}
17
+ */
18
+ export function frameworkResolves(appDir) {
19
+ try {
20
+ // The base file need not exist; createRequire only uses it to anchor the
21
+ // node_modules lookup at appDir.
22
+ const require = createRequire(join(appDir, '__webjs_resolve_probe__.js'));
23
+ require.resolve('@webjsdev/core');
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * CHECK 8, framework resolvability (#954). WARN when `@webjsdev/core` cannot be
32
+ * resolved FROM the app directory, which is the fresh-git-worktree trap: a
33
+ * worktree does not copy `node_modules`, so a plain `webjs dev` there dies at
34
+ * SSR with a raw `ERR_MODULE_NOT_FOUND: Cannot find package '@webjsdev/core'`
35
+ * whose remedy is not obvious. Silent PASS when the framework resolves (the
36
+ * common case), so this never slows a healthy app. WARN (not a hard fail): it
37
+ * is a setup/environment concern, the same tier as the version-coherence check.
38
+ * @param {string} appDir
39
+ * @returns {DoctorResult}
40
+ */
41
+ export function checkFrameworkResolves(appDir) {
42
+ const name = 'framework-resolve';
43
+ if (frameworkResolves(appDir)) {
44
+ return { name, status: 'pass', message: '@webjsdev/core resolves from the app directory.' };
45
+ }
46
+ const hasNodeModules = existsSync(join(appDir, 'node_modules'));
47
+ // A git worktree checks out `.git` as a FILE (a gitdir pointer), not a
48
+ // directory. That, plus a missing node_modules, is the exact #954 cause.
49
+ let isWorktree = false;
50
+ try {
51
+ isWorktree = statSync(join(appDir, '.git')).isFile();
52
+ } catch {
53
+ isWorktree = false;
54
+ }
55
+ if (isWorktree && !hasNodeModules) {
56
+ return {
57
+ name,
58
+ status: 'warn',
59
+ message:
60
+ '@webjsdev/core cannot be resolved from this directory, and this is a git worktree with no ' +
61
+ 'node_modules. Git worktrees do not copy node_modules, so the framework is unresolvable here ' +
62
+ 'and `webjs dev` / `webjs start` would fail at SSR with a raw ERR_MODULE_NOT_FOUND.',
63
+ fix: freshWorktreeFix(appDir),
64
+ };
65
+ }
66
+ if (!hasNodeModules) {
67
+ return {
68
+ name,
69
+ status: 'warn',
70
+ message: '@webjsdev/core cannot be resolved from this directory (no node_modules present).',
71
+ fix: 'Run `npm install` in the app directory so the framework resolves.',
72
+ };
73
+ }
74
+ return {
75
+ name,
76
+ status: 'warn',
77
+ message:
78
+ '@webjsdev/core cannot be resolved from this directory even though node_modules exists ' +
79
+ '(a partial or corrupted install).',
80
+ fix: linkAwareReinstallFix(appDir),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Whether `dir`'s own `node_modules` is a SYMLINK, which in a linked worktree
86
+ * means it points at the primary checkout's tree. The remedies below branch on
87
+ * this, because `npm install` is the right advice when it is false and is the
88
+ * exact command that corrupts the primary when it is true (#1442).
89
+ * @param {string} dir
90
+ * @returns {boolean}
91
+ */
92
+ function modulesAreLinked(dir) {
93
+ try { return lstatSync(join(dir, 'node_modules')).isSymbolicLink(); } catch { return false; }
94
+ }
95
+
96
+ /**
97
+ * The `npm run worktree:link` sentence, but ONLY where that script exists.
98
+ *
99
+ * This module ships in the PUBLISHED CLI, and `bin/webjs.js` prints these
100
+ * remedies verbatim as the `webjs dev` / `webjs start` preflight failure. A
101
+ * scaffolded app has no `worktree:link` script, so naming it unconditionally
102
+ * sends the exact audience this check exists for (#954, a fresh app worktree)
103
+ * to run something that does not exist. Walk up for a package.json that really
104
+ * declares it, and stay silent otherwise.
105
+ *
106
+ * @param {string} appDir
107
+ * @returns {string} a leading-space sentence, or the empty string
108
+ */
109
+ function linkScriptHint(appDir) {
110
+ let dir = resolve(appDir);
111
+ for (let i = 0; i < 8; i += 1) {
112
+ try {
113
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
114
+ if (pkg?.scripts?.['worktree:link']) {
115
+ return ' In this repo, `npm run worktree:link` does the whole setup and also repairs the shared tree.';
116
+ }
117
+ } catch { /* no package.json here, keep walking */ }
118
+ const up = dirname(dir);
119
+ if (up === dir) break;
120
+ dir = up;
121
+ }
122
+ return '';
123
+ }
124
+
125
+ /**
126
+ * Remedy for the #954 fresh-worktree case, which is a worktree with NO
127
+ * `node_modules` at all. There is no symlink in the way yet, so a real install
128
+ * is safe here, and this stays the app-generic advice it has always been.
129
+ * @param {string} appDir
130
+ * @returns {string}
131
+ */
132
+ function freshWorktreeFix(appDir) {
133
+ return (
134
+ 'Install dependencies in this worktree (`npm install`), or symlink node_modules from the ' +
135
+ 'primary checkout (`ln -s ../<primary-checkout>/node_modules node_modules`). If you symlink, ' +
136
+ 'never run an install through that link afterwards: it acts on the checkout that owns the ' +
137
+ 'tree, not this one (#1442).' + linkScriptHint(appDir)
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Remedy for a `node_modules` that exists but does not resolve the framework.
143
+ * When it is a SYMLINK, a bare `npm install` is the action that corrupts the
144
+ * checkout that owns the tree, so the advice has to differ.
145
+ * @param {string} appDir
146
+ * @returns {string}
147
+ */
148
+ function linkAwareReinstallFix(appDir) {
149
+ if (modulesAreLinked(appDir)) {
150
+ return (
151
+ 'node_modules here is a SYMLINK at another checkout, so do NOT run `npm install`: it would act ' +
152
+ 'on that checkout, not this one (#1442). Either reinstall in the checkout that owns the tree, ' +
153
+ 'or remove every node_modules symlink first, nested ones included ' +
154
+ '(`find . -maxdepth 5 -type l -name node_modules -delete`), and install here.' + linkScriptHint(appDir)
155
+ );
156
+ }
157
+ return 'Reinstall dependencies (`npm install`, or remove node_modules and reinstall).';
158
+ }
159
+
160
+ /**
161
+ * Classify the `@webjsdev/core` entry that `appDir` would resolve through.
162
+ *
163
+ * Walks up for the first `node_modules` carrying the package, then judges the
164
+ * link against the tree that PHYSICALLY owns that `node_modules`. That owner
165
+ * rule is the only one correct in a linked worktree: there `node_modules` is
166
+ * itself a symlink at the primary's, so the owning tree is the PRIMARY and a
167
+ * target inside it is right rather than foreign. Judging against `appDir` would
168
+ * report every correctly linked worktree as corrupted.
169
+ *
170
+ * @param {string} appDir
171
+ * @param {string} [pkg]
172
+ * @returns {{ state: 'absent'|'real'|'ok'|'dangling'|'foreign', entry?: string, target?: string, owner?: string }}
173
+ */
174
+ export function inspectFrameworkLink(appDir, pkg = '@webjsdev/core') {
175
+ const parts = pkg.split('/');
176
+ let dir = resolve(appDir);
177
+ for (;;) {
178
+ const modules = join(dir, 'node_modules');
179
+ const entry = join(modules, ...parts);
180
+ let st = null;
181
+ try { st = lstatSync(entry); } catch { st = null; }
182
+ if (st) {
183
+ if (!st.isSymbolicLink()) return { state: 'real', entry };
184
+ let target = '';
185
+ try { target = readlinkSync(entry); } catch { return { state: 'real', entry }; }
186
+ // Resolve the target against the directory the link PHYSICALLY sits in,
187
+ // which is what the OS does. In a linked worktree `node_modules` is itself
188
+ // a symlink, so the lexical `dirname(entry)` is under the WORKTREE while
189
+ // the link really lives in the primary. Resolving lexically turns every
190
+ // correct `../../packages/core` into a worktree path and reports a healthy
191
+ // linked worktree as `foreign`.
192
+ let base = dirname(entry);
193
+ try { base = realpathSync(base); } catch { /* fall back to the lexical path */ }
194
+ const abs = resolve(base, target);
195
+ let owner = dir;
196
+ try { owner = dirname(realpathSync(modules)); } catch { /* use dir as given */ }
197
+ if (!existsSync(abs)) return { state: 'dangling', entry, target, owner };
198
+ let real = abs;
199
+ try { real = realpathSync(abs); } catch { /* compare the unresolved path */ }
200
+ let ownerReal = owner;
201
+ try { ownerReal = realpathSync(owner); } catch { /* compare as given */ }
202
+ if (real !== ownerReal && !real.startsWith(ownerReal + sep)) {
203
+ return { state: 'foreign', entry, target, owner: ownerReal };
204
+ }
205
+ return { state: 'ok', entry, target, owner: ownerReal };
206
+ }
207
+ const up = dirname(dir);
208
+ if (up === dir) return { state: 'absent' };
209
+ dir = up;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * CHECK: framework link integrity (#1442). WARN when the `@webjsdev/core` entry
215
+ * in node_modules is a symlink that DANGLES or resolves OUTSIDE the tree that
216
+ * owns it. That is what an install run inside a linked worktree leaves behind,
217
+ * and it is invisible to the framework-resolve check above, which only asks
218
+ * whether the package resolves at all: a link into a live FOREIGN checkout
219
+ * resolves perfectly and silently runs another branch's framework source.
220
+ *
221
+ * Silent PASS for a real directory, a correct link, and no entry at all, so a
222
+ * normally installed app pays one `lstat`. WARN rather than fail, the same
223
+ * environment tier as the framework-resolve and version-coherence checks.
224
+ * @param {string} appDir
225
+ * @returns {DoctorResult}
226
+ */
227
+ export function checkFrameworkLinks(appDir) {
228
+ const name = 'framework-links';
229
+ const r = inspectFrameworkLink(appDir);
230
+ if (r.state === 'absent' || r.state === 'real' || r.state === 'ok') {
231
+ return {
232
+ name,
233
+ status: 'pass',
234
+ message: 'The @webjsdev framework links resolve inside the checkout that owns them.',
235
+ };
236
+ }
237
+ const fix =
238
+ 'Repoint the entry at the package inside the checkout that owns this node_modules. Do NOT run ' +
239
+ '`npm install` here while node_modules is a symlink: it acts on that owning checkout, not this ' +
240
+ 'one (#1442).' + linkScriptHint(appDir);
241
+ if (r.state === 'dangling') {
242
+ return {
243
+ name,
244
+ status: 'warn',
245
+ message:
246
+ `${r.entry} is a symlink to ${r.target}, which does not exist. An install run inside a linked ` +
247
+ 'worktree leaves these behind, and the worktree it named has since been removed.',
248
+ fix,
249
+ };
250
+ }
251
+ return {
252
+ name,
253
+ status: 'warn',
254
+ message:
255
+ `${r.entry} is a symlink to ${r.target}, which resolves OUTSIDE ${r.owner}, the checkout that ` +
256
+ 'owns this node_modules. It resolves fine, so nothing fails, and the framework source being run ' +
257
+ "is another checkout's. A deliberate `npm link` produces the same shape.",
258
+ fix,
259
+ };
260
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
6
+ */
7
+
8
+ /**
9
+ * CHECK 6 (optional), git pre-commit hook installed + executable. WARN when the
10
+ * repo is a git checkout but `.git/hooks/pre-commit` is absent or
11
+ * non-executable, since the test-gate / changelog hook would not fire. PASS when
12
+ * present + executable, or skip (PASS) when this is not a git checkout at all
13
+ * (an exported tarball, a non-repo dir). Respects a configured `core.hooksPath`
14
+ * is OUT of scope here: the common scaffold installs into `.git/hooks`, so this
15
+ * checks the default location and a configured path is the user's own concern.
16
+ * @param {string} appDir
17
+ * @returns {DoctorResult}
18
+ */
19
+ export function checkGitHook(appDir) {
20
+ const gitDir = join(appDir, '.git');
21
+ if (!existsSync(gitDir)) {
22
+ return {
23
+ name: 'git-hook',
24
+ status: 'pass',
25
+ message: 'Not a git checkout; no pre-commit hook expected.',
26
+ };
27
+ }
28
+ const hook = join(gitDir, 'hooks', 'pre-commit');
29
+ if (!existsSync(hook)) {
30
+ return {
31
+ name: 'git-hook',
32
+ status: 'warn',
33
+ message: 'No .git/hooks/pre-commit hook installed.',
34
+ fix: 'Install the project hooks (e.g. `npm install` runs the prepare step that wires them).',
35
+ };
36
+ }
37
+ let executable = false;
38
+ try {
39
+ // Owner-execute bit. On a checkout without exec bits (some Windows / CI
40
+ // setups) the hook will not run, so flag it.
41
+ executable = (statSync(hook).mode & 0o100) !== 0;
42
+ } catch {
43
+ executable = false;
44
+ }
45
+ if (!executable) {
46
+ return {
47
+ name: 'git-hook',
48
+ status: 'warn',
49
+ message: '.git/hooks/pre-commit exists but is not executable.',
50
+ fix: 'chmod +x .git/hooks/pre-commit',
51
+ };
52
+ }
53
+ return {
54
+ name: 'git-hook',
55
+ status: 'pass',
56
+ message: '.git/hooks/pre-commit is installed and executable.',
57
+ };
58
+ }
@@ -0,0 +1,158 @@
1
+ import { formatConflicts, makeInstalledManifestReader } from '../manifest.js';
2
+
3
+ /**
4
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
5
+ */
6
+
7
+ /**
8
+ * CHECK 7, importmap coherence (issue #450). Defense-in-depth that catches an
9
+ * INCOHERENT client dependency graph in the produced importmap, regardless of
10
+ * how the incoherence arose (a hand-edited pin file, a partial vendor pin, or
11
+ * the #446 resolution skew). For each resolved package, it checks that the
12
+ * version actually pinned for every OTHER resolved package it depends on
13
+ * satisfies the declared range; a miss warns naming both packages, the range,
14
+ * and the pinned version.
15
+ *
16
+ * Runs the SAME check over BOTH inputs and produces the same verdict for the
17
+ * same dep set (the parity invariant): the live importmap (resolved the way the
18
+ * server resolves it at runtime) AND the vendored `.webjs/vendor/importmap.json`.
19
+ * A vendored importmap is a freeze of the runtime-resolved graph, so a coherent
20
+ * runtime graph that gets vendored stays coherent.
21
+ *
22
+ * WARN-only and BEST-EFFORT: it never hard-fails (a runtime incoherence is the
23
+ * app's concern, not a broken toolchain), and it degrades to a soft
24
+ * "could not verify" whenever metadata or a live resolve is unavailable rather
25
+ * than failing closed. Dependency metadata is read from the already-installed
26
+ * `node_modules` manifests, no network call of its own; the only network touch
27
+ * is the live importmap resolve, which is wrapped so any failure degrades.
28
+ *
29
+ * The vendor functions + manifest reader are injectable via `opts.coherence`
30
+ * so a test can drive every branch without a network call.
31
+ *
32
+ * @param {string} appDir
33
+ * @param {{ coherence?: {
34
+ * liveImports?: () => Promise<Record<string,string> | null>,
35
+ * vendoredImports?: () => Promise<Record<string,string> | null>,
36
+ * getManifest?: (pkg: string, version: string) => Promise<any>,
37
+ * check?: (imports: Record<string,string>, o: { getManifest: any }) => Promise<{ conflicts: any[], unverified: any[], checked: number }>,
38
+ * } }} opts
39
+ * @returns {Promise<DoctorResult>}
40
+ */
41
+ export async function checkImportmapCoherence(appDir, opts) {
42
+ let inj = opts.coherence;
43
+ // Resolve the real vendor toolchain unless a test injected stubs. Both the
44
+ // importmap sources and the coherence-check function come from
45
+ // @webjsdev/server, so a missing install degrades to a WARN, never a throw.
46
+ if (!inj || !inj.check || !inj.liveImports || !inj.vendoredImports || !inj.getManifest) {
47
+ let mod;
48
+ try {
49
+ mod = await import('@webjsdev/server');
50
+ } catch {
51
+ return {
52
+ name: 'importmap-coherence',
53
+ status: 'warn',
54
+ bestEffort: true,
55
+ message: 'Could not load the vendor toolchain to check importmap coherence.',
56
+ fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
57
+ };
58
+ }
59
+ const real = {
60
+ check: mod.checkImportmapCoherence,
61
+ // Hoist-aware manifest read from the already-installed node_modules (no
62
+ // network of its own), so a monorepo-hoisted dep still resolves. Falls
63
+ // back to the local app/node_modules read if the server build predates
64
+ // getPackageManifest.
65
+ getManifest: typeof mod.getPackageManifest === 'function'
66
+ ? (pkg) => mod.getPackageManifest(pkg, appDir)
67
+ : makeInstalledManifestReader(appDir),
68
+ // Live importmap: resolve vendor imports the way the server does on the
69
+ // first request (prefers the pin file, else a live jspm.io resolve).
70
+ liveImports: async () => {
71
+ try {
72
+ const resolved = await mod.resolveVendorImports(appDir, () => mod.scanBareImports(appDir));
73
+ return resolved && resolved.imports ? resolved.imports : {};
74
+ } catch {
75
+ return null;
76
+ }
77
+ },
78
+ // Vendored importmap: the committed pin file, no network.
79
+ vendoredImports: async () => {
80
+ try {
81
+ const pin = await mod.readPinFile(appDir);
82
+ return pin && pin.imports ? pin.imports : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ },
87
+ };
88
+ inj = { ...real, ...(inj || {}) };
89
+ }
90
+
91
+ // Gather both importmaps. Either may be absent (no pin file, or a live
92
+ // resolve that failed / found no vendor imports); the check runs over
93
+ // whichever exist, identically.
94
+ let live = null;
95
+ let vendored = null;
96
+ try { live = await inj.liveImports(); } catch { live = null; }
97
+ try { vendored = await inj.vendoredImports(); } catch { vendored = null; }
98
+
99
+ const liveHas = live && Object.keys(live).length > 0;
100
+ const vendoredHas = vendored && Object.keys(vendored).length > 0;
101
+ if (!liveHas && !vendoredHas) {
102
+ return {
103
+ name: 'importmap-coherence',
104
+ status: 'pass',
105
+ message: 'No vendor importmap to check (the app imports no npm packages on the client).',
106
+ };
107
+ }
108
+
109
+ // Run the IDENTICAL check over each available importmap. The function is
110
+ // pure in (imports, getManifest), so the same pinned dep set produces the
111
+ // same verdict whichever input it came from (the runtime-vs-vendored parity
112
+ // invariant). Aggregate the conflicts; dedupe identical ones so a package
113
+ // pinned the same way in both maps is reported once.
114
+ /** @type {Map<string, any>} */
115
+ const conflictsByKey = new Map();
116
+ let anyChecked = 0;
117
+ let anyUnverified = 0;
118
+ for (const imports of [liveHas ? live : null, vendoredHas ? vendored : null]) {
119
+ if (!imports) continue;
120
+ let report;
121
+ try {
122
+ report = await inj.check(imports, { getManifest: inj.getManifest });
123
+ } catch {
124
+ // A check that threw is a "could not verify", never a doctor crash.
125
+ anyUnverified++;
126
+ continue;
127
+ }
128
+ anyChecked += report.checked || 0;
129
+ anyUnverified += (report.unverified || []).length;
130
+ for (const c of report.conflicts || []) {
131
+ conflictsByKey.set(`${c.pkg}@${c.version}->${c.dependsOn}@${c.pinnedVersion}`, c);
132
+ }
133
+ }
134
+
135
+ const conflicts = [...conflictsByKey.values()];
136
+ if (conflicts.length > 0) {
137
+ return {
138
+ name: 'importmap-coherence',
139
+ status: 'warn',
140
+ message: `Incoherent client dependency graph in the importmap: ${formatConflicts(conflicts)}.`,
141
+ 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.',
142
+ };
143
+ }
144
+ if (anyChecked === 0 && anyUnverified > 0) {
145
+ return {
146
+ name: 'importmap-coherence',
147
+ status: 'warn',
148
+ bestEffort: true,
149
+ message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).',
150
+ fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.',
151
+ };
152
+ }
153
+ return {
154
+ name: 'importmap-coherence',
155
+ status: 'pass',
156
+ message: 'The importmap dependency graph is coherent (every pinned package satisfies its dependents\' declared ranges).',
157
+ };
158
+ }
@@ -0,0 +1,37 @@
1
+ import { checkNodeInline } from '../../node-preflight.js';
2
+ import { readEngines } from '../util.js';
3
+
4
+ /**
5
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
6
+ */
7
+
8
+ /**
9
+ * CHECK 1, Node version. HARD-FAIL when the running major is below the required
10
+ * major (the strip-types + recursive fs.watch floor). `opts.nodeVersion` lets a
11
+ * test inject the running version so the fail case is assertable without being
12
+ * on old Node.
13
+ * @param {string} cliDir
14
+ * @param {{ nodeVersion?: string }} opts
15
+ * @returns {Promise<DoctorResult>}
16
+ */
17
+ export async function checkNode(cliDir, opts) {
18
+ const engines = await readEngines(cliDir);
19
+ const current = opts.nodeVersion || process.versions.node;
20
+ const r = checkNodeInline(current, engines);
21
+ if (r.ok) {
22
+ return {
23
+ name: 'node-version',
24
+ status: 'pass',
25
+ message: `Node ${r.current} satisfies the required Node ${r.requiredMajor}+.`,
26
+ };
27
+ }
28
+ return {
29
+ name: 'node-version',
30
+ status: 'fail',
31
+ message:
32
+ `Node ${r.current} is below the required Node ${r.requiredMajor}+. ` +
33
+ `webjs is buildless and relies on Node ${r.requiredMajor}'s built-in TypeScript ` +
34
+ `strip and recursive fs.watch.`,
35
+ fix: `Upgrade to Node ${r.requiredMajor}+ (see https://nodejs.org).`,
36
+ };
37
+ }
@@ -0,0 +1,58 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { newestMtimeMs } from '../util.js';
4
+
5
+ /**
6
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
7
+ */
8
+
9
+ /**
10
+ * ADVISORY: a declared `webjs.dev.regenerate` output is STALE on disk (a source
11
+ * is newer than the committed/built output). In DEV the framework recompiles it
12
+ * on request (#967), so this never bites locally, but the check is the explicit
13
+ * dev/prod PARITY backstop: it catches a stale `public/tailwind.css` that would
14
+ * be served as-is by `webjs start` (prod does NOT recompile on request) or
15
+ * committed into the repo. WARN-level: the fix is a one-line rebuild, and a
16
+ * missing output (a fresh clone before the first `css:build`) is not this app's
17
+ * bug to hard-fail on.
18
+ * @param {string} appDir
19
+ * @returns {Promise<DoctorResult>}
20
+ */
21
+ export async function checkStaticAssetFreshness(appDir) {
22
+ const name = 'Static build outputs (dev.regenerate freshness)';
23
+ let pkg;
24
+ try {
25
+ pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
26
+ } catch {
27
+ return { name, status: 'pass', message: 'no package.json to analyse' };
28
+ }
29
+ const rules = pkg && pkg.webjs && pkg.webjs.dev ? pkg.webjs.dev.regenerate : null;
30
+ if (!Array.isArray(rules) || rules.length === 0) {
31
+ return { name, status: 'pass', message: 'no webjs.dev.regenerate rules declared' };
32
+ }
33
+ const stale = [];
34
+ for (const rule of rules) {
35
+ if (!rule || typeof rule.output !== 'string') continue;
36
+ const output = rule.output.replace(/^\/+/, '');
37
+ const outMtime = newestMtimeMs(join(appDir, output));
38
+ if (outMtime === 0) continue; // missing output: not a staleness fail (built on first boot)
39
+ let newestSrc = 0;
40
+ for (const inp of Array.isArray(rule.inputs) ? rule.inputs : []) {
41
+ const m = newestMtimeMs(join(appDir, inp));
42
+ if (m > newestSrc) newestSrc = m;
43
+ }
44
+ if (newestSrc > outMtime) stale.push({ output, command: rule.command });
45
+ }
46
+ if (stale.length === 0) {
47
+ return { name, status: 'pass', message: 'every declared build output is up to date with its sources' };
48
+ }
49
+ return {
50
+ name,
51
+ status: 'warn',
52
+ message:
53
+ `${stale.length} static build output(s) are older than a source file:\n` +
54
+ stale.map((s) => ` ${s.output} (rebuild: ${s.command})`).join('\n') +
55
+ '\n In dev the framework recompiles these on request, so this only bites a `webjs start` (prod) or a committed stale file.',
56
+ 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.',
57
+ };
58
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { stripJsonc } from '../util.js';
5
+
6
+ /**
7
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
8
+ */
9
+
10
+ /**
11
+ * CHECK 2, tsconfig erasableSyntaxOnly. PASS when `true`; WARN when no tsconfig
12
+ * (a JS-only app legitimately has none) or the file is unparseable; HARD-FAIL
13
+ * when the file EXISTS but the flag is missing/false (non-erasable TS 500s at
14
+ * strip time).
15
+ * @param {string} appDir
16
+ * @returns {Promise<DoctorResult>}
17
+ */
18
+ export async function checkTsconfig(appDir) {
19
+ const path = join(appDir, 'tsconfig.json');
20
+ if (!existsSync(path)) {
21
+ return {
22
+ name: 'tsconfig-erasable',
23
+ status: 'warn',
24
+ message: 'No tsconfig.json found. A JS-only app needs none; a TypeScript app requires one.',
25
+ fix: 'If this app uses TypeScript, add a tsconfig.json with "erasableSyntaxOnly": true.',
26
+ };
27
+ }
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(stripJsonc(await readFile(path, 'utf8')));
31
+ } catch {
32
+ return {
33
+ name: 'tsconfig-erasable',
34
+ status: 'warn',
35
+ message: 'tsconfig.json could not be parsed (even after stripping comments + trailing commas).',
36
+ fix: 'Fix the tsconfig.json syntax, then ensure "compilerOptions.erasableSyntaxOnly": true.',
37
+ };
38
+ }
39
+ const flag = parsed?.compilerOptions?.erasableSyntaxOnly;
40
+ if (flag === true) {
41
+ return {
42
+ name: 'tsconfig-erasable',
43
+ status: 'pass',
44
+ message: 'tsconfig.json sets "erasableSyntaxOnly": true.',
45
+ };
46
+ }
47
+ return {
48
+ name: 'tsconfig-erasable',
49
+ status: 'fail',
50
+ message:
51
+ 'tsconfig.json is missing "compilerOptions.erasableSyntaxOnly": true. ' +
52
+ 'Non-erasable TypeScript (enum, namespace, parameter properties, ...) 500s at strip time.',
53
+ fix: 'Set "compilerOptions": { "erasableSyntaxOnly": true } in tsconfig.json.',
54
+ };
55
+ }