@mjasnikovs/pi-task 0.25.0 → 0.27.0

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.
@@ -6,6 +6,12 @@
6
6
  * server entry point … the Hono server cannot be started"), so the terminal defect
7
7
  * was FOUND and then erased by the very mechanism that found it. Persisted here so
8
8
  * the final gate re-checks and surfaces it instead of letting it die with the revert.
9
+ * - 'enforce-kept' — the same enforce re-verify FAIL, but the failing check named
10
+ * only files the ENFORCE COMMIT does not touch, so reverting that commit could not
11
+ * possibly repair it (mx5 run 18 TASK_0024: a one-line paren removal in `Admin.tsx`
12
+ * was reverted over a `MyListings.spec.tsx` CT failure it cannot reach, and the
13
+ * final gate re-made the identical change 5 minutes later). The edits are KEPT and
14
+ * the defect is recorded here — keeping the work must never mean losing the finding.
9
15
  * - 'frozen-blocked' — a repo-health verify-FAIL whose only fix is an edit to a path
10
16
  * THIS task's spec froze (mx5 run 12: `bun run lint` permanently red because the
11
17
  * created files need a tsconfig registration every spec forbids). Cross-task
@@ -43,7 +49,7 @@
43
49
  * (root-cause-repair.ts). Before this class existed the ledger recorded the same
44
50
  * root cause twice and nothing ever scheduled a fix, so it survived ~24h.
45
51
  */
46
- export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate' | 'root-cause';
52
+ export type DebtOrigin = 'accepted' | 'enforce-revert' | 'enforce-kept' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate' | 'root-cause';
47
53
  /** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
48
54
  export interface AcceptDebt {
49
55
  taskId: string;
@@ -82,6 +88,14 @@ export declare function recordAcceptDebt(cwd: string, taskId: string, reason: st
82
88
  * rather than letting it die with the revert.
83
89
  */
84
90
  export declare function recordEnforceRevertDebt(cwd: string, taskId: string, reason: string): Promise<void>;
91
+ /**
92
+ * Record an ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): an enforce re-verify FAILED
93
+ * on a check whose named files are DISJOINT from the enforce commit's own diff, so
94
+ * the edits were kept — discarding them could not have repaired a failure they cannot
95
+ * reach. The defect is real and still in the shipped tree, so it is recorded with the
96
+ * same durability as an enforce-revert; only the disposition of the edits differs.
97
+ */
98
+ export declare function recordEnforceKeptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
85
99
  /**
86
100
  * Record a FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a repo-health FAIL
87
101
  * whose static findings can only be fixed by editing a path this task's spec froze —
@@ -75,6 +75,7 @@ export function parseAcceptDebts(raw) {
75
75
  taskId: parts[0].trim(),
76
76
  reason: parts[1].trim(),
77
77
  ...((origin === 'enforce-revert'
78
+ || origin === 'enforce-kept'
78
79
  || origin === 'frozen-blocked'
79
80
  || origin === 'cross-task-deletion'
80
81
  || origin === 'yolo-accepted'
@@ -147,6 +148,20 @@ export async function recordEnforceRevertDebt(cwd, taskId, reason) {
147
148
  origin: 'enforce-revert'
148
149
  });
149
150
  }
151
+ /**
152
+ * Record an ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): an enforce re-verify FAILED
153
+ * on a check whose named files are DISJOINT from the enforce commit's own diff, so
154
+ * the edits were kept — discarding them could not have repaired a failure they cannot
155
+ * reach. The defect is real and still in the shipped tree, so it is recorded with the
156
+ * same durability as an enforce-revert; only the disposition of the edits differs.
157
+ */
158
+ export async function recordEnforceKeptDebt(cwd, taskId, reason) {
159
+ await appendDebt(cwd, {
160
+ taskId: taskId.trim(),
161
+ reason: normaliseReason(reason),
162
+ origin: 'enforce-kept'
163
+ });
164
+ }
150
165
  /**
151
166
  * Record a FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a repo-health FAIL
152
167
  * whose static findings can only be fixed by editing a path this task's spec froze —
@@ -364,6 +379,9 @@ export function describeDebt(d) {
364
379
  if (d.origin === 'enforce-revert') {
365
380
  return 'enforce re-verify FAILED then the edits were reverted (defect indicts the ORIGINAL work, still shipped)';
366
381
  }
382
+ if (d.origin === 'enforce-kept') {
383
+ return 'enforce re-verify FAILED on a check the enforce diff cannot reach — the guideline edits were KEPT (reverting them could not fix it) and the defect indicts the ORIGINAL work, still shipped';
384
+ }
367
385
  if (d.origin === 'frozen-blocked') {
368
386
  return 'repo health blocked by a spec-frozen path (cross-task contradiction — no task may perform the fixing edit)';
369
387
  }
@@ -24,6 +24,12 @@ export interface ProducedOutputs {
24
24
  opaque: Set<string>;
25
25
  /** Dirs known to be created (mkdir, outdirs) — satisfies dir-kind refs. */
26
26
  dirs: Set<string>;
27
+ /** Dirs a BUILD TOOL declared as its output location (`--outdir`, `Bun.build`
28
+ * outdir, a bundler's known dir, tsconfig/vite outDir). Strictly narrower
29
+ * than `dirs`: writing one file into `report/` makes `report` enumerable but
30
+ * never a build outdir. Generated-HTML scanning is gated on this set, so a
31
+ * one-off HTML report writer cannot pull its assets into the check. */
32
+ outdirs: Set<string>;
27
33
  }
28
34
  export declare function emptyProducers(): ProducedOutputs;
29
35
  /** Normalize a literal path: posix separators, strip ./ prefixes, query/hash
@@ -34,6 +40,30 @@ export declare function normalizeRefPath(raw: string): string | null;
34
40
  export declare function extractJsRefs(source: string, referencer: string): RuntimeRef[];
35
41
  /** Extract local-asset refs from one HTML source. */
36
42
  export declare function extractHtmlRefs(source: string, referencer: string): RuntimeRef[];
43
+ /** One HTML document a source writes into a build output. */
44
+ export interface EmittedHtml {
45
+ /** Repo-relative path written to (`dist/index.html`). */
46
+ docPath: string;
47
+ /** The literal's raw text. */
48
+ html: string;
49
+ }
50
+ /** HTML documents this source writes: `Bun.write('x.html', <literal|ident>)` and
51
+ * the writeFile family. Only literal destinations, only HTML extensions. */
52
+ export declare function collectEmittedHtml(source: string): EmittedHtml[];
53
+ /**
54
+ * Asset references inside HTML this source GENERATES into a build output.
55
+ *
56
+ * Resolution, deterministic:
57
+ * • root-relative (`/app.css`) resolves against the build OUTDIR the document
58
+ * lands in (`dist/index.html` ⇒ `dist/`) — that dir is the server's static
59
+ * root by construction;
60
+ * • document-relative (`app.css`, `./assets/x.js`) resolves against the
61
+ * document's own directory;
62
+ * • schemes (`https:`, `data:`, `cid:`), protocol-relative `//host/x`, and bare
63
+ * `#fragment`s are dropped by normalizeRefPath;
64
+ * • a literal not written into a declared build outdir is never scanned at all.
65
+ */
66
+ export declare function extractGeneratedHtmlRefs(source: string, referencer: string, prod: ProducedOutputs): RuntimeRef[];
37
67
  /** Script entrypoints: `bun x.ts`, `bun run x.ts`, `node x.js`, `tsx x.ts` —
38
68
  * the first path-shaped source arg of a runner command. */
39
69
  export declare function extractScriptEntrypoints(body: string, referencer: string): RuntimeRef[];
@@ -58,12 +88,34 @@ export declare function collectProducersFromCommand(cmd: string, prod: ProducedO
58
88
  * shape: tailwind's `-o dist/app.css` lives in a spawn array).
59
89
  */
60
90
  export declare function collectProducersFromSource(source: string, prod: ProducedOutputs): void;
91
+ /**
92
+ * A WATCH/DEV script — one a production build never invokes.
93
+ *
94
+ * The load-bearing rule of nexttask 3 (mx5 run 18): `dist/app.css` had exactly
95
+ * one producer, `dev:css` (`@tailwindcss/cli … -o dist/app.css --watch`), a
96
+ * watch-mode dev script. The gate's own commands are `build`, `test`, `lint` —
97
+ * none of them runs it, so the shipped page loaded zero CSS while the closure
98
+ * table happily reported the file "produced". A production artifact closed only
99
+ * by a watch script is dangling by construction.
100
+ *
101
+ * Deterministic and name-or-flag based, exactly as pre-registered: a script
102
+ * whose NAME starts with `dev`/`watch`, or whose BODY carries `--watch`.
103
+ */
104
+ export declare function isDevScript(name: string, body: string): boolean;
105
+ export interface ProducerOpts {
106
+ /**
107
+ * Drop watch/dev scripts (and the build files reachable only through them)
108
+ * from the producer table — the PRODUCTION view of what a release contains.
109
+ * Off by default: the shipped resolution path is unchanged by this task.
110
+ */
111
+ excludeDevScripts?: boolean;
112
+ }
61
113
  /**
62
114
  * Discover everything the project's own machinery produces: package.json script
63
115
  * bodies, build files those scripts run (plus conventional root build files),
64
116
  * tsconfig/vite outDirs.
65
117
  */
66
- export declare function discoverProducers(cwd: string): ProducedOutputs;
118
+ export declare function discoverProducers(cwd: string, opts?: ProducerOpts): ProducedOutputs;
67
119
  /**
68
120
  * Resolve refs against existence + producers. DANGLING requires POSITIVE
69
121
  * evidence (see the module doc): the ref sits under an ENUMERATED output dir
@@ -79,6 +131,24 @@ export declare function resolveDanglingRefs(refs: RuntimeRef[], prod: ProducedOu
79
131
  * producers (an app that writes its own cache file satisfies its own read).
80
132
  */
81
133
  export declare function findDanglingArtifacts(cwd: string): DanglingRef[];
134
+ /** How a generated-HTML asset reference resolves — the STEP 0 measurement. */
135
+ export type GeneratedHtmlRefClass =
136
+ /** Produced by the production build (or already on disk). */
137
+ 'produced'
138
+ /** Only a watch/dev script produces it — dangling for a production build. */
139
+ | 'dev-only'
140
+ /** Nothing produces it at all. */
141
+ | 'missing';
142
+ /** Classify one generated-HTML ref against both producer tables (STEP 0 / A/B
143
+ * reporting; the gate itself only needs the dangling verdict). */
144
+ export declare function classifyGeneratedHtmlRef(ref: RuntimeRef, full: ProducedOutputs, production: ProducedOutputs, exists: (rel: string) => boolean): GeneratedHtmlRefClass;
145
+ /** STEP 0 / A/B helper: every generated-HTML asset ref in a tree, with the
146
+ * producer tables it was measured against. Read-only, deterministic. */
147
+ export declare function collectGeneratedHtmlRefs(cwd: string): {
148
+ refs: RuntimeRef[];
149
+ full: ProducedOutputs;
150
+ production: ProducedOutputs;
151
+ };
82
152
  /** Ranked-failure text for the final gate (names referencer + missing path). */
83
153
  export declare function danglingGateFailureText(d: DanglingRef): string;
84
154
  /** Does the spec LIST the file as its own artifact — a file-tree entry or a
@@ -43,7 +43,13 @@
43
43
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
44
44
  import * as path from 'node:path';
45
45
  export function emptyProducers() {
46
- return { files: new Set(), enumerable: new Map(), opaque: new Set(), dirs: new Set() };
46
+ return {
47
+ files: new Set(),
48
+ enumerable: new Map(),
49
+ opaque: new Set(),
50
+ dirs: new Set(),
51
+ outdirs: new Set()
52
+ };
47
53
  }
48
54
  /** Normalize a literal path: posix separators, strip ./ prefixes, query/hash
49
55
  * tails (HTML), trailing slash. Returns null when the literal is not a
@@ -147,6 +153,159 @@ export function extractHtmlRefs(source, referencer) {
147
153
  }
148
154
  return out;
149
155
  }
156
+ // ---------------------------------------------------------------------------
157
+ // GENERATED HTML (nexttask 3, mx5 run 18).
158
+ //
159
+ // The run-13 checker fired correctly on `src/server/index.ts → dist/index.html`;
160
+ // the autofix satisfied it by appending an HTML template literal to `build.ts`
161
+ // and `Bun.write`ing it — and that page pointed at `/app.css`, which `bun run
162
+ // build` never emits (only the watch-mode `dev:css` does). The re-run gate saw
163
+ // nothing, because the extractor scans HTML FILES and JS READS, never the HTML a
164
+ // source GENERATES. So one dangling reference was closed by creating another one
165
+ // indirection deeper.
166
+ //
167
+ // Scope discipline, the reason this does not become an FP machine: a literal is
168
+ // scanned ONLY when it reaches a write whose destination is an HTML file inside a
169
+ // directory a BUILD TOOL declared as its output (`prod.outdirs`). An email-body
170
+ // template (`~/hub/aiz-server/src/connections/mailTemplate.ts` — `export default
171
+ // \`<!doctype html>…\``) is never written to a build output and is therefore
172
+ // never scanned, `<img src="cid:logo">` and all.
173
+ // ---------------------------------------------------------------------------
174
+ /** Asset attributes scanned in GENERATED HTML. Wider than HTML_PATTERNS (which
175
+ * keeps scanning on-disk .html files exactly as it always has) by the media
176
+ * tags nexttask 3 names. */
177
+ const GENERATED_HTML_PATTERNS = [
178
+ ...HTML_PATTERNS,
179
+ {
180
+ re: /<(?:source|video|audio)\b[^>]*\bsrc\s*=\s*(['"])([^'"]+)\1/gi,
181
+ construct: 'media src',
182
+ kind: 'file'
183
+ }
184
+ ];
185
+ /** Read a quoted/template literal starting at `i` (src[i] is the quote char).
186
+ * Returns the body and the index just past the closing quote, or null when
187
+ * unterminated. Escapes are honoured; a `${…}` hole is left in the text, where
188
+ * normalizeRefPath rejects it. */
189
+ function readLiteral(src, i) {
190
+ const q = src[i];
191
+ if (q !== '"' && q !== "'" && q !== '`')
192
+ return null;
193
+ let out = '';
194
+ for (let j = i + 1; j < src.length; j++) {
195
+ const c = src[j];
196
+ if (c === '\\') {
197
+ out += src[j + 1] ?? '';
198
+ j++;
199
+ continue;
200
+ }
201
+ if (c === q)
202
+ return { text: out, end: j + 1 };
203
+ if (c === '\n' && q !== '`')
204
+ return null; // unterminated single-line literal
205
+ out += c;
206
+ }
207
+ return null;
208
+ }
209
+ /** `const NAME = <literal>` bindings (also let/var) — the mx5 build.ts shape is
210
+ * `const html = \`…\`` followed by `Bun.write('dist/index.html', html)`. */
211
+ function literalBindings(src) {
212
+ const out = new Map();
213
+ const re = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?=['"`])/g;
214
+ for (let m = re.exec(src); m !== null; m = re.exec(src)) {
215
+ const lit = readLiteral(src, m.index + m[0].length);
216
+ if (lit === null)
217
+ continue;
218
+ out.set(m[1], lit.text);
219
+ re.lastIndex = lit.end;
220
+ }
221
+ return out;
222
+ }
223
+ /** HTML documents this source writes: `Bun.write('x.html', <literal|ident>)` and
224
+ * the writeFile family. Only literal destinations, only HTML extensions. */
225
+ export function collectEmittedHtml(source) {
226
+ const src = stripCommentLines(source);
227
+ const bindings = literalBindings(src);
228
+ const out = [];
229
+ const writeRe = /\b(?:Bun\.write|writeFileSync|writeFile|fs\.promises\.writeFile)\(\s*(['"`])([^'"`\n]+)\1\s*,\s*/g;
230
+ for (let m = writeRe.exec(src); m !== null; m = writeRe.exec(src)) {
231
+ const docPath = normalizeRefPath(m[2]);
232
+ if (docPath === null || !SCAN_HTML_RE.test(docPath))
233
+ continue;
234
+ const at = m.index + m[0].length;
235
+ let html;
236
+ const lit = readLiteral(src, at);
237
+ if (lit !== null)
238
+ html = lit.text;
239
+ else {
240
+ const id = /^[A-Za-z_$][\w$]*/.exec(src.slice(at, at + 80));
241
+ if (id)
242
+ html = bindings.get(id[0]);
243
+ }
244
+ if (html === undefined || !html.includes('<'))
245
+ continue;
246
+ out.push({ docPath, html });
247
+ }
248
+ return out;
249
+ }
250
+ /** The narrowest build outdir containing `p`, or null when no build tool
251
+ * declared one that covers it. */
252
+ function containingOutdir(p, prod) {
253
+ let best = null;
254
+ for (const d of prod.outdirs) {
255
+ if (!underDir(p, d))
256
+ continue;
257
+ if (best === null || d.length > best.length)
258
+ best = d;
259
+ }
260
+ return best;
261
+ }
262
+ /**
263
+ * Asset references inside HTML this source GENERATES into a build output.
264
+ *
265
+ * Resolution, deterministic:
266
+ * • root-relative (`/app.css`) resolves against the build OUTDIR the document
267
+ * lands in (`dist/index.html` ⇒ `dist/`) — that dir is the server's static
268
+ * root by construction;
269
+ * • document-relative (`app.css`, `./assets/x.js`) resolves against the
270
+ * document's own directory;
271
+ * • schemes (`https:`, `data:`, `cid:`), protocol-relative `//host/x`, and bare
272
+ * `#fragment`s are dropped by normalizeRefPath;
273
+ * • a literal not written into a declared build outdir is never scanned at all.
274
+ */
275
+ export function extractGeneratedHtmlRefs(source, referencer, prod) {
276
+ const out = [];
277
+ const seen = new Set();
278
+ for (const { docPath, html } of collectEmittedHtml(source)) {
279
+ const outdir = containingOutdir(docPath, prod);
280
+ if (outdir === null)
281
+ continue; // not a build artifact — out of scope
282
+ const docDir = path.posix.dirname(docPath);
283
+ for (const { re, construct, kind } of GENERATED_HTML_PATTERNS) {
284
+ re.lastIndex = 0;
285
+ for (let m = re.exec(html); m !== null; m = re.exec(html)) {
286
+ const raw = m[2];
287
+ const rootRelative = /^\/(?!\/)/.test(raw.trim());
288
+ const p = normalizeRefPath(raw);
289
+ if (p === null || !hasExt(p))
290
+ continue;
291
+ const base = rootRelative ? outdir : docDir;
292
+ const resolved = path.posix.normalize(base === '.' ? p : `${base}/${p}`);
293
+ if (resolved.startsWith('..'))
294
+ continue;
295
+ if (seen.has(resolved))
296
+ continue;
297
+ seen.add(resolved);
298
+ out.push({
299
+ path: resolved,
300
+ referencer,
301
+ construct: `${construct} in generated ${docPath}`,
302
+ kind
303
+ });
304
+ }
305
+ }
306
+ }
307
+ return out;
308
+ }
150
309
  /** A path-shaped shell token: contains a separator or an extension, no shell
151
310
  * metacharacters, not a flag. */
152
311
  function isPathToken(t) {
@@ -230,8 +389,10 @@ export function collectProducersFromCommand(cmd, prod, opts = {}) {
230
389
  let rest = cmd.replace(/2>&1|&>\s*\S+|2>\s*\S+/g, ' ');
231
390
  for (const { re, dirs } of OPAQUE_TOOL_DIRS) {
232
391
  if (re.test(rest))
233
- for (const d of dirs)
392
+ for (const d of dirs) {
234
393
  prod.opaque.add(d);
394
+ prod.outdirs.add(d);
395
+ }
235
396
  }
236
397
  // Redirect target.
237
398
  rest = rest.replace(/>>?\s*([^\s&|;]+)/g, (_, f) => {
@@ -248,12 +409,16 @@ export function collectProducersFromCommand(cmd, prod, opts = {}) {
248
409
  const p = normalizeRefPath(val);
249
410
  if (p === null)
250
411
  return ' ';
251
- if (flag === '--outdir' || flag === '--out-dir')
412
+ if (flag === '--outdir' || flag === '--out-dir') {
252
413
  addEnumerable(prod, p, []);
414
+ prod.outdirs.add(p);
415
+ }
253
416
  else if (hasExt(p))
254
417
  addFile(prod, p);
255
- else
418
+ else {
256
419
  addEnumerable(prod, p, []);
420
+ prod.outdirs.add(p);
421
+ }
257
422
  return ' ';
258
423
  });
259
424
  const toks = rest.trim().split(/\s+/);
@@ -304,8 +469,10 @@ export function collectProducersFromCommand(cmd, prod, opts = {}) {
304
469
  if (p !== null)
305
470
  declaredDirs.push(p);
306
471
  }
307
- for (const d of declaredDirs)
472
+ for (const d of declaredDirs) {
308
473
  addEnumerable(prod, d, sourceArgs.map(stem));
474
+ prod.outdirs.add(d);
475
+ }
309
476
  // Unrecognized command: any leftover path token pointing INTO a directory
310
477
  // makes that directory opaque — the tool may generate arbitrary files there.
311
478
  if ((opts.escalate ?? true) && !KNOWN_NON_PRODUCING_RE.test(bin) && !/^@/.test(bin)) {
@@ -375,6 +542,7 @@ export function collectProducersFromSource(source, prod) {
375
542
  const dir = normalizeRefPath(outdirM[2]);
376
543
  if (dir === null)
377
544
  continue;
545
+ prod.outdirs.add(dir);
378
546
  if (/\bnaming:/.test(body)) {
379
547
  prod.opaque.add(dir); // custom naming — outputs underivable
380
548
  continue;
@@ -422,8 +590,10 @@ function collectTsconfigOutDirs(cwd, prod) {
422
590
  const m = /"outDir"\s*:\s*"([^"]+)"/.exec(readFileSync(path.join(cwd, n), 'utf8'));
423
591
  if (m) {
424
592
  const p = normalizeRefPath(m[1]);
425
- if (p !== null)
593
+ if (p !== null) {
426
594
  prod.opaque.add(p);
595
+ prod.outdirs.add(p);
596
+ }
427
597
  }
428
598
  }
429
599
  catch {
@@ -445,12 +615,15 @@ function collectBundlerConfigOutDirs(cwd, prod) {
445
615
  if (!existsSync(f))
446
616
  continue;
447
617
  prod.opaque.add('dist');
618
+ prod.outdirs.add('dist');
448
619
  try {
449
620
  const m = /\boutDir:\s*(['"`])([^'"`\n]+)\1/.exec(readFileSync(f, 'utf8'));
450
621
  if (m) {
451
622
  const p = normalizeRefPath(m[2]);
452
- if (p !== null)
623
+ if (p !== null) {
453
624
  prod.opaque.add(p);
625
+ prod.outdirs.add(p);
626
+ }
454
627
  }
455
628
  }
456
629
  catch {
@@ -468,14 +641,33 @@ function packageScripts(cwd) {
468
641
  return {};
469
642
  }
470
643
  }
644
+ /**
645
+ * A WATCH/DEV script — one a production build never invokes.
646
+ *
647
+ * The load-bearing rule of nexttask 3 (mx5 run 18): `dist/app.css` had exactly
648
+ * one producer, `dev:css` (`@tailwindcss/cli … -o dist/app.css --watch`), a
649
+ * watch-mode dev script. The gate's own commands are `build`, `test`, `lint` —
650
+ * none of them runs it, so the shipped page loaded zero CSS while the closure
651
+ * table happily reported the file "produced". A production artifact closed only
652
+ * by a watch script is dangling by construction.
653
+ *
654
+ * Deterministic and name-or-flag based, exactly as pre-registered: a script
655
+ * whose NAME starts with `dev`/`watch`, or whose BODY carries `--watch`.
656
+ */
657
+ export function isDevScript(name, body) {
658
+ return /^(?:dev|watch)\b|^(?:dev|watch)[:._-]/i.test(name) || /(?:^|\s)--watch\b/.test(body);
659
+ }
471
660
  /**
472
661
  * Discover everything the project's own machinery produces: package.json script
473
662
  * bodies, build files those scripts run (plus conventional root build files),
474
663
  * tsconfig/vite outDirs.
475
664
  */
476
- export function discoverProducers(cwd) {
665
+ export function discoverProducers(cwd, opts = {}) {
477
666
  const prod = emptyProducers();
478
- const scripts = packageScripts(cwd);
667
+ const all = packageScripts(cwd);
668
+ const scripts = opts.excludeDevScripts === true ?
669
+ Object.fromEntries(Object.entries(all).filter(([n, b]) => !isDevScript(n, b)))
670
+ : all;
479
671
  const buildFiles = new Set(['build.ts', 'build.js', 'build.mjs'].filter(f => existsSync(path.join(cwd, f))));
480
672
  for (const body of Object.values(scripts)) {
481
673
  for (const cmd of splitShellCommands(body)) {
@@ -504,6 +696,19 @@ export function discoverProducers(cwd) {
504
696
  return prod;
505
697
  }
506
698
  const underDir = (p, dir) => p === dir || p.startsWith(dir + '/');
699
+ /** Is a FILE path positively produced — named exactly, under an opaque dir, or
700
+ * an enumerated stem of an enumerable outdir? */
701
+ function fileProduced(c, prod) {
702
+ if (prod.files.has(c))
703
+ return true;
704
+ if ([...prod.opaque].some(d => underDir(c, d)))
705
+ return true;
706
+ for (const [dir, stems] of prod.enumerable) {
707
+ if (underDir(path.posix.dirname(c), dir) && stems.has(stem(c)))
708
+ return true;
709
+ }
710
+ return false;
711
+ }
507
712
  /**
508
713
  * Resolve refs against existence + producers. DANGLING requires POSITIVE
509
714
  * evidence (see the module doc): the ref sits under an ENUMERATED output dir
@@ -549,17 +754,7 @@ export function resolveDanglingRefs(refs, prod, exists) {
549
754
  push(ref, `directory does not exist and no script or build step creates it`);
550
755
  continue;
551
756
  }
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
- });
757
+ const isSatisfied = candidates.some(c => fileProduced(c, prod));
563
758
  if (isSatisfied)
564
759
  continue;
565
760
  // Positive-evidence branches:
@@ -644,7 +839,14 @@ function scanCandidates(cwd, prod) {
644
839
  */
645
840
  export function findDanglingArtifacts(cwd) {
646
841
  const prod = discoverProducers(cwd);
842
+ // Second, PRODUCTION-only table: identical machinery minus watch/dev scripts.
843
+ // Generated-HTML refs resolve against this one, so a file whose only producer
844
+ // is `dev:css --watch` does not close a reference the built page makes
845
+ // (nexttask 3's load-bearing rule). Everything else keeps resolving against
846
+ // the full table, so no existing finding moves.
847
+ const prodProduction = discoverProducers(cwd, { excludeDevScripts: true });
647
848
  const refs = [];
849
+ const sources = [];
648
850
  for (const rel of scanCandidates(cwd, prod)) {
649
851
  let src;
650
852
  try {
@@ -659,12 +861,63 @@ export function findDanglingArtifacts(cwd) {
659
861
  else {
660
862
  refs.push(...extractJsRefs(src, rel));
661
863
  collectProducersFromSource(src, prod);
864
+ collectProducersFromSource(src, prodProduction);
865
+ sources.push({ rel, src });
662
866
  }
663
867
  }
664
868
  for (const [name, body] of Object.entries(packageScripts(cwd))) {
665
869
  refs.push(...extractScriptEntrypoints(body, `package.json scripts.${name}`));
666
870
  }
667
- return resolveDanglingRefs(refs, prod, rel => existsSync(path.join(cwd, rel)));
871
+ const out = resolveDanglingRefs(refs, prod, rel => existsSync(path.join(cwd, rel)));
872
+ // Generated HTML runs in a SECOND pass: whether a literal counts as a build
873
+ // artifact depends on outdirs any source in the tree may have declared, so
874
+ // the producer table has to be complete first.
875
+ const generated = [];
876
+ for (const { rel, src } of sources) {
877
+ generated.push(...extractGeneratedHtmlRefs(src, rel, prodProduction));
878
+ }
879
+ const seen = new Set(out.map(d => `${d.referencer} ${d.path}`));
880
+ for (const d of resolveDanglingRefs(generated, prodProduction, rel => existsSync(path.join(cwd, rel)))) {
881
+ if (seen.has(`${d.referencer} ${d.path}`))
882
+ continue;
883
+ seen.add(`${d.referencer} ${d.path}`);
884
+ out.push(d);
885
+ }
886
+ return out;
887
+ }
888
+ /** Classify one generated-HTML ref against both producer tables (STEP 0 / A/B
889
+ * reporting; the gate itself only needs the dangling verdict). */
890
+ export function classifyGeneratedHtmlRef(ref, full, production, exists) {
891
+ if (exists(ref.path) || fileProduced(ref.path, production))
892
+ return 'produced';
893
+ if (fileProduced(ref.path, full))
894
+ return 'dev-only';
895
+ return 'missing';
896
+ }
897
+ /** STEP 0 / A/B helper: every generated-HTML asset ref in a tree, with the
898
+ * producer tables it was measured against. Read-only, deterministic. */
899
+ export function collectGeneratedHtmlRefs(cwd) {
900
+ const full = discoverProducers(cwd);
901
+ const production = discoverProducers(cwd, { excludeDevScripts: true });
902
+ const sources = [];
903
+ for (const rel of scanCandidates(cwd, full)) {
904
+ if (SCAN_HTML_RE.test(rel))
905
+ continue;
906
+ let src;
907
+ try {
908
+ src = readFileSync(path.join(cwd, rel), 'utf8');
909
+ }
910
+ catch {
911
+ continue;
912
+ }
913
+ collectProducersFromSource(src, full);
914
+ collectProducersFromSource(src, production);
915
+ sources.push({ rel, src });
916
+ }
917
+ const refs = [];
918
+ for (const { rel, src } of sources)
919
+ refs.push(...extractGeneratedHtmlRefs(src, rel, production));
920
+ return { refs, full, production };
668
921
  }
669
922
  /** Ranked-failure text for the final gate (names referencer + missing path). */
670
923
  export function danglingGateFailureText(d) {