@mjasnikovs/pi-task 0.18.23 → 0.18.25

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.
@@ -0,0 +1,826 @@
1
+ /**
2
+ * artifact-closure — dangling runtime file references (the run-13 index.html
3
+ * class, nexttask PROMPT 2).
4
+ *
5
+ * The failure this closes (mx5 run 13, validated): a runtime file reference with
6
+ * NO producer anywhere in the plan shipped silently. The server's SPA fallback
7
+ * read `Bun.file('dist/index.html')`; the build emitted only `app.css` +
8
+ * `main.js`; no task, script, or build output ever CREATES `index.html` — so the
9
+ * shipped app 404'd on every non-API GET while coverage reported "0 unowned"
10
+ * (sentence-grounded coverage credited the SERVING side to the server task and
11
+ * was structurally blind to the missing PRODUCING side). The spec itself was
12
+ * internally dangling: its prose required serving `index.html` while its file
13
+ * tree and build section never defined it.
14
+ *
15
+ * Two seams consume this module:
16
+ * • plan-time (auto-orchestrator): refs extracted from the SPEC's own snippets
17
+ * that neither the spec's file tree, its build outputs, nor the existing
18
+ * scaffold produce become UNOWNED areas — they ride the coverage loop's
19
+ * `missing` list (forcing a round that assigns a producing task) and are
20
+ * carried into `.pi-tasks/requirements.md` when still unowned at exhaustion.
21
+ * • final gate: the shipped tree is scanned; a dangling reference is a ranked
22
+ * failure naming referencer + missing path (rides PROMPT 1's aggregation).
23
+ *
24
+ * FP discipline (the run-12 groundedCoverage lesson — ground in artifacts the
25
+ * model can't fake, and the standing guard direction — inconclusive is NEVER
26
+ * evidence):
27
+ * • literal string paths only; any dynamic expression steps aside.
28
+ * • a ref is DANGLING only on POSITIVE producer evidence: it must sit under a
29
+ * directory whose outputs we could actually ENUMERATE (parsed build
30
+ * script/flags) and not be among them — or be a missing source-extension
31
+ * script entrypoint, which nothing ever builds. A ref under a directory
32
+ * produced by machinery we could NOT enumerate (vite/tsc/next/unknown
33
+ * commands that mention it) is OPAQUE and always steps aside.
34
+ * • gitignored-but-built paths therefore never fire: being built means a
35
+ * producer names them (exact file, enumerable stem, or opaque dir).
36
+ * • existence is checked on the live tree (existsSync), so anything already
37
+ * present — committed, generated, or hand-made — is satisfied.
38
+ *
39
+ * Note the mx5 server GUARDED its read (`if (!(await htmlFile.exists())) return
40
+ * c.notFound()`): an existence guard is exactly how the bug presents (permanent
41
+ * 404), so guarded reads deliberately do NOT step aside.
42
+ */
43
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
44
+ import * as path from 'node:path';
45
+ export function emptyProducers() {
46
+ return { files: new Set(), enumerable: new Map(), opaque: new Set(), dirs: new Set() };
47
+ }
48
+ /** Normalize a literal path: posix separators, strip ./ prefixes, query/hash
49
+ * tails (HTML), trailing slash. Returns null when the literal is not a
50
+ * checkable relative path (URL, absolute, template hole, glob — step aside). */
51
+ export function normalizeRefPath(raw) {
52
+ let p = raw.trim().replace(/\\/g, '/');
53
+ // Markdown/shell wrapping (a spec bullet's `-o dist/app.css` arrives with a
54
+ // trailing backtick) — strip wrapping quote characters at both ends.
55
+ p = p.replace(/^[`'"]+/, '').replace(/[`'"]+$/, '');
56
+ // HTML-side noise: ?query / #fragment tails.
57
+ p = p.replace(/[?#].*$/, '');
58
+ if (p.length === 0 || p.length > 200)
59
+ return null;
60
+ // Dynamic/glob/scheme/absolute — inconclusive, never evidence. A remaining
61
+ // quote or whitespace mid-token means we mis-tokenized — step aside.
62
+ if (/\$\{|[*?]|^[a-z][a-z0-9+.-]*:|^\/\/|^~|[`'"\s]/i.test(p))
63
+ return null;
64
+ while (p.startsWith('./'))
65
+ p = p.slice(2);
66
+ if (p.startsWith('/'))
67
+ p = p.slice(1); // root-relative (HTML) → resolve from repo root
68
+ p = p.replace(/\/+$/, '');
69
+ if (p.length === 0 || p === '.' || p === '..')
70
+ return null;
71
+ if (p.startsWith('node_modules/') || p.includes('/node_modules/'))
72
+ return null;
73
+ return p;
74
+ }
75
+ const stem = (p) => path.posix.basename(p).replace(/\.[^.]+$/, '');
76
+ const hasExt = (p) => /\.[A-Za-z0-9]{1,8}$/.test(path.posix.basename(p));
77
+ /** Source-only extensions nothing ships un-built: a missing one of these is hard
78
+ * evidence on its own (a script entrypoint like `bun src/server/index.ts`). */
79
+ const SOURCE_ONLY_EXT_RE = /\.(?:ts|tsx|mts|cts|jsx)$/i;
80
+ /** Strip comment-only lines (`// …`, `* …`) — a ref quoted in a comment is not a
81
+ * runtime read. Inline comments are left alone (strings may contain `//`). */
82
+ function stripCommentLines(src) {
83
+ return src
84
+ .split('\n')
85
+ .filter(l => !/^\s*(?:\/\/|\*|\/\*)/.test(l))
86
+ .join('\n');
87
+ }
88
+ // Literal-first-argument read-side constructs. `[^'"\`\n]` keeps the argument on
89
+ // one line and free of a closing quote; normalizeRefPath rejects `${…}` holes.
90
+ const JS_READ_PATTERNS = [
91
+ { re: /\bBun\.file\(\s*(['"`])([^'"`\n]+)\1/g, construct: 'Bun.file', kind: 'file' },
92
+ { re: /\breadFile(?:Sync)?\(\s*(['"`])([^'"`\n]+)\1/g, construct: 'readFile', kind: 'file' },
93
+ {
94
+ re: /\bcreateReadStream\(\s*(['"`])([^'"`\n]+)\1/g,
95
+ construct: 'createReadStream',
96
+ kind: 'file'
97
+ },
98
+ { re: /\bsendFile\(\s*(['"`])([^'"`\n]+)\1/g, construct: 'sendFile', kind: 'file' },
99
+ {
100
+ re: /\bserveStatic\(\s*\{[^}]*?\broot:\s*(['"`])([^'"`\n]+)\1/g,
101
+ construct: 'serveStatic root',
102
+ kind: 'dir'
103
+ },
104
+ {
105
+ re: /\b(?:express|app)\.static\(\s*(['"`])([^'"`\n]+)\1/g,
106
+ construct: 'express.static root',
107
+ kind: 'dir'
108
+ }
109
+ ];
110
+ // Local-asset references in HTML: script/link/img only — an <a href> is a route,
111
+ // not a file the server must materialize.
112
+ const HTML_PATTERNS = [
113
+ { re: /<script\b[^>]*\bsrc\s*=\s*(['"])([^'"]+)\1/gi, construct: 'script src', kind: 'file' },
114
+ { re: /<link\b[^>]*\bhref\s*=\s*(['"])([^'"]+)\1/gi, construct: 'link href', kind: 'file' },
115
+ { re: /<img\b[^>]*\bsrc\s*=\s*(['"])([^'"]+)\1/gi, construct: 'img src', kind: 'file' }
116
+ ];
117
+ /** Extract runtime refs from one JS/TS source. */
118
+ export function extractJsRefs(source, referencer) {
119
+ const src = stripCommentLines(source);
120
+ const out = [];
121
+ for (const { re, construct, kind } of JS_READ_PATTERNS) {
122
+ re.lastIndex = 0;
123
+ for (let m = re.exec(src); m !== null; m = re.exec(src)) {
124
+ const p = normalizeRefPath(m[2]);
125
+ if (p === null)
126
+ continue;
127
+ if (kind === 'file' && !hasExt(p))
128
+ continue; // route-/name-shaped, not a file
129
+ out.push({ path: p, referencer, construct, kind });
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+ /** Extract local-asset refs from one HTML source. */
135
+ export function extractHtmlRefs(source, referencer) {
136
+ const out = [];
137
+ for (const { re, construct, kind } of HTML_PATTERNS) {
138
+ re.lastIndex = 0;
139
+ for (let m = re.exec(source); m !== null; m = re.exec(source)) {
140
+ const p = normalizeRefPath(m[2]);
141
+ if (p === null)
142
+ continue;
143
+ if (!hasExt(p))
144
+ continue; // extension-less href/src = route or directory
145
+ out.push({ path: p, referencer, construct, kind });
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+ /** A path-shaped shell token: contains a separator or an extension, no shell
151
+ * metacharacters, not a flag. */
152
+ function isPathToken(t) {
153
+ if (t.startsWith('-') || t.length === 0 || t.length > 200)
154
+ return false;
155
+ if (/["'`$(){}<>|;&*?[\]]/.test(t))
156
+ return false;
157
+ return t.includes('/') || hasExt(t);
158
+ }
159
+ /** Script entrypoints: `bun x.ts`, `bun run x.ts`, `node x.js`, `tsx x.ts` —
160
+ * the first path-shaped source arg of a runner command. */
161
+ export function extractScriptEntrypoints(body, referencer) {
162
+ const out = [];
163
+ for (const cmd of splitShellCommands(body)) {
164
+ const toks = cmd.trim().split(/\s+/);
165
+ let i = 0;
166
+ if (['bun', 'bunx', 'node', 'tsx', 'deno'].includes(toks[i] ?? '')) {
167
+ i++;
168
+ if (toks[i] === 'run')
169
+ i++;
170
+ while (toks[i]?.startsWith('-'))
171
+ i++;
172
+ const cand = toks[i];
173
+ if (cand !== undefined && isPathToken(cand) && /\.(?:ts|tsx|js|mjs|cjs)$/.test(cand)) {
174
+ const p = normalizeRefPath(cand);
175
+ if (p !== null)
176
+ out.push({ path: p, referencer, construct: 'script entrypoint', kind: 'file' });
177
+ }
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+ /** Split a package.json script body into individual commands (`&&`, `||`, `;`,
183
+ * `|`, background `&`, newlines). */
184
+ function splitShellCommands(body) {
185
+ return body
186
+ .split(/&&|\|\||[;|&]|\n/)
187
+ .map(s => s.trim())
188
+ .filter(s => s.length > 0);
189
+ }
190
+ function addEnumerable(prod, dir, stems) {
191
+ const d = prod.enumerable.get(dir) ?? new Set();
192
+ for (const s of stems)
193
+ d.add(s);
194
+ prod.enumerable.set(dir, d);
195
+ prod.dirs.add(dir);
196
+ }
197
+ function addFile(prod, file) {
198
+ prod.files.add(file);
199
+ const dir = path.posix.dirname(file);
200
+ if (dir !== '.')
201
+ addEnumerable(prod, dir, [stem(file)]);
202
+ }
203
+ /** Tools that never materialize project files on their own — their leftover path
204
+ * args must not escalate a directory to opaque. */
205
+ const KNOWN_NON_PRODUCING_RE = /^(?:prettier|eslint|tsc|bun|bunx|npx|node|tsx|deno|jest|vitest|mocha|playwright|cypress|grep|cat|echo|rm|test|cross-env|env|git|curl|wget|true|false|exit|sleep|kill|mkdir|cp|mv|touch|concurrently|npm-run-all|wait-on)$/;
206
+ /** Bundlers/compilers whose OUTPUT DIRECTORY is known but whose exact output
207
+ * file set we cannot enumerate statically — the dir becomes opaque. */
208
+ const OPAQUE_TOOL_DIRS = [
209
+ { re: /\bvite\s+build\b|\bvite\b\s*$/, dirs: ['dist'] },
210
+ { re: /\bnext\s+build\b/, dirs: ['.next', 'out'] },
211
+ { re: /\btsup\b/, dirs: ['dist'] },
212
+ { re: /\bwebpack\b/, dirs: ['dist', 'build'] },
213
+ { re: /\brollup\b/, dirs: ['dist'] },
214
+ { re: /\bparcel\s+build\b/, dirs: ['dist'] },
215
+ { re: /\breact-scripts\s+build\b/, dirs: ['build'] },
216
+ { re: /\bng\s+build\b/, dirs: ['dist'] },
217
+ { re: /\bastro\s+build\b/, dirs: ['dist'] },
218
+ { re: /\bnuxt\s+(?:build|generate)\b/, dirs: ['.output', 'dist'] }
219
+ ];
220
+ /**
221
+ * Producer facts from ONE shell command: output flags (`-o x`, `--outfile x`,
222
+ * `--outdir d`), redirects, `cp`/`mv`/`touch` destinations, `mkdir` dirs, known
223
+ * opaque bundlers. Leftover path tokens of an UNRECOGNIZED command escalate any
224
+ * directory they point into to opaque — that command may produce there, and
225
+ * inconclusive is never evidence. `escalate: false` disables that escalation
226
+ * for command text embedded in PROSE (a markdown bullet's surrounding words
227
+ * tokenize as junk "commands" and would opaque half the spec's paths).
228
+ */
229
+ export function collectProducersFromCommand(cmd, prod, opts = {}) {
230
+ let rest = cmd.replace(/2>&1|&>\s*\S+|2>\s*\S+/g, ' ');
231
+ for (const { re, dirs } of OPAQUE_TOOL_DIRS) {
232
+ if (re.test(rest))
233
+ for (const d of dirs)
234
+ prod.opaque.add(d);
235
+ }
236
+ // Redirect target.
237
+ rest = rest.replace(/>>?\s*([^\s&|;]+)/g, (_, f) => {
238
+ const p = normalizeRefPath(f);
239
+ if (p !== null && p !== 'dev/null')
240
+ addFile(prod, p);
241
+ return ' ';
242
+ });
243
+ // Explicit output flags. --outdir/--out-dir is always a dir; -o/--output/
244
+ // --outfile decide by extension (tailwind -o dist/app.css vs esbuild -o dir).
245
+ const flagRe = /(?:^|\s)(--out(?:file|put|dir|-dir)?|-o)(?:=|\s+)([^\s&|;]+)/g;
246
+ const sourceArgs = [];
247
+ rest = rest.replace(flagRe, (_, flag, val) => {
248
+ const p = normalizeRefPath(val);
249
+ if (p === null)
250
+ return ' ';
251
+ if (flag === '--outdir' || flag === '--out-dir')
252
+ addEnumerable(prod, p, []);
253
+ else if (hasExt(p))
254
+ addFile(prod, p);
255
+ else
256
+ addEnumerable(prod, p, []);
257
+ return ' ';
258
+ });
259
+ const toks = rest.trim().split(/\s+/);
260
+ const bin = (toks[0] ?? '').replace(/^.*\//, '');
261
+ if (bin === 'mkdir') {
262
+ for (const t of toks.slice(1)) {
263
+ const p = t.startsWith('-') ? null : normalizeRefPath(t);
264
+ if (p !== null)
265
+ prod.dirs.add(p);
266
+ }
267
+ return;
268
+ }
269
+ if (bin === 'cp' || bin === 'mv') {
270
+ const args = toks.slice(1).filter(t => !t.startsWith('-'));
271
+ const dest = args[args.length - 1];
272
+ const p = dest !== undefined ? normalizeRefPath(dest) : null;
273
+ if (p !== null && args.length >= 2) {
274
+ if (hasExt(p))
275
+ addFile(prod, p);
276
+ else
277
+ prod.opaque.add(p); // dir dest: contents unenumerable
278
+ }
279
+ return;
280
+ }
281
+ if (bin === 'touch') {
282
+ for (const t of toks.slice(1)) {
283
+ const p = t.startsWith('-') ? null : normalizeRefPath(t);
284
+ if (p !== null)
285
+ addFile(prod, p);
286
+ }
287
+ return;
288
+ }
289
+ // Source-shaped args (a bundler's entrypoints) contribute stems to any
290
+ // outdir this same command declared.
291
+ for (const t of toks.slice(1)) {
292
+ if (isPathToken(t) && /\.(?:ts|tsx|js|jsx|mjs|cjs|css|html)$/.test(t)) {
293
+ const p = normalizeRefPath(t);
294
+ if (p !== null)
295
+ sourceArgs.push(p);
296
+ }
297
+ }
298
+ // (flagRe already consumed outdirs; attribute stems to dirs declared here.)
299
+ // Re-scan the ORIGINAL command for the outdirs it declared:
300
+ const declaredDirs = [];
301
+ const dirFlagRe = /(?:^|\s)--out(?:dir|-dir)(?:=|\s+)([^\s&|;]+)/g;
302
+ for (let m = dirFlagRe.exec(cmd); m !== null; m = dirFlagRe.exec(cmd)) {
303
+ const p = normalizeRefPath(m[1]);
304
+ if (p !== null)
305
+ declaredDirs.push(p);
306
+ }
307
+ for (const d of declaredDirs)
308
+ addEnumerable(prod, d, sourceArgs.map(stem));
309
+ // Unrecognized command: any leftover path token pointing INTO a directory
310
+ // makes that directory opaque — the tool may generate arbitrary files there.
311
+ if ((opts.escalate ?? true) && !KNOWN_NON_PRODUCING_RE.test(bin) && !/^@/.test(bin)) {
312
+ for (const t of toks.slice(1)) {
313
+ if (!isPathToken(t))
314
+ continue;
315
+ const p = normalizeRefPath(t);
316
+ if (p === null)
317
+ continue;
318
+ const dir = hasExt(p) ? path.posix.dirname(p) : p;
319
+ if (dir !== '.')
320
+ prod.opaque.add(dir);
321
+ }
322
+ }
323
+ }
324
+ /**
325
+ * Producer facts from JS/TS source: write-side calls (`Bun.write`,
326
+ * `writeFile(Sync)`, `createWriteStream`, `copyFile` dest, `mkdir(Sync)`),
327
+ * `Bun.build({entrypoints, outdir})` (enumerable — unless a `naming` option
328
+ * makes the output names underivable, then opaque), `outfile:`, and `Bun.spawn`
329
+ * argv arrays re-fed through the shell-command collector (the mx5 build.ts
330
+ * shape: tailwind's `-o dist/app.css` lives in a spawn array).
331
+ */
332
+ export function collectProducersFromSource(source, prod) {
333
+ const src = stripCommentLines(source);
334
+ const litRe = (call) => new RegExp(String.raw `\b${call}\(\s*(['"\`])([^'"\`\n]+)\1`, 'g');
335
+ for (const call of [
336
+ 'Bun\\.write',
337
+ 'writeFile(?:Sync)?',
338
+ 'appendFile(?:Sync)?',
339
+ 'createWriteStream'
340
+ ]) {
341
+ const re = litRe(call);
342
+ for (let m = re.exec(src); m !== null; m = re.exec(src)) {
343
+ const p = normalizeRefPath(m[2]);
344
+ if (p !== null && hasExt(p))
345
+ addFile(prod, p);
346
+ }
347
+ }
348
+ const cpRe = /\bcopyFile(?:Sync)?\(\s*[^,()]+,\s*(['"`])([^'"`\n]+)\1/g;
349
+ for (let m = cpRe.exec(src); m !== null; m = cpRe.exec(src)) {
350
+ const p = normalizeRefPath(m[2]);
351
+ if (p !== null)
352
+ addFile(prod, p);
353
+ }
354
+ const mkRe = /\bmkdir(?:Sync)?\(\s*(['"`])([^'"`\n]+)\1/g;
355
+ for (let m = mkRe.exec(src); m !== null; m = mkRe.exec(src)) {
356
+ const p = normalizeRefPath(m[2]);
357
+ if (p !== null)
358
+ prod.dirs.add(p);
359
+ }
360
+ // Bun.build blocks: pair each `outdir` with the `entrypoints` in the same
361
+ // options object (nearest preceding within the call's brace span — a simple
362
+ // window keeps this robust to formatting without a real parser).
363
+ const buildRe = /Bun\.build\(\s*\{([\s\S]{0,2000}?)\}\s*\)/g;
364
+ for (let m = buildRe.exec(src); m !== null; m = buildRe.exec(src)) {
365
+ const body = m[1];
366
+ const outdirM = /\boutdir:\s*(['"`])([^'"`\n]+)\1/.exec(body);
367
+ const outfileM = /\boutfile:\s*(['"`])([^'"`\n]+)\1/.exec(body);
368
+ if (outfileM) {
369
+ const p = normalizeRefPath(outfileM[2]);
370
+ if (p !== null)
371
+ addFile(prod, p);
372
+ }
373
+ if (!outdirM)
374
+ continue;
375
+ const dir = normalizeRefPath(outdirM[2]);
376
+ if (dir === null)
377
+ continue;
378
+ if (/\bnaming:/.test(body)) {
379
+ prod.opaque.add(dir); // custom naming — outputs underivable
380
+ continue;
381
+ }
382
+ const entryM = /\bentrypoints:\s*\[([^\]]*)\]/.exec(body);
383
+ const entries = [];
384
+ if (entryM) {
385
+ const lit = /(['"`])([^'"`\n]+)\1/g;
386
+ for (let em = lit.exec(entryM[1]); em !== null; em = lit.exec(entryM[1])) {
387
+ const p = normalizeRefPath(em[2]);
388
+ if (p !== null)
389
+ entries.push(p);
390
+ }
391
+ }
392
+ if (entries.length === 0 && entryM === null) {
393
+ prod.opaque.add(dir); // dynamic entrypoints — cannot enumerate
394
+ }
395
+ else {
396
+ addEnumerable(prod, dir, entries.map(stem));
397
+ }
398
+ }
399
+ // Bun.spawn / spawnSync argv arrays → re-parse as a shell command.
400
+ const spawnRe = /\bspawn(?:Sync)?\(\s*\[([^\]]*)\]/gi;
401
+ for (let m = spawnRe.exec(src); m !== null; m = spawnRe.exec(src)) {
402
+ const lit = /(['"`])([^'"`\n]*)\1/g;
403
+ const argv = [];
404
+ for (let am = lit.exec(m[1]); am !== null; am = lit.exec(m[1]))
405
+ argv.push(am[2]);
406
+ if (argv.length > 0)
407
+ collectProducersFromCommand(argv.join(' '), prod);
408
+ }
409
+ }
410
+ /** Best-effort `outDir` from tsconfig-family files (JSONC-tolerant). tsc mirrors
411
+ * arbitrary source names into it, so it is always OPAQUE. */
412
+ function collectTsconfigOutDirs(cwd, prod) {
413
+ let names;
414
+ try {
415
+ names = readdirSync(cwd).filter(n => /^tsconfig(\..+)?\.json$/.test(n));
416
+ }
417
+ catch {
418
+ return;
419
+ }
420
+ for (const n of names) {
421
+ try {
422
+ const m = /"outDir"\s*:\s*"([^"]+)"/.exec(readFileSync(path.join(cwd, n), 'utf8'));
423
+ if (m) {
424
+ const p = normalizeRefPath(m[1]);
425
+ if (p !== null)
426
+ prod.opaque.add(p);
427
+ }
428
+ }
429
+ catch {
430
+ // unreadable config — nothing to learn
431
+ }
432
+ }
433
+ }
434
+ /** vite/astro-style config: `outDir: 'x'` (opaque), plus their default `dist`. */
435
+ function collectBundlerConfigOutDirs(cwd, prod) {
436
+ for (const n of [
437
+ 'vite.config.ts',
438
+ 'vite.config.js',
439
+ 'vite.config.mts',
440
+ 'vite.config.mjs',
441
+ 'astro.config.mjs',
442
+ 'astro.config.ts'
443
+ ]) {
444
+ const f = path.join(cwd, n);
445
+ if (!existsSync(f))
446
+ continue;
447
+ prod.opaque.add('dist');
448
+ try {
449
+ const m = /\boutDir:\s*(['"`])([^'"`\n]+)\1/.exec(readFileSync(f, 'utf8'));
450
+ if (m) {
451
+ const p = normalizeRefPath(m[2]);
452
+ if (p !== null)
453
+ prod.opaque.add(p);
454
+ }
455
+ }
456
+ catch {
457
+ // default already recorded
458
+ }
459
+ }
460
+ }
461
+ /** package.json scripts of `cwd` (empty on any fault). */
462
+ function packageScripts(cwd) {
463
+ try {
464
+ const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
465
+ return j.scripts ?? {};
466
+ }
467
+ catch {
468
+ return {};
469
+ }
470
+ }
471
+ /**
472
+ * Discover everything the project's own machinery produces: package.json script
473
+ * bodies, build files those scripts run (plus conventional root build files),
474
+ * tsconfig/vite outDirs.
475
+ */
476
+ export function discoverProducers(cwd) {
477
+ const prod = emptyProducers();
478
+ const scripts = packageScripts(cwd);
479
+ const buildFiles = new Set(['build.ts', 'build.js', 'build.mjs'].filter(f => existsSync(path.join(cwd, f))));
480
+ for (const body of Object.values(scripts)) {
481
+ for (const cmd of splitShellCommands(body)) {
482
+ collectProducersFromCommand(cmd, prod);
483
+ // A script that runs a local JS/TS file may produce through it —
484
+ // parse that file's source too (the mx5 `bun build.ts` shape).
485
+ for (const t of cmd.split(/\s+/)) {
486
+ if (isPathToken(t) && /\.(?:ts|js|mjs|cjs)$/.test(t)) {
487
+ const p = normalizeRefPath(t);
488
+ if (p !== null && existsSync(path.join(cwd, p)))
489
+ buildFiles.add(p);
490
+ }
491
+ }
492
+ }
493
+ }
494
+ for (const f of buildFiles) {
495
+ try {
496
+ collectProducersFromSource(readFileSync(path.join(cwd, f), 'utf8'), prod);
497
+ }
498
+ catch {
499
+ // unreadable build file: its outdirs stay whatever the scripts said
500
+ }
501
+ }
502
+ collectTsconfigOutDirs(cwd, prod);
503
+ collectBundlerConfigOutDirs(cwd, prod);
504
+ return prod;
505
+ }
506
+ const underDir = (p, dir) => p === dir || p.startsWith(dir + '/');
507
+ /**
508
+ * Resolve refs against existence + producers. DANGLING requires POSITIVE
509
+ * evidence (see the module doc): the ref sits under an ENUMERATED output dir
510
+ * and is not among its outputs, or is a missing source-only-extension file
511
+ * (nothing ever builds a `.ts`/`.tsx`). Everything inconclusive steps aside.
512
+ */
513
+ export function resolveDanglingRefs(refs, prod, exists) {
514
+ const out = [];
515
+ const seen = new Set();
516
+ for (const ref of refs) {
517
+ const p = ref.path;
518
+ // Candidate bases: repo root and the referencing file's directory (a JS
519
+ // relative read may resolve from either at runtime; HTML relative src
520
+ // resolves from its own dir). Satisfied under ANY plausible base.
521
+ const bases = [''];
522
+ const refDir = path.posix.dirname(ref.referencer.replace(/\\/g, '/'));
523
+ if (refDir !== '.'
524
+ && !ref.referencer.startsWith('package.json')
525
+ && ref.referencer !== 'spec') {
526
+ bases.push(refDir);
527
+ }
528
+ const candidates = bases
529
+ .map(b => path.posix.normalize(b === '' ? p : `${b}/${p}`))
530
+ .filter(c => !c.startsWith('..'));
531
+ if (candidates.length === 0)
532
+ continue; // escapes the repo — cannot ground
533
+ if (candidates.some(c => exists(c)))
534
+ continue;
535
+ if (ref.kind === 'dir') {
536
+ const satisfied = candidates.some(c => prod.dirs.has(c)
537
+ || prod.opaque.has(c)
538
+ || prod.enumerable.has(c)
539
+ || [...prod.files].some(f => underDir(f, c))
540
+ || [...prod.opaque].some(d => underDir(c, d)));
541
+ if (satisfied)
542
+ continue;
543
+ // A static root that neither exists nor is produced is only flagged
544
+ // when the project HAS producer machinery at all — a bare repo with
545
+ // no scripts yields no evidence either way.
546
+ if (prod.dirs.size + prod.enumerable.size + prod.opaque.size + prod.files.size === 0) {
547
+ continue;
548
+ }
549
+ push(ref, `directory does not exist and no script or build step creates it`);
550
+ continue;
551
+ }
552
+ const isSatisfied = candidates.some(c => {
553
+ if (prod.files.has(c))
554
+ return true;
555
+ if ([...prod.opaque].some(d => underDir(c, d)))
556
+ return true;
557
+ for (const [dir, stems] of prod.enumerable) {
558
+ if (underDir(path.posix.dirname(c), dir) && stems.has(stem(c)))
559
+ return true;
560
+ }
561
+ return false;
562
+ });
563
+ if (isSatisfied)
564
+ continue;
565
+ // Positive-evidence branches:
566
+ const underEnumerable = candidates.some(c => [...prod.enumerable.keys()].some(d => underDir(path.posix.dirname(c), d)));
567
+ if (underEnumerable) {
568
+ push(ref, 'it sits in a build output directory whose parsed outputs do not include it');
569
+ continue;
570
+ }
571
+ if (SOURCE_ONLY_EXT_RE.test(p) && ref.construct === 'script entrypoint') {
572
+ push(ref, 'the script entrypoint file does not exist and nothing generates sources');
573
+ }
574
+ // Everything else: inconclusive — step aside.
575
+ }
576
+ return out;
577
+ function push(ref, reason) {
578
+ const key = `${ref.referencer}${ref.path}`;
579
+ if (seen.has(key))
580
+ return;
581
+ seen.add(key);
582
+ out.push({ ...ref, reason });
583
+ }
584
+ }
585
+ // Directories never scanned for referencing sources: VCS/dep/artifact trees,
586
+ // every discovered produced dir (bundled output re-referencing its own chunks is
587
+ // noise), and test/fixture/doc trees — those reference fixture paths and quoted
588
+ // examples freely and are not the runtime serving surface this guard protects.
589
+ const SKIP_DIR_RE = /^(?:\.git|node_modules|\.pi-tasks|dist|build|out|coverage|target|vendor|__pycache__|\.venv|venv|tmp|test|tests|__tests__|__fixtures__|fixtures|e2e|examples|example|docs|doc)$/;
590
+ const SKIP_FILE_RE = /\.(?:test|spec|stories)\.[a-z]+$|\.d\.[mc]?ts$/i;
591
+ const SCAN_JS_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
592
+ const SCAN_HTML_RE = /\.html?$/i;
593
+ const MAX_SCAN_FILES = 3000;
594
+ const MAX_FILE_BYTES = 400_000;
595
+ /** Walk the tree for scannable sources (bounded, deterministic order). */
596
+ function scanCandidates(cwd, prod) {
597
+ const out = [];
598
+ const producedDirs = new Set([...prod.dirs, ...prod.opaque, ...prod.enumerable.keys()].map(d => d.split('/')[0]));
599
+ const walk = (rel) => {
600
+ if (out.length >= MAX_SCAN_FILES)
601
+ return;
602
+ let entries;
603
+ try {
604
+ entries = readdirSync(path.join(cwd, rel)).sort();
605
+ }
606
+ catch {
607
+ return;
608
+ }
609
+ for (const name of entries) {
610
+ if (out.length >= MAX_SCAN_FILES)
611
+ return;
612
+ const relPath = rel === '' ? name : `${rel}/${name}`;
613
+ let st;
614
+ try {
615
+ st = statSync(path.join(cwd, relPath));
616
+ }
617
+ catch {
618
+ continue;
619
+ }
620
+ if (st.isDirectory()) {
621
+ if (name.startsWith('.') || SKIP_DIR_RE.test(name))
622
+ continue;
623
+ if (rel === '' && producedDirs.has(name))
624
+ continue;
625
+ walk(relPath);
626
+ }
627
+ else if (st.isFile() && st.size <= MAX_FILE_BYTES) {
628
+ if (SKIP_FILE_RE.test(name))
629
+ continue;
630
+ if (SCAN_JS_RE.test(name) || SCAN_HTML_RE.test(name))
631
+ out.push(relPath);
632
+ }
633
+ }
634
+ };
635
+ walk('');
636
+ return out;
637
+ }
638
+ /**
639
+ * FINAL-GATE seam: scan the shipped tree for dangling runtime references.
640
+ * Deterministic, read-only, best-effort (throws nothing in normal operation;
641
+ * callers still guard). Producers are discovered first (scripts + build files +
642
+ * configs), then every authored source contributes refs AND runtime write-side
643
+ * producers (an app that writes its own cache file satisfies its own read).
644
+ */
645
+ export function findDanglingArtifacts(cwd) {
646
+ const prod = discoverProducers(cwd);
647
+ const refs = [];
648
+ for (const rel of scanCandidates(cwd, prod)) {
649
+ let src;
650
+ try {
651
+ src = readFileSync(path.join(cwd, rel), 'utf8');
652
+ }
653
+ catch {
654
+ continue;
655
+ }
656
+ if (SCAN_HTML_RE.test(rel)) {
657
+ refs.push(...extractHtmlRefs(src, rel));
658
+ }
659
+ else {
660
+ refs.push(...extractJsRefs(src, rel));
661
+ collectProducersFromSource(src, prod);
662
+ }
663
+ }
664
+ for (const [name, body] of Object.entries(packageScripts(cwd))) {
665
+ refs.push(...extractScriptEntrypoints(body, `package.json scripts.${name}`));
666
+ }
667
+ return resolveDanglingRefs(refs, prod, rel => existsSync(path.join(cwd, rel)));
668
+ }
669
+ /** Ranked-failure text for the final gate (names referencer + missing path). */
670
+ export function danglingGateFailureText(d) {
671
+ return (`dangling artifact: \`${d.referencer}\` references \`${d.path}\` (${d.construct}) `
672
+ + `but nothing in the tree, build outputs, or scripts produces it — ${d.reason}`);
673
+ }
674
+ // ---------------------------------------------------------------------------
675
+ // Plan-time seam: the SPEC's own snippets referencing artifacts the spec never
676
+ // defines (mx5 run 13: DESIGN/PROJECT.md L224 required serving index.html; the
677
+ // file tree (§7) and build section (§9) never defined it).
678
+ // ---------------------------------------------------------------------------
679
+ /** Does the spec LIST the file as its own artifact — a file-tree entry or a
680
+ * bullet whose first token is (or ends with) the basename? Prose that merely
681
+ * mentions the name mid-sentence ("serves the built index.html") does NOT
682
+ * count: that is the CONSUMING side, exactly what must not self-satisfy. */
683
+ export function specListsFile(spec, refPath) {
684
+ const base = path.posix.basename(refPath).toLowerCase();
685
+ for (const line of spec.split('\n')) {
686
+ const cleaned = line
687
+ .replace(/[│├└─┬┼]/g, ' ')
688
+ .replace(/^[\s*+-]+/, '')
689
+ .replace(/[`'"]/g, '')
690
+ .trim();
691
+ const tok = (cleaned.split(/\s+/)[0] ?? '').toLowerCase();
692
+ if (tok === base || tok.endsWith('/' + base))
693
+ return true;
694
+ }
695
+ return false;
696
+ }
697
+ /** Consuming-side prose verbs: a spec sentence that SERVES/READS/LOADS a
698
+ * backticked file is referencing it at runtime (mx5 run 13, the exact line:
699
+ * "**SPA fallback:** non-`/api` GETs serve the built `index.html`."). */
700
+ const PROSE_CONSUME_RE = /\b(?:serves?|serving|served|fallback|reads?|loads?|renders?)\b/i;
701
+ /** Runtime-artifact extensions the prose channel accepts. Prose is the loosest
702
+ * signal, so it is whitelist-tight: a backticked dotted identifier (`c.var.user`,
703
+ * `Bun.password.hash` — measured FPs on the real mx5 spec) must never read as a
704
+ * file, and doc files a spec tells the READER to read (`README.md`) don't
705
+ * count either. Code-construct refs are not subject to this list. */
706
+ const PROSE_ASSET_EXT_RE = /\.(?:html?|css|m?js|cjs|json|svg|png|jpe?g|gif|webp|ico|woff2?|ttf|otf|wasm|webmanifest|xml|csv|sql|ya?ml|toml|pdf|mp[34]|db|sqlite)$/i;
707
+ /** Backticked, asset-extension, path-shaped tokens on consuming-verb lines. */
708
+ export function extractSpecProseRefs(spec) {
709
+ const out = [];
710
+ for (const line of spec.split('\n')) {
711
+ if (!PROSE_CONSUME_RE.test(line))
712
+ continue;
713
+ const tick = /`([^`\n]+)`/g;
714
+ for (let m = tick.exec(line); m !== null; m = tick.exec(line)) {
715
+ const tok = m[1];
716
+ if (!/^[\w./-]+$/.test(tok))
717
+ continue; // code fragments, not paths
718
+ const p = normalizeRefPath(tok);
719
+ if (p === null || !PROSE_ASSET_EXT_RE.test(p))
720
+ continue;
721
+ out.push({
722
+ path: p,
723
+ referencer: 'spec',
724
+ construct: 'spec prose (serve/read)',
725
+ kind: 'file'
726
+ });
727
+ }
728
+ }
729
+ return out;
730
+ }
731
+ /**
732
+ * PLAN-TIME seam: runtime refs in the spec's snippets AND consuming prose that
733
+ * neither the existing scaffold (`fileExists`), the spec's parsed build
734
+ * outputs, nor its own file tree produce. Each result becomes an UNOWNED
735
+ * coverage area until some task title claims the artifact.
736
+ */
737
+ export function findSpecDanglingArtifacts(spec, fileExists) {
738
+ const prod = emptyProducers();
739
+ collectProducersFromSource(spec, prod);
740
+ // Shell-looking lines in the spec (build/script snippets) contribute
741
+ // producers too: only lines carrying an output-flag shape, and never
742
+ // markdown blockquotes (`> …` is quoting, not a shell redirect), so prose
743
+ // cannot feed the producer table and mask a real dangle.
744
+ for (const line of spec.split('\n')) {
745
+ if (line.trimStart().startsWith('>'))
746
+ continue;
747
+ if (/(?:^|\s)(?:--out(?:file|put|dir|-dir)?|-o)(?:=|\s)/.test(line)) {
748
+ for (const cmd of splitShellCommands(line)) {
749
+ collectProducersFromCommand(cmd, prod, { escalate: false });
750
+ }
751
+ }
752
+ }
753
+ // package.json-snippet script lines (`"build": "bun build.ts"`) — parse the
754
+ // body for producers.
755
+ const scriptLineRe = /"([A-Za-z0-9:_-]+)"\s*:\s*"([^"\n]+)"/g;
756
+ for (let m = scriptLineRe.exec(spec); m !== null; m = scriptLineRe.exec(spec)) {
757
+ const body = m[2];
758
+ if (/^(?:bun|bunx|node|tsx|npm|npx|deno)\b|--out|-o\s/.test(body)) {
759
+ for (const cmd of splitShellCommands(body))
760
+ collectProducersFromCommand(cmd, prod);
761
+ }
762
+ }
763
+ // Code-construct refs resolve exactly like tree refs.
764
+ const codeRefs = [
765
+ ...extractJsRefs(spec, 'spec'),
766
+ ...extractHtmlRefs(spec, 'spec')
767
+ ];
768
+ const dangling = resolveDanglingRefs(codeRefs, prod, fileExists);
769
+ // Prose refs carry no directory, so they resolve by BASENAME against the
770
+ // declared outputs — and dangle ONLY when the spec positively declares an
771
+ // enumerable output set that does not include them (a spec with no
772
+ // parseable build machinery, or any opaque producer, yields no verdict —
773
+ // inconclusive is never evidence).
774
+ const declaredStemCount = [...prod.enumerable.values()].reduce((n, s) => n + s.size, 0);
775
+ const hasOutputEvidence = prod.files.size > 0 || declaredStemCount > 0;
776
+ const producedBasenames = new Set([...prod.files].map(f => path.posix.basename(f)));
777
+ const producedStems = new Set([
778
+ ...[...prod.files].map(stem),
779
+ ...[...prod.enumerable.values()].flatMap(s => [...s])
780
+ ]);
781
+ const seen = new Set(dangling.map(d => path.posix.basename(d.path)));
782
+ for (const ref of extractSpecProseRefs(spec)) {
783
+ const base = path.posix.basename(ref.path);
784
+ if (seen.has(base))
785
+ continue;
786
+ if (!hasOutputEvidence || prod.opaque.size > 0)
787
+ continue;
788
+ const satisfied = fileExists(ref.path)
789
+ || [...prod.dirs, ...prod.enumerable.keys()].some(d => fileExists(`${d}/${ref.path}`))
790
+ || producedBasenames.has(base)
791
+ || producedStems.has(stem(ref.path));
792
+ if (satisfied)
793
+ continue;
794
+ seen.add(base);
795
+ dangling.push({
796
+ ...ref,
797
+ reason: 'the spec declares its build outputs and none of them is this file'
798
+ });
799
+ }
800
+ // The spec's own file tree / artifact bullets are plan-time producers: a
801
+ // listed file is an artifact some task will create.
802
+ return dangling.filter(d => !specListsFile(spec, d.path));
803
+ }
804
+ /** Does some task title claim the artifact (by basename or full path)? Titles
805
+ * are the one plan artifact the model cannot fake ownership INTO — mentioning
806
+ * the file is the grounded signal a producing task exists. */
807
+ export function titlesCoverArtifact(titles, ref) {
808
+ const base = path.posix.basename(ref.path).toLowerCase();
809
+ const full = ref.path.toLowerCase();
810
+ return titles.some(t => {
811
+ const tl = t.toLowerCase();
812
+ return tl.includes(base) || tl.includes(full);
813
+ });
814
+ }
815
+ /** Coverage-loop `missing` entry for an unowned dangling artifact. */
816
+ export function danglingMissingText(d) {
817
+ return (`dangling runtime artifact \`${d.path}\` — referenced by the spec (${d.construct}) `
818
+ + `but no file tree entry, build output, or task produces it; add a task that `
819
+ + `creates it or makes the build emit it`);
820
+ }
821
+ /** Carried-requirement line when still unowned at coverage exhaustion. */
822
+ export function danglingCarryText(d) {
823
+ return (`runtime artifact \`${d.path}\` is referenced (${d.construct}) but NOTHING creates it — `
824
+ + `whichever task builds the referencing side must also produce this file or wire the `
825
+ + `build to emit it`);
826
+ }