@sdsrs/code-graph 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.25.0",
7
+ "version": "0.26.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -87,13 +87,33 @@ function isNativeBinary(candidate) {
87
87
  }
88
88
  }
89
89
 
90
+ /**
91
+ * Decide whether a cached binary path is fresh enough to skip the full
92
+ * discovery walk. Matches the auto-update cache logic at ~:185-188 —
93
+ * cache wins only when its binary's version >= pkg version. Without this
94
+ * check, a stale cache entry (e.g. dev checkout's `bin/code-graph-mcp`
95
+ * recorded once, then never refreshed) shadows newer auto-update or
96
+ * platform-pkg binaries forever (see mem #8454).
97
+ *
98
+ * Permissive on unknown values: missing pkg version or unreadable binary
99
+ * version → trust cache (don't refuse the only path we know about).
100
+ */
101
+ function isCachedBinaryFresh(cachedPath, pkgVersion) {
102
+ if (!isNativeBinary(cachedPath)) return false;
103
+ if (!pkgVersion) return true;
104
+ const cacheVer = readBinaryVersion(cachedPath);
105
+ if (!cacheVer) return true;
106
+ return compareVersions(cacheVer, pkgVersion) >= 0;
107
+ }
108
+
90
109
  /**
91
110
  * Locate the code-graph-mcp binary using multiple strategies.
92
111
  * Results are cached to disk so repeated calls (e.g. per-hook) are fast.
93
112
  *
94
113
  * Priority:
95
- * cache (if valid) → dev-mode (target/release) → auto-update cache
96
- * → platform npm pkg → bundled (bin/) → cargo install → PATH → npx cache
114
+ * cache (if valid + version >= pkg) → dev-mode (target/release) →
115
+ * auto-update cache → platform npm pkg → bundled (bin/) →
116
+ * cargo install → PATH → npx cache
97
117
  *
98
118
  * Returns the absolute path or null if not found.
99
119
  */
@@ -101,7 +121,7 @@ function findBinary() {
101
121
  // Try disk cache first (avoids spawning `which` on hot paths)
102
122
  try {
103
123
  const cached = fs.readFileSync(CACHE_FILE, 'utf8').trim();
104
- if (isNativeBinary(cached)) return cached;
124
+ if (isCachedBinaryFresh(cached, getPackageVersion())) return cached;
105
125
  if (cached) clearCache();
106
126
  } catch { /* no cache or stale */ }
107
127
 
@@ -242,7 +262,7 @@ function clearCache() {
242
262
  module.exports = {
243
263
  findBinary, findBinaryUncached, clearCache,
244
264
  globalNodeModulesCandidates, findPlatformBinary,
245
- getPackageVersion, compareVersions,
265
+ getPackageVersion, compareVersions, isCachedBinaryFresh,
246
266
  CACHE_FILE, BINARY_NAME, PLATFORM_PKG,
247
267
  };
248
268
 
@@ -6,7 +6,7 @@ const os = require('os');
6
6
  const path = require('path');
7
7
 
8
8
  const { globalNodeModulesCandidates, findPlatformBinary, BINARY_NAME,
9
- compareVersions, getPackageVersion } = require('./find-binary');
9
+ compareVersions, getPackageVersion, isCachedBinaryFresh } = require('./find-binary');
10
10
 
11
11
  function mkDir(t, prefix) {
12
12
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -145,3 +145,77 @@ test('getPackageVersion reads root package.json', () => {
145
145
  const v = getPackageVersion();
146
146
  assert.match(v, /^\d+\.\d+\.\d+$/, `expected semver-ish, got: ${v}`);
147
147
  });
148
+
149
+ // ─── isCachedBinaryFresh: disk cache version-check (mem #8454) ────────────
150
+ //
151
+ // Builds a fake binary that responds to `--version` with a controllable
152
+ // string. process.execPath (node itself) won't do — we need a binary
153
+ // whose --version line we control. Smallest approach: shell wrapper.
154
+
155
+ function buildFakeBinary(t, versionLine) {
156
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cgmcp-fake-bin-'));
157
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
158
+ const binPath = path.join(dir, BINARY_NAME);
159
+ // readBinaryVersion parses "code-graph-mcp X.Y.Z" via the binary's first
160
+ // stdout line on `--version`. Shell wrapper is simpler than compiling.
161
+ const script = process.platform === 'win32'
162
+ ? `@echo off\r\necho ${versionLine}\r\n`
163
+ : `#!/bin/sh\necho '${versionLine}'\n`;
164
+ fs.writeFileSync(binPath, script);
165
+ if (process.platform !== 'win32') fs.chmodSync(binPath, 0o755);
166
+ return binPath;
167
+ }
168
+
169
+ test('isCachedBinaryFresh: cache binary version >= pkg → fresh', (t) => {
170
+ const bin = buildFakeBinary(t, 'code-graph-mcp 9.9.9');
171
+ assert.equal(isCachedBinaryFresh(bin, '0.25.0'), true);
172
+ });
173
+
174
+ test('isCachedBinaryFresh: cache binary version equals pkg → fresh', (t) => {
175
+ const bin = buildFakeBinary(t, 'code-graph-mcp 0.25.0');
176
+ assert.equal(isCachedBinaryFresh(bin, '0.25.0'), true);
177
+ });
178
+
179
+ test('isCachedBinaryFresh: cache binary version < pkg → stale (THE BUG)', (t) => {
180
+ // Reproduces mem #8454: cache pointed at bin/code-graph-mcp v0.5.28
181
+ // while pkg was v0.25.0 → cache was returned silently with no
182
+ // version-check, shadowing the installed 0.25.0 platform binary.
183
+ // After this fix, returns false → caller clears cache + falls through.
184
+ const bin = buildFakeBinary(t, 'code-graph-mcp 0.5.28');
185
+ assert.equal(isCachedBinaryFresh(bin, '0.25.0'), false);
186
+ });
187
+
188
+ test('isCachedBinaryFresh: missing pkg version → permissive (trust cache)', (t) => {
189
+ // Caller couldn't read package.json; refusing the cache would leave us
190
+ // with nothing. Better to trust the one path we have.
191
+ const bin = buildFakeBinary(t, 'code-graph-mcp 0.5.28');
192
+ assert.equal(isCachedBinaryFresh(bin, null), true);
193
+ assert.equal(isCachedBinaryFresh(bin, ''), true);
194
+ });
195
+
196
+ test('isCachedBinaryFresh: unreadable cache binary version → permissive', (t) => {
197
+ // Old binary that doesn't support `--version`, or output we can't
198
+ // parse. Same permissive path as missing pkg version.
199
+ const bin = buildFakeBinary(t, 'whatever garbage no semver here');
200
+ assert.equal(isCachedBinaryFresh(bin, '0.25.0'), true);
201
+ });
202
+
203
+ test('isCachedBinaryFresh: cache path does not exist → not fresh', () => {
204
+ assert.equal(isCachedBinaryFresh('/nonexistent/path/code-graph-mcp', '0.25.0'), false);
205
+ });
206
+
207
+ test('isCachedBinaryFresh: empty/null cache path → not fresh', () => {
208
+ assert.equal(isCachedBinaryFresh('', '0.25.0'), false);
209
+ assert.equal(isCachedBinaryFresh(null, '0.25.0'), false);
210
+ assert.equal(isCachedBinaryFresh(undefined, '0.25.0'), false);
211
+ });
212
+
213
+ test('isCachedBinaryFresh: file basename mismatch → not fresh', (t) => {
214
+ // realpathSync.basename check inside isNativeBinary — wrong name = not ours.
215
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cgmcp-wrongname-'));
216
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
217
+ const wrongName = path.join(dir, 'other-tool');
218
+ fs.writeFileSync(wrongName, '#!/bin/sh\necho wrong\n');
219
+ if (process.platform !== 'win32') fs.chmodSync(wrongName, 0o755);
220
+ assert.equal(isCachedBinaryFresh(wrongName, '0.25.0'), false);
221
+ });
@@ -118,13 +118,18 @@ try {
118
118
  process.exit(0);
119
119
  }
120
120
 
121
- // --- Only inject if high-impact (2+ production callers) ---
121
+ // --- Inject when the symbol has any caller (1+) ---
122
+ // Earlier gate was 2+ direct callers; reality is that editing a function with
123
+ // even one production caller benefits from a one-line impact summary, and the
124
+ // per-symbol 2-minute cooldown caps frequency. The 2+ floor was a remnant of
125
+ // the v0.21 "agent picks tools without push" assumption — same bias mem #8234
126
+ // records as bounded leverage at the bench level.
122
127
  const directCallers = jsonResult.direct_callers || 0;
123
128
  const totalCallers = jsonResult.total_callers || 0;
124
129
  const affectedFiles = jsonResult.affected_files || 0;
125
130
  const risk = jsonResult.risk || 'low';
126
131
 
127
- if (directCallers < 2) process.exit(0);
132
+ if (directCallers < 1) process.exit(0);
128
133
 
129
134
  // Mark cooldown
130
135
  try { fs.writeFileSync(cooldownFile, ''); } catch { /* ok */ }
@@ -33,23 +33,30 @@ function markCooldown(type) {
33
33
  } catch { /* ok */ }
34
34
  }
35
35
 
36
- // v0.21 — flipped to opt-in default. Routing-bench backend P@1=100% (v0.20.0)
37
- // proves Sonnet 4.5 picks tools correctly without push injection; per-prompt
38
- // CLI exec was costing 200-500 tokens/turn across N turns to repeat what the
39
- // agent would have called itself. Mirrors session-init.js computeQuietHooks
40
- // priority chain so a single env knob covers both hooks.
36
+ // Default ON. The v0.21 opt-in flip relied on routing-bench P@1=100% to argue
37
+ // "Sonnet 4.5 picks tools without push injection." That bench measures *triage
38
+ // accuracy once the agent has decided to query a tool*; it does not measure
39
+ // the prior question — *whether the agent reaches for a tool at all*.
40
+ // pre-grep-guide.js sees the real-world counter-evidence: a 15-day baseline of
41
+ // 429 raw `grep` vs 191 functional CLI calls on the same indexed source tree
42
+ // — a 13× pre-training bias toward grep. Push injection on the relevant turns
43
+ // is the corrective; cooldowns (impact 30s / overview 5min / callgraph 60s /
44
+ // search 60s) already cap per-type frequency. SessionStart `project_map` stays
45
+ // default OFF (lifecycle.js / session-init.js) — that one is a static dump
46
+ // duplicated by MEMORY.md; this hook is reactive and trigger-shaped, so the
47
+ // two defaults diverge intentionally.
41
48
  //
42
49
  // Priority (high → low):
43
- // 1. CODE_GRAPH_QUIET_HOOKS=0 → forced noisy (legacy back-compat)
44
- // 2. CODE_GRAPH_QUIET_HOOKS=1 → forced quiet (legacy back-compat)
45
- // 3. CODE_GRAPH_VERBOSE_HOOKS=1 → opt-in noisy (new, recommended)
46
- // 4. default → quiet
50
+ // 1. CODE_GRAPH_QUIET_HOOKS=1 → forced quiet (escape hatch)
51
+ // 2. CODE_GRAPH_QUIET_HOOKS=0 → forced noisy (legacy back-compat, redundant with default)
52
+ // 3. CODE_GRAPH_VERBOSE_HOOKS=1 → noisy (legacy back-compat, redundant with default)
53
+ // 4. default → noisy
47
54
  function computeQuietHooks(env = process.env) {
48
55
  const envQuiet = env.CODE_GRAPH_QUIET_HOOKS;
49
- if (envQuiet === '0') return false;
50
56
  if (envQuiet === '1') return true;
57
+ if (envQuiet === '0') return false;
51
58
  if (env.CODE_GRAPH_VERBOSE_HOOKS === '1') return false;
52
- return true;
59
+ return false;
53
60
  }
54
61
 
55
62
  // --- Pure logic (exported for testing) ---
@@ -511,62 +511,48 @@ test('skills: only expected skills exist', () => {
511
511
  assert.deepEqual(files, ['explore.md', 'index.md']);
512
512
  });
513
513
 
514
- test('CODE_GRAPH_QUIET_HOOKS=1 short-circuits silently on stdout, stderr, exit 0', () => {
515
- const { spawnSync } = require('node:child_process');
516
- const script = path.join(__dirname, 'user-prompt-context.js');
517
- const proc = spawnSync(process.execPath, [script], {
518
- input: JSON.stringify({ message: 'impact analysis for fn_that_would_trigger_search' }),
519
- env: { ...process.env, CODE_GRAPH_QUIET_HOOKS: '1' },
520
- encoding: 'utf8',
521
- timeout: 2000,
522
- });
523
- // Quiet mode must be fully silent — any stderr leaks into Claude's display.
524
- assert.equal(proc.stdout, '', 'stdout must be empty');
525
- assert.equal(proc.stderr, '', 'stderr must be empty');
526
- assert.equal(proc.status, 0, 'must exit 0');
527
- });
514
+ // ── computeQuietHooks priority chain (default-noisy flip) ────────
528
515
 
529
- // ── computeQuietHooks priority chain (v0.21 opt-in flip) ────────
530
-
531
- test('computeQuietHooks: default (no env) is QUIET', () => {
532
- // v0.21: flipped from opt-out to opt-in. Routing-bench P@1=100% earned
533
- // the right to stop pushing context the agent would have requested.
534
- assert.equal(computeQuietHooks({}), true);
516
+ test('computeQuietHooks: default (no env) is NOISY', () => {
517
+ // Default flipped back to push-on. The v0.21 opt-in default relied on
518
+ // routing-bench P@1=100% but that measures triage accuracy, not whether
519
+ // the agent reaches for a tool at all. pre-grep-guide.js sees 13× raw-grep
520
+ // bias on the same source tree push is the corrective.
521
+ assert.equal(computeQuietHooks({}), false);
535
522
  });
536
523
 
537
- test('computeQuietHooks: CODE_GRAPH_VERBOSE_HOOKS=1 enables push (opt-in)', () => {
538
- assert.equal(computeQuietHooks({ CODE_GRAPH_VERBOSE_HOOKS: '1' }), false);
524
+ test('computeQuietHooks: CODE_GRAPH_QUIET_HOOKS=1 forces quiet (escape hatch)', () => {
525
+ assert.equal(computeQuietHooks({ CODE_GRAPH_QUIET_HOOKS: '1' }), true);
539
526
  });
540
527
 
541
- test('computeQuietHooks: legacy CODE_GRAPH_QUIET_HOOKS=0 forces noisy (back-compat)', () => {
528
+ test('computeQuietHooks: CODE_GRAPH_QUIET_HOOKS=0 stays noisy (back-compat, same as default)', () => {
542
529
  assert.equal(computeQuietHooks({ CODE_GRAPH_QUIET_HOOKS: '0' }), false);
543
530
  });
544
531
 
545
- test('computeQuietHooks: legacy CODE_GRAPH_QUIET_HOOKS=1 forces quiet (back-compat)', () => {
546
- assert.equal(computeQuietHooks({ CODE_GRAPH_QUIET_HOOKS: '1' }), true);
532
+ test('computeQuietHooks: CODE_GRAPH_VERBOSE_HOOKS=1 stays noisy (back-compat, same as default)', () => {
533
+ assert.equal(computeQuietHooks({ CODE_GRAPH_VERBOSE_HOOKS: '1' }), false);
547
534
  });
548
535
 
549
- test('computeQuietHooks: legacy QUIET_HOOKS=0 wins over VERBOSE_HOOKS=1 (priority chain)', () => {
550
- // Priority order: CODE_GRAPH_QUIET_HOOKS=0/1 > CODE_GRAPH_VERBOSE_HOOKS > default.
551
- assert.equal(computeQuietHooks({ CODE_GRAPH_QUIET_HOOKS: '0', CODE_GRAPH_VERBOSE_HOOKS: '0' }), false);
536
+ test('computeQuietHooks: QUIET_HOOKS=1 wins over VERBOSE_HOOKS=1 (priority chain)', () => {
537
+ // Priority: CODE_GRAPH_QUIET_HOOKS=1 (escape) > QUIET_HOOKS=0 / VERBOSE_HOOKS=1 > default.
552
538
  assert.equal(computeQuietHooks({ CODE_GRAPH_QUIET_HOOKS: '1', CODE_GRAPH_VERBOSE_HOOKS: '1' }), true);
539
+ assert.equal(computeQuietHooks({ CODE_GRAPH_QUIET_HOOKS: '0', CODE_GRAPH_VERBOSE_HOOKS: '0' }), false);
553
540
  });
554
541
 
555
- test('default env (no flags) short-circuits silently opt-in flip', () => {
556
- // End-to-end check: with the opt-in flip, a default-env spawn produces
557
- // no stdout/stderr even on a message that previously would have injected.
542
+ test('CODE_GRAPH_QUIET_HOOKS=1 short-circuits silently on stdout, stderr, exit 0 (escape hatch verified end-to-end)', () => {
543
+ // End-to-end: the escape hatch must produce zero stdout/stderr noise
544
+ // (any leak would land in Claude's display). Was the only e2e check before
545
+ // the default-noisy flip — kept under the new default to guarantee that
546
+ // setting the env still fully silences the hook.
558
547
  const { spawnSync } = require('node:child_process');
559
548
  const script = path.join(__dirname, 'user-prompt-context.js');
560
- const cleanEnv = { ...process.env };
561
- delete cleanEnv.CODE_GRAPH_QUIET_HOOKS;
562
- delete cleanEnv.CODE_GRAPH_VERBOSE_HOOKS;
563
549
  const proc = spawnSync(process.execPath, [script], {
564
550
  input: JSON.stringify({ message: 'impact of refactoring parse_code function' }),
565
- env: cleanEnv,
551
+ env: { ...process.env, CODE_GRAPH_QUIET_HOOKS: '1' },
566
552
  encoding: 'utf8',
567
553
  timeout: 2000,
568
554
  });
569
- assert.equal(proc.stdout, '', 'default must be silent on stdout');
570
- assert.equal(proc.stderr, '', 'default must be silent on stderr');
571
- assert.equal(proc.status, 0, 'default must exit 0');
555
+ assert.equal(proc.stdout, '', 'quiet must be silent on stdout');
556
+ assert.equal(proc.stderr, '', 'quiet must be silent on stderr');
557
+ assert.equal(proc.status, 0, 'quiet must exit 0');
572
558
  });
@@ -15,12 +15,16 @@ type: reference
15
15
  > 的最新决策表(本文件 SHA 与 template 差异时覆盖)。手动编辑会被覆盖——
16
16
  > 要锁定自己的版本,设 `CODE_GRAPH_NO_TEMPLATE_REFRESH=1`(不影响首次 adopt)。
17
17
  >
18
- > **v0.17.0 起**:SessionStart `project_map` 注入 **默认 OFF**(不再随 adoption
19
- > 切换)。本文件 + 7 个工具描述已经覆盖路由所需的全部决策信息,每次会话再
20
- > dump ≈2.3 KB 的项目地图是冗余的常驻上下文成本。
21
- > 显式启用:`CODE_GRAPH_VERBOSE_HOOKS=1` —— Bash 调 `code-graph-mcp map --compact`
22
- > 也是等价的按需替代。
23
- > 向后兼容:`CODE_GRAPH_QUIET_HOOKS=0` 强制 noisy / `=1` 强制 quiet(优先级最高)。
18
+ > **Hook 默认值(两个 hook,默认不同 —— 故意的)**:
19
+ > - **SessionStart `project_map` 注入:默认 OFF**(v0.17.0 起)。本文件 + 7
20
+ > 工具描述已经覆盖路由所需决策信息,每次会话再 dump ≈2.3 KB 项目地图是冗余的
21
+ > 常驻上下文。显式启用:`CODE_GRAPH_VERBOSE_HOOKS=1`;或按需 `code-graph-mcp map --compact`。
22
+ > - **UserPromptSubmit context push:默认 ON**。基于用户消息 intent 推 impact /
23
+ > overview / callgraph / search 结果(per-type cooldown 30s–5min)。routing-bench
24
+ > P@1=100% 测的是分诊准确率(已决定查工具时选哪个),不等于触发率(是否
25
+ > 决定查工具)—— 真实 baseline 是 raw-grep ≈13× 偏向于内置 Grep。Push 是
26
+ > pre-training bias 的矫正。Escape hatch:`CODE_GRAPH_QUIET_HOOKS=1`。
27
+ > - 优先级:`CODE_GRAPH_QUIET_HOOKS=1` (escape) > 其他 env > 默认。
24
28
  >
25
29
  > **v0.18.4 起**:原"进阶 5"(impact / similar / deps / dead-code / trace)已折叠
26
30
  > 进核心 7 的 flag —— Claude Code 现在能直接通过 MCP 调用,不必落到 CLI:
@@ -122,4 +126,5 @@ code-graph-mcp health-check # 索引健康
122
126
  - `CODE_GRAPH_NO_AUTO_ADOPT=1`(`~/.claude/settings.json` env) — 阻止未来自动 adopt,不影响已 adopted 状态。
123
127
  - `CODE_GRAPH_NO_TEMPLATE_REFRESH=1`(v0.11.0+) — 锁定本文件不随插件升级刷新;允许手动编辑长久保留。
124
128
  - `CODE_GRAPH_VERBOSE_HOOKS=1`(v0.17.0+) — opt in 到 SessionStart `project_map` 注入(默认 OFF)。
125
- - `CODE_GRAPH_QUIET_HOOKS=0` — 强制恢复 `project_map` 注入;优先级高于 VERBOSE_HOOKS(向后兼容路径)。
129
+ - `CODE_GRAPH_QUIET_HOOKS=1` — UserPromptSubmit context push 的 escape hatch(默认 ON);同时强制 SessionStart `project_map` quiet。
130
+ - `CODE_GRAPH_QUIET_HOOKS=0` — 强制恢复 SessionStart `project_map` 注入(向后兼容路径)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.25.0",
3
+ "version": "0.26.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.25.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.25.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.25.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.25.0",
42
- "@sdsrs/code-graph-win32-x64": "0.25.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.26.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.26.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.26.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.26.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.26.0"
43
43
  }
44
44
  }