@mjasnikovs/pi-task 0.25.0 → 0.26.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.
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
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",