@sdsrs/code-graph 0.19.0 → 0.21.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.19.0",
7
+ "version": "0.21.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -28,12 +28,234 @@ function readAdoptedBy(filePath) {
28
28
  // line is a structural fix, not a signal change. Decision table lives in the
29
29
  // linked plugin_code_graph_mcp.md; this line is the router. Tag syntax
30
30
  // `[tag1, tag2]` per spec for explicit keyword matching.
31
+ //
32
+ // Generic default — used when no project-type markers detected (e.g. /tmp,
33
+ // scratch dirs, mixed repos). Per-type variants live in `buildIndexLine` and
34
+ // are computed per-cwd at adopt + needsRefresh time. Adopted-project receives
35
+ // the typed variant; everyone else falls back to this canonical line.
31
36
  const INDEX_LINE =
32
37
  '- [code-graph-mcp](plugin_code_graph_mcp.md) ' +
33
38
  '[impact, callgraph, refs, overview, semantic, ast-search, dead-code, similar, deps, trace] — ' +
34
39
  '改 X 影响面/谁调用 X/X 被谁用/看 X 源码/Y 模块长啥样/概念查询 优先于 Grep;字面匹配走 Grep。' +
35
40
  '核心 7(get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map)' +
36
41
  '+ 进阶 5(impact_analysis/trace_http_chain/dependency_graph/find_similar_code/find_dead_code),决策表见全文';
42
+
43
+ // memdir L1 升格 (per sdscc 重构方案 §5.0): the INDEX_LINE that lands in
44
+ // MEMORY.md is what Claude sees first on every keyword match. Tailoring it
45
+ // per project type primes the right tools and demotes the irrelevant ones —
46
+ // e.g. a Rust CLI never benefits from `trace_http_chain` priming, and a React
47
+ // frontend cares more about `find_references` for rename audits than `impact`.
48
+ //
49
+ // Detection is cheap substring-on-marker (no AST, no graph): the cost is one
50
+ // fs.readFileSync per cwd. Failure mode is silent fall-back to 'generic' —
51
+ // false-negatives are strictly safer than false-positives that promote the
52
+ // wrong tool.
53
+ function readFileQuiet(p) {
54
+ try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
55
+ }
56
+
57
+ // Valid project type buckets — also serves as the allow-list for
58
+ // `CODE_GRAPH_PROJECT_TYPE` env override. Anything not in this set falls back
59
+ // to file-based detection (so a typo'd env var does not silently break).
60
+ const PROJECT_TYPES = new Set([
61
+ 'rust', 'web-rs', 'web-node', 'web-py', 'web-go',
62
+ 'frontend', 'python', 'go', 'node', 'generic',
63
+ ]);
64
+
65
+ // Cargo.toml: strip `# ...` comment lines (line-leading or trailing). Then
66
+ // extract the contents of the [dependencies] section only — dev/build/target
67
+ // deps don't characterize the project's runtime web-vs-cli posture.
68
+ //
69
+ // Why a state machine and not a TOML parser: we have zero deps and don't
70
+ // want to add one for a coarse classification. This handles the >95% case
71
+ // (well-formed [dependencies] block); pathological TOML (e.g. inline-table
72
+ // dependencies) falls through to false-negative `rust` which is safer than
73
+ // false-positive `web-rs`.
74
+ function extractCargoRuntimeDeps(cargo) {
75
+ const lines = cargo.split(/\r?\n/);
76
+ const out = [];
77
+ let inDeps = false;
78
+ for (const raw of lines) {
79
+ // Strip `# comment` (line-leading or trailing). Inside string literals
80
+ // `#` is rare in Cargo dep specs; accept the false-strip risk.
81
+ const line = raw.replace(/(^|\s)#.*$/, '$1').trim();
82
+ if (line.startsWith('[')) {
83
+ // New section heading — only `[dependencies]` (canonical, exact match)
84
+ // gates web-framework detection. `[dev-dependencies]`,
85
+ // `[build-dependencies]`, `[target.'...'.dependencies]` deliberately
86
+ // skipped: a project that pulls in axum only for tests is not a web
87
+ // project for routing purposes.
88
+ inDeps = (line === '[dependencies]');
89
+ continue;
90
+ }
91
+ if (inDeps && line) out.push(line);
92
+ }
93
+ return out.join('\n');
94
+ }
95
+
96
+ // pyproject.toml: same pattern as Cargo.toml — strip comments, scan only
97
+ // [tool.poetry.dependencies] (Poetry) or [project.dependencies] (PEP 621).
98
+ function extractPyRuntimeDeps(pyproj) {
99
+ const lines = pyproj.split(/\r?\n/);
100
+ const out = [];
101
+ let inDeps = false;
102
+ for (const raw of lines) {
103
+ const line = raw.replace(/(^|\s)#.*$/, '$1').trim();
104
+ if (line.startsWith('[')) {
105
+ inDeps = (
106
+ line === '[tool.poetry.dependencies]' ||
107
+ line === '[project.dependencies]' ||
108
+ line === '[project]' // PEP 621 inline `dependencies = [...]` lives here
109
+ );
110
+ continue;
111
+ }
112
+ if (inDeps && line) out.push(line);
113
+ }
114
+ return out.join('\n');
115
+ }
116
+
117
+ // go.mod: skip `// indirect` lines (transitive deps) and `// comment` lines.
118
+ // Direct require blocks are what matter for "is this a web service?" — a
119
+ // project that transitively pulls gin via a CLI dep is still a CLI.
120
+ function extractGoDirectRequires(gomod) {
121
+ const lines = gomod.split(/\r?\n/);
122
+ const out = [];
123
+ let inRequire = false;
124
+ for (const raw of lines) {
125
+ const trimmed = raw.trim();
126
+ if (trimmed.startsWith('//')) continue; // pure comment line
127
+ if (/\/\/\s*indirect\b/.test(raw)) continue; // indirect dep marker
128
+ if (trimmed === 'require (') { inRequire = true; continue; }
129
+ if (inRequire && trimmed === ')') { inRequire = false; continue; }
130
+ if (trimmed.startsWith('require ')) out.push(trimmed.slice(8).trim());
131
+ else if (inRequire && trimmed) out.push(trimmed);
132
+ }
133
+ return out.join('\n');
134
+ }
135
+
136
+ function detectProjectType(cwd = process.cwd(), env = process.env) {
137
+ // 2D: env override beats file-based detection. Honors only valid bucket
138
+ // names; invalid override silently falls through to detection (avoids a
139
+ // typo'd env var silently classifying everything as 'generic'). Power
140
+ // users / CI can pin without touching the heuristics.
141
+ const override = env && env.CODE_GRAPH_PROJECT_TYPE;
142
+ if (override && PROJECT_TYPES.has(override)) {
143
+ return override;
144
+ }
145
+
146
+ const cargo = readFileQuiet(path.join(cwd, 'Cargo.toml'));
147
+ if (cargo) {
148
+ const deps = extractCargoRuntimeDeps(cargo);
149
+ // Web-framework detection: match on the dep name token (start-of-line
150
+ // or after whitespace/quote) to avoid hits inside path strings or
151
+ // unrelated metadata. `hyper` deliberately omitted from web-rs — it is
152
+ // also commonly used as a plain HTTP client in CLI tools (false-positive
153
+ // risk too high). axum/actix-web/etc. are unambiguous server stacks.
154
+ if (/^(actix-web|axum|rocket|warp|poem|tide|salvo)\s*=/m.test(deps)) {
155
+ return 'web-rs';
156
+ }
157
+ return 'rust';
158
+ }
159
+
160
+ const pkgRaw = readFileQuiet(path.join(cwd, 'package.json'));
161
+ if (pkgRaw) {
162
+ let pkg = null;
163
+ try { pkg = JSON.parse(pkgRaw); } catch { /* malformed → fall through */ }
164
+ if (pkg && typeof pkg === 'object') {
165
+ // Only `dependencies` matters — devDependencies are build/test only,
166
+ // and a project with `react` only in devDependencies is likely a
167
+ // component library, not a frontend app.
168
+ const deps = pkg.dependencies && typeof pkg.dependencies === 'object'
169
+ ? Object.keys(pkg.dependencies)
170
+ : [];
171
+ const has = (name) => deps.includes(name);
172
+ if (has('next') || has('react') || has('vue') || has('svelte') ||
173
+ has('@angular/core') || has('nuxt') || has('astro') ||
174
+ has('remix') || has('solid-js')) {
175
+ return 'frontend';
176
+ }
177
+ if (has('express') || has('fastify') || has('koa') || has('hono') ||
178
+ has('@nestjs/core') || has('@hapi/hapi')) {
179
+ return 'web-node';
180
+ }
181
+ }
182
+ return 'node';
183
+ }
184
+
185
+ const pyproj = readFileQuiet(path.join(cwd, 'pyproject.toml'));
186
+ if (pyproj) {
187
+ const deps = extractPyRuntimeDeps(pyproj);
188
+ if (/\b(django|flask|fastapi|starlette|sanic|tornado|quart)\b/i.test(deps)) {
189
+ return 'web-py';
190
+ }
191
+ return 'python';
192
+ }
193
+ // requirements.txt fallback: line-format `pkg==ver` / `pkg>=ver`. Strip
194
+ // `#` comment lines; scan remaining as a flat list (no section headers
195
+ // in this format).
196
+ const reqs = readFileQuiet(path.join(cwd, 'requirements.txt'));
197
+ if (reqs) {
198
+ const cleaned = reqs.split(/\r?\n/)
199
+ .map(l => l.replace(/(^|\s)#.*$/, '$1').trim())
200
+ .filter(Boolean)
201
+ .join('\n');
202
+ if (/^(django|flask|fastapi|starlette|sanic|tornado|quart)\b/im.test(cleaned)) {
203
+ return 'web-py';
204
+ }
205
+ return 'python';
206
+ }
207
+
208
+ const gomod = readFileQuiet(path.join(cwd, 'go.mod'));
209
+ if (gomod) {
210
+ const direct = extractGoDirectRequires(gomod);
211
+ if (/\b(gin-gonic|labstack\/echo|gofiber|go-chi|gorilla\/mux)\b/.test(direct)) {
212
+ return 'web-go';
213
+ }
214
+ return 'go';
215
+ }
216
+ return 'generic';
217
+ }
218
+
219
+ // Build the MEMORY.md index line for a project type. The 'generic' bucket
220
+ // returns the canonical INDEX_LINE so untyped projects (and the existing
221
+ // adopt.test.js fixtures, which use empty tmp dirs) stay byte-identical.
222
+ //
223
+ // For typed projects, the difference from generic is the tag set + the lead
224
+ // sentence — body of plugin_code_graph_mcp.md is unchanged. Decision table
225
+ // stays one source of truth; the index line just primes which subset matters
226
+ // most for THIS project.
227
+ function buildIndexLine(projectType = 'generic') {
228
+ const prefix = '- [code-graph-mcp](plugin_code_graph_mcp.md) ';
229
+ const coreSuffix =
230
+ '核心 7(get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map)' +
231
+ '+ 进阶 5(impact_analysis/trace_http_chain/dependency_graph/find_similar_code/find_dead_code),决策表见全文';
232
+ switch (projectType) {
233
+ case 'web-rs':
234
+ case 'web-node':
235
+ case 'web-py':
236
+ case 'web-go':
237
+ return prefix +
238
+ '[trace, route, callgraph, impact, refs, overview, semantic, deps] — ' +
239
+ 'HTTP 路由→handler 链路用 trace_http_chain(或 get_call_graph route_path=);改 handler 影响面用 impact;' +
240
+ '其他结构化查询同上 优先于 Grep。' + coreSuffix;
241
+ case 'frontend':
242
+ return prefix +
243
+ '[refs, overview, semantic, callgraph, impact, ast-search] — ' +
244
+ '组件重命名/重构用 find_references(含 imports/inherits);模块层级用 module_overview;' +
245
+ '改 props/接口前用 impact 看下游;HTTP route 通常不适用。' + coreSuffix;
246
+ case 'rust':
247
+ case 'go':
248
+ case 'python':
249
+ case 'node':
250
+ return prefix +
251
+ '[callgraph, impact, refs, overview, semantic, ast-search, dead-code, deps] — ' +
252
+ '改 X 影响面/谁调用 X/Y 模块 优先于 Grep;HTTP route 追踪通常不适用(无 web 框架);' +
253
+ '字面匹配走 Grep。' + coreSuffix;
254
+ case 'generic':
255
+ default:
256
+ return INDEX_LINE;
257
+ }
258
+ }
37
259
  const TEMPLATE_PATH = path.resolve(__dirname, '..', 'templates', 'plugin_code_graph_mcp.md');
38
260
  const TARGET_NAME = 'plugin_code_graph_mcp.md';
39
261
 
@@ -126,7 +348,11 @@ function adopt({ cwd, home, templatePath } = {}) {
126
348
 
127
349
  const indexPath = path.join(dir, 'MEMORY.md');
128
350
  const index = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '# Memory Index\n';
129
- const desiredBlock = `${SENTINEL_BEGIN}\n${INDEX_LINE}\n${SENTINEL_END}`;
351
+ // Per-project index line: tagged tools + lead sentence tailored to the
352
+ // detected project type. Falls back to the canonical INDEX_LINE for
353
+ // generic / untyped cwds (preserves byte-identity with prior versions).
354
+ const indexLine = buildIndexLine(detectProjectType(effectiveCwd));
355
+ const desiredBlock = `${SENTINEL_BEGIN}\n${indexLine}\n${SENTINEL_END}`;
130
356
 
131
357
  // Already-adopted-and-well-formed: skip the write entirely.
132
358
  if (index.includes(desiredBlock)) {
@@ -175,7 +401,13 @@ function needsRefresh({ cwd, home, templatePath } = {}) {
175
401
  }
176
402
  if (!shipped.equals(body)) return true;
177
403
  const index = fs.readFileSync(indexPath, 'utf8');
178
- const desiredBlock = `${SENTINEL_BEGIN}\n${INDEX_LINE}\n${SENTINEL_END}`;
404
+ // Compare against the typed INDEX_LINE for this project. Detection is
405
+ // deterministic (file-existence + substring scan) so adopt and needsRefresh
406
+ // always agree on the variant. Drift triggers refresh — including when a
407
+ // project gains a web framework dep and switches type bucket.
408
+ const effectiveCwd = cwd || process.cwd();
409
+ const indexLine = buildIndexLine(detectProjectType(effectiveCwd));
410
+ const desiredBlock = `${SENTINEL_BEGIN}\n${indexLine}\n${SENTINEL_END}`;
179
411
  return !index.includes(desiredBlock);
180
412
  }
181
413
 
@@ -294,6 +526,8 @@ if (require.main === module) {
294
526
  module.exports = {
295
527
  adopt, unadopt, memoryDir, formatResult, stripSentinelBlock,
296
528
  isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
529
+ detectProjectType, buildIndexLine,
530
+ extractCargoRuntimeDeps, extractPyRuntimeDeps, extractGoDirectRequires,
297
531
  SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
298
- PROJECT_MARKERS,
532
+ PROJECT_MARKERS, PROJECT_TYPES,
299
533
  };
@@ -7,6 +7,7 @@ const os = require('os');
7
7
  const {
8
8
  adopt, unadopt, memoryDir, stripSentinelBlock,
9
9
  isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
10
+ detectProjectType, buildIndexLine,
10
11
  SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
11
12
  PROJECT_MARKERS,
12
13
  } = require('./adopt');
@@ -585,3 +586,234 @@ test('needsRefresh ignores the adopted-by marker when bytewise comparing', () =>
585
586
  'needsRefresh should be false right after adopt — marker must not trigger drift');
586
587
  } finally { sb.cleanup(); }
587
588
  });
589
+
590
+ // memdir L1 升格 — project-typed INDEX_LINE coverage.
591
+
592
+ test('detectProjectType returns generic for an empty cwd', () => {
593
+ const sb = makeSandbox();
594
+ try {
595
+ assert.strictEqual(detectProjectType(sb.cwd), 'generic');
596
+ } finally { sb.cleanup(); }
597
+ });
598
+
599
+ test('detectProjectType returns rust for a Cargo.toml without web framework', () => {
600
+ const sb = makeSandbox();
601
+ try {
602
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n[dependencies]\nserde="1"\n');
603
+ assert.strictEqual(detectProjectType(sb.cwd), 'rust');
604
+ } finally { sb.cleanup(); }
605
+ });
606
+
607
+ test('detectProjectType returns web-rs when Cargo.toml has axum/actix/etc', () => {
608
+ const sb = makeSandbox();
609
+ try {
610
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[dependencies]\naxum = "0.7"\n');
611
+ assert.strictEqual(detectProjectType(sb.cwd), 'web-rs');
612
+ } finally { sb.cleanup(); }
613
+ });
614
+
615
+ test('detectProjectType returns frontend for package.json with React/Next/Vue', () => {
616
+ const sb = makeSandbox();
617
+ try {
618
+ fs.writeFileSync(path.join(sb.cwd, 'package.json'),
619
+ '{"dependencies":{"next":"^14","react":"^18"}}');
620
+ assert.strictEqual(detectProjectType(sb.cwd), 'frontend');
621
+ } finally { sb.cleanup(); }
622
+ });
623
+
624
+ test('detectProjectType returns web-node for package.json with express/fastify', () => {
625
+ const sb = makeSandbox();
626
+ try {
627
+ fs.writeFileSync(path.join(sb.cwd, 'package.json'),
628
+ '{"dependencies":{"express":"^4"}}');
629
+ assert.strictEqual(detectProjectType(sb.cwd), 'web-node');
630
+ } finally { sb.cleanup(); }
631
+ });
632
+
633
+ test('detectProjectType returns web-py for FastAPI in pyproject.toml', () => {
634
+ const sb = makeSandbox();
635
+ try {
636
+ fs.writeFileSync(path.join(sb.cwd, 'pyproject.toml'),
637
+ '[tool.poetry.dependencies]\nfastapi = "^0.115"\n');
638
+ assert.strictEqual(detectProjectType(sb.cwd), 'web-py');
639
+ } finally { sb.cleanup(); }
640
+ });
641
+
642
+ test('buildIndexLine generic returns the canonical INDEX_LINE byte-for-byte', () => {
643
+ // Critical: keeps backward compatibility with adopted projects that have no
644
+ // markers. Any drift here invalidates needsRefresh's idempotency assumption.
645
+ assert.strictEqual(buildIndexLine('generic'), INDEX_LINE);
646
+ assert.strictEqual(buildIndexLine(undefined), INDEX_LINE);
647
+ });
648
+
649
+ test('buildIndexLine web-rs prepends route/trace tags + handler-focused lead', () => {
650
+ const line = buildIndexLine('web-rs');
651
+ assert.match(line, /\[trace, route,/, 'web-rs index line should lead with trace/route tags');
652
+ assert.match(line, /HTTP 路由/, 'lead sentence should mention HTTP routes');
653
+ });
654
+
655
+ test('buildIndexLine frontend emphasizes refs/overview, drops HTTP route priming', () => {
656
+ const line = buildIndexLine('frontend');
657
+ assert.match(line, /组件重命名|find_references/, 'frontend should mention rename audit / refs');
658
+ assert.match(line, /HTTP route 通常不适用/, 'frontend should explicitly demote HTTP route tracing');
659
+ });
660
+
661
+ test('adopt + needsRefresh agree on typed INDEX_LINE — no spurious refresh in a Rust project', () => {
662
+ // The detection function is deterministic + adopt and needsRefresh both call
663
+ // it; together they must produce a consistent indexLine, otherwise every
664
+ // SessionStart triggers a rewrite.
665
+ const sb = makeSandbox();
666
+ try {
667
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
668
+ adopt({ cwd: sb.cwd, home: sb.home });
669
+ assert.strictEqual(needsRefresh({ cwd: sb.cwd, home: sb.home }), false,
670
+ 'needsRefresh must be false right after adopt for a Rust project');
671
+ const indexPath = path.join(sb.dir, 'MEMORY.md');
672
+ const index = fs.readFileSync(indexPath, 'utf8');
673
+ assert.ok(index.includes('优先于 Grep'),
674
+ 'MEMORY.md should contain the rust-typed index line');
675
+ } finally { sb.cleanup(); }
676
+ });
677
+
678
+ // 2A — false-positive hardening: comment-strip + section-aware scan.
679
+
680
+ test('detectProjectType ignores commented-out web-framework deps in Cargo.toml', () => {
681
+ // Pre-fix: `# axum = "0.7"` substring-matched and falsely promoted to web-rs.
682
+ // Post-fix: comment stripping happens before section scan.
683
+ const sb = makeSandbox();
684
+ try {
685
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
686
+ '[package]\nname="x"\n[dependencies]\n# axum = "0.7" # disabled, was for prototype\nserde = "1"\n');
687
+ assert.strictEqual(detectProjectType(sb.cwd), 'rust',
688
+ 'commented dep must not promote to web-rs');
689
+ } finally { sb.cleanup(); }
690
+ });
691
+
692
+ test('detectProjectType ignores axum in [dev-dependencies] only', () => {
693
+ // axum used solely for tests does not make this a web project.
694
+ const sb = makeSandbox();
695
+ try {
696
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
697
+ '[package]\nname="x"\n[dependencies]\nserde = "1"\n[dev-dependencies]\naxum = "0.7"\n');
698
+ assert.strictEqual(detectProjectType(sb.cwd), 'rust',
699
+ 'axum in dev-dependencies must not promote to web-rs');
700
+ } finally { sb.cleanup(); }
701
+ });
702
+
703
+ test('detectProjectType ignores axum in [build-dependencies] only', () => {
704
+ const sb = makeSandbox();
705
+ try {
706
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
707
+ '[package]\nname="x"\n[dependencies]\nserde = "1"\n[build-dependencies]\naxum = "0.7"\n');
708
+ assert.strictEqual(detectProjectType(sb.cwd), 'rust',
709
+ 'axum in build-dependencies must not promote to web-rs');
710
+ } finally { sb.cleanup(); }
711
+ });
712
+
713
+ test('detectProjectType ignores react in devDependencies of package.json', () => {
714
+ // A library that lists react in devDependencies for testing should not
715
+ // be classified as a frontend app.
716
+ const sb = makeSandbox();
717
+ try {
718
+ fs.writeFileSync(path.join(sb.cwd, 'package.json'),
719
+ JSON.stringify({
720
+ dependencies: { lodash: '^4' },
721
+ devDependencies: { react: '^18', 'react-dom': '^18' },
722
+ }));
723
+ assert.strictEqual(detectProjectType(sb.cwd), 'node',
724
+ 'react in devDependencies must not promote to frontend');
725
+ } finally { sb.cleanup(); }
726
+ });
727
+
728
+ test('detectProjectType ignores // indirect deps in go.mod', () => {
729
+ const sb = makeSandbox();
730
+ try {
731
+ fs.writeFileSync(path.join(sb.cwd, 'go.mod'),
732
+ 'module example.com/x\n\nrequire (\n\tgithub.com/some/cli v1.0.0\n\tgithub.com/gin-gonic/gin v1.9.0 // indirect\n)\n');
733
+ assert.strictEqual(detectProjectType(sb.cwd), 'go',
734
+ 'indirect gin must not promote to web-go');
735
+ } finally { sb.cleanup(); }
736
+ });
737
+
738
+ test('detectProjectType handles malformed package.json without throwing', () => {
739
+ // JSON.parse failure must not crash detection; falls back to 'node' since
740
+ // package.json exists but is unreadable.
741
+ const sb = makeSandbox();
742
+ try {
743
+ fs.writeFileSync(path.join(sb.cwd, 'package.json'), '{not valid json');
744
+ assert.strictEqual(detectProjectType(sb.cwd), 'node',
745
+ 'malformed package.json should fall back to node bucket');
746
+ } finally { sb.cleanup(); }
747
+ });
748
+
749
+ test('detectProjectType detects PEP 621 [project] dependencies block', () => {
750
+ // PEP 621 puts `dependencies = [...]` inside [project], not a separate
751
+ // [project.dependencies] section — our state machine accepts both.
752
+ const sb = makeSandbox();
753
+ try {
754
+ fs.writeFileSync(path.join(sb.cwd, 'pyproject.toml'),
755
+ '[project]\nname = "x"\ndependencies = ["fastapi>=0.115", "uvicorn"]\n');
756
+ assert.strictEqual(detectProjectType(sb.cwd), 'web-py');
757
+ } finally { sb.cleanup(); }
758
+ });
759
+
760
+ test('detectProjectType reads requirements.txt as fallback', () => {
761
+ const sb = makeSandbox();
762
+ try {
763
+ fs.writeFileSync(path.join(sb.cwd, 'requirements.txt'),
764
+ '# web stack\nflask>=3.0\ngunicorn\n');
765
+ assert.strictEqual(detectProjectType(sb.cwd), 'web-py');
766
+ } finally { sb.cleanup(); }
767
+ });
768
+
769
+ // 2D — env override.
770
+
771
+ test('CODE_GRAPH_PROJECT_TYPE env override beats file-based detection', () => {
772
+ const sb = makeSandbox();
773
+ try {
774
+ // Cargo.toml says rust — env says web-rs. Env wins.
775
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
776
+ assert.strictEqual(
777
+ detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'web-rs' }),
778
+ 'web-rs',
779
+ );
780
+ } finally { sb.cleanup(); }
781
+ });
782
+
783
+ test('CODE_GRAPH_PROJECT_TYPE env override falls through on invalid value', () => {
784
+ // Typo'd / unknown bucket name should not silently classify everything as
785
+ // 'generic' — fall through to file-based detection so the project still
786
+ // gets a meaningful index line.
787
+ const sb = makeSandbox();
788
+ try {
789
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
790
+ assert.strictEqual(
791
+ detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'web-rust' /* typo */ }),
792
+ 'rust',
793
+ 'invalid override must fall through to file detection',
794
+ );
795
+ } finally { sb.cleanup(); }
796
+ });
797
+
798
+ test('CODE_GRAPH_PROJECT_TYPE env override unset uses file detection', () => {
799
+ // Empty env reaches file-based detection unchanged.
800
+ const sb = makeSandbox();
801
+ try {
802
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
803
+ assert.strictEqual(detectProjectType(sb.cwd, {}), 'rust');
804
+ } finally { sb.cleanup(); }
805
+ });
806
+
807
+ test('CODE_GRAPH_PROJECT_TYPE env override forces generic in a Rust project', () => {
808
+ // Power-user case: explicit opt-out of typed routing for a project that
809
+ // would otherwise be auto-classified.
810
+ const sb = makeSandbox();
811
+ try {
812
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
813
+ '[package]\nname="x"\n[dependencies]\naxum = "0.7"\n');
814
+ assert.strictEqual(
815
+ detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'generic' }),
816
+ 'generic',
817
+ );
818
+ } finally { sb.cleanup(); }
819
+ });
@@ -3,12 +3,41 @@
3
3
  const { execFileSync } = require('child_process');
4
4
  const { findBinary } = require('./find-binary');
5
5
 
6
- const bin = findBinary();
7
- if (!bin) process.exit(0); // silent binary not installed yet
8
-
9
- try {
10
- execFileSync(bin, ['incremental-index', '--quiet'], {
11
- timeout: 8000,
12
- stdio: ['pipe', 'pipe', 'pipe']
13
- });
14
- } catch { /* timeout or error — silent for hook */ }
6
+ // v0.21 gated default-off. v0.18.0 added query-time freshness
7
+ // (ensure_file_indexed) inside MCP tools that take a file_path arg, so a
8
+ // PostToolUse hook spawning a fresh process on every Edit/Write was redundant
9
+ // for the MCP-driven workflow and just burnt ~80ms cold-start per edit.
10
+ //
11
+ // CLI-only workflows (running `code-graph-mcp search` after Bash-side edits
12
+ // without going through MCP) need the hook to keep the DB fresh, so the knob
13
+ // lets users opt back in.
14
+ //
15
+ // Priority (high → low):
16
+ // 1. CODE_GRAPH_HOOK_INDEX=on → run the hook (opt-in)
17
+ // 2. CODE_GRAPH_HOOK_INDEX=off → skip
18
+ // 3. default → skip (v0.21 flip)
19
+ function shouldRun(env = process.env) {
20
+ const v = (env.CODE_GRAPH_HOOK_INDEX || '').toLowerCase();
21
+ if (v === 'on' || v === '1' || v === 'true') return true;
22
+ return false;
23
+ }
24
+
25
+ function runMain() {
26
+ if (!shouldRun()) return;
27
+
28
+ const bin = findBinary();
29
+ if (!bin) return; // silent — binary not installed yet
30
+
31
+ try {
32
+ execFileSync(bin, ['incremental-index', '--quiet'], {
33
+ timeout: 8000,
34
+ stdio: ['pipe', 'pipe', 'pipe']
35
+ });
36
+ } catch { /* timeout or error — silent for hook */ }
37
+ }
38
+
39
+ if (require.main === module) {
40
+ runMain();
41
+ }
42
+
43
+ module.exports = { shouldRun };
@@ -7,6 +7,61 @@ const os = require('os');
7
7
  const { spawnSync } = require('child_process');
8
8
 
9
9
  const { findBinary } = require('./find-binary');
10
+ const { shouldRun } = require('./incremental-index');
11
+
12
+ // ── shouldRun gate (v0.21 opt-in flip) ──────────────────
13
+
14
+ test('shouldRun: default (no env) is OFF', () => {
15
+ // v0.21: hook-driven incremental-index is opt-in. Rely on
16
+ // ensure_file_indexed query-time freshness for MCP workflows.
17
+ assert.equal(shouldRun({}), false);
18
+ });
19
+
20
+ test('shouldRun: CODE_GRAPH_HOOK_INDEX=on enables hook (opt-in)', () => {
21
+ assert.equal(shouldRun({ CODE_GRAPH_HOOK_INDEX: 'on' }), true);
22
+ });
23
+
24
+ test('shouldRun: CODE_GRAPH_HOOK_INDEX=1 enables hook (truthy alias)', () => {
25
+ assert.equal(shouldRun({ CODE_GRAPH_HOOK_INDEX: '1' }), true);
26
+ });
27
+
28
+ test('shouldRun: CODE_GRAPH_HOOK_INDEX=true enables hook (truthy alias)', () => {
29
+ assert.equal(shouldRun({ CODE_GRAPH_HOOK_INDEX: 'true' }), true);
30
+ });
31
+
32
+ test('shouldRun: CODE_GRAPH_HOOK_INDEX=off keeps hook off (explicit)', () => {
33
+ assert.equal(shouldRun({ CODE_GRAPH_HOOK_INDEX: 'off' }), false);
34
+ });
35
+
36
+ test('shouldRun: CODE_GRAPH_HOOK_INDEX with empty string is OFF', () => {
37
+ assert.equal(shouldRun({ CODE_GRAPH_HOOK_INDEX: '' }), false);
38
+ });
39
+
40
+ test('shouldRun: CODE_GRAPH_HOOK_INDEX is case-insensitive', () => {
41
+ assert.equal(shouldRun({ CODE_GRAPH_HOOK_INDEX: 'ON' }), true);
42
+ assert.equal(shouldRun({ CODE_GRAPH_HOOK_INDEX: 'On' }), true);
43
+ assert.equal(shouldRun({ CODE_GRAPH_HOOK_INDEX: 'TRUE' }), true);
44
+ });
45
+
46
+ test('hook script default-env spawn does not invoke the binary (default OFF)', () => {
47
+ // End-to-end check: with the v0.21 opt-in flip, a default-env spawn of
48
+ // incremental-index.js exits 0 immediately without touching the binary.
49
+ const script = path.join(__dirname, 'incremental-index.js');
50
+ const cleanEnv = { ...process.env };
51
+ delete cleanEnv.CODE_GRAPH_HOOK_INDEX;
52
+ const t0 = Date.now();
53
+ const proc = spawnSync(process.execPath, [script], {
54
+ env: cleanEnv,
55
+ encoding: 'utf8',
56
+ timeout: 2000,
57
+ stdio: ['pipe', 'pipe', 'pipe'],
58
+ });
59
+ // Should be much faster than the 80ms+ cold-start of running the binary.
60
+ // 500ms is generous — actual is ~30-50ms node startup.
61
+ assert.equal(proc.status, 0, `expected exit 0, got ${proc.status}; stderr: ${proc.stderr}`);
62
+ assert.equal(proc.stdout, '', 'stdout must be empty when default-OFF');
63
+ assert.ok(Date.now() - t0 < 500, 'default-OFF must short-circuit fast (< 500ms)');
64
+ });
10
65
 
11
66
  test('incremental-index bails silently when cwd is not a git repo', (t) => {
12
67
  const bin = findBinary();
@@ -8,28 +8,8 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
10
 
11
- // --- Mid-session install detection ---
12
- // If hooks are running but lifecycle install() hasn't executed yet (no manifest),
13
- // the plugin was installed mid-session and the MCP server isn't connected.
14
- // Claude Code only starts MCP servers at session startup; /mcp reconnect cannot
15
- // start servers that were never initialized.
11
+ // Mid-session install detection: hook fires but no manifest yet.
16
12
  const MANIFEST_PATH = path.join(os.homedir(), '.cache', 'code-graph', 'install-manifest.json');
17
- if (!fs.existsSync(MANIFEST_PATH)) {
18
- const noticeFile = path.join(os.tmpdir(), '.code-graph-mcp-restart-notice');
19
- try {
20
- // Show once per hour to avoid spam
21
- if (Date.now() - fs.statSync(noticeFile).mtimeMs < 3600000) process.exit(0);
22
- } catch { /* first notice */ }
23
- try { fs.writeFileSync(noticeFile, ''); } catch { /* ok */ }
24
- process.stdout.write(
25
- '[code-graph] Plugin installed — MCP server requires a session restart to connect.\n' +
26
- 'MCP servers are only initialized at session startup. To activate:\n' +
27
- ' 1. Press Ctrl+C to exit the current session\n' +
28
- ' 2. Re-run `claude` to start a new session\n' +
29
- 'Meanwhile, CLI tools work directly: code-graph-mcp search <query>, code-graph-mcp map, etc.\n'
30
- );
31
- process.exit(0);
32
- }
33
13
 
34
14
  // --- Per-type rate limiting (replaces single global cooldown) ---
35
15
  const COOLDOWNS = {
@@ -53,25 +33,24 @@ function markCooldown(type) {
53
33
  } catch { /* ok */ }
54
34
  }
55
35
 
56
- // CODE_GRAPH_QUIET_HOOKS=1 skip passive per-prompt injection entirely.
57
- // Users opt in to this mode when MEMORY.md + explicit tool calls cover their needs.
58
- if (process.env.CODE_GRAPH_QUIET_HOOKS === '1') process.exit(0);
59
-
60
- // --- Read user message ---
61
- let message;
62
- try {
63
- const input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
64
- message = (input && input.message) || '';
65
- } catch {
66
- process.exit(0);
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.
41
+ //
42
+ // 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
47
+ function computeQuietHooks(env = process.env) {
48
+ const envQuiet = env.CODE_GRAPH_QUIET_HOOKS;
49
+ if (envQuiet === '0') return false;
50
+ if (envQuiet === '1') return true;
51
+ if (env.CODE_GRAPH_VERBOSE_HOOKS === '1') return false;
52
+ return true;
67
53
  }
68
- // Chinese chars are ~3 bytes but 1 char; "看看 fts5_search" is only 16 chars
69
- if (!message || message.length < 8) process.exit(0);
70
-
71
- // --- Check index ---
72
- const cwd = process.cwd();
73
- const dbPath = path.join(cwd, '.code-graph', 'index.db');
74
- if (!fs.existsSync(dbPath)) process.exit(0);
75
54
 
76
55
  // --- Pure logic (exported for testing) ---
77
56
 
@@ -121,14 +100,203 @@ function extractSymbols(msg) {
121
100
  return { symbols: candidates, lowConfidence };
122
101
  }
123
102
 
103
+ // v0.21 — replaced 6 mixed-language regex piles with per-keyword weighted
104
+ // patterns. Each row is testable in isolation, weights ready for tuning when
105
+ // false-positive data accumulates. Threshold 0.5 + uniform weight 1.0
106
+ // preserves the original OR-of-alternatives behavior 1:1; future tuning can
107
+ // downweight noisy short keywords like "bug" or "什么" that currently fire
108
+ // too eagerly. Maintenance cost: ~150 lines of table vs 6 × 200-char regex —
109
+ // regression history (#5754, #7713) shows the regex form was the higher
110
+ // silent-bug surface.
111
+ const INTENT_PATTERNS = {
112
+ impact: [
113
+ [/impact/i, 1.0],
114
+ [/影响/, 1.0],
115
+ [/修改前/, 1.0],
116
+ [/改之前/, 1.0],
117
+ [/blast radius/i, 1.0],
118
+ [/before (?:edit|chang|modif)/i, 1.0],
119
+ [/risk/i, 1.0],
120
+ [/风险/, 1.0],
121
+ [/改动范围/, 1.0],
122
+ [/波及/, 1.0],
123
+ [/问题在/, 1.0],
124
+ [/bug/i, 1.0],
125
+ [/干扰/, 1.0],
126
+ [/冲突/, 1.0],
127
+ [/卡/, 1.0],
128
+ ],
129
+ modify: [
130
+ [/改(?!变)/, 1.0],
131
+ [/修改/, 1.0],
132
+ [/修复/, 1.0],
133
+ [/重构/, 1.0],
134
+ [/优化/, 1.0],
135
+ [/简化/, 1.0],
136
+ [/精简/, 1.0],
137
+ [/适配/, 1.0],
138
+ [/统一/, 1.0],
139
+ [/修正/, 1.0],
140
+ [/调整/, 1.0],
141
+ [/去掉/, 1.0],
142
+ [/整理/, 1.0],
143
+ [/清理/, 1.0],
144
+ [/解耦/, 1.0],
145
+ [/更新/, 1.0],
146
+ [/\brefactor\b/i, 1.0],
147
+ [/\bchange\b/i, 1.0],
148
+ [/\brename\b/i, 1.0],
149
+ [/\bfix\b/i, 1.0],
150
+ [/移动/, 1.0],
151
+ [/\bmove\b/i, 1.0],
152
+ [/删(?!除文件)/, 1.0],
153
+ [/\bremove\b/i, 1.0],
154
+ [/替换/, 1.0],
155
+ [/\breplace\b/i, 1.0],
156
+ [/\bupdate\b/i, 1.0],
157
+ [/升级/, 1.0],
158
+ [/\bmigrate\b/i, 1.0],
159
+ [/迁移/, 1.0],
160
+ [/拆分/, 1.0],
161
+ [/\bsplit\b/i, 1.0],
162
+ [/合并/, 1.0],
163
+ [/\bmerge\b/i, 1.0],
164
+ [/提取/, 1.0],
165
+ [/\bextract\b/i, 1.0],
166
+ [/改成/, 1.0],
167
+ [/改为/, 1.0],
168
+ [/换成/, 1.0],
169
+ [/转为/, 1.0],
170
+ [/异步/, 1.0],
171
+ [/同步/, 1.0],
172
+ ],
173
+ implement: [
174
+ [/\badd\b/i, 1.0],
175
+ [/\bimplement\b/i, 1.0],
176
+ [/\bcreate\b/i, 1.0],
177
+ [/\bbuild\b/i, 1.0],
178
+ [/\bwrite\b/i, 1.0],
179
+ [/新增/, 1.0],
180
+ [/添加/, 1.0],
181
+ [/实现/, 1.0],
182
+ [/创建/, 1.0],
183
+ [/编写/, 1.0],
184
+ [/开发/, 1.0],
185
+ [/增加/, 1.0],
186
+ [/加上/, 1.0],
187
+ [/加个/, 1.0],
188
+ [/写/, 1.0],
189
+ [/做个/, 1.0],
190
+ [/搭建/, 1.0],
191
+ [/补充/, 1.0],
192
+ [/引入/, 1.0],
193
+ [/支持/, 1.0],
194
+ [/封装/, 1.0],
195
+ [/接入/, 1.0],
196
+ [/对接/, 1.0],
197
+ [/配置/, 1.0],
198
+ ],
199
+ understand: [
200
+ [/how does/i, 1.0],
201
+ [/怎么工作/, 1.0],
202
+ [/怎么实现/, 1.0],
203
+ [/怎么做/, 1.0],
204
+ [/什么/, 1.0],
205
+ [/理解/, 1.0],
206
+ [/看看/, 1.0],
207
+ [/看一下/, 1.0],
208
+ [/了解/, 1.0],
209
+ [/分析/, 1.0],
210
+ [/explain/i, 1.0],
211
+ [/understand/i, 1.0],
212
+ [/架构/, 1.0],
213
+ [/architecture/i, 1.0],
214
+ [/structure/i, 1.0],
215
+ [/overview/i, 1.0],
216
+ [/模块/, 1.0],
217
+ [/概览/, 1.0],
218
+ [/干什么/, 1.0],
219
+ [/做什么/, 1.0],
220
+ [/工作原理/, 1.0],
221
+ [/逻辑/, 1.0],
222
+ [/机制/, 1.0],
223
+ [/流程/, 1.0],
224
+ [/功能/, 1.0],
225
+ [/结合度/, 1.0],
226
+ [/效率/, 1.0],
227
+ [/评估/, 1.0],
228
+ [/调研/, 1.0],
229
+ [/是什么/, 1.0],
230
+ [/有什么/, 1.0],
231
+ [/能用不/, 1.0],
232
+ [/高效不/, 1.0],
233
+ [/达标/, 1.0],
234
+ [/起作用/, 1.0],
235
+ [/科学/, 1.0],
236
+ [/深入思考/, 1.0],
237
+ [/源码/, 1.0],
238
+ [/检查/, 1.0],
239
+ [/审核/, 1.0],
240
+ [/审查/, 1.0],
241
+ [/验证/, 1.0],
242
+ [/诊断/, 1.0],
243
+ ],
244
+ callgraph: [
245
+ [/who calls/i, 1.0],
246
+ [/what calls/i, 1.0],
247
+ [/调用/, 1.0],
248
+ [/call(?:graph|er|ee)/i, 1.0],
249
+ [/trace/i, 1.0],
250
+ [/链路/, 1.0],
251
+ [/追踪/, 1.0],
252
+ [/谁调/, 1.0],
253
+ [/被谁调/, 1.0],
254
+ [/调了谁/, 1.0],
255
+ [/上下游/, 1.0],
256
+ [/依赖关系/, 1.0],
257
+ [/触发/, 1.0],
258
+ [/路径/, 1.0],
259
+ [/覆盖/, 1.0],
260
+ [/介入/, 1.0],
261
+ ],
262
+ search: [
263
+ [/where is/i, 1.0],
264
+ [/在哪/, 1.0],
265
+ [/find/i, 1.0],
266
+ [/search/i, 1.0],
267
+ [/搜索/, 1.0],
268
+ [/找/, 1.0],
269
+ [/locate/i, 1.0],
270
+ [/哪里用/, 1.0],
271
+ [/哪里定义/, 1.0],
272
+ [/定义在/, 1.0],
273
+ [/实现在/, 1.0],
274
+ [/处理没/, 1.0],
275
+ [/在源码/, 1.0],
276
+ [/加不加/, 1.0],
277
+ ],
278
+ };
279
+
280
+ const INTENT_THRESHOLD = 0.5;
281
+
282
+ function scoreIntent(msg, intent) {
283
+ const patterns = INTENT_PATTERNS[intent];
284
+ if (!patterns) return 0;
285
+ let max = 0;
286
+ for (const [pattern, weight] of patterns) {
287
+ if (pattern.test(msg) && weight > max) max = weight;
288
+ }
289
+ return max;
290
+ }
291
+
124
292
  function detectIntents(msg) {
125
293
  return {
126
- impact: /(?:impact|影响|修改前|改之前|blast radius|before (?:edit|chang|modif)|risk|风险|改动范围|波及|问题在|bug|干扰|冲突|卡)/i.test(msg),
127
- modify: /(?:改(?!变)|修改|修复|重构|优化|简化|精简|适配|统一|修正|调整|去掉|整理|清理|解耦|更新|\brefactor\b|\bchange\b|\brename\b|\bfix\b|移动|\bmove\b|删(?!除文件)|\bremove\b|替换|\breplace\b|\bupdate\b|升级|\bmigrate\b|迁移|拆分|\bsplit\b|合并|\bmerge\b|提取|\bextract\b|改成|改为|换成|转为|异步|同步)/i.test(msg),
128
- implement: /(?:\badd\b|\bimplement\b|\bcreate\b|\bbuild\b|\bwrite\b|新增|添加|实现|创建|编写|开发|增加|加上|加个|写|做个|搭建|补充|引入|支持|封装|接入|对接|配置)/i.test(msg),
129
- understand: /(?:how does|怎么工作|怎么实现|怎么做|什么|理解|看看|看一下|了解|分析|explain|understand|架构|architecture|structure|overview|模块|概览|干什么|做什么|工作原理|逻辑|机制|流程|功能|结合度|效率|评估|调研|是什么|有什么|能用不|高效不|达标|起作用|科学|深入思考|源码|检查|审核|审查|验证|诊断)/i.test(msg),
130
- callgraph: /(?:who calls|what calls|调用|call(?:graph|er|ee)|trace|链路|追踪|谁调|被谁调|调了谁|上下游|依赖关系|触发|路径|覆盖|介入)/i.test(msg),
131
- search: /(?:where is|在哪|find|search|搜索|找|locate|哪里用|哪里定义|定义在|实现在|处理没|在源码|加不加)/i.test(msg),
294
+ impact: scoreIntent(msg, 'impact') >= INTENT_THRESHOLD,
295
+ modify: scoreIntent(msg, 'modify') >= INTENT_THRESHOLD,
296
+ implement: scoreIntent(msg, 'implement') >= INTENT_THRESHOLD,
297
+ understand: scoreIntent(msg, 'understand') >= INTENT_THRESHOLD,
298
+ callgraph: scoreIntent(msg, 'callgraph') >= INTENT_THRESHOLD,
299
+ search: scoreIntent(msg, 'search') >= INTENT_THRESHOLD,
132
300
  };
133
301
  }
134
302
 
@@ -152,15 +320,55 @@ function determineQueryType(intents, symbols, filePaths, isCoolingDownFn) {
152
320
  }
153
321
 
154
322
  // --- Main execution (only when run directly) ---
155
- if (require.main === module) {
156
- if (shouldSkip(message)) process.exit(0);
323
+ // All exit-on-condition checks (manifest, computeQuietHooks, message length,
324
+ // db presence) live INSIDE this guard so `require()` from tests doesn't
325
+ // terminate the test process on module load.
326
+ function runMain() {
327
+ // Mid-session install: lifecycle.js install() hasn't run yet (no manifest).
328
+ // MCP server only starts at session startup — tell the user to restart.
329
+ if (!fs.existsSync(MANIFEST_PATH)) {
330
+ const noticeFile = path.join(os.tmpdir(), '.code-graph-mcp-restart-notice');
331
+ try {
332
+ // Show once per hour to avoid spam
333
+ if (Date.now() - fs.statSync(noticeFile).mtimeMs < 3600000) return;
334
+ } catch { /* first notice */ }
335
+ try { fs.writeFileSync(noticeFile, ''); } catch { /* ok */ }
336
+ process.stdout.write(
337
+ '[code-graph] Plugin installed — MCP server requires a session restart to connect.\n' +
338
+ 'MCP servers are only initialized at session startup. To activate:\n' +
339
+ ' 1. Press Ctrl+C to exit the current session\n' +
340
+ ' 2. Re-run `claude` to start a new session\n' +
341
+ 'Meanwhile, CLI tools work directly: code-graph-mcp search <query>, code-graph-mcp map, etc.\n'
342
+ );
343
+ return;
344
+ }
345
+
346
+ if (computeQuietHooks()) return;
347
+
348
+ // --- Read user message ---
349
+ let message;
350
+ try {
351
+ const input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
352
+ message = (input && input.message) || '';
353
+ } catch {
354
+ return;
355
+ }
356
+ // Chinese chars are ~3 bytes but 1 char; "看看 fts5_search" is only 16 chars
357
+ if (!message || message.length < 8) return;
358
+
359
+ // --- Check index ---
360
+ const cwd = process.cwd();
361
+ const dbPath = path.join(cwd, '.code-graph', 'index.db');
362
+ if (!fs.existsSync(dbPath)) return;
363
+
364
+ if (shouldSkip(message)) return;
157
365
 
158
366
  const filePaths = extractFilePaths(message);
159
367
  const symbols = extractSymbols(message);
160
368
  const intents = detectIntents(message);
161
369
  const query = determineQueryType(intents, symbols, filePaths, isCoolingDown);
162
370
 
163
- if (!query) process.exit(0);
371
+ if (!query) return;
164
372
 
165
373
  const PREFIXES = {
166
374
  impact: '[code-graph:impact] Blast radius — review before editing:',
@@ -169,6 +377,15 @@ if (require.main === module) {
169
377
  search: '[code-graph:search] Relevant code:',
170
378
  };
171
379
 
380
+ function run(cmd, args) {
381
+ return execFileSync(cmd, args, {
382
+ cwd,
383
+ timeout: 3000,
384
+ encoding: 'utf8',
385
+ stdio: ['pipe', 'pipe', 'pipe'],
386
+ });
387
+ }
388
+
172
389
  try {
173
390
  let result = '';
174
391
  if (query.type === 'impact') result = run('code-graph-mcp', ['impact', query.symbol]);
@@ -181,19 +398,12 @@ if (require.main === module) {
181
398
  process.stdout.write(`${PREFIXES[query.type]}\n${result.trim()}\n`);
182
399
  }
183
400
  } catch {
184
- process.exit(0);
401
+ /* return silently */
185
402
  }
186
403
  }
187
404
 
188
- module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, determineQueryType, STOP_WORDS, PLAIN_WORD_EXCLUDE };
189
-
190
- // --- Helpers ---
191
-
192
- function run(cmd, args) {
193
- return execFileSync(cmd, args, {
194
- cwd,
195
- timeout: 3000,
196
- encoding: 'utf8',
197
- stdio: ['pipe', 'pipe', 'pipe'],
198
- });
405
+ if (require.main === module) {
406
+ runMain();
199
407
  }
408
+
409
+ module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE };
@@ -9,7 +9,11 @@ const {
9
9
  extractFilePaths,
10
10
  extractSymbols,
11
11
  detectIntents,
12
+ scoreIntent,
13
+ INTENT_PATTERNS,
14
+ INTENT_THRESHOLD,
12
15
  determineQueryType,
16
+ computeQuietHooks,
13
17
  } = require('./user-prompt-context');
14
18
 
15
19
  // ── shouldSkip ──────────────────────────────────────────
@@ -250,6 +254,48 @@ test('detectIntents: search (ZH)', () => {
250
254
  assert.ok(detectIntents('在哪里用了这个常量').search);
251
255
  });
252
256
 
257
+ // --- Per-keyword scoring (v0.21 weighted-scorer refactor) ---
258
+ test('scoreIntent: matched keyword returns its weight, unmatched returns 0', () => {
259
+ // Each pattern in INTENT_PATTERNS is testable in isolation now.
260
+ assert.equal(scoreIntent('this bug is critical', 'impact'), 1.0);
261
+ assert.equal(scoreIntent('hello world', 'impact'), 0);
262
+ assert.equal(scoreIntent('refactor this module', 'modify'), 1.0);
263
+ assert.equal(scoreIntent('refactor this module', 'implement'), 0);
264
+ });
265
+
266
+ test('scoreIntent: max weight wins when multiple patterns match', () => {
267
+ // "this bug needs a fix and impact analysis" matches `impact`, `bug`,
268
+ // `risk`-no, all three impact rows are weight 1.0 currently — score is 1.0.
269
+ // Spec: scoreIntent returns max(weight) of matching patterns, never sum.
270
+ const score = scoreIntent('this bug needs impact analysis', 'impact');
271
+ assert.equal(score, 1.0);
272
+ });
273
+
274
+ test('scoreIntent: unknown intent returns 0 (no throw)', () => {
275
+ assert.equal(scoreIntent('anything', 'nonexistent_intent'), 0);
276
+ });
277
+
278
+ test('INTENT_PATTERNS: every intent has at least 5 patterns and uniform weights', () => {
279
+ // v0.21 starts with uniform weights; future tuning can vary them per-pattern.
280
+ // This test guards against regression to the giant single-regex form.
281
+ const intents = ['impact', 'modify', 'implement', 'understand', 'callgraph', 'search'];
282
+ for (const intent of intents) {
283
+ const patterns = INTENT_PATTERNS[intent];
284
+ assert.ok(Array.isArray(patterns), `${intent} must have patterns array`);
285
+ assert.ok(patterns.length >= 5, `${intent} must have >=5 patterns, got ${patterns.length}`);
286
+ for (const [pattern, weight] of patterns) {
287
+ assert.ok(pattern instanceof RegExp, `${intent} pattern must be RegExp`);
288
+ assert.ok(typeof weight === 'number' && weight > 0 && weight <= 1, `${intent} weight must be (0, 1]`);
289
+ }
290
+ }
291
+ });
292
+
293
+ test('INTENT_THRESHOLD is 0.5 — single weight-1.0 match fires the intent', () => {
294
+ // Threshold contract: any pattern @ weight >= 0.5 → intent fires.
295
+ // If we lower a pattern to weight 0.4, it must NOT fire alone.
296
+ assert.equal(INTENT_THRESHOLD, 0.5);
297
+ });
298
+
253
299
  // --- No false positives ---
254
300
  test('detectIntents: simple confirmations have no code intent', () => {
255
301
  const r = detectIntents('好的');
@@ -479,3 +525,48 @@ test('CODE_GRAPH_QUIET_HOOKS=1 short-circuits silently on stdout, stderr, exit 0
479
525
  assert.equal(proc.stderr, '', 'stderr must be empty');
480
526
  assert.equal(proc.status, 0, 'must exit 0');
481
527
  });
528
+
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);
535
+ });
536
+
537
+ test('computeQuietHooks: CODE_GRAPH_VERBOSE_HOOKS=1 enables push (opt-in)', () => {
538
+ assert.equal(computeQuietHooks({ CODE_GRAPH_VERBOSE_HOOKS: '1' }), false);
539
+ });
540
+
541
+ test('computeQuietHooks: legacy CODE_GRAPH_QUIET_HOOKS=0 forces noisy (back-compat)', () => {
542
+ assert.equal(computeQuietHooks({ CODE_GRAPH_QUIET_HOOKS: '0' }), false);
543
+ });
544
+
545
+ test('computeQuietHooks: legacy CODE_GRAPH_QUIET_HOOKS=1 forces quiet (back-compat)', () => {
546
+ assert.equal(computeQuietHooks({ CODE_GRAPH_QUIET_HOOKS: '1' }), true);
547
+ });
548
+
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);
552
+ assert.equal(computeQuietHooks({ CODE_GRAPH_QUIET_HOOKS: '1', CODE_GRAPH_VERBOSE_HOOKS: '1' }), true);
553
+ });
554
+
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.
558
+ const { spawnSync } = require('node:child_process');
559
+ 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
+ const proc = spawnSync(process.execPath, [script], {
564
+ input: JSON.stringify({ message: 'impact of refactoring parse_code function' }),
565
+ env: cleanEnv,
566
+ encoding: 'utf8',
567
+ timeout: 2000,
568
+ });
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');
572
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.19.0",
3
+ "version": "0.21.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.19.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.19.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.19.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.19.0",
42
- "@sdsrs/code-graph-win32-x64": "0.19.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.21.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.21.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.21.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.21.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.21.0"
43
43
  }
44
44
  }