@sdsrs/code-graph 0.135.0 → 0.137.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/README.md +1 -0
- 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 +35 -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 +19 -12
- package/claude-plugin/scripts/lifecycle.js +40 -3
- package/claude-plugin/scripts/post-grep-inject.js +7 -0
- package/claude-plugin/scripts/pre-grep-guide.js +15 -4
- package/claude-plugin/scripts/pre-read-guide.js +8 -0
- 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
package/README.md
CHANGED
|
@@ -536,6 +536,7 @@ defaults are what you get by doing nothing.
|
|
|
536
536
|
| `CODE_GRAPH_HOOK_INDEX=on\|off` | Force the incremental-index hook on or off instead of letting it decide. |
|
|
537
537
|
| `CODE_GRAPH_MODEL_DIR=<dir>` | Load the embedding model from here (air-gapped installs). |
|
|
538
538
|
| `CODE_GRAPH_DISABLE_MODEL_DOWNLOAD=1` | Never fetch the model; fail instead. |
|
|
539
|
+
| `CODE_GRAPH_REQUIRE_MODEL=1` | Test-only: make the embedding tests FAIL when no model can be loaded instead of self-skipping. Set by the release gate and the cache-warm cron, which supply pinned weights — without it those tests pass by doing nothing on any runner with no cached model. |
|
|
539
540
|
| `CODE_GRAPH_MAX_FILE_SIZE=<bytes>` | Skip files larger than this (default 1 MiB). |
|
|
540
541
|
| `CODE_GRAPH_MAX_CODE_LEN=<bytes>` | Truncate stored per-node source at this length. |
|
|
541
542
|
| `CODE_GRAPH_PARSE_TIMEOUT_MS=<ms>` | Per-file parse timeout. |
|
|
@@ -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.
|
|
@@ -217,7 +225,13 @@ function runGrepAnswer(opts = {}) {
|
|
|
217
225
|
// Older binaries exit 0 on no-match with NO_MATCH_PREFIX on stdout — that
|
|
218
226
|
// shape resolves to 'no-hits' through isEmptyAnswer below.
|
|
219
227
|
const verdict = classifyRun(res, { exitOneIsNoHits: true });
|
|
220
|
-
|
|
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
|
+
}
|
|
221
235
|
const out = (res.stdout || '').trim();
|
|
222
236
|
if (isEmptyAnswer(out)) {
|
|
223
237
|
return { status: 'no-hits' };
|
|
@@ -268,7 +282,7 @@ function runShowAnswer(opts = {}) {
|
|
|
268
282
|
// docs) is that the deny funnel has to tell those causes apart. Nothing
|
|
269
283
|
// later in the loop can succeed either: the budget is gone for all three
|
|
270
284
|
// (pre-ship review 2026-09-05).
|
|
271
|
-
if (res.budgetExhausted) return { status: 'unavailable' };
|
|
285
|
+
if (res.budgetExhausted) return { status: 'unavailable', reason: 'budget' };
|
|
272
286
|
// A symbol that did not resolve is SKIPPED, not fatal — exit 1 included,
|
|
273
287
|
// which is why this asks for `exitOneIsNoHits: false` and then treats
|
|
274
288
|
// every non-`ok` verdict the same way.
|
|
@@ -322,7 +336,13 @@ function runOverviewAnswer(opts = {}) {
|
|
|
322
336
|
// green against it.
|
|
323
337
|
const verdict = classifyRun(res, { exitOneIsNoHits: false });
|
|
324
338
|
if (verdict !== 'ok') {
|
|
325
|
-
|
|
339
|
+
// Same arm as the three sibling runners: an exhausted hook budget reaches
|
|
340
|
+
// `classifyRun` as a synthetic timeout, so without this it is reported as
|
|
341
|
+
// a bare `unavailable` — indistinguishable from a wedged or broken binary.
|
|
342
|
+
// This runner is the read-fanout hint's only answer path, and its budget is
|
|
343
|
+
// the one most often already spent (pre-read runs after a SessionStart that
|
|
344
|
+
// already burned six children), so it is the worst place to lose the cause.
|
|
345
|
+
return { status: verdict, ...(res.budgetExhausted ? { reason: 'budget' } : {}) };
|
|
326
346
|
}
|
|
327
347
|
const out = (res.stdout || '').trim();
|
|
328
348
|
if (isEmptyAnswer(out)) return { status: 'no-hits' };
|
|
@@ -368,7 +388,13 @@ function runCallgraphAnswer(opts = {}) {
|
|
|
368
388
|
const res = runCg(binary, ['callgraph', symbol], { cwd, timeoutMs });
|
|
369
389
|
// grep-parity exit codes: 1 = symbol not found (no graph node).
|
|
370
390
|
const verdict = classifyRun(res, { exitOneIsNoHits: true });
|
|
371
|
-
|
|
391
|
+
// `reason` separates the two things `unavailable` covers. The binary failing
|
|
392
|
+
// and the binary never being given time are different facts, and the caller
|
|
393
|
+
// renders one of them to the user — "ran but failed" is simply untrue of a
|
|
394
|
+
// run that never started (audit 2026-09-05 NEW-08).
|
|
395
|
+
if (verdict !== 'ok') {
|
|
396
|
+
return { status: verdict, ...(res.budgetExhausted ? { reason: 'budget' } : {}) };
|
|
397
|
+
}
|
|
372
398
|
const out = (res.stdout || '').trim();
|
|
373
399
|
// Only an edge-bearing tree is marginal over the grep the model already ran.
|
|
374
400
|
if (isEmptyAnswer(out) ||
|
|
@@ -385,4 +411,8 @@ function runCallgraphAnswer(opts = {}) {
|
|
|
385
411
|
module.exports = {
|
|
386
412
|
runGrepAnswer, runShowAnswer, runOverviewAnswer, runCallgraphAnswer,
|
|
387
413
|
truncateAtLine, sanitizeSearchPath,
|
|
414
|
+
// Exported so the integer property can be asserted where it is ESTABLISHED.
|
|
415
|
+
// Asserting it end-to-end instead passes either way: `remainingMs` floors
|
|
416
|
+
// again downstream, so such a test cannot fail and proves nothing (NEW-02).
|
|
417
|
+
DEFAULT_TIMEOUT_MS,
|
|
388
418
|
};
|
|
@@ -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
|
|
@@ -12,6 +12,27 @@ const PLATFORM = os.platform();
|
|
|
12
12
|
const ARCH = os.arch();
|
|
13
13
|
const CACHE_FILE = path.join(os.homedir(), '.cache', 'code-graph', 'binary-path');
|
|
14
14
|
const BINARY_NAME = PLATFORM === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
|
|
15
|
+
// PATH lookup bound (NEW-07). 2 s matches the sibling `npm root -g` probe; the
|
|
16
|
+
// floor is what a budget-exhausted hook still gives it, because a `which` that
|
|
17
|
+
// cannot answer in 250 ms was not going to rescue this hook anyway.
|
|
18
|
+
const PATH_PROBE_TIMEOUT_MS = 2000;
|
|
19
|
+
const PATH_PROBE_MIN_MS = 250;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How long the PATH probe may run: the hook budget when one is armed, the
|
|
23
|
+
* default otherwise, never 0 and never fractional.
|
|
24
|
+
*
|
|
25
|
+
* Both edges matter. `timeout: 0` is read by node as NO TIMEOUT AT ALL — it
|
|
26
|
+
* would restore the exact unbounded call this replaced — and a fractional value
|
|
27
|
+
* makes `child_process` throw ERR_OUT_OF_RANGE, which the `catch` below would
|
|
28
|
+
* silently turn into "not on PATH" (bugfix #7's shape, in a function that runs
|
|
29
|
+
* before any hook has spent a millisecond).
|
|
30
|
+
*/
|
|
31
|
+
function pathProbeTimeoutMs(remaining = require('./hook-fail-open').remainingMs) {
|
|
32
|
+
const left = remaining(PATH_PROBE_TIMEOUT_MS);
|
|
33
|
+
const ms = left === null ? PATH_PROBE_MIN_MS : Math.floor(left);
|
|
34
|
+
return Math.max(ms, PATH_PROBE_MIN_MS);
|
|
35
|
+
}
|
|
15
36
|
const PLATFORM_PKG = `@sdsrs/code-graph-${PLATFORM}-${ARCH}`;
|
|
16
37
|
|
|
17
38
|
/**
|
|
@@ -471,9 +492,28 @@ function findBinaryUncached() {
|
|
|
471
492
|
}
|
|
472
493
|
|
|
473
494
|
// --- PATH lookup (last resort for intentionally installed binaries) ---
|
|
495
|
+
//
|
|
496
|
+
// BOUNDED. This had no timeout at all, and it runs BEFORE any hook has called
|
|
497
|
+
// `remainingMs` even once — `findBinary()` is the first thing every hook does
|
|
498
|
+
// (audit 2026-09-05 NEW-07). It was called "the only unbounded child on the
|
|
499
|
+
// hook path" when it was fixed; the pre-ship review then found `ps` in
|
|
500
|
+
// `lifecycle.js`, so that was never true. Do not restore the superlative
|
|
501
|
+
// without re-deriving it. A `which` against a wedged PATH entry (a dead NFS mount, an
|
|
502
|
+
// automounter) hangs until Claude Code kills the hook, which surfaces to the
|
|
503
|
+
// user as an error on THEIR tool call.
|
|
504
|
+
//
|
|
505
|
+
// Spends the hook budget when one is armed, floored at 250 ms so the probe is
|
|
506
|
+
// always actually attempted: skipping it outright would make `findBinary`
|
|
507
|
+
// answer "no binary" for one that is on PATH, and a fabricated absence is
|
|
508
|
+
// worse than a probe that ran out of time. Outside a hook `remainingMs`
|
|
509
|
+
// returns the default, so nothing changes for doctor / statusline / the
|
|
510
|
+
// launcher.
|
|
474
511
|
try {
|
|
475
512
|
const which = PLATFORM === 'win32' ? 'where' : 'which';
|
|
476
|
-
const found = execFileSync(which, [BINARY_NAME], hidden({
|
|
513
|
+
const found = execFileSync(which, [BINARY_NAME], hidden({
|
|
514
|
+
timeout: pathProbeTimeoutMs(),
|
|
515
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
516
|
+
}))
|
|
477
517
|
.toString().trim().split('\n')[0];
|
|
478
518
|
const hit = gate.consider(found);
|
|
479
519
|
if (hit) return hit;
|
|
@@ -513,6 +553,7 @@ module.exports = {
|
|
|
513
553
|
getPackageVersion, compareVersions, isCachedBinaryFresh,
|
|
514
554
|
detectLibc, unsupportedPlatformHint,
|
|
515
555
|
CACHE_FILE, BINARY_NAME, PLATFORM_PKG,
|
|
556
|
+
pathProbeTimeoutMs, PATH_PROBE_TIMEOUT_MS, PATH_PROBE_MIN_MS,
|
|
516
557
|
};
|
|
517
558
|
|
|
518
559
|
// Allow direct invocation for testing
|
|
@@ -25,15 +25,14 @@
|
|
|
25
25
|
// pins both registration sites to this table so a bump in one cannot drift from
|
|
26
26
|
// the other.
|
|
27
27
|
//
|
|
28
|
-
// `session-init.js` is
|
|
29
|
-
// `
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// binary health check or a quarantine probe, which is not a mechanical change.
|
|
28
|
+
// `session-init.js` is registered from `claude-plugin/hooks/hooks.json` rather
|
|
29
|
+
// than by `lifecycle.js` (SessionStart is the one event Claude Code loads from
|
|
30
|
+
// plugin-cache), so `hooks.test.js` pins that file to this table too. It was
|
|
31
|
+
// the last unclamped hook — 21.5 s of serial children against the 5 s below,
|
|
32
|
+
// the largest overrun of the seven — until audit 2026-09-05 NEW-05 wired it.
|
|
33
|
+
// Its skips are not uniform: see the budget block at the top of
|
|
34
|
+
// `session-init.js` for which children may be dropped silently and which two
|
|
35
|
+
// report a distinct result instead of a fabricated all-clear.
|
|
37
36
|
const HOOK_TIMEOUT_SECONDS = {
|
|
38
37
|
'pre-edit-guide.js': 4,
|
|
39
38
|
'pre-grep-guide.js': 3,
|
|
@@ -93,9 +92,17 @@ function remainingMs(defaultMs) {
|
|
|
93
92
|
return Math.floor(Math.min(defaultMs, left));
|
|
94
93
|
}
|
|
95
94
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Test seam: drop any armed deadline so one test file can't leak into another.
|
|
97
|
+
*
|
|
98
|
+
* `at` (an absolute epoch ms) arms one directly instead. `armHookDeadline`
|
|
99
|
+
* derives its instant from `process.uptime()`, so a test that wants the
|
|
100
|
+
* budget-EXHAUSTED branch would otherwise have to wait out a real budget — a
|
|
101
|
+
* clock race dressed up as a test. Pass `Date.now() - 1` to make every
|
|
102
|
+
* `remainingMs` return null deterministically.
|
|
103
|
+
*/
|
|
104
|
+
function resetHookDeadline(at = null) {
|
|
105
|
+
deadlineAt = at === null ? null : Math.floor(at);
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
function installHookFailOpen(label) {
|
|
@@ -13,6 +13,11 @@ const OLD_PLUGIN_IDS = [
|
|
|
13
13
|
];
|
|
14
14
|
const MARKETPLACE_NAME = 'code-graph-mcp';
|
|
15
15
|
const CACHE_DIR = path.join(os.homedir(), '.cache', 'code-graph');
|
|
16
|
+
// Bound for the `ps` fallback in getActiveCmdlines (pre-ship review 2026-09-06).
|
|
17
|
+
// 2 s matches the other hook-path probes; the floor is what a budget-exhausted
|
|
18
|
+
// hook still gives it, since an empty list degrades to recency-only.
|
|
19
|
+
const PS_PROBE_TIMEOUT_MS = 2000;
|
|
20
|
+
const PS_PROBE_MIN_MS = 250;
|
|
16
21
|
// Always derive from __dirname — CLAUDE_PLUGIN_ROOT env var can leak from other
|
|
17
22
|
// plugins when hooks run in shared process context (e.g. claude-mem-lite sets it
|
|
18
23
|
// to its own marketplace path, polluting all subsequent settings.json hook processes).
|
|
@@ -1647,10 +1652,19 @@ function update() {
|
|
|
1647
1652
|
settingsChanged = true;
|
|
1648
1653
|
}
|
|
1649
1654
|
|
|
1650
|
-
// 1. Update composite command path if version changed
|
|
1655
|
+
// 1. Update composite command path if version changed.
|
|
1656
|
+
//
|
|
1657
|
+
// Same predicate as install()'s heal arm, not a bare string comparison: an
|
|
1658
|
+
// exact mismatch is NOT staleness. Two copies of this plugin (plugin cache +
|
|
1659
|
+
// global npm, or a dev checkout) derive different absolute paths for the SAME
|
|
1660
|
+
// current composite, so rewriting on mismatch makes each surface take the slot
|
|
1661
|
+
// back from the other. install() has carried the guard since that ping-pong
|
|
1662
|
+
// was diagnosed; update() kept the string test, which reopened the pair for
|
|
1663
|
+
// one round on every version bump and walked straight past the
|
|
1664
|
+
// `statuslineDisplaced` stand-down (audit 2026-09-05 JS-08).
|
|
1651
1665
|
if (isOurComposite(settings)) {
|
|
1652
1666
|
const cmd = compositeCommand();
|
|
1653
|
-
if (settings.statusLine.command !== cmd) {
|
|
1667
|
+
if (settings.statusLine.command !== cmd && compositeSlotIsStale(settings.statusLine.command)) {
|
|
1654
1668
|
settings.statusLine.command = cmd;
|
|
1655
1669
|
settingsChanged = true;
|
|
1656
1670
|
}
|
|
@@ -1775,10 +1789,33 @@ function readActiveProcessCmdlines() {
|
|
|
1775
1789
|
} catch { /* fall through to ps */ }
|
|
1776
1790
|
try {
|
|
1777
1791
|
const { execFileSync } = require('child_process');
|
|
1792
|
+
// BOUNDED. Reached in-process from the SessionStart hook —
|
|
1793
|
+
// runSessionInit -> syncLifecycleConfig -> update() ->
|
|
1794
|
+
// cleanupOldCacheVersions -> here, unconditionally, on the first session
|
|
1795
|
+
// after every plugin update, which is precisely the session the hook budget
|
|
1796
|
+
// work targets. It carried no timeout, so a wedged `ps` (a stuck process
|
|
1797
|
+
// table, a paused container) hung the hook until Claude Code killed it —
|
|
1798
|
+
// which the user sees as an error on their own tool call. Found by pre-ship
|
|
1799
|
+
// review 2026-09-06 after find-binary's `which` was fixed and the release
|
|
1800
|
+
// notes called it "the only unbounded child a hook could reach".
|
|
1801
|
+
//
|
|
1802
|
+
// Spends the hook budget when one is armed, floored so the probe is always
|
|
1803
|
+
// attempted.
|
|
1804
|
+
//
|
|
1805
|
+
// Be honest about the cost: an empty answer drops pruning back to
|
|
1806
|
+
// recency-only, which is exactly the state this guard exists to escape —
|
|
1807
|
+
// see cleanupOldCacheVersions, where pruning a version a live process is
|
|
1808
|
+
// bound to breaks `/mcp` reconnect with MODULE_NOT_FOUND. So a timeout is a
|
|
1809
|
+
// NEW route to the guard being off, not a free fallback. It is still the
|
|
1810
|
+
// right trade: a `ps` slow enough to hit this is rare, and hanging the hook
|
|
1811
|
+
// past its budget is a certain failure rather than a possible one.
|
|
1812
|
+
const { remainingMs } = require('./hook-fail-open');
|
|
1813
|
+
const budget = remainingMs(PS_PROBE_TIMEOUT_MS);
|
|
1778
1814
|
return execFileSync('ps', ['-axww', '-o', 'command='], hidden({
|
|
1815
|
+
timeout: budget === null ? PS_PROBE_MIN_MS : Math.max(budget, PS_PROBE_MIN_MS),
|
|
1779
1816
|
encoding: 'utf8', maxBuffer: 8 * 1024 * 1024,
|
|
1780
1817
|
})).split('\n').filter(Boolean);
|
|
1781
|
-
} catch { /* unsupported platform — caller falls back to recency-only */ }
|
|
1818
|
+
} catch { /* unsupported platform, or out of time — caller falls back to recency-only */ }
|
|
1782
1819
|
return [];
|
|
1783
1820
|
}
|
|
1784
1821
|
|
|
@@ -316,6 +316,13 @@ function runMain() {
|
|
|
316
316
|
...(pattern ? { pattern } : {}),
|
|
317
317
|
fallthrough: answer.status,
|
|
318
318
|
reason: answer.status,
|
|
319
|
+
// Sub-divides `unavailable` without touching it: a starved hook and a
|
|
320
|
+
// wedged binary arrive under the same word and only one of them means
|
|
321
|
+
// something is broken. It cannot ride in `reason` — src/cli/usage.rs:448
|
|
322
|
+
// scores `reason:"unavailable"` as an inconclusive follow-up, so a new
|
|
323
|
+
// value there would silently re-file every budget skip as "the inline
|
|
324
|
+
// answer was insufficient". Same field pre-grep-guide.js:790 uses.
|
|
325
|
+
...(answer.reason ? { fallthrough_reason: answer.reason } : {}),
|
|
319
326
|
// Attribute the skip to the mode that burned the attempt — the LAST mode
|
|
320
327
|
// tried, so a callgraph miss that fell through to grep is charged to grep,
|
|
321
328
|
// matching the fallback order above. Without this the aggregator files
|
|
@@ -642,8 +642,14 @@ function buildNoHitsFyi(pattern) {
|
|
|
642
642
|
// only (PreToolUse exit-0 stdout → debug log, never the model); the operative
|
|
643
643
|
// effect at the call site is the ALLOW (no deny emitted) so the raw grep runs
|
|
644
644
|
// intact instead of a static deny that would hand the model nothing.
|
|
645
|
-
function buildUnavailableFyi(pattern, status) {
|
|
646
|
-
|
|
645
|
+
function buildUnavailableFyi(pattern, status, reason) {
|
|
646
|
+
// Three causes, not two. A hook whose budget was already spent at startup
|
|
647
|
+
// (cold node on a loaded machine — see the reserve note in cg-answer.js)
|
|
648
|
+
// deliberately runs no children at all; calling that "ran but failed" blames
|
|
649
|
+
// the binary for something it was never asked to do (audit 2026-09-05 NEW-08).
|
|
650
|
+
const why = status === 'no-binary' ? 'binary not found'
|
|
651
|
+
: reason === 'budget' ? 'no time left in the hook budget'
|
|
652
|
+
: 'ran but failed';
|
|
647
653
|
return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` unavailable (${why}) — raw grep proceeding.`;
|
|
648
654
|
}
|
|
649
655
|
|
|
@@ -777,11 +783,16 @@ function runMain() {
|
|
|
777
783
|
// static deny (the answer never ran → status stays the default 'unavailable')
|
|
778
784
|
// — that path falls through to the v0.46 static deny below.
|
|
779
785
|
if (answer.status !== 'hits' && !isAnswerDisabled()) {
|
|
780
|
-
recordRecommendation(root, {
|
|
786
|
+
recordRecommendation(root, {
|
|
787
|
+
hook: 'grep', action: 'hint', fallthrough: answer.status,
|
|
788
|
+
// So the funnel can tell a starved hook from a broken binary; both
|
|
789
|
+
// arrive as `unavailable` and only one of them means anything is wrong.
|
|
790
|
+
...(answer.reason ? { fallthrough_reason: answer.reason } : {}),
|
|
791
|
+
});
|
|
781
792
|
process.stdout.write(
|
|
782
793
|
(answer.status === 'no-hits'
|
|
783
794
|
? buildNoHitsFyi(pattern)
|
|
784
|
-
: buildUnavailableFyi(pattern, answer.status)) + '\n');
|
|
795
|
+
: buildUnavailableFyi(pattern, answer.status, answer.reason)) + '\n');
|
|
785
796
|
return;
|
|
786
797
|
}
|
|
787
798
|
|
|
@@ -192,7 +192,15 @@ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
|
192
192
|
// 'no-binary' (delivered overview dark — binary missing) vs 'unavailable'
|
|
193
193
|
// (binary ran but failed/timed out) vs 'no-hits'. Mirrors pre-grep-guide
|
|
194
194
|
// so the read-fanout funnel can tell a dark flagship apart from no result.
|
|
195
|
+
//
|
|
196
|
+
// `fallthrough_reason` sub-divides 'unavailable' rather than replacing it:
|
|
197
|
+
// a spent hook budget and a wedged binary are the same word here, and only
|
|
198
|
+
// the former is the healthy-hook-answers-nothing case NEW-08 traded a kill
|
|
199
|
+
// for. It cannot go in `reason` — src/cli/usage.rs:448 scores
|
|
200
|
+
// `reason:"unavailable"` as an inconclusive follow-up, so a new value there
|
|
201
|
+
// would re-file every budget skip as "the answer was insufficient".
|
|
195
202
|
...(answered ? {} : { reason: answer.status }),
|
|
203
|
+
...(answered || !answer.reason ? {} : { fallthrough_reason: answer.reason }),
|
|
196
204
|
});
|
|
197
205
|
// Compound-grep sibling sweep: emit via the PreToolUse allow+additionalContext
|
|
198
206
|
// envelope (shared hook-emit.js). Bare stdout on a PreToolUse exit-0 lands in
|
|
@@ -12,11 +12,47 @@ const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-util
|
|
|
12
12
|
const { maybeAutoAdopt, isAdopted, unadopt } = require('./adopt');
|
|
13
13
|
const { isNonProjectCwd } = require('./project-detect');
|
|
14
14
|
const { hidden } = require('./proc-opts');
|
|
15
|
+
const { installHookFailOpen, remainingMs } = require('./hook-fail-open');
|
|
15
16
|
// Module scope on purpose: `detectHookDark` reads it inside a try/catch that
|
|
16
17
|
// treats any throw as "nothing to conclude", so a lazy require in there would
|
|
17
18
|
// turn a resolution failure into a silent disable (pre-tag review, JS-08).
|
|
18
19
|
const { resolveProjectRoot } = require('./project-root');
|
|
19
20
|
|
|
21
|
+
// ── SessionStart budget (audit 2026-09-05 NEW-05) ─────────────────────────
|
|
22
|
+
//
|
|
23
|
+
// Claude Code kills this hook at 5 s (`HOOK_TIMEOUT_SECONDS['session-init.js']`,
|
|
24
|
+
// mirrored by hooks.json). Its blocking children were each sized alone and run
|
|
25
|
+
// in SERIES: the darwin quarantine probe 3 s, `git log` 2 s, `health-check` 3 s,
|
|
26
|
+
// `map --compact` 5 s, `git status` 1 s, `git diff` 1 s, `affected` 1.5 s and
|
|
27
|
+
// `readBinaryVersion` 5 s — 21.5 s worst case. Nothing enforced the sum, so a
|
|
28
|
+
// binary wedged on `index.lock` got the hook killed and took the SessionStart
|
|
29
|
+
// output the user had already earned with it. JS-03 fixed the other six hooks;
|
|
30
|
+
// this one was left because clamping it is a per-child decision, not a
|
|
31
|
+
// mechanical edit.
|
|
32
|
+
//
|
|
33
|
+
// Every blocking child now spends `budgetFor(...)`, which returns null when the
|
|
34
|
+
// budget is gone — meaning SKIP, never "run unbounded" (node reads `timeout: 0`
|
|
35
|
+
// as no timeout at all).
|
|
36
|
+
//
|
|
37
|
+
// Two skips would otherwise fabricate a POSITIVE result, so they get their own
|
|
38
|
+
// answer rather than folding into an existing bucket — the mistake NEW-09 fixed
|
|
39
|
+
// in cg-answer, where "budget exhausted" arrived as `no-hits`:
|
|
40
|
+
// * a skipped freshness probe reports 'unknown', NOT 'fresh';
|
|
41
|
+
// * a skipped Gatekeeper probe reports `quarantine-probe-skipped`, NOT a
|
|
42
|
+
// silent "the binary runs".
|
|
43
|
+
// The rest degrade to "nothing injected", which is a state they already reach
|
|
44
|
+
// for ordinary reasons (no index, clean tree) and which claims nothing untrue.
|
|
45
|
+
// `budgetSkipped` in the return value names whichever ones actually skipped, so
|
|
46
|
+
// a starved SessionStart is legible to `doctor` and to tests instead of just
|
|
47
|
+
// being quieter than usual.
|
|
48
|
+
let budgetSkips = [];
|
|
49
|
+
|
|
50
|
+
function budgetFor(label, defaultMs) {
|
|
51
|
+
const ms = remainingMs(defaultMs);
|
|
52
|
+
if (ms === null) budgetSkips.push(label);
|
|
53
|
+
return ms;
|
|
54
|
+
}
|
|
55
|
+
|
|
20
56
|
// v0.17.0 — quietHooks: unconditional quiet 默认。
|
|
21
57
|
// 项目地图与 MEMORY.md plugin contract + on-demand `project_map` 工具高度重叠,
|
|
22
58
|
// 默认每次 SessionStart 都注入 ≈2.3 KB 是不必要的常驻上下文成本。
|
|
@@ -344,13 +380,19 @@ function syncLifecycleConfig() {
|
|
|
344
380
|
* project where the MCP server isn't running, nothing else nudges a rebuild.
|
|
345
381
|
* health-check carries the verdict in `index_version_stale`. Best-effort: any
|
|
346
382
|
* failure → false (never force work off a bad probe).
|
|
383
|
+
*
|
|
384
|
+
* Returns true | false | null, where null means the SessionStart budget ran out
|
|
385
|
+
* before the probe could run. `false` says "asked, not stale"; conflating the
|
|
386
|
+
* two would let the caller report a freshness it never established.
|
|
347
387
|
*/
|
|
348
388
|
function indexNeedsRevalidation(bin, cwd) {
|
|
389
|
+
const budget = budgetFor('health-check', 3000);
|
|
390
|
+
if (budget === null) return null;
|
|
349
391
|
try {
|
|
350
392
|
let out;
|
|
351
393
|
try {
|
|
352
394
|
out = execFileSync(bin, ['health-check', '--format', 'json'],
|
|
353
|
-
hidden({ cwd, timeout:
|
|
395
|
+
hidden({ cwd, timeout: budget, stdio: ['pipe', 'pipe', 'pipe'] })).toString();
|
|
354
396
|
} catch (e) {
|
|
355
397
|
// health-check exits non-zero on an unhealthy index but still writes JSON.
|
|
356
398
|
out = ((e && e.stdout) || '').toString();
|
|
@@ -370,7 +412,10 @@ function indexNeedsRevalidation(bin, cwd) {
|
|
|
370
412
|
* 1. git HEAD newer than index.db mtime — content drifted since last index.
|
|
371
413
|
* 2. INDEX_VERSION mismatch (post-upgrade) — see indexNeedsRevalidation.
|
|
372
414
|
*
|
|
373
|
-
* Returns 'fresh' | 'refreshing' | 'skipped'
|
|
415
|
+
* Returns 'fresh' | 'refreshing' | 'skipped' | 'unknown', where 'unknown' means
|
|
416
|
+
* the SessionStart budget ran out before either trigger could be evaluated —
|
|
417
|
+
* distinct from 'fresh' (both triggers asked and neither fired) and from
|
|
418
|
+
* 'skipped' (no binary / no index, so there was nothing to ask).
|
|
374
419
|
*/
|
|
375
420
|
function ensureIndexFresh() {
|
|
376
421
|
const { findBinary } = require('./find-binary');
|
|
@@ -387,19 +432,33 @@ function ensureIndexFresh() {
|
|
|
387
432
|
if (!fs.existsSync(dbPath)) return 'skipped';
|
|
388
433
|
|
|
389
434
|
let needsRefresh = false;
|
|
435
|
+
let unprobed = false;
|
|
390
436
|
// Trigger 1: git HEAD newer than index mtime.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
437
|
+
const gitBudget = budgetFor('git-log', 2000);
|
|
438
|
+
if (gitBudget === null) {
|
|
439
|
+
unprobed = true;
|
|
440
|
+
} else {
|
|
441
|
+
try {
|
|
442
|
+
const dbMtime = fs.statSync(dbPath).mtimeMs;
|
|
443
|
+
const gitTs = parseInt(
|
|
444
|
+
execSync('git log -1 --format=%ct', hidden({ cwd, timeout: gitBudget, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })).trim()
|
|
445
|
+
) * 1000;
|
|
446
|
+
if (gitTs > dbMtime) needsRefresh = true;
|
|
447
|
+
} catch { /* no git / not a repo — fall through to the version probe */ }
|
|
448
|
+
}
|
|
398
449
|
|
|
399
450
|
// Trigger 2: INDEX_VERSION mismatch (only probe when mtime looked fresh).
|
|
400
|
-
if (!needsRefresh
|
|
451
|
+
if (!needsRefresh) {
|
|
452
|
+
const stale = indexNeedsRevalidation(bin, cwd);
|
|
453
|
+
if (stale === true) needsRefresh = true;
|
|
454
|
+
else if (stale === null) unprobed = true;
|
|
455
|
+
}
|
|
401
456
|
|
|
402
|
-
|
|
457
|
+
// No trigger fired. Whether that means "fresh" depends on whether anything
|
|
458
|
+
// was actually asked: with a spent budget this is a claim about an index
|
|
459
|
+
// nothing looked at, and 'fresh' is the one answer that would stop a caller
|
|
460
|
+
// from looking again.
|
|
461
|
+
if (!needsRefresh) return unprobed ? 'unknown' : 'fresh';
|
|
403
462
|
|
|
404
463
|
const child = spawn(bin, ['incremental-index', '--quiet'], hidden({
|
|
405
464
|
cwd,
|
|
@@ -463,8 +522,17 @@ function verifyBinary() {
|
|
|
463
522
|
|
|
464
523
|
// On macOS, verify the binary can actually run (Gatekeeper may block it)
|
|
465
524
|
if (process.platform === 'darwin') {
|
|
525
|
+
const budget = budgetFor('quarantine-probe', 3000);
|
|
526
|
+
if (budget === null) {
|
|
527
|
+
// Out of budget before the probe. The binary exists and is executable, so
|
|
528
|
+
// `available: false` here would be a false alarm that sends the user to
|
|
529
|
+
// `xattr -d` for nothing. But "it actually runs" is precisely what the
|
|
530
|
+
// probe establishes and we did not establish it — so say which half is
|
|
531
|
+
// unverified instead of returning the clean shape.
|
|
532
|
+
return { available: true, binary, issue: 'quarantine-probe-skipped' };
|
|
533
|
+
}
|
|
466
534
|
try {
|
|
467
|
-
execFileSync(binary, ['--version'], hidden({ timeout:
|
|
535
|
+
execFileSync(binary, ['--version'], hidden({ timeout: budget, stdio: 'pipe' }));
|
|
468
536
|
} catch (err) {
|
|
469
537
|
const msg = (err.message || '') + (err.stderr ? err.stderr.toString() : '');
|
|
470
538
|
if (msg.includes('quarantine') || msg.includes('not permitted') ||
|
|
@@ -499,7 +567,15 @@ function consistencyCheck(binary) {
|
|
|
499
567
|
// Check 1: Binary version vs plugin version
|
|
500
568
|
try {
|
|
501
569
|
const pluginVersion = getPluginVersion();
|
|
502
|
-
|
|
570
|
+
// The last child of the hook and the most expensive one (5s of its own
|
|
571
|
+
// against a 5s total). Skipping folds into `binaryVersion === null`, which
|
|
572
|
+
// this check already treats as "no comparison to make" — it only ever
|
|
573
|
+
// reports a MISMATCH, so a skip suppresses a warning rather than inventing
|
|
574
|
+
// an all-clear.
|
|
575
|
+
const versionBudget = budgetFor('binary-version', 5000);
|
|
576
|
+
const binaryVersion = versionBudget === null
|
|
577
|
+
? null
|
|
578
|
+
: readBinaryVersion(binary, { timeoutMs: versionBudget });
|
|
503
579
|
if (binaryVersion && binaryVersion !== pluginVersion) {
|
|
504
580
|
issues.push({
|
|
505
581
|
id: 'version-mismatch',
|
|
@@ -565,6 +641,10 @@ function samePath(a, b) {
|
|
|
565
641
|
}
|
|
566
642
|
|
|
567
643
|
function runSessionInit({ source } = {}) {
|
|
644
|
+
// Fresh per run: this is module state, and the test suite calls this function
|
|
645
|
+
// many times in one process. A carried-over array would report last run's
|
|
646
|
+
// skips as this one's.
|
|
647
|
+
budgetSkips = [];
|
|
568
648
|
// GC the shared tmp dir before anything else, so it happens even on the
|
|
569
649
|
// inactive / non-project early returns below — those sessions still wrote
|
|
570
650
|
// cooldown flags on the way in. Cheap (one readdir + a stat per entry) and
|
|
@@ -775,6 +855,10 @@ function runSessionInit({ source } = {}) {
|
|
|
775
855
|
autoUpdateLaunched, indexFreshness, mapInjected, recentImpactInjected, binaryCheck, consistencyIssues,
|
|
776
856
|
quietHooks, adopted, autoAdopted: autoAdopt.attempted,
|
|
777
857
|
hookFireWarn: !!hookFireWarn, hookDarkWarn: !!hookDarkWarn,
|
|
858
|
+
// Which children the 5s budget cut, in the order they were reached. Empty
|
|
859
|
+
// on every healthy run; non-empty is the only signal that this SessionStart
|
|
860
|
+
// did less than it looks like it did.
|
|
861
|
+
budgetSkipped: budgetSkips.slice(),
|
|
778
862
|
};
|
|
779
863
|
}
|
|
780
864
|
|
|
@@ -797,9 +881,15 @@ function injectProjectMap() {
|
|
|
797
881
|
const bin = findBinary();
|
|
798
882
|
if (!bin) return false;
|
|
799
883
|
|
|
884
|
+
// Skipping here degrades to "nothing injected", the same state a project
|
|
885
|
+
// with no index reaches — no false claim, just less context. Safe to fold
|
|
886
|
+
// (unlike the freshness/quarantine probes) because this dump is opt-in,
|
|
887
|
+
// off by default, and duplicates MEMORY.md plus the on-demand tool.
|
|
888
|
+
const mapBudget = budgetFor('map', 5000);
|
|
889
|
+
if (mapBudget === null) return false;
|
|
800
890
|
const output = execFileSync(bin, ['map', '--compact'], hidden({
|
|
801
891
|
cwd,
|
|
802
|
-
timeout:
|
|
892
|
+
timeout: mapBudget,
|
|
803
893
|
encoding: 'utf8',
|
|
804
894
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
805
895
|
// Hook-internal delivery, not a model conversion — keep record_cli_use from
|
|
@@ -846,16 +936,28 @@ function injectRecentImpact({ source } = {}) {
|
|
|
846
936
|
// last commit. Timeouts tightened (finding #1): worst-case cap sum is now
|
|
847
937
|
// status(1s) + HEAD~1(1s) + affected(1.5s) = 3.5s, comfortably under the 5s
|
|
848
938
|
// SessionStart hook budget; the old 2+2+3=7s could get the whole hook killed.
|
|
849
|
-
|
|
939
|
+
// Each git call re-reads the budget and builds its own opts rather than
|
|
940
|
+
// sharing one object: the fallback `git diff` runs AFTER `git status` has
|
|
941
|
+
// already spent wall clock, so a timeout computed once would let the pair
|
|
942
|
+
// overshoot by up to its own value. `hidden(...)` stays inline at both call
|
|
943
|
+
// sites — windows-hide.test.js follows a `const x = hidden(...)` binding but
|
|
944
|
+
// not one returned from a helper, and that guard is right to be that strict.
|
|
850
945
|
let changed = [];
|
|
851
946
|
let isWip = false;
|
|
852
947
|
try {
|
|
948
|
+
const statusMs = budgetFor('git-status', 1000);
|
|
949
|
+
if (statusMs === null) return false;
|
|
853
950
|
changed = filterSourceFiles(parseGitStatusPaths(
|
|
854
|
-
execSync('git status --porcelain --untracked-files=all',
|
|
951
|
+
execSync('git status --porcelain --untracked-files=all',
|
|
952
|
+
hidden({ cwd: sessionDir, timeout: statusMs, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }))));
|
|
855
953
|
isWip = changed.length > 0;
|
|
856
954
|
// Clean tree → fall back to what the last commit touched.
|
|
857
955
|
if (!isWip) {
|
|
858
|
-
|
|
956
|
+
const diffMs = budgetFor('git-diff', 1000);
|
|
957
|
+
if (diffMs === null) return false;
|
|
958
|
+
changed = filterSourceFiles(
|
|
959
|
+
execSync('git diff --name-only HEAD~1 HEAD',
|
|
960
|
+
hidden({ cwd: sessionDir, timeout: diffMs, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })));
|
|
859
961
|
}
|
|
860
962
|
} catch {
|
|
861
963
|
return false; // not a git repo / no commits — nothing to diff
|
|
@@ -870,10 +972,13 @@ function injectRecentImpact({ source } = {}) {
|
|
|
870
972
|
const bin = findBinary();
|
|
871
973
|
if (!bin) return false;
|
|
872
974
|
|
|
975
|
+
const affectedBudget = budgetFor('affected', 1500);
|
|
976
|
+
if (affectedBudget === null) return false;
|
|
977
|
+
|
|
873
978
|
let affected;
|
|
874
979
|
try {
|
|
875
980
|
const raw = execFileSync(bin, ['affected', ...changed, '--json'], hidden({
|
|
876
|
-
cwd, timeout:
|
|
981
|
+
cwd, timeout: affectedBudget, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
877
982
|
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
878
983
|
}));
|
|
879
984
|
affected = JSON.parse(raw);
|
|
@@ -998,6 +1103,12 @@ module.exports = {
|
|
|
998
1103
|
};
|
|
999
1104
|
|
|
1000
1105
|
if (require.main === module) {
|
|
1106
|
+
// Arms the 5s deadline every `budgetFor` above spends against, and adds the
|
|
1107
|
+
// async-throw coverage the try/catch below cannot reach (a rejected promise
|
|
1108
|
+
// from a spawn or a read). Only here, never on `require`: the test suite
|
|
1109
|
+
// calls `runSessionInit` in-process, where an armed deadline from a foreign
|
|
1110
|
+
// argv[1] would clamp children against a budget nobody granted.
|
|
1111
|
+
installHookFailOpen('SessionStart');
|
|
1001
1112
|
// SessionStart passes {source:"startup"|"clear"|"compact"|"resume"} on stdin.
|
|
1002
1113
|
// Best-effort + TTY-guarded: a hook gets piped JSON (EOF closes it), but a
|
|
1003
1114
|
// manual `node session-init.js` in a terminal must not block on fd 0.
|
|
@@ -12,12 +12,17 @@ const { hidden } = require('./proc-opts');
|
|
|
12
12
|
// rejected by the same parse: a self-sustaining loop.
|
|
13
13
|
const VERSION_OUTPUT_RE = /^code-graph-mcp\s+v?(\d+\.\d+\.\d+)/m;
|
|
14
14
|
|
|
15
|
-
function readBinaryVersion(binaryPath) {
|
|
15
|
+
function readBinaryVersion(binaryPath, { timeoutMs = 5000 } = {}) {
|
|
16
16
|
try {
|
|
17
17
|
const out = execFileSync(binaryPath, ['--version'], hidden({
|
|
18
|
-
// 5s: a cold exec of a freshly-written ~40MB binary (page-in,
|
|
19
|
-
// scan) regularly exceeded the old 2s, misclassifying a good
|
|
20
|
-
|
|
18
|
+
// 5s default: a cold exec of a freshly-written ~40MB binary (page-in,
|
|
19
|
+
// Windows AV scan) regularly exceeded the old 2s, misclassifying a good
|
|
20
|
+
// binary. `timeoutMs` exists for callers running inside a hook budget —
|
|
21
|
+
// session-init spends what is LEFT of its 5s SessionStart allowance here,
|
|
22
|
+
// which is otherwise the single largest unclamped child in that hook
|
|
23
|
+
// (audit 2026-09-05 NEW-05). Must be an integer: `child_process`
|
|
24
|
+
// validates it and throws ERR_OUT_OF_RANGE on a fraction.
|
|
25
|
+
timeout: timeoutMs,
|
|
21
26
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
22
27
|
})).toString().trim();
|
|
23
28
|
const match = out.match(VERSION_OUTPUT_RE);
|
|
@@ -35,7 +35,7 @@ jobs:
|
|
|
35
35
|
node-version: '20'
|
|
36
36
|
- name: Build snapshot
|
|
37
37
|
run: |
|
|
38
|
-
npx -y -p @sdsrs/code-graph@0.
|
|
38
|
+
npx -y -p @sdsrs/code-graph@0.137.0 code-graph-mcp snapshot create --out snapshot.db
|
|
39
39
|
zstd -9 snapshot.db -o snapshot.db.zst
|
|
40
40
|
mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
|
|
41
41
|
- name: Upload to release
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.137.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.137.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.137.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.137.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.137.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.137.0"
|
|
43
43
|
}
|
|
44
44
|
}
|