@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
|
@@ -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.136.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.136.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.136.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.136.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.136.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.136.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.136.0"
|
|
43
43
|
}
|
|
44
44
|
}
|