@mjasnikovs/pi-task 0.18.24 → 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,107 @@
1
+ export interface RuntimeRef {
2
+ /** Repo-relative normalized path (posix separators, no leading ./). */
3
+ path: string;
4
+ /** Where the reference lives: repo-relative file, `package.json scripts.X`, or 'spec'. */
5
+ referencer: string;
6
+ /** The construct that matched (Bun.file, readFile, script src, …). */
7
+ construct: string;
8
+ /** file = must exist as a file; dir = a static root that must exist as a directory. */
9
+ kind: 'file' | 'dir';
10
+ }
11
+ export interface DanglingRef extends RuntimeRef {
12
+ /** Why the producer resolution came up empty (human- and prompt-readable). */
13
+ reason: string;
14
+ }
15
+ /** Everything the tree/scripts/build POSITIVELY produce. */
16
+ export interface ProducedOutputs {
17
+ /** Exact output files (tailwind -o, --outfile, redirects, Bun.write, cp dest…). */
18
+ files: Set<string>;
19
+ /** Enumerable outdir → output STEMS (basename sans extension) it emits —
20
+ * from parsed Bun.build entrypoints, explicit output files, source args. */
21
+ enumerable: Map<string, Set<string>>;
22
+ /** Dirs produced by machinery we could not enumerate (vite/tsc/next/unknown
23
+ * commands naming them) — everything under them steps aside. */
24
+ opaque: Set<string>;
25
+ /** Dirs known to be created (mkdir, outdirs) — satisfies dir-kind refs. */
26
+ dirs: Set<string>;
27
+ }
28
+ export declare function emptyProducers(): ProducedOutputs;
29
+ /** Normalize a literal path: posix separators, strip ./ prefixes, query/hash
30
+ * tails (HTML), trailing slash. Returns null when the literal is not a
31
+ * checkable relative path (URL, absolute, template hole, glob — step aside). */
32
+ export declare function normalizeRefPath(raw: string): string | null;
33
+ /** Extract runtime refs from one JS/TS source. */
34
+ export declare function extractJsRefs(source: string, referencer: string): RuntimeRef[];
35
+ /** Extract local-asset refs from one HTML source. */
36
+ export declare function extractHtmlRefs(source: string, referencer: string): RuntimeRef[];
37
+ /** Script entrypoints: `bun x.ts`, `bun run x.ts`, `node x.js`, `tsx x.ts` —
38
+ * the first path-shaped source arg of a runner command. */
39
+ export declare function extractScriptEntrypoints(body: string, referencer: string): RuntimeRef[];
40
+ /**
41
+ * Producer facts from ONE shell command: output flags (`-o x`, `--outfile x`,
42
+ * `--outdir d`), redirects, `cp`/`mv`/`touch` destinations, `mkdir` dirs, known
43
+ * opaque bundlers. Leftover path tokens of an UNRECOGNIZED command escalate any
44
+ * directory they point into to opaque — that command may produce there, and
45
+ * inconclusive is never evidence. `escalate: false` disables that escalation
46
+ * for command text embedded in PROSE (a markdown bullet's surrounding words
47
+ * tokenize as junk "commands" and would opaque half the spec's paths).
48
+ */
49
+ export declare function collectProducersFromCommand(cmd: string, prod: ProducedOutputs, opts?: {
50
+ escalate?: boolean;
51
+ }): void;
52
+ /**
53
+ * Producer facts from JS/TS source: write-side calls (`Bun.write`,
54
+ * `writeFile(Sync)`, `createWriteStream`, `copyFile` dest, `mkdir(Sync)`),
55
+ * `Bun.build({entrypoints, outdir})` (enumerable — unless a `naming` option
56
+ * makes the output names underivable, then opaque), `outfile:`, and `Bun.spawn`
57
+ * argv arrays re-fed through the shell-command collector (the mx5 build.ts
58
+ * shape: tailwind's `-o dist/app.css` lives in a spawn array).
59
+ */
60
+ export declare function collectProducersFromSource(source: string, prod: ProducedOutputs): void;
61
+ /**
62
+ * Discover everything the project's own machinery produces: package.json script
63
+ * bodies, build files those scripts run (plus conventional root build files),
64
+ * tsconfig/vite outDirs.
65
+ */
66
+ export declare function discoverProducers(cwd: string): ProducedOutputs;
67
+ /**
68
+ * Resolve refs against existence + producers. DANGLING requires POSITIVE
69
+ * evidence (see the module doc): the ref sits under an ENUMERATED output dir
70
+ * and is not among its outputs, or is a missing source-only-extension file
71
+ * (nothing ever builds a `.ts`/`.tsx`). Everything inconclusive steps aside.
72
+ */
73
+ export declare function resolveDanglingRefs(refs: RuntimeRef[], prod: ProducedOutputs, exists: (rel: string) => boolean): DanglingRef[];
74
+ /**
75
+ * FINAL-GATE seam: scan the shipped tree for dangling runtime references.
76
+ * Deterministic, read-only, best-effort (throws nothing in normal operation;
77
+ * callers still guard). Producers are discovered first (scripts + build files +
78
+ * configs), then every authored source contributes refs AND runtime write-side
79
+ * producers (an app that writes its own cache file satisfies its own read).
80
+ */
81
+ export declare function findDanglingArtifacts(cwd: string): DanglingRef[];
82
+ /** Ranked-failure text for the final gate (names referencer + missing path). */
83
+ export declare function danglingGateFailureText(d: DanglingRef): string;
84
+ /** Does the spec LIST the file as its own artifact — a file-tree entry or a
85
+ * bullet whose first token is (or ends with) the basename? Prose that merely
86
+ * mentions the name mid-sentence ("serves the built index.html") does NOT
87
+ * count: that is the CONSUMING side, exactly what must not self-satisfy. */
88
+ export declare function specListsFile(spec: string, refPath: string): boolean;
89
+ /** Backticked, asset-extension, path-shaped tokens on consuming-verb lines. */
90
+ export declare function extractSpecProseRefs(spec: string): RuntimeRef[];
91
+ /**
92
+ * PLAN-TIME seam: runtime refs in the spec's snippets AND consuming prose that
93
+ * neither the existing scaffold (`fileExists`), the spec's parsed build
94
+ * outputs, nor its own file tree produce. Each result becomes an UNOWNED
95
+ * coverage area until some task title claims the artifact.
96
+ */
97
+ export declare function findSpecDanglingArtifacts(spec: string, fileExists: (rel: string) => boolean): DanglingRef[];
98
+ /** Does some task title claim the artifact (by basename or full path)? Titles
99
+ * are the one plan artifact the model cannot fake ownership INTO — mentioning
100
+ * the file is the grounded signal a producing task exists. */
101
+ export declare function titlesCoverArtifact(titles: string[], ref: {
102
+ path: string;
103
+ }): boolean;
104
+ /** Coverage-loop `missing` entry for an unowned dangling artifact. */
105
+ export declare function danglingMissingText(d: DanglingRef): string;
106
+ /** Carried-requirement line when still unowned at coverage exhaustion. */
107
+ export declare function danglingCarryText(d: DanglingRef): string;
@@ -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
+ }
@@ -5,6 +5,7 @@
5
5
  * This module currently holds the planning half (AutoDeps + planAuto). The run
6
6
  * loop, command handlers, and defaultDeps are added by the next task.
7
7
  */
8
+ import { existsSync } from 'node:fs';
8
9
  import * as fsp from 'node:fs/promises';
9
10
  import * as path from 'node:path';
10
11
  import { gateRunTask, markResumable } from './orchestrator.js';
@@ -35,6 +36,7 @@ import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, app
35
36
  import { reconcileTitleSources } from './decompose-fidelity.js';
36
37
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
37
38
  import { decideAdoption, groundedCoverage } from './coverage-loop.js';
39
+ import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
38
40
  import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
39
41
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
40
42
  // when the model emits NONE), but a model that never says NONE would otherwise
@@ -493,6 +495,25 @@ export async function planAuto(ctx, cwd, feature, deps) {
493
495
  catch {
494
496
  // best-effort channel
495
497
  }
498
+ // Artifact-production closure, plan side (mx5 run 13, PROMPT 2): runtime
499
+ // files the spec REFERENCES (server snippets, prose "serve the built
500
+ // index.html") that neither its file tree, its parsed build outputs, nor the
501
+ // existing scaffold produce. Sentence-grounded coverage credited the SERVING
502
+ // side and reported "0 unowned" while nothing ever CREATED the file — so
503
+ // these ride the coverage loop's `missing` list as unowned areas until some
504
+ // task title claims the artifact (grounded in titles, which the coverage-map
505
+ // model cannot fake — the run-12 lesson). Deterministic and best-effort.
506
+ let specDangling = [];
507
+ try {
508
+ specDangling = findSpecDanglingArtifacts(featureForModel, rel => existsSync(path.join(cwd, rel)));
509
+ if (specDangling.length > 0) {
510
+ logPlanDebug(cwd, `artifact closure: ${specDangling.length} dangling runtime artifact(s) in the `
511
+ + `spec: ${specDangling.map(d => d.path).join(', ')}`);
512
+ }
513
+ }
514
+ catch {
515
+ // best-effort channel
516
+ }
496
517
  // decompose
497
518
  const decomposePrompt = AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications, buildRequirementsLedger(reqEntries));
498
519
  // Parse + FIDELITY RECONCILIATION (mx5 run 11, goal B): ground each title's
@@ -589,6 +610,13 @@ export async function planAuto(ctx, cwd, feature, deps) {
589
610
  }
590
611
  }
591
612
  const missing = [...verdictMissing, ...(acc?.unmapped ?? []).map(e => `"${e.quote}"`)];
613
+ // Unclaimed dangling artifacts are unowned areas: they force a coverage
614
+ // round that assigns a producing task, and clear as soon as a title
615
+ // names the file.
616
+ for (const d of specDangling) {
617
+ if (!titlesCoverArtifact(titles, d))
618
+ missing.push(danglingMissingText(d));
619
+ }
592
620
  return {
593
621
  plan: { titles, covered, missing },
594
622
  accounting: acc,
@@ -707,12 +735,23 @@ export async function planAuto(ctx, cwd, feature, deps) {
707
735
  const carriedCrossCutting = accounting?.crossCutting ?? [];
708
736
  const carriedUnmapped = accounting?.unmapped ?? [];
709
737
  const carriedJudge = best.judgeMissing;
710
- if (carriedCrossCutting.length > 0 || carriedUnmapped.length > 0 || carriedJudge.length > 0) {
711
- await appendCarriedRequirements(cwd, carriedCrossCutting, carriedUnmapped, carriedJudge);
738
+ // Dangling artifacts still unclaimed by any title of the SHIPPING plan are a
739
+ // fourth channel: the producing obligation travels verbatim into every task
740
+ // (whichever task builds the referencing side must also produce the file),
741
+ // and the final gate re-checks the shipped tree regardless.
742
+ const carriedDangling = specDangling
743
+ .filter(d => !titlesCoverArtifact(planTitles, d))
744
+ .map(danglingCarryText);
745
+ if (carriedCrossCutting.length > 0
746
+ || carriedUnmapped.length > 0
747
+ || carriedJudge.length > 0
748
+ || carriedDangling.length > 0) {
749
+ await appendCarriedRequirements(cwd, carriedCrossCutting, carriedUnmapped, carriedJudge, carriedDangling);
712
750
  const parts = [
713
751
  carriedCrossCutting.length > 0 ? `${carriedCrossCutting.length} cross-cutting` : '',
714
752
  carriedUnmapped.length > 0 ? `${carriedUnmapped.length} unowned` : '',
715
- carriedJudge.length > 0 ? `${carriedJudge.length} judge-flagged` : ''
753
+ carriedJudge.length > 0 ? `${carriedJudge.length} judge-flagged` : '',
754
+ carriedDangling.length > 0 ? `${carriedDangling.length} dangling-artifact` : ''
716
755
  ].filter(p => p.length > 0);
717
756
  ctx.ui.notify(`/task-auto: carrying ${parts.join(', ')} requirement(s) into every task`
718
757
  + ' — see .pi-tasks/requirements.md.', 'info');
@@ -47,6 +47,7 @@ import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts }
47
47
  import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
48
48
  import { runRenderCheck } from './render-check.js';
49
49
  import { taskThatIntroduced } from './task-provenance.js';
50
+ import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
50
51
  function packageScripts(cwd) {
51
52
  try {
52
53
  const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -772,6 +773,21 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
772
773
  warnings.push(b.renderNote);
773
774
  }
774
775
  }
776
+ // Artifact-production closure (mx5 run 13, PROMPT 2): a runtime file
777
+ // reference with NO producer anywhere ships silently — the server read
778
+ // `Bun.file('dist/index.html')` while the build emitted only app.css +
779
+ // main.js, so every non-API GET 404'd behind 32/32 green checkoffs.
780
+ // Deterministic scan of the shipped tree (literal refs only, positive
781
+ // producer evidence required — see artifact-closure.ts); each dangle is a
782
+ // ranked failure naming referencer + missing path. Rank 0: "the app cannot
783
+ // serve what it references" is the same load-bearing class as boot/render.
784
+ try {
785
+ for (const d of findDanglingArtifacts(cwd))
786
+ fail(danglingGateFailureText(d), 0);
787
+ }
788
+ catch {
789
+ // best-effort scan — a scanner fault must never break the gate
790
+ }
775
791
  if (failures.length > 0) {
776
792
  // Stable sort: boot/render (rank 0) leads, everything else keeps execution
777
793
  // order. One failure keeps the exact single-failure wording; several become
@@ -88,8 +88,13 @@ export declare function readRequirements(cwd: string): Promise<string>;
88
88
  * was warned-about then dropped). These are plain strings, not quotes of the
89
89
  * source; marked distinctly so a task can tell an inferred area from a verbatim
90
90
  * obligation.
91
+ * • `danglingArtifacts` — runtime files the spec references but nothing
92
+ * produces (mx5 run 13: the served `index.html` no task, tree entry, or
93
+ * build output ever created), still unclaimed by any title at coverage
94
+ * exhaustion. Deterministically extracted (artifact-closure.ts), so like
95
+ * judge areas they are host-authored strings, not source quotes.
91
96
  */
92
- export declare function appendCarriedRequirements(cwd: string, crossCutting: RequirementEntry[], unresolved?: RequirementEntry[], judgeFlagged?: string[]): Promise<void>;
97
+ export declare function appendCarriedRequirements(cwd: string, crossCutting: RequirementEntry[], unresolved?: RequirementEntry[], judgeFlagged?: string[], danglingArtifacts?: string[]): Promise<void>;
93
98
  /**
94
99
  * The read-only block refine/compose receive when carried requirements exist.
95
100
  * Verbatim content travels with every task (the directive pattern that works),
@@ -320,10 +320,19 @@ function formatEntry(e, marker) {
320
320
  * was warned-about then dropped). These are plain strings, not quotes of the
321
321
  * source; marked distinctly so a task can tell an inferred area from a verbatim
322
322
  * obligation.
323
+ * • `danglingArtifacts` — runtime files the spec references but nothing
324
+ * produces (mx5 run 13: the served `index.html` no task, tree entry, or
325
+ * build output ever created), still unclaimed by any title at coverage
326
+ * exhaustion. Deterministically extracted (artifact-closure.ts), so like
327
+ * judge areas they are host-authored strings, not source quotes.
323
328
  */
324
- export async function appendCarriedRequirements(cwd, crossCutting, unresolved = [], judgeFlagged = []) {
325
- if (crossCutting.length === 0 && unresolved.length === 0 && judgeFlagged.length === 0)
329
+ export async function appendCarriedRequirements(cwd, crossCutting, unresolved = [], judgeFlagged = [], danglingArtifacts = []) {
330
+ if (crossCutting.length === 0
331
+ && unresolved.length === 0
332
+ && judgeFlagged.length === 0
333
+ && danglingArtifacts.length === 0) {
326
334
  return;
335
+ }
327
336
  try {
328
337
  const existing = (await readRequirements(cwd)).split('\n').filter(l => l.trim().length > 0);
329
338
  const seen = new Set(existing.map(l => {
@@ -337,6 +346,10 @@ export async function appendCarriedRequirements(cwd, crossCutting, unresolved =
337
346
  [
338
347
  judgeFlagged.map(q => ({ quote: q, anchor: '' })),
339
348
  'judge-flagged uncovered area, no task owns this — surfaced at plan time'
349
+ ],
350
+ [
351
+ danglingArtifacts.map(q => ({ quote: q, anchor: '' })),
352
+ 'dangling runtime artifact, nothing produces it — surfaced at plan time'
340
353
  ]
341
354
  ]) {
342
355
  for (const e of entries) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.24",
3
+ "version": "0.18.25",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",