@sdsrs/code-graph 0.134.0 → 0.136.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.134.0",
7
+ "version": "0.136.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -167,6 +167,43 @@ function saveState(state) {
167
167
  }
168
168
  }
169
169
 
170
+ // ── Failure diagnostics ────────────────────────────────────
171
+ //
172
+ // The updater's main trigger is `launchBackgroundAutoUpdate` in session-init.js:
173
+ // `spawn(..., { detached: true, stdio: 'ignore' })`. On that path EVERY
174
+ // `console.error` in this file goes to /dev/null — the sha-sidecar refusal, the
175
+ // size floor, the checksum mismatch, the promote EACCES, the repoint block. All
176
+ // the user gets is the statusline's "⚠ update stuck" and a `doctor` that re-runs
177
+ // its checks instead of reporting what already failed (audit 2026-09-05 JS-02).
178
+ //
179
+ // Recorded in memory and persisted by the ONE site that already writes state,
180
+ // rather than a `saveState` at each rejection point: `CACHE_DIR` is
181
+ // `~/.cache/code-graph` with no env seam, so a leaf function that persisted on
182
+ // its own would have in-process unit tests writing into the developer's real
183
+ // cache directory.
184
+ let lastFailure = null;
185
+
186
+ /** Record why the updater refused/failed. Returns `message` so call sites can
187
+ * print the same string they store, instead of maintaining two copies. */
188
+ function noteUpdateFailure(stage, message) {
189
+ lastFailure = {
190
+ at: new Date().toISOString(),
191
+ stage,
192
+ // Bounded: `e.message` can carry a whole curl transcript, and this lands in
193
+ // a JSON file read on every session start.
194
+ message: String(message == null ? '' : message).slice(0, 300),
195
+ };
196
+ return message;
197
+ }
198
+
199
+ /** Read-and-clear. The caller is about to persist it (or to record a success,
200
+ * which must not leave the previous attempt's reason behind). */
201
+ function takeUpdateFailure() {
202
+ const f = lastFailure;
203
+ lastFailure = null;
204
+ return f;
205
+ }
206
+
170
207
  // ── Throttle ───────────────────────────────────────────────
171
208
 
172
209
  // The updater has given up on the current target release (MAX_UPDATE_ATTEMPTS
@@ -525,7 +562,8 @@ async function downloadBinary(latest, { needsUpdate = cachedBinaryNeedsUpdate }
525
562
  if (!latest || !latest.binaryUrl) return false;
526
563
  if (!needsUpdate(latest)) return false; // already at latest.version — no fetch
527
564
  if (!commandExists('curl')) {
528
- console.error('[code-graph] Binary download skipped: curl not on PATH.');
565
+ console.error(`[code-graph] ${noteUpdateFailure('curl-missing',
566
+ 'Binary download skipped: curl not on PATH.')}`);
529
567
  return false;
530
568
  }
531
569
 
@@ -569,14 +607,16 @@ async function downloadBinary(latest, { needsUpdate = cachedBinaryNeedsUpdate }
569
607
  }
570
608
  }
571
609
  if (!expectedSha) {
572
- console.error(`[code-graph] Refusing to install: no sha256 sidecar for ${latest.binaryUrl} (fetched twice). The current binary is unchanged; the next update check will retry.`);
610
+ console.error(`[code-graph] ${noteUpdateFailure('binary-sha-sidecar-missing',
611
+ `Refusing to install: no sha256 sidecar for ${latest.binaryUrl} (fetched twice). The current binary is unchanged; the next update check will retry.`)}`);
573
612
  try { fs.unlinkSync(binaryTmp); } catch { /* ok */ }
574
613
  return false;
575
614
  }
576
615
 
577
616
  return promoteVerifiedBinary(binaryTmp, binaryDst, latest.version, expectedSha);
578
617
  } catch (e) {
579
- console.error(`[code-graph] Binary download failed: ${e.message}`);
618
+ console.error(`[code-graph] ${noteUpdateFailure('binary-download-failed',
619
+ `Binary download failed: ${e.message}`)}`);
580
620
  return false;
581
621
  }
582
622
  }
@@ -599,11 +639,10 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
599
639
  // burning one of MAX_UPDATE_ATTEMPTS with nothing on stderr to explain it.
600
640
  const stat = fs.statSync(binaryTmp);
601
641
  if (stat.size <= 1_000_000) {
602
- console.error(
603
- `[code-graph] Refusing to install: downloaded binary is ${stat.size} bytes — far below the ~1 MB floor, ` +
642
+ console.error(`[code-graph] ${noteUpdateFailure('binary-too-small',
643
+ `Refusing to install: downloaded binary is ${stat.size} bytes — far below the ~1 MB floor, ` +
604
644
  'so the transfer was truncated or the server returned an error page. ' +
605
- 'The current binary is unchanged; the next update check retries.'
606
- );
645
+ 'The current binary is unchanged; the next update check retries.')}`);
607
646
  return false;
608
647
  }
609
648
 
@@ -618,13 +657,15 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
618
657
  // a fail-closed `src/snapshot/install.rs` — and a warning printed to stderr
619
658
  // during a background auto-update is seen by nobody.
620
659
  if (!expectedSha256) {
621
- console.error('[code-graph] No expected sha256 supplied — refusing to install an unverified binary.');
660
+ console.error(`[code-graph] ${noteUpdateFailure('binary-sha-missing',
661
+ 'No expected sha256 supplied — refusing to install an unverified binary.')}`);
622
662
  try { fs.unlinkSync(binaryTmp); } catch { /* ok */ }
623
663
  return false;
624
664
  }
625
665
  const actualSha = sha256File(binaryTmp);
626
666
  if (actualSha.toLowerCase() !== String(expectedSha256).toLowerCase()) {
627
- console.error(`[code-graph] Binary checksum mismatch (sha256): expected ${expectedSha256}, got ${actualSha} — refusing to install.`);
667
+ console.error(`[code-graph] ${noteUpdateFailure('binary-checksum-mismatch',
668
+ `Binary checksum mismatch (sha256): expected ${expectedSha256}, got ${actualSha} — refusing to install.`)}`);
628
669
  return false;
629
670
  }
630
671
 
@@ -642,10 +683,9 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
642
683
  // Sibling of the size floor above: silent for the same reason and with the
643
684
  // same cost. `--version` failing to run at all (wrong arch, missing libc)
644
685
  // reads identically to a version mismatch without this.
645
- console.error(
646
- `[code-graph] Refusing to install: downloaded binary reports ${actualVersion ? `v${actualVersion}` : 'no runnable --version'}` +
647
- `${expectedVersion ? `, expected v${expectedVersion}` : ''} — not installing it.`
648
- );
686
+ console.error(`[code-graph] ${noteUpdateFailure('binary-version-unrunnable',
687
+ `Refusing to install: downloaded binary reports ${actualVersion ? `v${actualVersion}` : 'no runnable --version'}` +
688
+ `${expectedVersion ? `, expected v${expectedVersion}` : ''} — not installing it.`)}`);
649
689
  return false;
650
690
  }
651
691
 
@@ -658,7 +698,8 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
658
698
  // server is running), EBUSY, EXDEV. A bare `catch { return false }` made all
659
699
  // of them one indistinguishable failure that the caller counted as an
660
700
  // attempt and printed nothing about.
661
- console.error(`[code-graph] Binary promote failed${e && e.code ? ` (${e.code})` : ''}: ${e && e.message}`);
701
+ console.error(`[code-graph] ${noteUpdateFailure('binary-promote-failed',
702
+ `Binary promote failed${e && e.code ? ` (${e.code})` : ''}: ${e && e.message}`)}`);
662
703
  return false;
663
704
  } finally {
664
705
  try {
@@ -706,7 +747,8 @@ async function downloadAndInstall(latest, {
706
747
  // Pre-flight: check required CLI tools before attempting any download
707
748
  const missingTools = ['curl', 'tar'].filter(cmd => !cmdExists(cmd));
708
749
  if (missingTools.length > 0) {
709
- console.error(`[code-graph] Auto-update skipped: missing required tools: ${missingTools.join(', ')}. Install them to enable auto-updates.`);
750
+ console.error(`[code-graph] ${noteUpdateFailure('missing-tools',
751
+ `Auto-update skipped: missing required tools: ${missingTools.join(', ')}. Install them to enable auto-updates.`)}`);
710
752
  return { pluginUpdated: false, binaryUpdated: false };
711
753
  }
712
754
 
@@ -743,7 +785,8 @@ async function downloadAndInstall(latest, {
743
785
  // the user on their current, working plugin version; the binary update below
744
786
  // still runs.
745
787
  if (!latest.pluginTarballUrl) {
746
- console.error(`[code-graph] Plugin update skipped: release ${latest.version} publishes no ${PLUGIN_ASSET_NAME} — refusing to install plugin code from an unverifiable source archive.`);
788
+ console.error(`[code-graph] ${noteUpdateFailure('plugin-tarball-absent',
789
+ `Plugin update skipped: release ${latest.version} publishes no ${PLUGIN_ASSET_NAME} — refusing to install plugin code from an unverifiable source archive.`)}`);
747
790
  return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
748
791
  }
749
792
  const tarballPath = path.join(tmpDir, PLUGIN_ASSET_NAME);
@@ -773,7 +816,8 @@ async function downloadAndInstall(latest, {
773
816
  }
774
817
  const actualSha = fs.existsSync(tarballPath) ? sha256File(tarballPath) : null;
775
818
  if (!expectedSha || !actualSha || expectedSha.toLowerCase() !== actualSha.toLowerCase()) {
776
- console.error(`[code-graph] Plugin tarball integrity check failed (expected ${expectedSha || '<no sidecar>'}, got ${actualSha || '<no download>'}) — refusing to extract.`);
819
+ console.error(`[code-graph] ${noteUpdateFailure('plugin-tarball-integrity',
820
+ `Plugin tarball integrity check failed (expected ${expectedSha || '<no sidecar>'}, got ${actualSha || '<no download>'}) — refusing to extract.`)}`);
777
821
  return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
778
822
  }
779
823
 
@@ -837,11 +881,10 @@ async function downloadAndInstall(latest, {
837
881
  const why = installedRead.error
838
882
  ? (installedRead.error.code || installedRead.error.message)
839
883
  : 'it does not contain a JSON object';
840
- console.error(
841
- `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
884
+ console.error(`[code-graph] ${noteUpdateFailure('repoint-registry-unreadable',
885
+ `plugin ${latest.version} is installed, but ${installedPath} ` +
842
886
  `could not be read (${why}) — its entry for this plugin still points at the ` +
843
- 'previous version. Run `/plugin update` or repair that file by hand.'
844
- );
887
+ 'previous version. Run `/plugin update` or repair that file by hand.')}`);
845
888
  repointBlocked = true;
846
889
  } else {
847
890
  let installed = installedRead.value;
@@ -861,12 +904,11 @@ async function downloadAndInstall(latest, {
861
904
  `to ${backup} first.`
862
905
  );
863
906
  } else {
864
- console.error(
865
- `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
907
+ console.error(`[code-graph] ${noteUpdateFailure('repoint-lossy-no-backup',
908
+ `plugin ${latest.version} is installed, but ${installedPath} ` +
866
909
  'contains bytes that are not valid UTF-8 and no backup copy could be made — ' +
867
910
  'its entry for this plugin still points at the previous version. Rewriting it ' +
868
- 'would replace those bytes permanently. Run `/plugin update` after repairing it.'
869
- );
911
+ 'would replace those bytes permanently. Run `/plugin update` after repairing it.')}`);
870
912
  installed = null;
871
913
  repointBlocked = true;
872
914
  }
@@ -885,11 +927,10 @@ async function downloadAndInstall(latest, {
885
927
  // Present but not the shape we can write into (`[]`, or a truthy
886
928
  // non-array). Blocked, NOT skipped: a silent skip here would feed the
887
929
  // JS-02 treadmill through a new door.
888
- console.error(
889
- `[code-graph] plugin ${latest.version} is installed, but this plugin's entry in ` +
930
+ console.error(`[code-graph] ${noteUpdateFailure('repoint-entry-malformed',
931
+ `plugin ${latest.version} is installed, but this plugin's entry in ` +
890
932
  `${installedPath} is malformed (expected a non-empty array) — it still points at ` +
891
- 'the previous version. Run `/plugin update` or repair that file by hand.'
892
- );
933
+ 'the previous version. Run `/plugin update` or repair that file by hand.')}`);
893
934
  repointBlocked = true;
894
935
  }
895
936
  if (repointable) {
@@ -899,11 +940,10 @@ async function downloadAndInstall(latest, {
899
940
  try {
900
941
  writeJsonAtomic(installedPath, installed);
901
942
  } catch (err) {
902
- console.error(
903
- `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
943
+ console.error(`[code-graph] ${noteUpdateFailure('repoint-write-failed',
944
+ `plugin ${latest.version} is installed, but ${installedPath} ` +
904
945
  `could not be written (${err.code || err.name}) — its entry for this plugin ` +
905
- 'still points at the previous version. Run `/plugin update`.'
906
- );
946
+ 'still points at the previous version. Run `/plugin update`.')}`);
907
947
  repointBlocked = true;
908
948
  }
909
949
  }
@@ -946,7 +986,8 @@ async function downloadAndInstall(latest, {
946
986
 
947
987
  return { pluginUpdated, binaryUpdated, marketplaceRefreshed, repointBlocked };
948
988
  } catch (e) {
949
- console.error(`[code-graph] Plugin download/extract failed: ${e.message}`);
989
+ console.error(`[code-graph] ${noteUpdateFailure('plugin-extract-failed',
990
+ `Plugin download/extract failed: ${e.message}`)}`);
950
991
  return { pluginUpdated: false, binaryUpdated: false, marketplaceRefreshed };
951
992
  } finally {
952
993
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ }
@@ -1311,6 +1352,11 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1311
1352
  latestVersion: latest.version,
1312
1353
  updateAvailable: true,
1313
1354
  updateAttempts: attempts,
1355
+ // The suspension notice below says "N failed attempts" without ever
1356
+ // saying what failed. Carry the last recorded reason forward so
1357
+ // `doctor` can answer that; `healedMissing` above may have recorded a
1358
+ // fresh one on the way in.
1359
+ lastError: takeUpdateFailure() || state.lastError || null,
1314
1360
  // Stamp on ENTRY to suspension, then leave it alone: the retry clock
1315
1361
  // must measure time since we gave up, not time since the last check
1316
1362
  // (which every session would reset, making the retry unreachable).
@@ -1366,6 +1412,13 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1366
1412
  suspendedAt: nextSuspendedAt,
1367
1413
  lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
1368
1414
  rateLimited: false,
1415
+ // The reason this attempt failed, or null once one succeeds. Every
1416
+ // refusal above printed to a stderr nobody reads (detached, stdio
1417
+ // 'ignore'); this is the only channel that survives to `doctor`.
1418
+ // `takeUpdateFailure()` runs on BOTH arms so a stale reason from an
1419
+ // earlier attempt in the same process can never be attributed to this
1420
+ // one (JS-02).
1421
+ lastError: (() => { const f = takeUpdateFailure(); return success ? null : (f || state.lastError || null); })(),
1369
1422
  binaryUpdated: result.binaryUpdated,
1370
1423
  marketplaceRefreshed: result.marketplaceRefreshed,
1371
1424
  };
@@ -1413,6 +1466,11 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1413
1466
  updateAttempts: 0,
1414
1467
  suspendedAt: null,
1415
1468
  rateLimited: false,
1469
+ // Same reasoning one field up: the shell is current, so a stored reason
1470
+ // describes an update that is no longer pending. Cleared unless the
1471
+ // binary self-heal ON THIS PASS recorded a fresh one — that chain keeps
1472
+ // its own budget and can still be failing while the shell is fine.
1473
+ lastError: takeUpdateFailure() || null,
1416
1474
  binaryUpdated: selfHealedBinary || state.binaryUpdated,
1417
1475
  // The shell-update counters above reset because the shell IS current.
1418
1476
  // The BINARY heal keeps its own, un-reset budget — clearing it here is
@@ -1445,6 +1503,7 @@ module.exports = {
1445
1503
  selfHealGlobalPkgs, staleGlobalPkgs, globalPkgVersion, npmInstallGlobal,
1446
1504
  shouldHealGlobalsOnThrottle, inactiveNodeGlobalRelics,
1447
1505
  downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
1506
+ noteUpdateFailure, takeUpdateFailure,
1448
1507
  };
1449
1508
 
1450
1509
  // CLI: node auto-update.js [check|status] [--silent] [--install-missing]
@@ -33,9 +33,17 @@ const { hidden } = require('./proc-opts');
33
33
  // roughly one run in seven, while 12/12 isolated runs passed. An intermittently
34
34
  // red suite teaches people to re-run instead of read, which is the one habit
35
35
  // this whole audit is about.
36
+ //
37
+ // FLOORED AT THE SOURCE. `Number('1500.5')` is finite and positive, and
38
+ // `child_process` rejects a fractional `timeout` with ERR_OUT_OF_RANGE — which
39
+ // each runner's own try/catch turns into a silent `unavailable`, so the hook
40
+ // exits 0 having answered nothing. That regression is what `remainingMs` was
41
+ // hardened for; this value happens to pass through it today, which means the
42
+ // property is currently held one file away by a function that has no obligation
43
+ // to keep holding it (audit 2026-09-05 NEW-02).
36
44
  const DEFAULT_TIMEOUT_MS = (() => {
37
45
  const override = Number(process.env._CG_ANSWER_TIMEOUT_MS);
38
- return Number.isFinite(override) && override > 0 ? override : 2000;
46
+ return Number.isFinite(override) && override >= 1 ? Math.floor(override) : 2000;
39
47
  })();
40
48
  // ~1000 tokens. A deny reason carrying more than this stops being an answer
41
49
  // and starts being a context tax.
@@ -105,11 +113,41 @@ function resolveAnswerBinary(opts) {
105
113
  return binary || null;
106
114
  }
107
115
 
108
- /** One spawn, one options block, one `CODE_GRAPH_INTERNAL` stamp. */
116
+ /**
117
+ * One spawn, one options block, one `CODE_GRAPH_INTERNAL` stamp.
118
+ *
119
+ * The timeout is the SMALLER of this call's own budget and whatever is left of
120
+ * the hook process's registered budget, because the callers run these in
121
+ * series: post-grep-inject loops callgraph over every symbol in the pattern
122
+ * before falling back to show and then grep, so three 2 s answers overran a 5 s
123
+ * hook and Claude Code killed it — which the user sees as a hook error on their
124
+ * own tool call, not as a missing hint (audit 2026-09-05 JS-03). Out of budget
125
+ * returns a synthetic timeout the exit-code table already reads as
126
+ * `unavailable`, so the caller degrades to the static path exactly as it does
127
+ * for a real timeout.
128
+ *
129
+ * `killSignal: 'SIGKILL'`: the child we are giving up on is most often one
130
+ * wedged waiting on `index.lock`, and node's `timeout` sends SIGTERM and then
131
+ * WAITS. It reads no stdin and holds no lock file worth unwinding — the same
132
+ * reasoning statusline.js and doctor.js already apply (see proc-opts.js).
133
+ */
109
134
  function runCg(binary, args, { cwd, timeoutMs }) {
135
+ const budget = require('./hook-fail-open').remainingMs(timeoutMs);
136
+ if (budget === null) {
137
+ // `error` is what classifyRun reads (→ `unavailable`); `budgetExhausted` is
138
+ // for the one caller that loops and must not mistake this for "that symbol
139
+ // did not resolve" — see runShowAnswer.
140
+ return {
141
+ error: new Error('hook budget exhausted'),
142
+ budgetExhausted: true,
143
+ status: null,
144
+ stdout: '',
145
+ };
146
+ }
110
147
  return spawnSync(binary, args, hidden({
111
148
  cwd,
112
- timeout: timeoutMs,
149
+ timeout: budget,
150
+ killSignal: 'SIGKILL',
113
151
  encoding: 'utf8',
114
152
  maxBuffer: 4 * 1024 * 1024,
115
153
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -187,7 +225,13 @@ function runGrepAnswer(opts = {}) {
187
225
  // Older binaries exit 0 on no-match with NO_MATCH_PREFIX on stdout — that
188
226
  // shape resolves to 'no-hits' through isEmptyAnswer below.
189
227
  const verdict = classifyRun(res, { exitOneIsNoHits: true });
190
- if (verdict !== 'ok') return { status: verdict };
228
+ // `reason` separates the two things `unavailable` covers. The binary failing
229
+ // and the binary never being given time are different facts, and the caller
230
+ // renders one of them to the user — "ran but failed" is simply untrue of a
231
+ // run that never started (audit 2026-09-05 NEW-08).
232
+ if (verdict !== 'ok') {
233
+ return { status: verdict, ...(res.budgetExhausted ? { reason: 'budget' } : {}) };
234
+ }
191
235
  const out = (res.stdout || '').trim();
192
236
  if (isEmptyAnswer(out)) {
193
237
  return { status: 'no-hits' };
@@ -230,6 +274,15 @@ function runShowAnswer(opts = {}) {
230
274
  for (const sym of symbols.slice(0, 3)) {
231
275
  if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
232
276
  const res = runCg(binary, ['show', sym], { cwd, timeoutMs });
277
+ // Out of hook budget is not "this symbol did not resolve". This loop
278
+ // `continue`s past every failure and reports `no-hits` when none of the
279
+ // three produced output, so without this arm an exhausted budget reached
280
+ // recordRecommendation as a genuine empty result — and the whole reason
281
+ // `no-binary` is kept distinct from `unavailable` (see this function's
282
+ // docs) is that the deny funnel has to tell those causes apart. Nothing
283
+ // later in the loop can succeed either: the budget is gone for all three
284
+ // (pre-ship review 2026-09-05).
285
+ if (res.budgetExhausted) return { status: 'unavailable', reason: 'budget' };
233
286
  // A symbol that did not resolve is SKIPPED, not fatal — exit 1 included,
234
287
  // which is why this asks for `exitOneIsNoHits: false` and then treats
235
288
  // every non-`ok` verdict the same way.
@@ -329,7 +382,13 @@ function runCallgraphAnswer(opts = {}) {
329
382
  const res = runCg(binary, ['callgraph', symbol], { cwd, timeoutMs });
330
383
  // grep-parity exit codes: 1 = symbol not found (no graph node).
331
384
  const verdict = classifyRun(res, { exitOneIsNoHits: true });
332
- if (verdict !== 'ok') return { status: verdict };
385
+ // `reason` separates the two things `unavailable` covers. The binary failing
386
+ // and the binary never being given time are different facts, and the caller
387
+ // renders one of them to the user — "ran but failed" is simply untrue of a
388
+ // run that never started (audit 2026-09-05 NEW-08).
389
+ if (verdict !== 'ok') {
390
+ return { status: verdict, ...(res.budgetExhausted ? { reason: 'budget' } : {}) };
391
+ }
333
392
  const out = (res.stdout || '').trim();
334
393
  // Only an edge-bearing tree is marginal over the grep the model already ran.
335
394
  if (isEmptyAnswer(out) ||
@@ -346,4 +405,8 @@ function runCallgraphAnswer(opts = {}) {
346
405
  module.exports = {
347
406
  runGrepAnswer, runShowAnswer, runOverviewAnswer, runCallgraphAnswer,
348
407
  truncateAtLine, sanitizeSearchPath,
408
+ // Exported so the integer property can be asserted where it is ESTABLISHED.
409
+ // Asserting it end-to-end instead passes either way: `remainingMs` floors
410
+ // again downstream, so such a test cannot fail and proves nothing (NEW-02).
411
+ DEFAULT_TIMEOUT_MS,
349
412
  };
@@ -384,17 +384,25 @@ function runDiagnostics({ checkOnly = false } = {}) {
384
384
  // suspended, so offering it as a repair would print "✅ Update check
385
385
  // complete" and count a fix that cannot happen. Say what is true and hand
386
386
  // the user the manual route.
387
+ const last = autoUpdateLastError(state);
387
388
  results.push({
388
389
  name: 'Auto-update',
389
390
  status: 'warn',
391
+ // "failed to install 5×" was the whole diagnosis until JS-02: the count
392
+ // without the cause, for a chain whose every explanation had already
393
+ // been written to a discarded stderr. A user cannot tell a missing
394
+ // `curl` from a full disk from a blocked CDN out of a number.
390
395
  detail: `v${state.latestVersion} failed to install ${attempts}× — auto-retry throttled to once a day. `
396
+ + (last ? `${last}. ` : '')
391
397
  + 'Update manually: `npm install -g @sdsrs/code-graph` (or `/plugin update code-graph-mcp`)',
392
398
  });
393
399
  } else if (state && state.updateAvailable && state.binaryUpdated === false) {
400
+ const last = autoUpdateLastError(state);
394
401
  results.push({
395
402
  name: 'Auto-update',
396
403
  status: 'warn',
397
- detail: `plugin v${state.latestVersion}, binary download incomplete`,
404
+ detail: `plugin v${state.latestVersion}, binary download incomplete`
405
+ + (last ? ` — ${last}` : ''),
398
406
  fixId: 'update-incomplete',
399
407
  });
400
408
  } else {
@@ -530,7 +538,17 @@ function runDiagnostics({ checkOnly = false } = {}) {
530
538
  const failed = fire.results.filter(r => !r.ok).map(r => r.label).join(', ') || fire.error || 'unknown';
531
539
  results.push({ name: 'Hook firing', status: 'warn', detail: `did not fire: ${failed}` });
532
540
  }
533
- } catch { /* probe failed — skip */ }
541
+ } catch (err) {
542
+ // Same stance as step 7 above, which learned it the hard way: dropping the
543
+ // row makes the table look complete while a check never ran, and doctor
544
+ // then exits 0 on a shorter all-green report. A probe that cannot run is
545
+ // itself a finding (audit 2026-09-05 JS-04).
546
+ results.push({
547
+ name: 'Hook firing',
548
+ status: 'warn',
549
+ detail: `probe could not run (${(err && err.message) || err}) — the registered hooks were NOT verified`,
550
+ });
551
+ }
534
552
 
535
553
  // 9. Global npm residue — the launcher's background install (or the user)
536
554
  // may have `npm install -g`'d the shell + platform packages. Surface what
@@ -584,7 +602,16 @@ function runDiagnostics({ checkOnly = false } = {}) {
584
602
  : ` — no plugin-install marker; uninstall leaves them (remove: npm uninstall -g ${found.map((p) => p.name).join(' ')})`)),
585
603
  });
586
604
  }
587
- } catch { /* probe failed — skip */ }
605
+ } catch (err) {
606
+ // Sibling of the step-8 arm above (JS-04). Here the row is conditional on
607
+ // `found.length`, so absence is ALREADY the normal "nothing installed"
608
+ // answer — which is exactly why a throw must not produce the same silence.
609
+ results.push({
610
+ name: 'Global npm packages',
611
+ status: 'warn',
612
+ detail: `probe could not run (${(err && err.message) || err}) — global npm residue was NOT checked`,
613
+ });
614
+ }
588
615
 
589
616
  return results;
590
617
  }
@@ -742,6 +769,30 @@ function buildBinaryFromSource(cmd) {
742
769
  return true;
743
770
  }
744
771
 
772
+ /**
773
+ * The part of a failed child that its own output does NOT already show.
774
+ *
775
+ * The build / update / rebuild helpers all run `stdio: 'inherit'`, so a cargo
776
+ * error or an npm error is already on the user's terminal and repeating it here
777
+ * would be noise. What that stream cannot show is a child that never ran
778
+ * (`ENOENT` — cargo or node not on PATH) or one this process killed
779
+ * (`ETIMEDOUT`, `SIGTERM` — the 10-minute build budget): the inherited stream is
780
+ * empty and "Build failed" is then the whole explanation the user gets
781
+ * (audit 2026-09-05 JS-04). Returns '' when the child spoke for itself.
782
+ */
783
+ function silentFailureReason(e) {
784
+ if (!e) return '';
785
+ if (e.code === 'ETIMEDOUT' || e.signal === 'SIGTERM' || e.signal === 'SIGKILL') {
786
+ return ' — timed out; it was still running when the budget ran out';
787
+ }
788
+ if (e.code === 'ENOENT') {
789
+ return ' — the command is not on PATH';
790
+ }
791
+ // A non-zero exit means the child ran and printed its own diagnosis above.
792
+ if (typeof e.status === 'number') return '';
793
+ return e.code ? ` (${e.code})` : '';
794
+ }
795
+
745
796
  /** Manual recovery for a binary we could not repair — the end of every failed arm. */
746
797
  function printBinaryRecovery() {
747
798
  console.log(' Reinstall: npm install -g @sdsrs/code-graph');
@@ -846,10 +897,35 @@ function autoUpdateNoOpReason(state = readUpdateState(), env = process.env) {
846
897
  return null;
847
898
  }
848
899
 
900
+ /**
901
+ * What the updater's last failed attempt actually said, if it recorded one.
902
+ *
903
+ * Every refusal in auto-update.js prints to stderr, and the main trigger path
904
+ * spawns it `detached` with `stdio: 'ignore'` — so on the path that matters the
905
+ * explanation went to /dev/null and doctor could only re-run its own checks
906
+ * (audit 2026-09-05 JS-02). `lastError` is that explanation, persisted.
907
+ *
908
+ * Separate from `autoUpdateNoOpReason` on purpose: that answers "why is nothing
909
+ * happening" (suspended / throttled / switched off) and can be null while this
910
+ * is set — a single failed attempt records a reason without parking anything.
911
+ */
912
+ function autoUpdateLastError(state = readUpdateState()) {
913
+ const e = state && state.lastError;
914
+ if (!e || !e.message) return null;
915
+ const when = e.at ? new Date(e.at) : null;
916
+ const stamp = when && !Number.isNaN(when.getTime()) ? ` on ${when.toISOString().slice(0, 16).replace('T', ' ')} UTC` : '';
917
+ return `last failure${stamp}${e.stage ? ` [${e.stage}]` : ''}: ${e.message}`;
918
+ }
919
+
849
920
  function reportAutoUpdateNoOp(what) {
850
921
  console.log(` ❌ ${what}`);
851
922
  const why = autoUpdateNoOpReason();
852
923
  if (why) console.log(` Why: ${why}.`);
924
+ // Printed even when `why` is null: "nothing is parked" and "the last attempt
925
+ // failed for reason X" are different facts, and X is the one the user can act
926
+ // on (a missing curl, a full disk, a blocked CDN).
927
+ const last = autoUpdateLastError();
928
+ if (last) console.log(` ${last}`);
853
929
  console.log(' Update manually: `npm install -g @sdsrs/code-graph` (or `/plugin update code-graph-mcp`)');
854
930
  }
855
931
 
@@ -875,8 +951,8 @@ function runRepairs(results, {
875
951
  console.log('\n Triggering binary update...');
876
952
  try {
877
953
  runAutoUpdate();
878
- } catch {
879
- console.log(' \u274c Update check failed — install manually');
954
+ } catch (e) {
955
+ console.log(` \u274c Update check failed${silentFailureReason(e)} — install manually`);
880
956
  break;
881
957
  }
882
958
  // Exited 0 — which says nothing about whether the binary moved (see
@@ -908,8 +984,8 @@ function runRepairs(results, {
908
984
  buildBinary(buildCmd);
909
985
  console.log(' \u2705 Build complete');
910
986
  fixed++;
911
- } catch {
912
- console.log(' \u274c Build failed');
987
+ } catch (e) {
988
+ console.log(` \u274c Build failed${silentFailureReason(e)}`);
913
989
  }
914
990
  break;
915
991
  }
@@ -926,8 +1002,8 @@ function runRepairs(results, {
926
1002
  buildBinary('cargo build --release --no-default-features');
927
1003
  console.log(' \u2705 Build complete');
928
1004
  fixed++;
929
- } catch {
930
- console.log(' \u274c Build failed');
1005
+ } catch (e) {
1006
+ console.log(` \u274c Build failed${silentFailureReason(e)}`);
931
1007
  }
932
1008
  } else {
933
1009
  console.log(' Install: npm install -g @sdsrs/code-graph');
@@ -959,8 +1035,8 @@ function runRepairs(results, {
959
1035
  console.log(' ❌ Build failed');
960
1036
  break;
961
1037
  }
962
- } catch {
963
- console.log(' ❌ Build failed');
1038
+ } catch (e) {
1039
+ console.log(` ❌ Build failed${silentFailureReason(e)}`);
964
1040
  break;
965
1041
  }
966
1042
  } else {
@@ -1000,8 +1076,12 @@ function runRepairs(results, {
1000
1076
  fs.chmodSync(binary, 0o755);
1001
1077
  console.log(`\n \u2705 Fixed permissions: chmod +x ${binary}`);
1002
1078
  fixed++;
1003
- } catch {
1004
- console.log(`\n \u274c Could not fix permissions: ${binary}`);
1079
+ } catch (e) {
1080
+ // Nothing else here speaks: `chmodSync` inherits no stream, so the
1081
+ // errno IS the diagnosis — EPERM (not the owner), EROFS (read-only
1082
+ // mount) and ENOENT are three different next steps for the user.
1083
+ console.log(`\n \u274c Could not fix permissions: ${binary}` +
1084
+ `${e && e.code ? ` (${e.code})` : ''}`);
1005
1085
  }
1006
1086
  if (os.platform() === 'darwin') {
1007
1087
  console.log(` Also try: xattr -d com.apple.quarantine "${binary}"`);
@@ -1023,8 +1103,8 @@ function runRepairs(results, {
1023
1103
  }));
1024
1104
  console.log(' \u2705 Index rebuilt');
1025
1105
  fixed++;
1026
- } catch {
1027
- console.log(' \u274c Index rebuild failed');
1106
+ } catch (e) {
1107
+ console.log(` \u274c Index rebuild failed${silentFailureReason(e)}`);
1028
1108
  }
1029
1109
  }
1030
1110
  break;
@@ -1034,8 +1114,8 @@ function runRepairs(results, {
1034
1114
  console.log('\n Completing auto-update...');
1035
1115
  try {
1036
1116
  runAutoUpdate();
1037
- } catch {
1038
- console.log(' \u274c Update check failed');
1117
+ } catch (e) {
1118
+ console.log(` \u274c Update check failed${silentFailureReason(e)}`);
1039
1119
  break;
1040
1120
  }
1041
1121
  // Same as the version-mismatch arm: exit 0 is not evidence. Re-read
@@ -1208,7 +1288,7 @@ function runDoctor(opts = {}) {
1208
1288
  return { results, issueCount: issues.length, unresolved };
1209
1289
  }
1210
1290
 
1211
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, binaryBrokenResolved, autoUpdateNoOpReason };
1291
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, binaryBrokenResolved, autoUpdateNoOpReason, autoUpdateLastError, silentFailureReason };
1212
1292
 
1213
1293
  // Shared by BOTH doctor entry points: `node doctor.js …` and `node lifecycle.js
1214
1294
  // doctor …`. It exists as one function because the first version of this guard