@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.
@@ -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: 3000, stdio: ['pipe', 'pipe', 'pipe'] })).toString();
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
- try {
392
- const dbMtime = fs.statSync(dbPath).mtimeMs;
393
- const gitTs = parseInt(
394
- execSync('git log -1 --format=%ct', hidden({ cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })).trim()
395
- ) * 1000;
396
- if (gitTs > dbMtime) needsRefresh = true;
397
- } catch { /* no git / not a repo — fall through to the version probe */ }
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 && indexNeedsRevalidation(bin, cwd)) needsRefresh = true;
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
- if (!needsRefresh) return 'fresh';
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: 3000, stdio: 'pipe' }));
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
- const binaryVersion = readBinaryVersion(binary);
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: 5000,
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
- const gitOpts = hidden({ cwd: sessionDir, timeout: 1000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
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', gitOpts)));
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
- changed = filterSourceFiles(execSync('git diff --name-only HEAD~1 HEAD', gitOpts));
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: 1500, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
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, Windows AV
19
- // scan) regularly exceeded the old 2s, misclassifying a good binary.
20
- timeout: 5000,
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.134.0 code-graph-mcp snapshot create --out snapshot.db
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.134.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.134.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.134.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.134.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.134.0",
42
- "@sdsrs/code-graph-win32-x64": "0.134.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
  }