@mjasnikovs/pi-task 0.38.15 → 0.38.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/shared/child-process.js +9 -16
  4. package/dist/task/accept-debt.d.ts +7 -5
  5. package/dist/task/accept-debt.js +18 -14
  6. package/dist/task/artifact-closure.js +18 -63
  7. package/dist/task/auto-orchestrator.js +211 -218
  8. package/dist/task/autofix-ledger.d.ts +113 -0
  9. package/dist/task/autofix-ledger.js +152 -0
  10. package/dist/task/boot-probe.d.ts +109 -1
  11. package/dist/task/boot-probe.js +139 -23
  12. package/dist/task/child-runner.d.ts +50 -6
  13. package/dist/task/child-runner.js +48 -69
  14. package/dist/task/command-run.d.ts +49 -6
  15. package/dist/task/command-run.js +154 -18
  16. package/dist/task/coverage-loop.d.ts +11 -0
  17. package/dist/task/coverage-loop.js +16 -0
  18. package/dist/task/external-context.d.ts +9 -12
  19. package/dist/task/external-context.js +5 -5
  20. package/dist/task/failure-classifier.d.ts +9 -1
  21. package/dist/task/failure-classifier.js +9 -0
  22. package/dist/task/final-gate-fix.d.ts +22 -26
  23. package/dist/task/final-gate-fix.js +16 -31
  24. package/dist/task/final-gate.d.ts +10 -2
  25. package/dist/task/final-gate.js +55 -89
  26. package/dist/task/fix-child.d.ts +64 -0
  27. package/dist/task/fix-child.js +66 -0
  28. package/dist/task/gate-deps.js +20 -13
  29. package/dist/task/lint-fix.d.ts +7 -0
  30. package/dist/task/lint-fix.js +45 -9
  31. package/dist/task/orchestrator.d.ts +33 -24
  32. package/dist/task/orchestrator.js +75 -46
  33. package/dist/task/phases.d.ts +120 -34
  34. package/dist/task/phases.js +221 -134
  35. package/dist/task/plan-orchestrator.js +2 -2
  36. package/dist/task/plan-rounds.d.ts +86 -0
  37. package/dist/task/plan-rounds.js +105 -0
  38. package/dist/task/plan-session.d.ts +31 -21
  39. package/dist/task/plan-session.js +97 -120
  40. package/dist/task/qa-transcript.d.ts +100 -0
  41. package/dist/task/qa-transcript.js +99 -0
  42. package/dist/task/question-source.d.ts +117 -0
  43. package/dist/task/question-source.js +174 -0
  44. package/dist/task/repo-health-check.d.ts +21 -21
  45. package/dist/task/repo-health-check.js +43 -112
  46. package/dist/task/run-end.d.ts +77 -0
  47. package/dist/task/run-end.js +37 -0
  48. package/dist/task/run-final-gate.js +71 -79
  49. package/dist/task/serve-entry.js +6 -57
  50. package/dist/task/shipped-source.d.ts +67 -0
  51. package/dist/task/shipped-source.js +144 -0
  52. package/dist/task/task-gates.d.ts +9 -1
  53. package/dist/task/task-gates.js +27 -6
  54. package/dist/task/terminal-outcome.d.ts +1 -1
  55. package/dist/task/terminal-outcome.js +12 -0
  56. package/dist/task/verify-work.d.ts +46 -0
  57. package/dist/task/verify-work.js +51 -3
  58. package/dist/workers/brave-search.d.ts +7 -0
  59. package/dist/workers/brave-search.js +36 -55
  60. package/dist/workers/ddg-search.d.ts +1 -1
  61. package/dist/workers/ddg-search.js +27 -47
  62. package/dist/workers/docs-core.d.ts +71 -1
  63. package/dist/workers/docs-core.js +131 -71
  64. package/dist/workers/exa-search.d.ts +2 -2
  65. package/dist/workers/exa-search.js +53 -68
  66. package/dist/workers/html-clean.js +67 -88
  67. package/dist/workers/http-request.d.ts +74 -0
  68. package/dist/workers/http-request.js +103 -0
  69. package/dist/workers/npm-version.js +37 -42
  70. package/dist/workers/pi-worker-core.d.ts +13 -2
  71. package/dist/workers/pi-worker-core.js +35 -25
  72. package/dist/workers/pi-worker-docs.d.ts +1 -1
  73. package/dist/workers/pi-worker-docs.js +49 -68
  74. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  75. package/dist/workers/pi-worker-fetch.js +20 -21
  76. package/dist/workers/pi-worker-search.js +6 -4
  77. package/dist/workers/pi-worker.js +5 -4
  78. package/dist/workers/search-core.d.ts +1 -1
  79. package/dist/workers/search-core.js +36 -42
  80. package/dist/workers/search-types.d.ts +13 -0
  81. package/dist/workers/search-types.js +27 -0
  82. package/dist/workers/shared.d.ts +51 -11
  83. package/dist/workers/shared.js +0 -0
  84. package/dist/workers/worker-channels.d.ts +60 -0
  85. package/dist/workers/worker-channels.js +98 -0
  86. package/package.json +1 -1
@@ -189,5 +189,31 @@ export declare const DEFAULT_CONFIG: PiTaskConfig;
189
189
  * object/number can't reach the child argv as `-e [object Object]`.
190
190
  */
191
191
  export declare function sanitizeExtensionWhitelist(value: unknown): string[];
192
+ /**
193
+ * How each setting's STORED value becomes a safe in-memory value — one loader per
194
+ * key, keyed on the config's own type.
195
+ *
196
+ * `ConfigItem` (config/register.ts) already absorbed three of the four edits its
197
+ * own header names: *"Adding one enum setting meant four coordinated edits (row,
198
+ * format arm, parse arm, sanitizer) and NONE of them failed to compile if you
199
+ * forgot it."* The sanitizer was the fourth, and it stayed behind in this file as
200
+ * a hand-ordered statement ladder covering 7 of 14 keys. This is the mapped type
201
+ * that closes it: a new field on `PiTaskConfig` is a compile error until it
202
+ * declares how a hostile value becomes a safe one.
203
+ */
204
+ export declare const CONFIG_LOADERS: {
205
+ [K in keyof PiTaskConfig]: (raw: unknown) => PiTaskConfig[K];
206
+ };
207
+ /**
208
+ * Turn parsed config JSON into a `PiTaskConfig`. Pure — this is the seam the load
209
+ * block used to have none of, so every hostile-value case was reachable only
210
+ * through `getConfig()`, which reads the developer's own machine.
211
+ *
212
+ * A non-object `raw` yields the defaults. That is not merely defensive: the old
213
+ * block spread `parsed` directly, and `{...DEFAULT_CONFIG, ...'ab'}` produces
214
+ * numeric index keys. Keys absent from the table are dropped rather than carried
215
+ * into the config object.
216
+ */
217
+ export declare function loadConfig(raw: unknown): PiTaskConfig;
192
218
  export declare function getConfig(): PiTaskConfig;
193
219
  export declare function saveConfig(config: PiTaskConfig): Promise<void>;
@@ -115,6 +115,73 @@ export function sanitizeExtensionWhitelist(value) {
115
115
  return [];
116
116
  return value.filter((p) => typeof p === 'string' && p.trim().length > 0);
117
117
  }
118
+ /**
119
+ * A boolean setting's loader. Only a REAL boolean counts; anything else falls
120
+ * back to the shipped default.
121
+ *
122
+ * This is `yoloMode`'s guard, generalised. Its comment — *"a hand-edited
123
+ * `"yoloMode": "false"` is a truthy string"* — was true verbatim of the other
124
+ * seven booleans, none of which had it, so a stale `"verifyWork": "off"` reached
125
+ * `getConfig().verifyWork` as truthy and a `"autoCommit": 0` reached it as falsy.
126
+ */
127
+ function asBoolean(key) {
128
+ return raw => (typeof raw === 'boolean' ? raw : DEFAULT_CONFIG[key]);
129
+ }
130
+ /**
131
+ * How each setting's STORED value becomes a safe in-memory value — one loader per
132
+ * key, keyed on the config's own type.
133
+ *
134
+ * `ConfigItem` (config/register.ts) already absorbed three of the four edits its
135
+ * own header names: *"Adding one enum setting meant four coordinated edits (row,
136
+ * format arm, parse arm, sanitizer) and NONE of them failed to compile if you
137
+ * forgot it."* The sanitizer was the fourth, and it stayed behind in this file as
138
+ * a hand-ordered statement ladder covering 7 of 14 keys. This is the mapped type
139
+ * that closes it: a new field on `PiTaskConfig` is a compile error until it
140
+ * declares how a hostile value becomes a safe one.
141
+ */
142
+ export const CONFIG_LOADERS = {
143
+ remote: asBoolean('remote'),
144
+ autoCommit: asBoolean('autoCommit'),
145
+ orientation: asBoolean('orientation'),
146
+ enforceGuidelines: asBoolean('enforceGuidelines'),
147
+ verifyWork: asBoolean('verifyWork'),
148
+ parallelResearchWorkers: asBoolean('parallelResearchWorkers'),
149
+ researchCache: asBoolean('researchCache'),
150
+ yoloMode: asBoolean('yoloMode'),
151
+ // A hand-edited or stale enum value must not leak an unknown provider into
152
+ // the dispatch switch — fall back to the default.
153
+ searchProvider: raw => (isSearchProvider(raw) ? raw : DEFAULT_CONFIG.searchProvider),
154
+ extensionWhitelist: sanitizeExtensionWhitelist,
155
+ requestTimeoutMs: sanitizeRequestTimeoutMs,
156
+ commandTimeoutExemptTools: sanitizeCommandTimeoutExemptTools,
157
+ streamInactivityMs: sanitizeStreamInactivityMs,
158
+ debugLogs: sanitizeDebugLogs
159
+ };
160
+ /**
161
+ * Turn parsed config JSON into a `PiTaskConfig`. Pure — this is the seam the load
162
+ * block used to have none of, so every hostile-value case was reachable only
163
+ * through `getConfig()`, which reads the developer's own machine.
164
+ *
165
+ * A non-object `raw` yields the defaults. That is not merely defensive: the old
166
+ * block spread `parsed` directly, and `{...DEFAULT_CONFIG, ...'ab'}` produces
167
+ * numeric index keys. Keys absent from the table are dropped rather than carried
168
+ * into the config object.
169
+ */
170
+ export function loadConfig(raw) {
171
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
172
+ return { ...DEFAULT_CONFIG };
173
+ }
174
+ const stored = raw;
175
+ const out = { ...DEFAULT_CONFIG };
176
+ for (const key of Object.keys(CONFIG_LOADERS)) {
177
+ // `undefined` reaches the loader like any other hostile value: every
178
+ // loader answers a missing key with its own default, which is what the
179
+ // old `delete parsed.X` + spread did.
180
+ ;
181
+ out[key] = CONFIG_LOADERS[key](stored[key]);
182
+ }
183
+ return out;
184
+ }
118
185
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
119
186
  const _g = globalThis;
120
187
  if (!_g.__piTaskConfig) {
@@ -125,23 +192,7 @@ const G = _g.__piTaskConfig;
125
192
  // before any session_start handler fires.
126
193
  if (!G.loaded) {
127
194
  try {
128
- const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
129
- const parsed = JSON.parse(raw);
130
- // A hand-edited or stale enum value must not leak an unknown provider
131
- // into the dispatch switch — fall back to the default.
132
- if (!isSearchProvider(parsed.searchProvider))
133
- delete parsed.searchProvider;
134
- parsed.extensionWhitelist = sanitizeExtensionWhitelist(parsed.extensionWhitelist);
135
- parsed.requestTimeoutMs = sanitizeRequestTimeoutMs(parsed.requestTimeoutMs);
136
- parsed.commandTimeoutExemptTools = sanitizeCommandTimeoutExemptTools(parsed.commandTimeoutExemptTools);
137
- parsed.streamInactivityMs = sanitizeStreamInactivityMs(parsed.streamInactivityMs);
138
- // A hand-edited `"yoloMode": "false"` is a truthy string — it must not
139
- // silently switch a watched run into unattended auto-pick. Only a real
140
- // boolean counts; anything else falls back to the OFF default.
141
- if (typeof parsed.yoloMode !== 'boolean')
142
- delete parsed.yoloMode;
143
- parsed.debugLogs = sanitizeDebugLogs(parsed.debugLogs);
144
- G.config = { ...DEFAULT_CONFIG, ...parsed };
195
+ G.config = loadConfig(JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')));
145
196
  }
146
197
  catch {
147
198
  G.config = { ...DEFAULT_CONFIG };
@@ -1,5 +1,6 @@
1
1
  import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
2
2
  import { realStreamTimerDeps, StreamWatchdog } from './stream-watchdog.js';
3
+ import { workerChannel } from '../workers/worker-channels.js';
3
4
  /** Grace period between SIGTERM and SIGKILL (ms). */
4
5
  export const KILL_GRACE_MS = 5000;
5
6
  /** Base flags shared by all child pi invocations. */
@@ -460,25 +461,17 @@ export function summarizeToolArgs(toolName, args) {
460
461
  if (!args || typeof args !== 'object')
461
462
  return '';
462
463
  const a = args;
463
- const clip = (s) => {
464
- const one = s.replace(/\s+/g, ' ').trim();
465
- return one.length > 60 ? one.slice(0, 59) + '…' : one;
466
- };
467
464
  if (toolName === 'bash' && typeof a.command === 'string') {
468
465
  return a.command.replace(/\s+/g, ' ').trim();
469
466
  }
470
- if (toolName === 'pi-worker-docs'
471
- && typeof a.module === 'string'
472
- && typeof a.query === 'string') {
473
- return `${a.module} "${clip(a.query)}"`;
474
- }
475
- // Search/fetch workers: without these the debug log shows a bare tool name
476
- // and a run audit cannot tell WHAT was searched or fetched.
477
- if (toolName === 'pi-worker-search' && typeof a.query === 'string') {
478
- return `"${clip(a.query)}"`;
479
- }
480
- if (toolName === 'pi-worker-fetch' && typeof a.url === 'string') {
481
- return clip(a.url);
467
+ // Each worker tool's argument shape is its own row's fact (worker-channels.ts);
468
+ // this used to be a third copy of the names AND a re-statement of each one's
469
+ // parameters. A row that has nothing to say falls through to the generic keys.
470
+ const channel = workerChannel(toolName);
471
+ if (channel) {
472
+ const summary = channel.summarize(a);
473
+ if (summary)
474
+ return summary;
482
475
  }
483
476
  if (typeof a.file_path === 'string')
484
477
  return a.file_path;
@@ -190,12 +190,12 @@ export declare function recheckAcceptDebts(debts: AcceptDebt[], opts: {
190
190
  * caller is expected to have made `pass` mean "ran, exited 0, and changed
191
191
  * nothing tracked" (`inv-no-write`).
192
192
  */
193
- rerunVerify?: (command: string, debt: AcceptDebt) => VerifyRerunResult;
194
- }): {
193
+ rerunVerify?: (command: string, debt: AcceptDebt) => Promise<VerifyRerunResult>;
194
+ }): Promise<{
195
195
  open: AcceptDebt[];
196
196
  resolved: AcceptDebt[];
197
197
  trail: string[];
198
- };
198
+ }>;
199
199
  /**
200
200
  * Extract the paths whose EXISTENCE the reason asserts as the failure — a path
201
201
  * token immediately followed by "exists" / "still exists", or by "must/should not
@@ -247,7 +247,9 @@ export declare function describeDebt(d: AcceptDebt): string;
247
247
  * thing that can auto-close a static-class debt — so a caller that does not know
248
248
  * must pass `false` (unprovable ⇒ stays open), never a guess.
249
249
  */
250
- export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise<{
250
+ export declare function deriveOpenDebts(cwd: string, staticOk: boolean,
251
+ /** The spawner for the VERIFY-COMMAND re-runs. Defaults to the real one. */
252
+ run?: CommandRunner, signal?: AbortSignal): Promise<{
251
253
  openDebts: AcceptDebt[];
252
254
  debtNote?: string;
253
255
  trail?: string[];
@@ -272,4 +274,4 @@ export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise
272
274
  export declare function rerunDebtVerifyCommand(cwd: string, command: string,
273
275
  /** The spawner, for BOTH the command and the tracked-state reads. Injected so
274
276
  * the guard's four outcomes are testable without a repo or a real command. */
275
- run?: CommandRunner): VerifyRerunResult;
277
+ run?: CommandRunner, signal?: AbortSignal): Promise<VerifyRerunResult>;
@@ -29,6 +29,7 @@ import { existsSync } from 'node:fs';
29
29
  import * as path from 'node:path';
30
30
  import * as fsp from 'node:fs/promises';
31
31
  import { runVerifyCommandLine, spawnCommand } from './command-run.js';
32
+ import { failClassOfReason, isStaticClass } from './verify-work.js';
32
33
  import { taskThatIntroduced } from './task-provenance.js';
33
34
  import { makeLedger } from './ledger.js';
34
35
  import { parseVerifyBlockStrict } from './spec-validation.js';
@@ -233,7 +234,7 @@ export async function writeAcceptDebts(cwd, debts) {
233
234
  * behavioral and cannot be proven resolved without re-running the model.
234
235
  */
235
236
  export function isStaticClassDebt(reason) {
236
- return /^\s*repo health:/i.test(reason);
237
+ return isStaticClass(failClassOfReason(reason));
237
238
  }
238
239
  /**
239
240
  * The VERIFY-COMMAND class (nexttask 5): the ONE command a recorded reason itself
@@ -339,7 +340,7 @@ const MAX_VERIFY_RERUNS = 3;
339
340
  * passed. FP-safe: the only auto-closes are ones a deterministic check can stand
340
341
  * behind, and here the check is the task spec's own command.
341
342
  */
342
- export function recheckAcceptDebts(debts, opts) {
343
+ export async function recheckAcceptDebts(debts, opts) {
343
344
  const open = [];
344
345
  const resolved = [];
345
346
  const trail = [];
@@ -381,7 +382,7 @@ export function recheckAcceptDebts(debts, opts) {
381
382
  rerunsLeft -= 1;
382
383
  let r;
383
384
  try {
384
- r = opts.rerunVerify(cmd, d);
385
+ r = await opts.rerunVerify(cmd, d);
385
386
  }
386
387
  catch {
387
388
  // A harness fault observes nothing, so it proves nothing.
@@ -499,8 +500,10 @@ export function describeDebt(d) {
499
500
  * thing that can auto-close a static-class debt — so a caller that does not know
500
501
  * must pass `false` (unprovable ⇒ stays open), never a guess.
501
502
  */
502
- export async function deriveOpenDebts(cwd, staticOk) {
503
- const { open: openRaw, resolved, trail } = recheckAcceptDebts(await readAcceptDebts(cwd), {
503
+ export async function deriveOpenDebts(cwd, staticOk,
504
+ /** The spawner for the VERIFY-COMMAND re-runs. Defaults to the real one. */
505
+ run = spawnCommand, signal) {
506
+ const { open: openRaw, resolved, trail } = await recheckAcceptDebts(await readAcceptDebts(cwd), {
504
507
  staticOk,
505
508
  // Cross-task-deletion debts auto-close iff the deleted file is back in the
506
509
  // tree — a deterministic existence check, corroborating the per-file
@@ -509,7 +512,7 @@ export async function deriveOpenDebts(cwd, staticOk) {
509
512
  // VERIFY-COMMAND class (nexttask 5): a debt that NAMES a command is settled
510
513
  // by running that command, under the gate's own env-gap contract and behind
511
514
  // the no-write guard below.
512
- rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd)
515
+ rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd, run, signal)
513
516
  });
514
517
  if (resolved.length > 0)
515
518
  await writeAcceptDebts(cwd, openRaw);
@@ -552,26 +555,27 @@ const DEBT_INFRA_GAP_RE = /ERR_POSTGRES_CONNECTION_CLOSED|ERR_MYSQL_CONNECTION|E
552
555
  * the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
553
556
  * assumption rather than an observation.
554
557
  */
555
- export function rerunDebtVerifyCommand(cwd, command,
558
+ export async function rerunDebtVerifyCommand(cwd, command,
556
559
  /** The spawner, for BOTH the command and the tracked-state reads. Injected so
557
560
  * the guard's four outcomes are testable without a repo or a real command. */
558
- run = spawnCommand) {
559
- const tracked = () => {
560
- const r = run({
561
+ run = spawnCommand, signal) {
562
+ const tracked = async () => {
563
+ const r = await run({
561
564
  cwd,
562
565
  bin: 'git',
563
566
  args: ['status', '--porcelain', '--untracked-files=no'],
564
- timeoutMs: 60_000
567
+ timeoutMs: 60_000,
568
+ ...(signal === undefined ? {} : { signal })
565
569
  });
566
570
  return r.failedToStart || r.status !== 0 ? null : r.stdout;
567
571
  };
568
- const before = tracked();
569
- const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run);
572
+ const before = await tracked();
573
+ const r = await runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run, signal);
570
574
  if (r.outcome === 'fail')
571
575
  return { outcome: 'fail', detail: `exit ${r.status} — ${r.tail}` };
572
576
  if (r.outcome === 'gap')
573
577
  return { outcome: 'gap', detail: r.detail };
574
- const after = tracked();
578
+ const after = await tracked();
575
579
  if (before === null || after === null) {
576
580
  return { outcome: 'gap', detail: 'tracked-state guard could not read git status' };
577
581
  }
@@ -40,8 +40,9 @@
40
40
  * c.notFound()`): an existence guard is exactly how the bug presents (permanent
41
41
  * 404), so guarded reads deliberately do NOT step aside.
42
42
  */
43
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
43
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
44
44
  import * as path from 'node:path';
45
+ import { shippedSources, stripCommentLines, SOURCE_HTML_RE, SOURCE_JS_RE } from './shipped-source.js';
45
46
  export function emptyProducers() {
46
47
  return {
47
48
  files: new Set(),
@@ -83,14 +84,6 @@ const hasExt = (p) => /\.[A-Za-z0-9]{1,8}$/.test(path.posix.basename(p));
83
84
  /** Source-only extensions nothing ships un-built: a missing one of these is hard
84
85
  * evidence on its own (a script entrypoint like `bun src/server/index.ts`). */
85
86
  const SOURCE_ONLY_EXT_RE = /\.(?:ts|tsx|mts|cts|jsx)$/i;
86
- /** Strip comment-only lines (`// …`, `* …`) — a ref quoted in a comment is not a
87
- * runtime read. Inline comments are left alone (strings may contain `//`). */
88
- function stripCommentLines(src) {
89
- return src
90
- .split('\n')
91
- .filter(l => !/^\s*(?:\/\/|\*|\/\*)/.test(l))
92
- .join('\n');
93
- }
94
87
  // Literal-first-argument read-side constructs. `[^'"\`\n]` keeps the argument on
95
88
  // one line and free of a closing quote; normalizeRefPath rejects `${…}` holes.
96
89
  const JS_READ_PATTERNS = [
@@ -229,7 +222,7 @@ export function collectEmittedHtml(source) {
229
222
  const writeRe = /\b(?:Bun\.write|writeFileSync|writeFile|fs\.promises\.writeFile)\(\s*(['"`])([^'"`\n]+)\1\s*,\s*/g;
230
223
  for (let m = writeRe.exec(src); m !== null; m = writeRe.exec(src)) {
231
224
  const docPath = normalizeRefPath(m[2]);
232
- if (docPath === null || !SCAN_HTML_RE.test(docPath))
225
+ if (docPath === null || !SOURCE_HTML_RE.test(docPath))
233
226
  continue;
234
227
  const at = m.index + m[0].length;
235
228
  let html;
@@ -777,58 +770,20 @@ export function resolveDanglingRefs(refs, prod, exists) {
777
770
  out.push({ ...ref, reason });
778
771
  }
779
772
  }
780
- // Directories never scanned for referencing sources: VCS/dep/artifact trees,
781
- // every discovered produced dir (bundled output re-referencing its own chunks is
782
- // noise), and test/fixture/doc trees those reference fixture paths and quoted
783
- // examples freely and are not the runtime serving surface this guard protects.
784
- 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)$/;
785
- const SKIP_FILE_RE = /\.(?:test|spec|stories)\.[a-z]+$|\.d\.[mc]?ts$/i;
786
- const SCAN_JS_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
787
- const SCAN_HTML_RE = /\.html?$/i;
788
- const MAX_SCAN_FILES = 3000;
789
- const MAX_FILE_BYTES = 400_000;
790
- /** Walk the tree for scannable sources (bounded, deterministic order). */
773
+ // The tree walk, the caps and the skip sets live in task/shipped-source.ts
774
+ // this was serve-entry's walker written a second time, and the two skip sets had
775
+ // drifted: `bench`/`benchmarks` and `*.bench.*` were skipped there and scanned
776
+ // here, so a dangling reference in a benchmark was a run-level finding while the
777
+ // same file was invisible to the sibling scan.
778
+ //
779
+ // `producedDirs` stays this scan's own: bundled output re-referencing its own
780
+ // chunks is noise, and which dirs are produced is discovered per run.
791
781
  function scanCandidates(cwd, prod) {
792
- const out = [];
793
- const producedDirs = new Set([...prod.dirs, ...prod.opaque, ...prod.enumerable.keys()].map(d => d.split('/')[0]));
794
- const walk = (rel) => {
795
- if (out.length >= MAX_SCAN_FILES)
796
- return;
797
- let entries;
798
- try {
799
- entries = readdirSync(path.join(cwd, rel)).sort();
800
- }
801
- catch {
802
- return;
803
- }
804
- for (const name of entries) {
805
- if (out.length >= MAX_SCAN_FILES)
806
- return;
807
- const relPath = rel === '' ? name : `${rel}/${name}`;
808
- let st;
809
- try {
810
- st = statSync(path.join(cwd, relPath));
811
- }
812
- catch {
813
- continue;
814
- }
815
- if (st.isDirectory()) {
816
- if (name.startsWith('.') || SKIP_DIR_RE.test(name))
817
- continue;
818
- if (rel === '' && producedDirs.has(name))
819
- continue;
820
- walk(relPath);
821
- }
822
- else if (st.isFile() && st.size <= MAX_FILE_BYTES) {
823
- if (SKIP_FILE_RE.test(name))
824
- continue;
825
- if (SCAN_JS_RE.test(name) || SCAN_HTML_RE.test(name))
826
- out.push(relPath);
827
- }
828
- }
829
- };
830
- walk('');
831
- return out;
782
+ const producedRoots = new Set([...prod.dirs, ...prod.opaque, ...prod.enumerable.keys()].map(d => d.split('/')[0]));
783
+ return shippedSources(cwd, {
784
+ ext: new RegExp(`${SOURCE_JS_RE.source}|${SOURCE_HTML_RE.source}`, 'i'),
785
+ excludeRoots: producedRoots
786
+ });
832
787
  }
833
788
  /**
834
789
  * FINAL-GATE seam: scan the shipped tree for dangling runtime references.
@@ -855,7 +810,7 @@ export function findDanglingArtifacts(cwd) {
855
810
  catch {
856
811
  continue;
857
812
  }
858
- if (SCAN_HTML_RE.test(rel)) {
813
+ if (SOURCE_HTML_RE.test(rel)) {
859
814
  refs.push(...extractHtmlRefs(src, rel));
860
815
  }
861
816
  else {
@@ -901,7 +856,7 @@ export function collectGeneratedHtmlRefs(cwd) {
901
856
  const production = discoverProducers(cwd, { excludeDevScripts: true });
902
857
  const sources = [];
903
858
  for (const rel of scanCandidates(cwd, full)) {
904
- if (SCAN_HTML_RE.test(rel))
859
+ if (SOURCE_HTML_RE.test(rel))
905
860
  continue;
906
861
  let src;
907
862
  try {