@sdsrs/code-graph 0.19.0 → 0.20.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.20.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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.19.0",
3
+ "version": "0.20.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.20.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.20.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.20.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.20.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.20.0"
43
43
  }
44
44
  }