@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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/auto-update.js +93 -34
- package/claude-plugin/scripts/cg-answer.js +68 -5
- package/claude-plugin/scripts/doctor.js +98 -18
- package/claude-plugin/scripts/find-binary.js +42 -1
- package/claude-plugin/scripts/hook-fail-open.js +94 -1
- package/claude-plugin/scripts/lifecycle.js +109 -14
- package/claude-plugin/scripts/pre-edit-guide.js +16 -3
- package/claude-plugin/scripts/pre-grep-guide.js +15 -4
- package/claude-plugin/scripts/session-init.js +129 -18
- package/claude-plugin/scripts/version-utils.js +9 -4
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
|
@@ -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(
|
|
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]
|
|
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]
|
|
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
|
-
`
|
|
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(
|
|
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]
|
|
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
|
-
`
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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]
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|