@ngockhoale/ukit 3.4.1 → 3.4.2

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 (110) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/code.js +29 -5
  4. package/src/cli/commands/decision.js +18 -4
  5. package/src/cli/commands/doctor.js +7 -3
  6. package/src/cli/commands/install.js +29 -4
  7. package/src/cli/commands/memory.js +25 -5
  8. package/src/cli/commands/telemetry.js +18 -1
  9. package/src/cli/commands/vm.js +7 -1
  10. package/src/context/detectProjectContext.js +7 -2
  11. package/src/core/agentRuntime/contract.js +5 -1
  12. package/src/core/agentRuntime/eventStore.js +54 -7
  13. package/src/core/agentRuntime/recovery.js +22 -15
  14. package/src/core/agentRuntime/supervisor.js +71 -13
  15. package/src/core/applyPlan.js +11 -1
  16. package/src/core/codeintel/compiler.js +51 -8
  17. package/src/core/codeintel/diagnostics.js +124 -33
  18. package/src/core/codeintel/freshness.js +25 -12
  19. package/src/core/codeintel/invalidation.js +11 -3
  20. package/src/core/codeintel/retriever.js +53 -20
  21. package/src/core/codeintel/router.js +19 -9
  22. package/src/core/codeintel/summaries.js +4 -3
  23. package/src/core/codeintel/vectorProvider.js +30 -4
  24. package/src/core/compact/index.js +24 -7
  25. package/src/core/compact/threshold.js +49 -14
  26. package/src/core/diffPlan.js +51 -23
  27. package/src/core/ensureGitignore.js +19 -2
  28. package/src/core/fileOps.js +61 -0
  29. package/src/core/memory/hygiene.js +51 -1
  30. package/src/core/memory/migrate.js +41 -21
  31. package/src/core/memory/store.js +96 -61
  32. package/src/core/metadata.js +37 -2
  33. package/src/core/observability/adapters/ingest.js +30 -2
  34. package/src/core/observability/emit/config.js +19 -4
  35. package/src/core/observability/emit/crash.js +3 -1
  36. package/src/core/observability/emit/recorder.js +15 -7
  37. package/src/core/observability/privacy/sanitizeObserved.js +3 -1
  38. package/src/core/observability/segments/internal.js +36 -8
  39. package/src/core/observability/segments/retention.js +11 -0
  40. package/src/core/observability/support/import.js +27 -1
  41. package/src/core/output/index.js +16 -1
  42. package/src/core/permissionDoctor.js +72 -9
  43. package/src/core/repairBrokenHooks.js +15 -2
  44. package/src/core/reviewPanelAggregate.js +26 -10
  45. package/src/core/runInstallPipeline.js +71 -22
  46. package/src/core/runtimeConfig.js +2 -0
  47. package/src/core/status.js +2 -0
  48. package/src/core/taskBudgetValidator.js +7 -1
  49. package/src/core/taskProgressGuard.js +11 -1
  50. package/src/core/unattendedDoctor.js +36 -5
  51. package/src/core/uninstall.js +52 -12
  52. package/src/core/update.js +5 -1
  53. package/src/decision/client.js +158 -27
  54. package/src/decision/reviewVerdict.js +23 -7
  55. package/src/diagnostics/failurePatterns.js +1 -1
  56. package/src/diagnostics/feedbackEvents.js +1 -1
  57. package/src/diagnostics/routeOutcomes.js +42 -4
  58. package/src/diagnostics/skillAccuracy.js +35 -4
  59. package/src/index/buildIndex.js +123 -26
  60. package/src/index/fixLoopEscalation.js +3 -0
  61. package/src/index/gitHooks.js +99 -29
  62. package/src/index/importResolution.js +7 -1
  63. package/src/index/playbookRegistry.js +15 -11
  64. package/src/index/queryIndex.js +28 -10
  65. package/src/index/routeResolver.js +8 -3
  66. package/src/index/taskRouting.js +37 -2
  67. package/src/learning/codeProposals.js +24 -5
  68. package/src/learning/selfImprove.js +29 -5
  69. package/src/learning/tunedOverlay.js +18 -7
  70. package/src/learning/tuning.js +10 -4
  71. package/src/skill/auditSkill.js +46 -7
  72. package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
  73. package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
  74. package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
  75. package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
  76. package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
  77. package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
  78. package/template_project/.claude/hooks/session-episode.sh +60 -28
  79. package/template_project/.claude/hooks/verification-guard.sh +26 -15
  80. package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
  81. package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
  82. package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
  83. package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
  84. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
  85. package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
  86. package/template_project/.claude/ukit/index/query-index.mjs +13 -6
  87. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
  88. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
  89. package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
  90. package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
  91. package/template_project/.claude/ukit/index/route-task.mjs +15 -0
  92. package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
  93. package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
  94. package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
  95. package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
  96. package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
  97. package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
  98. package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
  99. package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
  100. package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
  101. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
  102. package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
  103. package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
  104. package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
  105. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
  106. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
  107. package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
  108. package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
  109. package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
  110. package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
@@ -310,15 +310,30 @@ export function createSupervisor(opts = {}) {
310
310
  op.seq += 1;
311
311
  const event = makeEvent(op, 'operation.transition', { from, to, ...extra }, artifactRefs);
312
312
  await append(runtimeDir, event);
313
- await writeOperationState(runtimeDir, op.id, {
314
- state: to,
315
- fencingEpoch,
316
- ownerEpoch,
317
- });
313
+ await writeOperationState(runtimeDir, op.id, stateRecord(op, to));
318
314
  return event;
319
315
  });
320
316
  }
321
317
 
318
+ /**
319
+ * Full-overwrite state-cache record (C92-E-03). recovery.js fences restart
320
+ * reconciliation on `pid`/`processStartTime`/`spawned`/`exitStatus` — every
321
+ * write must re-carry them or a later transition silently drops the launch
322
+ * metadata (writeOperationState overwrites wholesale). `undefined` fields
323
+ * are dropped by JSON.stringify, so pre-spawn records omit pid honestly.
324
+ */
325
+ function stateRecord(op, to) {
326
+ return {
327
+ state: to,
328
+ fencingEpoch,
329
+ ownerEpoch,
330
+ pid: op.pgid ?? undefined,
331
+ processStartTime: op.processStartTime ?? undefined,
332
+ spawned: op.proc != null,
333
+ exitStatus: Number.isInteger(op.exitInfo?.code) ? op.exitInfo.code : undefined,
334
+ };
335
+ }
336
+
322
337
  function emitObservation(op, cls) {
323
338
  if (op.observedClasses.has(cls)) return;
324
339
  op.observedClasses.add(cls);
@@ -340,6 +355,14 @@ export function createSupervisor(opts = {}) {
340
355
  if (op.exited || signal === 0) {
341
356
  return;
342
357
  }
358
+ // C92-E-01: target only a real owned process group. `-null` evaluates to
359
+ // -0, which process.kill accepts as group 0 — OUR OWN process group. A
360
+ // pre-spawn cancel or close() hits exactly that window (pgid set at
361
+ // spawn+assignment only), so a missing/invalid pgid must skip, never
362
+ // signal.
363
+ if (!Number.isInteger(op.pgid) || op.pgid <= 0) {
364
+ return;
365
+ }
343
366
  try {
344
367
  killImpl(signal, -op.pgid);
345
368
  } catch {
@@ -368,11 +391,7 @@ export function createSupervisor(opts = {}) {
368
391
  reason: op.cancelReason ?? 'killed',
369
392
  }, op.artifactRefs);
370
393
  await append(runtimeDir, event);
371
- await writeOperationState(runtimeDir, op.id, {
372
- state: 'cancelled',
373
- fencingEpoch,
374
- ownerEpoch,
375
- });
394
+ await writeOperationState(runtimeDir, op.id, stateRecord(op, 'cancelled'));
376
395
  closeOpSpans(op, 'failed', {
377
396
  errorCode: reasonCodeForFailure({ cancelReason: op.cancelReason ?? 'cancel_requested' }),
378
397
  exitCode: op.exitInfo?.code,
@@ -484,7 +503,7 @@ export function createSupervisor(opts = {}) {
484
503
  op.seq += 1;
485
504
  const event = makeEvent(op, 'operation.transition', { from, to, ...extra }, op.artifactRefs);
486
505
  await append(runtimeDir, event);
487
- await writeOperationState(runtimeDir, op.id, { state: to, fencingEpoch, ownerEpoch });
506
+ await writeOperationState(runtimeDir, op.id, stateRecord(op, to));
488
507
  // DF2-FR07: terminal transition committed — close tool + execution
489
508
  // spans with the same outcome. Cancelled is a failed run with a
490
509
  // USER_CANCELLED/TOOL_TIMEOUT code, never a silent open span.
@@ -576,6 +595,7 @@ export function createSupervisor(opts = {}) {
576
595
  chainError: null,
577
596
  proc: null,
578
597
  pgid: null,
598
+ processStartTime: null,
579
599
  exited: false,
580
600
  finalized: false,
581
601
  cancelRequested: false,
@@ -593,11 +613,24 @@ export function createSupervisor(opts = {}) {
593
613
  op.telemetryCtx = ctx;
594
614
  operations.set(op.id, op);
595
615
 
616
+ // C90-08: the 'starting' append is the durable start record — if the
617
+ // journal chain failed, the enqueue's catch has already swallowed the
618
+ // throw into op.chainError, so it must be checked HERE, before spawn
619
+ // releases a side effect with no proof-of-start on disk.
596
620
  await transition(op, 'starting', {
597
621
  attempt: spec.attempt,
598
622
  sideEffectClass: spec.sideEffectClass,
599
623
  ...(hostLane ? { host: hostLane.name } : {}),
600
624
  });
625
+ if (op.chainError) {
626
+ const startErr = op.chainError;
627
+ operations.delete(op.id);
628
+ endExecutionSpan(ctx, 'failed', {
629
+ operation: spec.operationId,
630
+ error_code: reasonCodeForFailure({ code: startErr?.code }),
631
+ });
632
+ throw startErr;
633
+ }
601
634
 
602
635
  let proc;
603
636
  // DF2-FR07: the owned child process IS this run's tool invocation —
@@ -621,8 +654,17 @@ export function createSupervisor(opts = {}) {
621
654
  cwd: spec.cwd,
622
655
  });
623
656
  } catch (err) {
624
- await transition(op, 'failed', {
657
+ // C92-E-02: spawn threw while 'starting' → land terminal. The
658
+ // target edge is derived from op.state — never from
659
+ // cancelRequested, which close() sets on ANY non-terminal op:
660
+ // 'starting → cancelled' is illegal and silently strands the op.
661
+ // A cancel-claimed op is already 'cancel_pending' (performCancel
662
+ // and close() both take that edge first) → 'cancelled' is legal;
663
+ // anything else routes the throw through 'starting → failed'.
664
+ const to = op.state === 'cancel_pending' ? 'cancelled' : 'failed';
665
+ await transition(op, to, {
625
666
  error: { code: 'spawn_failed', message: String(err?.message ?? err) },
667
+ ...(to === 'cancelled' ? { reason: op.cancelReason ?? 'killed' } : {}),
626
668
  });
627
669
  if (telemetry) {
628
670
  const code = reasonCodeForFailure({ code: 'spawn_failed', errno: err?.code });
@@ -642,6 +684,7 @@ export function createSupervisor(opts = {}) {
642
684
 
643
685
  op.proc = proc;
644
686
  op.pgid = proc.pid;
687
+ op.processStartTime = nowMs();
645
688
  op.lastOutputAt = nowMs();
646
689
 
647
690
  // C80-23: attach exit/error listeners BEFORE any other work — a fast-
@@ -669,7 +712,16 @@ export function createSupervisor(opts = {}) {
669
712
  }
670
713
  }
671
714
 
672
- await transition(op, 'running', { pid: proc.pid ?? null });
715
+ // C92-E-01: a cancel that landed before spawn (killGroup saw pgid
716
+ // null and skipped) must not leave the just-spawned child running —
717
+ // reap its group now that ownership is proven, and never commit the
718
+ // 'running' edge (cancel_pending → running is not a legal edge; the
719
+ // op resolves terminal via exit/backstop as 'cancelled').
720
+ if (op.cancelRequested) {
721
+ killGroup(op, SIGKILL);
722
+ } else {
723
+ await transition(op, 'running', { pid: proc.pid ?? null });
724
+ }
673
725
  if (!op.exited) scheduleTick(op);
674
726
 
675
727
  return {
@@ -752,6 +804,10 @@ export function createSupervisor(opts = {}) {
752
804
  /**
753
805
  * Reap in-flight owned children and drain journals with a bounded wait
754
806
  * on the REAL clock — the injected clock may never advance on its own.
807
+ * Every close-kill claims the op via the legal `cancel_pending` edge
808
+ * (R4.5 C92-E-02): without it a 'starting'/'running' op would need an
809
+ * illegal direct `→ cancelled` edge on exit, which `transition`/
810
+ * `finalizeExit` reject and strand the op non-terminal.
755
811
  */
756
812
  async close() {
757
813
  closed = true;
@@ -760,6 +816,8 @@ export function createSupervisor(opts = {}) {
760
816
  op.timers.clear();
761
817
  if (!op.exited && !isTerminal(op.state)) {
762
818
  op.cancelRequested = true;
819
+ op.cancelReason ??= 'supervisor_close';
820
+ transition(op, 'cancel_pending', { reason: op.cancelReason });
763
821
  killGroup(op, SIGKILL);
764
822
  }
765
823
  }
@@ -203,8 +203,18 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
203
203
  }
204
204
  throw writeError;
205
205
  }
206
+ // W2-C8: the content write already committed (atomic rename / wx create), so a
207
+ // chmod failure here must NOT abort the install — the same error-tolerance
208
+ // policy as write-fallback warns. The file exists with correct content at
209
+ // default mode; skipping the entry would misreport a committed write.
206
210
  if (typeof entry.mode === 'number') {
207
- await fs.chmod(entry.targetPath, entry.mode);
211
+ try {
212
+ await fs.chmod(entry.targetPath, entry.mode);
213
+ } catch (chmodError) {
214
+ console.warn(
215
+ `[UKit] Warning: chmod ${entry.mode.toString(8)} failed for ${entry.targetPath} — ${chmodError?.code ?? chmodError?.message ?? chmodError}. File content was written; adjust permissions manually if needed.`,
216
+ );
217
+ }
208
218
  }
209
219
  writes.push({
210
220
  id: entry.id,
@@ -11,11 +11,13 @@ import { runDiagnostics } from './diagnostics.js';
11
11
  import { findAnalogies } from './analogy.js';
12
12
  import { summarizeFile } from './summaries.js';
13
13
  import { createEdge } from './providers.js';
14
+ import { resolveProjectPathReal } from '../fileOps.js';
14
15
  import { loadRuntimeConfig } from '../runtimeConfig.js';
15
16
 
16
17
  // Context Compiler v1 (SPEC §5–§8) — the peek→expand→read detail ladder.
17
18
  //
18
- // Pipeline: routeTask (unless mode forced) → guardLevel (L0 freshness guard)
19
+ // Pipeline: routeTask (unless mode forced) → guardLevel (freshness guard —
20
+ // reports the real repair level, repairs nothing itself)
19
21
  // → retrieve → budget-fitted evidence selection (lowest-confidence first out,
20
22
  // drops recorded in `omitted`) → validatePacket.
21
23
  //
@@ -89,8 +91,15 @@ function fitToBudget({ evidence, anchors, relations }, budgetTokens, dropped) {
89
91
  }
90
92
 
91
93
  async function readExcerpt(rootDir, relPath) {
94
+ // C92-F-03 (+symlink follow-up): containment guard identical to
95
+ // summaries.js readHead — absolute paths, `..` escapes, and entries that
96
+ // resolve outside the realpath'd root through a symlink must never reach
97
+ // fs.readFile. The resolved path is opened so the read hits exactly what
98
+ // was containment-checked.
99
+ const abs = await resolveProjectPathReal(rootDir, relPath);
100
+ if (!abs) return null;
92
101
  try {
93
- const content = await fs.readFile(path.join(rootDir, relPath), 'utf8');
102
+ const content = await fs.readFile(abs, 'utf8');
94
103
  const lines = content.split('\n').slice(0, EXCERPT_MAX_LINES);
95
104
  return lines.join('\n').slice(0, EXCERPT_MAX_CHARS);
96
105
  } catch {
@@ -145,10 +154,14 @@ export async function compileContext(projectRoot, task, options = {}) {
145
154
  // `read` step of the ladder: L2 excerpts when routed depth >= 2 or --read.
146
155
  const wantRead = opts.read === true || routed.depth >= 2;
147
156
 
148
- // L0 freshness guard — never throws; staleness is reported, not repaired here.
157
+ // Freshness guard — never throws; staleness is reported, never repaired
158
+ // here. C92-F-02: maxLevel 'L4' so recommendedLevel is the REAL repair level
159
+ // (same call-site contract as indexTools.js doctor) — the old 'L0' clamp made
160
+ // clampLevel rewrite an L4 verdict to 'L0', yielding the impossible
161
+ // {stale:true, level:'L0'} pair and a "repair level L0" next_action.
149
162
  let guard = null;
150
163
  try {
151
- guard = await guardLevel(rootDir, { maxLevel: 'L0' });
164
+ guard = await guardLevel(rootDir, { maxLevel: 'L4' });
152
165
  } catch {
153
166
  guard = null;
154
167
  }
@@ -169,7 +182,7 @@ export async function compileContext(projectRoot, task, options = {}) {
169
182
  stale,
170
183
  };
171
184
  if (stale) {
172
- packet.next_actions.push(`ukit index refresh — snapshot stale (repair level ${guard?.recommendedLevel ?? 'L2'})`);
185
+ packet.next_actions.push(`ukit index refresh — snapshot stale (repair level ${guard.recommendedLevel})`);
173
186
  }
174
187
 
175
188
  // `none` mode: valid packet, empty evidence, reason recorded — never fabricate.
@@ -313,16 +326,40 @@ export async function compileContext(projectRoot, task, options = {}) {
313
326
  }
314
327
  }
315
328
 
316
- // L2 read lane: attach excerpts to the top candidates only.
329
+ // Selection bookkeeping: `dropped` collects every packet-visible omission —
330
+ // read-lane refusals (out-of-tree paths), budget drops, and lane-suppressed
331
+ // anchors/relations. It lands verbatim in `packet.omitted` below.
332
+ const dropped = [];
333
+
334
+ // L2 read lane: attach excerpts to the top candidates only — reads are
335
+ // budget-gated so a candidate that cannot survive fitToBudget is never
336
+ // opened (C92-F-11). `reserved` mirrors the evidence budget: the item's own
337
+ // token cost when it can be carried bare, plus the excerpt once read. A
338
+ // dropped candidate's cost does NOT accumulate, matching fit()'s kept-only
339
+ // accounting. Read bound = (items that fit bare, in order) + one overflow
340
+ // probe — never the full candidate list.
317
341
  if (wantRead && level !== 'L0') {
342
+ let reserved = 0;
318
343
  for (const item of candidates) {
319
- if (!item.path) continue;
344
+ // Defence in depth behind the retriever (C92-F-03): every escaping
345
+ // path is refused AND accounted for, even when the budget is spent —
346
+ // post-realpath, so symlinked evidence entries cannot slip through.
347
+ if (item.path && !(await resolveProjectPathReal(rootDir, item.path))) {
348
+ dropped.push({ what: item.path, why: 'out-of-tree' });
349
+ continue;
350
+ }
351
+ const bare = estimateTokens(item);
352
+ if (reserved + bare > requested) continue; // cannot fit even bare — fit drops it
353
+ if (!item.path) {
354
+ reserved += bare;
355
+ continue;
356
+ }
320
357
  const excerpt = await readExcerpt(rootDir, item.path);
321
358
  if (excerpt) item.excerpt = excerpt;
359
+ reserved += estimateTokens(item);
322
360
  }
323
361
  }
324
362
 
325
- const dropped = [];
326
363
  const expandLanes = level !== 'L0';
327
364
  const fitted = fitToBudget(
328
365
  {
@@ -341,6 +378,12 @@ export async function compileContext(projectRoot, task, options = {}) {
341
378
  }
342
379
  }
343
380
  if (!expandLanes) {
381
+ // C92-F-07: peek suppresses BOTH lanes — anchors get the same `omitted`
382
+ // bookkeeping relations already had, so a peek packet is distinguishable
383
+ // from "the retriever found no anchors".
384
+ for (const anchor of result.anchors ?? []) {
385
+ dropped.push({ what: anchor?.path ?? 'anchor', why: 'budget' });
386
+ }
344
387
  for (const rel of result.relations ?? []) {
345
388
  dropped.push({ what: `${rel.from}→${rel.to}`, why: 'budget' });
346
389
  }
@@ -1,15 +1,33 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
2
3
  import fs from 'node:fs/promises';
3
4
  import path from 'node:path';
5
+ import { resolveProjectPathReal } from '../fileOps.js';
4
6
 
5
7
  // Post-edit diagnostics (SPEC §7 — CI-206). Cheap checks over dirty paths:
6
8
  // `node --check` for .js/.mjs/.cjs, `JSON.parse` for .json. Everything else is
7
9
  // skipped silently. Never throws: missing files, spawn failures and timeouts
8
10
  // degrade to a `severity:'warning'` entry or are skipped — the caller always
9
11
  // gets a (possibly empty) normalized diagnostics list.
12
+ //
13
+ // Dirty-set entries are untrusted input (dirty.json can be tampered with):
14
+ // any entry that escapes the project root ('../x', absolute, or a symlink /
15
+ // symlinked directory resolving outside the realpath'd root) is refused with
16
+ // the resolveProjectPathReal guard, and the refusal is reported
17
+ // {source:'guard'} rather than silently dropped.
18
+ //
19
+ // C92-F-06: `timeoutMs` is a TOTAL deadline for the run, not a per-file budget.
20
+ // Checks run on async execFile (spawnSync blocked the event loop for the whole
21
+ // run, so no caller-side timer could fire). A file whose check loses the
22
+ // deadline race is reported {source:'deadline'}, as is a checkable file the
23
+ // deadline never let start — a silently short run is the same class of bug as
24
+ // the old clamp.
10
25
 
11
26
  const SYNTAX_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
12
27
  const MAX_DIAGNOSTICS = 50;
28
+ const TIMED_OUT = Symbol('timed-out');
29
+
30
+ const execFileAsync = promisify(execFile);
13
31
 
14
32
  function parseCheckLine(stderr) {
15
33
  const lines = String(stderr ?? '')
@@ -27,10 +45,10 @@ function parseCheckLine(stderr) {
27
45
  };
28
46
  }
29
47
 
30
- async function checkJson(rootDir, relPath) {
48
+ async function checkJson(absPath, relPath) {
31
49
  let content;
32
50
  try {
33
- content = await fs.readFile(path.join(rootDir, relPath), 'utf8');
51
+ content = await fs.readFile(absPath, 'utf8');
34
52
  } catch {
35
53
  return null; // missing file → skip
36
54
  }
@@ -50,66 +68,139 @@ async function checkJson(rootDir, relPath) {
50
68
  }
51
69
  }
52
70
 
53
- async function checkSyntax(rootDir, relPath, timeoutMs, spawn) {
54
- const absPath = path.join(rootDir, relPath);
71
+ async function checkSyntax(absPath, relPath, timeoutMs, exec) {
55
72
  try {
56
73
  await fs.access(absPath);
57
74
  } catch {
58
75
  return null; // missing file → skip
59
76
  }
60
- let result;
61
77
  try {
62
- result = spawn(process.execPath, ['--check', absPath], {
78
+ await exec(process.execPath, ['--check', absPath], {
63
79
  timeout: timeoutMs,
64
80
  encoding: 'utf8',
65
81
  maxBuffer: 1024 * 1024,
66
82
  });
83
+ return null; // exit 0 → clean
67
84
  } catch (error) {
68
- return { file: relPath, severity: 'warning', message: `spawn failed: ${error?.message ?? error}`, source: 'syntax' };
69
- }
70
- if (!result || typeof result !== 'object') {
71
- return { file: relPath, severity: 'warning', message: 'spawn failed: no result', source: 'syntax' };
72
- }
73
- const timedOut = result.signal === 'SIGTERM' || result.error?.code === 'ETIMEDOUT';
74
- if (timedOut) {
75
- return { file: relPath, severity: 'warning', message: `syntax check timed out after ${timeoutMs}ms`, source: 'syntax' };
76
- }
77
- if (result.error) {
78
- return { file: relPath, severity: 'warning', message: `spawn failed: ${result.error.message}`, source: 'syntax' };
85
+ if (!error || typeof error !== 'object') {
86
+ return { file: relPath, severity: 'warning', message: 'spawn failed: no result', source: 'syntax' };
87
+ }
88
+ // maxBuffer overflow is a spawn failure, not a timeout — execFile kills
89
+ // with SIGTERM for both, so check the buffer code first.
90
+ const hitBuffer = error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || error.code === 'ENOBUFS';
91
+ const timedOut = !hitBuffer && (error.killed === true || error.signal === 'SIGTERM' || error.code === 'ETIMEDOUT');
92
+ if (timedOut) {
93
+ return { file: relPath, severity: 'warning', message: `syntax check timed out after ${timeoutMs}ms`, source: 'syntax' };
94
+ }
95
+ if (typeof error.code !== 'number') {
96
+ // spawn failure (ENOENT, EACCES, …) — no syntax verdict.
97
+ return { file: relPath, severity: 'warning', message: `spawn failed: ${error.message ?? String(error)}`, source: 'syntax' };
98
+ }
99
+ const { message, line } = parseCheckLine(error.stderr || error.stdout);
100
+ const diag = { file: relPath, severity: 'error', message, source: 'syntax' };
101
+ if (line !== null) diag.line = line;
102
+ return diag;
79
103
  }
80
- if (result.status === 0) return null;
81
- const { message, line } = parseCheckLine(result.stderr || result.stdout);
82
- const diag = { file: relPath, severity: 'error', message, source: 'syntax' };
83
- if (line !== null) diag.line = line;
84
- return diag;
104
+ }
105
+
106
+ function delay(ms) {
107
+ return new Promise((resolve) => {
108
+ const t = setTimeout(resolve, ms);
109
+ // Never pin the process if the race resolves first.
110
+ t.unref?.();
111
+ });
112
+ }
113
+
114
+ function checkable(relPath) {
115
+ const ext = path.posix.extname(relPath.replace(/\\/g, '/')).toLowerCase();
116
+ return ext === '.json' || SYNTAX_EXTENSIONS.has(ext) ? ext : null;
85
117
  }
86
118
 
87
119
  /**
88
- * runDiagnostics(projectRoot, relPaths, { timeoutMs?, maxFiles? })
89
- * → [{ file, severity:'error'|'warning', message, line?, source:'syntax'|'json' }]
120
+ * runDiagnostics(projectRoot, relPaths, { timeoutMs?, maxFiles?, _exec? })
121
+ * → [{ file, severity:'error'|'warning', message, line?,
122
+ * source:'syntax'|'json'|'deadline'|'guard' }]
90
123
  *
91
- * Deterministic order (input order preserved); capped at `maxFiles` inputs and
92
- * MAX_DIAGNOSTICS total entries. `_spawn` is a test seam for timeout injection.
124
+ * `timeoutMs` bounds the WHOLE run as a total deadline (C92-F-06), never
125
+ * per-file — worst case is timeoutMs plus one in-flight check, not
126
+ * maxFiles × timeoutMs. Files the deadline never let start are reported
127
+ * {source:'deadline'} so a truncated run is visible, and a check that loses
128
+ * its deadline race reports the same. Deterministic order (input order
129
+ * preserved); capped at `maxFiles` inputs and MAX_DIAGNOSTICS entries.
130
+ * `_exec` is a test seam: promisified execFile shape — (file, args, opts) →
131
+ * Promise<{stdout, stderr}> — and must honor opts.timeout.
93
132
  */
94
133
  export async function runDiagnostics(projectRoot, relPaths, {
95
134
  timeoutMs = 8000,
96
135
  maxFiles = 50,
97
- _spawn = spawnSync,
136
+ _exec = execFileAsync,
98
137
  } = {}) {
99
138
  const rootDir = path.resolve(projectRoot ?? process.cwd());
100
139
  const diagnostics = [];
101
140
  const inputs = (Array.isArray(relPaths) ? relPaths : [])
102
141
  .filter((p) => typeof p === 'string' && p.length > 0)
103
142
  .slice(0, Math.max(0, maxFiles));
143
+ const deadline = Date.now() + Math.max(0, timeoutMs);
104
144
 
105
145
  for (const relPath of inputs) {
106
146
  if (diagnostics.length >= MAX_DIAGNOSTICS) break;
107
- const ext = path.posix.extname(relPath.replace(/\\/g, '/')).toLowerCase();
108
- let diag = null;
147
+ const ext = checkable(relPath);
148
+ if (!ext) continue; // unchecked extensions stay silent — no entry at all
149
+ // Resolve containment post-realpath: a dirty entry that is a symlink (or
150
+ // resolves through a symlinked directory) to outside content passes the
151
+ // lexical check yet reads out-of-tree bytes — refuse it here.
152
+ const resolved = await resolveProjectPathReal(rootDir, relPath);
153
+ if (!resolved) {
154
+ // Tampered dirty.json entry ('../x.json', '/abs', symlink escape) —
155
+ // never read outside the project root, and report the refusal so a
156
+ // hostile/dirty entry is visible rather than silently dropped.
157
+ diagnostics.push({
158
+ file: relPath,
159
+ severity: 'warning',
160
+ message: 'refused: path escapes project root',
161
+ source: 'guard',
162
+ });
163
+ continue;
164
+ }
165
+ const remaining = deadline - Date.now();
166
+ if (remaining <= 0) {
167
+ diagnostics.push({
168
+ file: relPath,
169
+ severity: 'warning',
170
+ message: 'skipped: total diagnostics budget exhausted',
171
+ source: 'deadline',
172
+ });
173
+ continue;
174
+ }
175
+ let check;
109
176
  if (ext === '.json') {
110
- diag = await checkJson(rootDir, relPath);
111
- } else if (SYNTAX_EXTENSIONS.has(ext)) {
112
- diag = await checkSyntax(rootDir, relPath, timeoutMs, _spawn);
177
+ check = checkJson(resolved, relPath);
178
+ } else {
179
+ // The slice of the total budget left for this file — the last file in
180
+ // a nearly-spent budget cannot overrun the deadline on its own.
181
+ check = checkSyntax(resolved, relPath, remaining, _exec);
182
+ }
183
+ check.catch(() => {}); // late rejection after a lost race must never be unhandled
184
+ let diag;
185
+ try {
186
+ diag = await Promise.race([check, delay(remaining).then(() => TIMED_OUT)]);
187
+ } catch (error) {
188
+ // Contract: never throws — an internal check failure degrades to a
189
+ // warning entry rather than propagating.
190
+ diag = {
191
+ file: relPath,
192
+ severity: 'warning',
193
+ message: `check failed: ${error?.message ?? String(error)}`,
194
+ source: ext === '.json' ? 'json' : 'syntax',
195
+ };
196
+ }
197
+ if (diag === TIMED_OUT) {
198
+ diag = {
199
+ file: relPath,
200
+ severity: 'warning',
201
+ message: 'check cut off at total diagnostics deadline',
202
+ source: 'deadline',
203
+ };
113
204
  }
114
205
  if (diag) diagnostics.push(diag);
115
206
  }
@@ -6,7 +6,10 @@ import { getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION } from '../../index/
6
6
  import { loadRuntimeConfig } from '../runtimeConfig.js';
7
7
 
8
8
  const GIT_TIMEOUT_MS = 3000;
9
- const NON_GIT_MANIFEST_CAP = 2000;
9
+ // In-flight fs.stat bound for the non-git manifest overlay: coverage is the
10
+ // whole manifest (every indexed path contributes to identity), but stat calls
11
+ // run in bounded batches so in-flight I/O never scales with tree size.
12
+ const MANIFEST_STAT_BATCH = 64;
10
13
  const LEVELS = ['L0', 'L1', 'L2', 'L3', 'L4'];
11
14
  const SNAPSHOT_FILE = 'freshness.json';
12
15
  const DIRTY_FILE = 'dirty.json';
@@ -113,9 +116,13 @@ async function overlayEntriesGit(rootDir, statusOutput, extraPaths = []) {
113
116
  return entries;
114
117
  }
115
118
 
116
- // Non-git fallback: hash LIVE mtimes for index-covered files (files.json),
117
- // capped at NON_GIT_MANIFEST_CAP entries. Stored mtimeMs from files.json is
118
- // stale by definition — only live fs.stat detects post-index edits.
119
+ // Non-git fallback: hash LIVE mtimes for every index-covered path in
120
+ // files.json. Stored mtimeMs from files.json is stale by definition — only a
121
+ // live fs.stat detects post-index edits, and there is no cheaper signal
122
+ // (directory mtimes do not propagate on content writes), so coverage cannot
123
+ // be capped without losing tail edits. Work is bounded by the manifest itself
124
+ // — an index-built artifact — and stat batches are capped at
125
+ // MANIFEST_STAT_BATCH in-flight calls.
119
126
  // Incremental mode: only when dirty-set tracking is active (dirty.json exists,
120
127
  // not saturated) AND a prior snapshot carries overlayEntries — reuse stored
121
128
  // entries for paths proven unchanged by the dirty set; live-stat dirty,
@@ -131,7 +138,7 @@ async function overlayEntriesManifest(rootDir, { incremental, snapshot, dirty }
131
138
  }
132
139
  const manifestPaths = [];
133
140
  const seen = new Set();
134
- for (const item of items.slice(0, NON_GIT_MANIFEST_CAP)) {
141
+ for (const item of items) {
135
142
  const rel = typeof item?.filePath === 'string' ? item.filePath : null;
136
143
  if (!rel || seen.has(rel)) continue;
137
144
  seen.add(rel);
@@ -145,14 +152,20 @@ async function overlayEntriesManifest(rootDir, { incremental, snapshot, dirty }
145
152
  const canReuse = incremental === true && stored !== null && dirtyPaths !== null;
146
153
 
147
154
  const entries = {};
148
- for (const rel of manifestPaths) {
149
- // Reuse only when dirty tracking proves the path unchanged and a stored
150
- // entry exists; otherwise stat live (never trust files.json mtimeMs).
151
- if (canReuse && !dirtyPaths.has(rel) && typeof stored[rel] === 'string') {
152
- entries[rel] = stored[rel];
153
- continue;
155
+ // Bounded-batch live stat: every manifest path contributes to the hash.
156
+ for (let i = 0; i < manifestPaths.length; i += MANIFEST_STAT_BATCH) {
157
+ const batch = manifestPaths.slice(i, i + MANIFEST_STAT_BATCH);
158
+ const values = await Promise.all(batch.map(async (rel) => {
159
+ // Reuse only when dirty tracking proves the path unchanged and a stored
160
+ // entry exists; otherwise stat live (never trust files.json mtimeMs).
161
+ if (canReuse && !dirtyPaths.has(rel) && typeof stored[rel] === 'string') {
162
+ return stored[rel];
163
+ }
164
+ return statEntry(rootDir, rel);
165
+ }));
166
+ for (let j = 0; j < batch.length; j += 1) {
167
+ entries[batch[j]] = values[j];
154
168
  }
155
- entries[rel] = await statEntry(rootDir, rel);
156
169
  }
157
170
  // Dirty paths outside the manifest still affect identity (stat live);
158
171
  // index-internal artifacts are skipped.
@@ -94,7 +94,10 @@ export async function notifyEdit(projectRoot, relPaths) {
94
94
  const current = await readDirtyData(projectRoot);
95
95
  const merged = new Set(current.paths);
96
96
  for (const p of incoming) merged.add(p);
97
- const saturated = merged.size > DIRTY_MAX_PATHS;
97
+ // `saturated` is latched: the truncated tail was already discarded, so a
98
+ // merge that lands back under DIRTY_MAX_PATHS is still incomplete. The
99
+ // marker clears only when a full rebuild rewrites the record.
100
+ const saturated = current.saturated || merged.size > DIRTY_MAX_PATHS;
98
101
  const paths = [...merged].sort().slice(0, DIRTY_MAX_PATHS);
99
102
  await writeDirtyData(projectRoot, paths, saturated);
100
103
  return paths;
@@ -141,14 +144,19 @@ export async function readDirty(projectRoot) {
141
144
  /**
142
145
  * Return the accumulated dirty set and clear the file (index-refresh lane).
143
146
  * `saturated` is surfaced so callers can treat a saturated set as full
144
- * invalidation — a saturated drain with few/no paths is NOT a clean state.
147
+ * invalidation — a saturated drain with few/no paths is NOT a clean state,
148
+ * and the marker stays latched on disk until a full rebuild clears it.
145
149
  * @returns {Promise<{ paths: string[], saturated: boolean }>}
146
150
  */
147
151
  export async function drainDirty(projectRoot) {
148
152
  const { paths, saturated } = await readDirtyData(projectRoot);
149
153
  if (paths.length === 0 && !saturated) return { paths: [], saturated: false };
150
154
  try {
151
- await writeDirtyData(projectRoot, [], false);
155
+ // Preserve the saturated marker on disk: the consumer (`readDirtySet` in
156
+ // freshness.js) must keep seeing "this set is incomplete" — laundered to
157
+ // saturated:false it would justify reusing entries for dropped paths. Only
158
+ // a full rebuild rewrites the record as clean.
159
+ await writeDirtyData(projectRoot, [], saturated);
152
160
  } catch {
153
161
  // never throw — drained set is still returned
154
162
  }