@sdsrs/code-graph 0.73.1 → 0.74.1

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.
@@ -7,447 +7,389 @@ const os = require('os');
7
7
  const {
8
8
  adopt, unadopt, memoryDir, stripSentinelBlock,
9
9
  isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
10
- detectProjectType, buildIndexLine,
11
- SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
10
+ detectProjectType, buildBlock, migrateLegacyMemoryDir,
11
+ SENTINEL_BEGIN, SENTINEL_END, MANAGED_BY, TEMPLATE_PATH, TARGET_NAME,
12
12
  PROJECT_MARKERS,
13
13
  } = require('./adopt');
14
14
 
15
+ // Legacy v1 sentinel (pre-v0.74, lived in the memory-dir MEMORY.md). Hard-coded
16
+ // here because the strip/migration path must keep removing it after the constant
17
+ // moved to v2.
18
+ const SENTINEL_BEGIN_V1 = '<!-- code-graph-mcp:begin v1 -->';
19
+
15
20
  function makeSandbox() {
16
21
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
17
22
  const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
18
- // Mark the sandbox cwd as a real project — adopt() now gates on a project
19
- // marker unconditionally (see project-detect.js), so a bare mkdtemp would be
20
- // treated as a non-project and refused.
23
+ // Mark the sandbox cwd as a real project — adopt() gates on a project marker.
21
24
  fs.mkdirSync(path.join(cwd, '.git'));
22
- // Pre-create the memory dir (claude-mem convention — we don't create it).
23
- const dir = memoryDir(cwd, home);
24
- fs.mkdirSync(dir, { recursive: true });
25
- return { home, cwd, dir, cleanup: () => {
26
- fs.rmSync(home, { recursive: true, force: true });
27
- fs.rmSync(cwd, { recursive: true, force: true });
28
- }};
25
+ return {
26
+ home, cwd,
27
+ claudeMd: path.join(cwd, 'CLAUDE.md'),
28
+ detail: path.join(cwd, '.claude', TARGET_NAME),
29
+ cleanup: () => {
30
+ fs.rmSync(home, { recursive: true, force: true });
31
+ fs.rmSync(cwd, { recursive: true, force: true });
32
+ },
33
+ };
29
34
  }
30
35
 
36
+ // ── memoryDir (legacy slug — still used by migrateLegacyMemoryDir) ──────────
37
+
31
38
  test('memoryDir slugifies cwd path', () => {
32
- const dir = memoryDir('/home/alice/proj', '/home/alice');
33
- assert.strictEqual(dir, '/home/alice/.claude/projects/-home-alice-proj/memory');
39
+ assert.strictEqual(
40
+ memoryDir('/home/alice/proj', '/home/alice'),
41
+ '/home/alice/.claude/projects/-home-alice-proj/memory'
42
+ );
34
43
  });
35
44
 
36
45
  test('memoryDir replaces underscores and dots (Claude Code slug convention)', () => {
37
- // Real-world bug: /mnt/data_ssd/... needs data-ssd slug, not data_ssd
38
46
  assert.strictEqual(
39
47
  memoryDir('/mnt/data_ssd/dev/projects/code-graph-mcp', '/home/u'),
40
48
  '/home/u/.claude/projects/-mnt-data-ssd-dev-projects-code-graph-mcp/memory'
41
49
  );
42
- // Hidden dirs: /home/sds/.claude/x → -home-sds--claude-x (double-dash)
43
50
  assert.strictEqual(
44
51
  memoryDir('/home/sds/.claude/x', '/home/sds'),
45
52
  '/home/sds/.claude/projects/-home-sds--claude-x/memory'
46
53
  );
47
- // Preserves case and hyphens
48
- assert.strictEqual(
49
- memoryDir('/Users/Alice/my-Project_v2.1', '/'),
50
- '/.claude/projects/-Users-Alice-my-Project-v2-1/memory'
51
- );
52
54
  });
53
55
 
54
- test('adopt writes template and appends sentinel block when index absent', () => {
56
+ test('memoryDir honors CLAUDE_CONFIG_DIR override (multi-account isolation)', () => {
57
+ const prev = process.env.CLAUDE_CONFIG_DIR;
58
+ process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
59
+ try {
60
+ assert.strictEqual(
61
+ memoryDir('/home/alice/proj', '/home/alice'),
62
+ '/home/alice/work-claude/projects/-home-alice-proj/memory'
63
+ );
64
+ } finally {
65
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
66
+ else process.env.CLAUDE_CONFIG_DIR = prev;
67
+ }
68
+ });
69
+
70
+ // ── buildBlock — the managed CLAUDE.md block ────────────────────────────────
71
+
72
+ test('buildBlock generic: v2 sentinel + 6 base rows + pointer', () => {
73
+ const block = buildBlock('generic');
74
+ assert.ok(block.startsWith(SENTINEL_BEGIN), 'opens with v2 BEGIN');
75
+ assert.ok(block.endsWith(SENTINEL_END), 'closes with END');
76
+ assert.ok(block.includes('| Who calls X / what X calls | `code-graph-mcp callgraph X` |'));
77
+ assert.ok(block.includes('| Impact before editing a fn | `code-graph-mcp impact X` |'));
78
+ assert.ok(block.includes('Full command + MCP-tool table: `.claude/plugin_code_graph_mcp.md`'));
79
+ assert.ok(!block.includes('trace'), 'generic has no HTTP-trace row');
80
+ });
81
+
82
+ test('buildBlock web-rs inserts the HTTP-route → handler row', () => {
83
+ const block = buildBlock('web-rs');
84
+ assert.ok(block.includes('HTTP route → handler chain'), 'web project gets trace row');
85
+ assert.ok(block.includes('`code-graph-mcp trace "GET /api/x"`'));
86
+ });
87
+
88
+ test('buildBlock frontend surfaces a find-references audit row', () => {
89
+ const block = buildBlock('frontend');
90
+ assert.ok(block.includes('Rename / refactor audit (refs)'));
91
+ assert.ok(block.includes('`code-graph-mcp refs X`'));
92
+ });
93
+
94
+ test('buildBlock is deterministic (byte-identical across calls)', () => {
95
+ assert.strictEqual(buildBlock('rust'), buildBlock('rust'));
96
+ assert.strictEqual(buildBlock('generic'), buildBlock(undefined));
97
+ });
98
+
99
+ // ── adopt — installs CLAUDE.md block + .claude/ detail ──────────────────────
100
+
101
+ test('adopt creates CLAUDE.md with the block when none exists', () => {
55
102
  const sb = makeSandbox();
56
103
  try {
57
- const res = adopt({ cwd: sb.cwd, home: sb.home });
104
+ const res = adopt({ cwd: sb.cwd });
58
105
  assert.strictEqual(res.ok, true);
59
- assert.strictEqual(res.indexed, true);
60
- assert.ok(fs.existsSync(res.target), 'plugin file written');
61
- const index = fs.readFileSync(res.indexPath, 'utf8');
62
- assert.match(index, /^# Memory Index/);
63
- assert.ok(index.includes(SENTINEL_BEGIN));
64
- assert.ok(index.includes(SENTINEL_END));
65
- assert.ok(index.includes(INDEX_LINE));
106
+ assert.strictEqual(res.created, true);
107
+ assert.strictEqual(res.claudeMdWritten, true);
108
+ assert.strictEqual(res.detailWritten, true);
109
+ const cmd = fs.readFileSync(sb.claudeMd, 'utf8');
110
+ assert.ok(cmd.includes(SENTINEL_BEGIN) && cmd.includes(SENTINEL_END));
111
+ assert.ok(fs.existsSync(sb.detail), 'detail file written under .claude/');
112
+ assert.ok(fs.readFileSync(sb.detail, 'utf8').startsWith(MANAGED_BY), 'detail has managed-by marker');
66
113
  } finally { sb.cleanup(); }
67
114
  });
68
115
 
69
- test('adopt is idempotent no duplicate sentinel on re-run', () => {
116
+ test('adopt injects the block into an existing CLAUDE.md, preserving user prose', () => {
70
117
  const sb = makeSandbox();
71
118
  try {
72
- adopt({ cwd: sb.cwd, home: sb.home });
73
- const res2 = adopt({ cwd: sb.cwd, home: sb.home });
74
- assert.strictEqual(res2.indexed, false, 'second run leaves index alone');
75
- const index = fs.readFileSync(res2.indexPath, 'utf8');
76
- const matches = index.match(new RegExp(SENTINEL_BEGIN.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&'), 'g'));
77
- assert.strictEqual(matches.length, 1, 'sentinel appears exactly once');
119
+ fs.writeFileSync(sb.claudeMd, '# My Project\n\nUser instructions here.\n');
120
+ const res = adopt({ cwd: sb.cwd });
121
+ assert.strictEqual(res.created, false);
122
+ assert.strictEqual(res.claudeMdWritten, true);
123
+ const cmd = fs.readFileSync(sb.claudeMd, 'utf8');
124
+ assert.ok(cmd.includes('User instructions here.'), 'preserves user prose');
125
+ assert.ok(cmd.includes(SENTINEL_BEGIN), 'block appended');
78
126
  } finally { sb.cleanup(); }
79
127
  });
80
128
 
81
- test('adopt preserves existing MEMORY.md content and appends', () => {
129
+ test('adopt is idempotent no duplicate block, no write on re-run', () => {
82
130
  const sb = makeSandbox();
83
131
  try {
84
- const indexPath = path.join(sb.dir, 'MEMORY.md');
85
- fs.writeFileSync(indexPath, '# Memory Index\n\n- [foo.md](foo.md) existing entry\n');
86
- adopt({ cwd: sb.cwd, home: sb.home });
87
- const index = fs.readFileSync(indexPath, 'utf8');
88
- assert.ok(index.includes('existing entry'), 'preserves prior entries');
89
- assert.ok(index.includes(SENTINEL_BEGIN), 'appends sentinel');
132
+ adopt({ cwd: sb.cwd });
133
+ const res2 = adopt({ cwd: sb.cwd });
134
+ assert.strictEqual(res2.claudeMdWritten, false, 'second run leaves CLAUDE.md alone');
135
+ assert.strictEqual(res2.detailWritten, false, 'second run leaves detail alone');
136
+ const cmd = fs.readFileSync(sb.claudeMd, 'utf8');
137
+ const esc = SENTINEL_BEGIN.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
138
+ assert.strictEqual((cmd.match(new RegExp(esc, 'g')) || []).length, 1, 'block appears exactly once');
90
139
  } finally { sb.cleanup(); }
91
140
  });
92
141
 
93
- test('adopt + unadopt write atomically no .tmp residue in the memory dir', () => {
94
- // The memory dir is shared with claude-mem-lite, which reads MEMORY.md on
95
- // every keyword match; a non-atomic write crashing mid-flight would corrupt
96
- // it. adopt/unadopt now go through writeFileAtomic (tmp + rename). Proof the
97
- // rename completed cleanly across all three write sites (detail file + adopt
98
- // index + unadopt prune): no leftover `*.tmp.<pid>` entries.
142
+ test('adopt block reflects detected project type (web-rs trace row in CLAUDE.md)', () => {
99
143
  const sb = makeSandbox();
100
144
  try {
101
- adopt({ cwd: sb.cwd, home: sb.home });
102
- unadopt({ cwd: sb.cwd, home: sb.home });
103
- const residue = fs.readdirSync(sb.dir).filter((f) => f.includes('.tmp.'));
104
- assert.deepStrictEqual(residue, [], `no atomic-write tmp residue; found: ${residue}`);
145
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[dependencies]\naxum = "0.7"\n');
146
+ adopt({ cwd: sb.cwd });
147
+ assert.ok(fs.readFileSync(sb.claudeMd, 'utf8').includes('HTTP route → handler chain'));
105
148
  } finally { sb.cleanup(); }
106
149
  });
107
150
 
108
- test('writeFileAtomic cleans its temp file when rename fails (no orphaned .tmp)', () => {
109
- // The success path leaves no residue (above). This pins the FAILURE path: if
110
- // renameSync throws (ENOSPC/EACCES/EROFS on the shared memory dir) the temp must
111
- // be unlinked, not orphaned. Force every rename to fail and assert no `.tmp.<pid>`
112
- // survives the (failed) adopt.
151
+ test('adopt heals a malformed prior block (orphan BEGIN) and preserves neighbors', () => {
113
152
  const sb = makeSandbox();
114
- const realRename = fs.renameSync;
115
153
  try {
116
- fs.renameSync = () => { const e = new Error('EROFS: simulated read-only fs'); e.code = 'EROFS'; throw e; };
117
- try { adopt({ cwd: sb.cwd, home: sb.home }); } catch { /* expected — rename failed */ }
118
- const residue = fs.readdirSync(sb.dir).filter((f) => f.includes('.tmp.'));
119
- assert.deepStrictEqual(residue, [], `failed rename must not orphan a temp; found: ${residue}`);
120
- } finally {
121
- fs.renameSync = realRename;
122
- sb.cleanup();
123
- }
154
+ fs.writeFileSync(sb.claudeMd,
155
+ `# Project\n\nKeep me.\n\n${SENTINEL_BEGIN}\n- stale partial block\n\nAlso keep me.\n`);
156
+ const res = adopt({ cwd: sb.cwd });
157
+ assert.strictEqual(res.healed, true);
158
+ const cmd = fs.readFileSync(sb.claudeMd, 'utf8');
159
+ const esc = SENTINEL_BEGIN.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
160
+ assert.strictEqual((cmd.match(new RegExp(esc, 'g')) || []).length, 1, 'exactly one block');
161
+ assert.ok(cmd.includes('Keep me.') && cmd.includes('Also keep me.'), 'neighbors preserved');
162
+ assert.ok(!cmd.includes('stale partial block'), 'stale block purged');
163
+ } finally { sb.cleanup(); }
124
164
  });
125
165
 
126
- test('adopt refuses a non-project cwd even when the memory dir already exists (regression: /tmp adoption)', () => {
127
- // Bug: the isProjectRoot guard was nested inside `if (!fs.existsSync(dir))`,
128
- // so when Claude Code had already created ~/.claude/projects/<slug>/memory
129
- // (it does this for every session, incl. the ~2035 headless /tmp mem-lite
130
- // calls), adopt() sailed past the guard and wrote its sentinel into /tmp's
131
- // MEMORY.md. Pre-fix this test FAILS (adopt returns ok:true and writes).
166
+ test('adopt refuses a non-project cwd and writes nothing', () => {
132
167
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
133
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-')); // no project marker
134
- const dir = memoryDir(cwd, home);
135
- fs.mkdirSync(dir, { recursive: true }); // simulate CC pre-creating the memory dir
168
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-')); // no marker
136
169
  try {
137
- const res = adopt({ cwd, home });
170
+ const res = adopt({ cwd });
138
171
  assert.strictEqual(res.ok, false);
139
172
  assert.strictEqual(res.reason, 'not-a-project');
140
- const indexPath = path.join(dir, 'MEMORY.md');
141
- assert.ok(
142
- !fs.existsSync(indexPath) || !fs.readFileSync(indexPath, 'utf8').includes(SENTINEL_BEGIN),
143
- 'must NOT write the code-graph sentinel into a non-project MEMORY.md'
144
- );
173
+ assert.ok(!fs.existsSync(path.join(cwd, 'CLAUDE.md')), 'no CLAUDE.md written');
174
+ assert.ok(!fs.existsSync(path.join(cwd, '.claude')), 'no .claude dir created');
145
175
  } finally {
146
176
  fs.rmSync(home, { recursive: true, force: true });
147
177
  fs.rmSync(cwd, { recursive: true, force: true });
148
178
  }
149
179
  });
150
180
 
151
- test('adopt fails gracefully when cwd is not a project root', () => {
152
- // v0.16.9: behavior change — adopt now mkdir's the memory dir when cwd has
153
- // a project marker (.git / Cargo.toml / package.json / ...). Bare mkdtemp
154
- // without markers still fails with the more specific 'not-a-project' reason
155
- // to prevent /tmp pollution.
156
- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
157
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
181
+ test('adopt writes atomically no .tmp residue in cwd or .claude', () => {
182
+ const sb = makeSandbox();
158
183
  try {
159
- const res = adopt({ cwd, home });
160
- assert.strictEqual(res.ok, false);
161
- assert.strictEqual(res.reason, 'not-a-project');
184
+ adopt({ cwd: sb.cwd });
185
+ const cwdResidue = fs.readdirSync(sb.cwd).filter((f) => f.includes('.tmp.'));
186
+ const claudeResidue = fs.readdirSync(path.join(sb.cwd, '.claude')).filter((f) => f.includes('.tmp.'));
187
+ assert.deepStrictEqual(cwdResidue, []);
188
+ assert.deepStrictEqual(claudeResidue, []);
189
+ } finally { sb.cleanup(); }
190
+ });
191
+
192
+ test('writeFileAtomic cleans its temp file when rename fails (no orphaned .tmp)', () => {
193
+ const sb = makeSandbox();
194
+ const realRename = fs.renameSync;
195
+ try {
196
+ fs.renameSync = () => { const e = new Error('EROFS: simulated read-only fs'); e.code = 'EROFS'; throw e; };
197
+ try { adopt({ cwd: sb.cwd }); } catch { /* expected — rename failed */ }
198
+ fs.renameSync = realRename;
199
+ // .claude may or may not exist depending on which write failed first; tolerate both.
200
+ const dirs = [sb.cwd, path.join(sb.cwd, '.claude')].filter((d) => fs.existsSync(d));
201
+ for (const d of dirs) {
202
+ assert.deepStrictEqual(fs.readdirSync(d).filter((f) => f.includes('.tmp.')), [],
203
+ `failed rename must not orphan a temp in ${d}`);
204
+ }
162
205
  } finally {
163
- fs.rmSync(home, { recursive: true, force: true });
164
- fs.rmSync(cwd, { recursive: true, force: true });
206
+ fs.renameSync = realRename;
207
+ sb.cleanup();
165
208
  }
166
209
  });
167
210
 
168
- test('unadopt removes file and sentinel block, preserves other entries', () => {
211
+ // ── unadopt ─────────────────────────────────────────────────────────────────
212
+
213
+ test('unadopt removes the block + detail file, preserving user prose', () => {
169
214
  const sb = makeSandbox();
170
215
  try {
171
- adopt({ cwd: sb.cwd, home: sb.home });
172
- // add a neighboring entry
173
- const indexPath = path.join(sb.dir, 'MEMORY.md');
174
- const withNeighbor = fs.readFileSync(indexPath, 'utf8') + '- [bar.md](bar.md) — neighbor\n';
175
- fs.writeFileSync(indexPath, withNeighbor);
176
-
216
+ fs.writeFileSync(sb.claudeMd, '# Project\n\nMy own notes.\n');
217
+ adopt({ cwd: sb.cwd });
177
218
  const res = unadopt({ cwd: sb.cwd, home: sb.home });
178
219
  assert.strictEqual(res.fileRemoved, true);
179
- assert.strictEqual(res.indexPruned, true);
180
- assert.ok(!fs.existsSync(res.target), 'plugin file gone');
181
- const final = fs.readFileSync(indexPath, 'utf8');
182
- assert.ok(!final.includes(SENTINEL_BEGIN), 'sentinel removed');
183
- assert.ok(final.includes('neighbor'), 'neighbor preserved');
220
+ assert.strictEqual(res.blockPruned, true);
221
+ assert.strictEqual(res.claudeMdRemoved, false, 'CLAUDE.md kept — has user prose');
222
+ assert.ok(!fs.existsSync(sb.detail), 'detail file gone');
223
+ const cmd = fs.readFileSync(sb.claudeMd, 'utf8');
224
+ assert.ok(!cmd.includes(SENTINEL_BEGIN), 'block removed');
225
+ assert.ok(cmd.includes('My own notes.'), 'user prose preserved');
184
226
  } finally { sb.cleanup(); }
185
227
  });
186
228
 
187
- test('unadopt is a no-op when never adopted', () => {
229
+ test('unadopt deletes a CLAUDE.md that contained only our block', () => {
188
230
  const sb = makeSandbox();
189
231
  try {
232
+ adopt({ cwd: sb.cwd }); // creates a block-only CLAUDE.md
190
233
  const res = unadopt({ cwd: sb.cwd, home: sb.home });
191
- assert.strictEqual(res.fileRemoved, false);
192
- assert.strictEqual(res.indexPruned, false);
234
+ assert.strictEqual(res.claudeMdRemoved, true);
235
+ assert.ok(!fs.existsSync(sb.claudeMd), 'block-only CLAUDE.md removed');
193
236
  } finally { sb.cleanup(); }
194
237
  });
195
238
 
196
- test('template file exists and contains decision table', () => {
197
- assert.ok(fs.existsSync(TEMPLATE_PATH), `template at ${TEMPLATE_PATH}`);
198
- const content = fs.readFileSync(TEMPLATE_PATH, 'utf8');
199
- assert.ok(content.includes('get_call_graph'), 'mentions get_call_graph');
200
- assert.ok(content.includes('impact_analysis'), 'mentions impact_analysis');
201
- assert.ok(content.includes('CODE_GRAPH_QUIET_HOOKS'), 'mentions env gate');
202
- });
203
-
204
- test('stripSentinelBlock removes well-formed block', () => {
205
- const before = `# Index\n${SENTINEL_BEGIN}\n${INDEX_LINE}\n${SENTINEL_END}\n- [x.md](x.md)\n`;
206
- const after = stripSentinelBlock(before);
207
- assert.ok(!after.includes(SENTINEL_BEGIN));
208
- assert.ok(!after.includes(SENTINEL_END));
209
- assert.ok(after.includes('- [x.md](x.md)'), 'preserves neighbors');
210
- });
211
-
212
- test('stripSentinelBlock self-heals orphan BEGIN without END', () => {
213
- // Truncation / partial edit scenario
214
- const before = `# Index\n- [a.md](a.md) — entry\n${SENTINEL_BEGIN}\n${INDEX_LINE}\n\n- [b.md](b.md) — survivor\n`;
215
- const after = stripSentinelBlock(before);
216
- assert.ok(!after.includes(SENTINEL_BEGIN), 'orphan BEGIN removed');
217
- assert.ok(after.includes('survivor'), 'content past blank-line boundary preserved');
218
- assert.ok(after.includes('entry'), 'content before BEGIN preserved');
219
- });
220
-
221
- test('stripSentinelBlock self-heals orphan END line', () => {
222
- const before = `# Index\n- [a.md](a.md)\n${SENTINEL_END}\n- [b.md](b.md)\n`;
223
- const after = stripSentinelBlock(before);
224
- assert.ok(!after.includes(SENTINEL_END));
225
- assert.ok(after.includes('- [a.md](a.md)') && after.includes('- [b.md](b.md)'));
239
+ test('unadopt will NOT delete a user file lacking our managed-by marker', () => {
240
+ const sb = makeSandbox();
241
+ try {
242
+ fs.mkdirSync(path.join(sb.cwd, '.claude'));
243
+ fs.writeFileSync(sb.detail, 'user-authored notes, not ours\n');
244
+ const res = unadopt({ cwd: sb.cwd, home: sb.home });
245
+ assert.strictEqual(res.fileRemoved, false, 'unmarked file is not deleted');
246
+ assert.ok(fs.existsSync(sb.detail), 'user file survives');
247
+ } finally { sb.cleanup(); }
226
248
  });
227
249
 
228
- test('adopt heals malformed sentinel (orphan BEGIN) on re-run', () => {
250
+ test('unadopt is a no-op when never adopted', () => {
229
251
  const sb = makeSandbox();
230
252
  try {
231
- const indexPath = path.join(sb.dir, 'MEMORY.md');
232
- // Simulate truncated prior adopt — BEGIN line + stale entry, no END
233
- fs.writeFileSync(
234
- indexPath,
235
- `# Memory Index\n- [old.md](old.md) — preserved\n${SENTINEL_BEGIN}\n- [stale](stale.md) — wrong entry\n\n- [neighbor.md](neighbor.md) — survives\n`
236
- );
237
- const res = adopt({ cwd: sb.cwd, home: sb.home });
238
- assert.strictEqual(res.ok, true);
239
- assert.strictEqual(res.healed, true, 'reports healed');
240
- const final = fs.readFileSync(indexPath, 'utf8');
241
- // Exactly one well-formed block now
242
- const beginCount = (final.match(new RegExp(SENTINEL_BEGIN.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&'), 'g')) || []).length;
243
- const endCount = (final.match(new RegExp(SENTINEL_END.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&'), 'g')) || []).length;
244
- assert.strictEqual(beginCount, 1, 'one BEGIN');
245
- assert.strictEqual(endCount, 1, 'one END');
246
- assert.ok(final.includes('preserved'), 'preserves pre-BEGIN content');
247
- assert.ok(final.includes('neighbor.md'), 'preserves post-malformed-block content');
248
- assert.ok(!final.includes('stale.md'), 'old wrong entry purged');
249
- assert.ok(final.includes(INDEX_LINE), 'fresh canonical line written');
253
+ const res = unadopt({ cwd: sb.cwd, home: sb.home });
254
+ assert.strictEqual(res.fileRemoved, false);
255
+ assert.strictEqual(res.blockPruned, false);
250
256
  } finally { sb.cleanup(); }
251
257
  });
252
258
 
253
- test('adopt is a true no-op when desired block is already present verbatim', () => {
259
+ // ── isAdopted ───────────────────────────────────────────────────────────────
260
+
261
+ test('isAdopted: false fresh, true after adopt, false after unadopt', () => {
254
262
  const sb = makeSandbox();
255
263
  try {
256
- adopt({ cwd: sb.cwd, home: sb.home });
257
- const indexPath = path.join(sb.dir, 'MEMORY.md');
258
- const before = fs.readFileSync(indexPath, 'utf8');
259
- const beforeMtime = fs.statSync(indexPath).mtimeMs;
260
- const res2 = adopt({ cwd: sb.cwd, home: sb.home });
261
- assert.strictEqual(res2.indexed, false);
262
- assert.strictEqual(res2.healed, false);
263
- assert.strictEqual(fs.readFileSync(indexPath, 'utf8'), before, 'file content identical');
264
- // mtime may equal beforeMtime since we skipped the write
265
- assert.strictEqual(fs.statSync(indexPath).mtimeMs, beforeMtime, 'no write occurred');
264
+ assert.strictEqual(isAdopted({ cwd: sb.cwd }), false);
265
+ adopt({ cwd: sb.cwd });
266
+ assert.strictEqual(isAdopted({ cwd: sb.cwd }), true);
267
+ unadopt({ cwd: sb.cwd, home: sb.home });
268
+ assert.strictEqual(isAdopted({ cwd: sb.cwd }), false);
266
269
  } finally { sb.cleanup(); }
267
270
  });
268
271
 
269
- test('unadopt heals malformed sentinel (orphan BEGIN)', () => {
272
+ test('isAdopted: false when block present but detail file missing', () => {
270
273
  const sb = makeSandbox();
271
274
  try {
272
- const indexPath = path.join(sb.dir, 'MEMORY.md');
273
- fs.writeFileSync(
274
- indexPath,
275
- `# Index\n${SENTINEL_BEGIN}\n${INDEX_LINE}\n\n- [keep.md](keep.md) — survives\n`
276
- );
277
- const res = unadopt({ cwd: sb.cwd, home: sb.home });
278
- assert.strictEqual(res.indexPruned, true);
279
- const final = fs.readFileSync(indexPath, 'utf8');
280
- assert.ok(!final.includes(SENTINEL_BEGIN), 'orphan BEGIN purged');
281
- assert.ok(final.includes('keep.md'), 'content past blank-line preserved');
275
+ fs.writeFileSync(sb.claudeMd, `${SENTINEL_BEGIN}\nx\n${SENTINEL_END}\n`);
276
+ assert.strictEqual(isAdopted({ cwd: sb.cwd }), false, 'needs both block + detail');
282
277
  } finally { sb.cleanup(); }
283
278
  });
284
279
 
285
- // ──────────────────────────────────────────────────────────────────────────
286
- // v0.9.0 — C' context-aware auto-adopt
287
- // ──────────────────────────────────────────────────────────────────────────
280
+ // ── needsRefresh ────────────────────────────────────────────────────────────
288
281
 
289
- test('isAdopted returns false on fresh project (no files)', () => {
282
+ test('needsRefresh: false right after adopt', () => {
290
283
  const sb = makeSandbox();
291
284
  try {
292
- assert.strictEqual(isAdopted({ cwd: sb.cwd, home: sb.home }), false);
285
+ adopt({ cwd: sb.cwd });
286
+ assert.strictEqual(needsRefresh({ cwd: sb.cwd }), false);
293
287
  } finally { sb.cleanup(); }
294
288
  });
295
289
 
296
- test('isAdopted returns true after adopt()', () => {
290
+ test('needsRefresh: true when detail-doc body drifts from shipped template', () => {
297
291
  const sb = makeSandbox();
298
292
  try {
299
- adopt({ cwd: sb.cwd, home: sb.home });
300
- assert.strictEqual(isAdopted({ cwd: sb.cwd, home: sb.home }), true);
293
+ adopt({ cwd: sb.cwd });
294
+ fs.writeFileSync(sb.detail, `${MANAGED_BY}\n# stale content from an older plugin\n`);
295
+ assert.strictEqual(needsRefresh({ cwd: sb.cwd }), true);
301
296
  } finally { sb.cleanup(); }
302
297
  });
303
298
 
304
- test('isAdopted returns false after unadopt()', () => {
299
+ test('needsRefresh: true when the CLAUDE.md block drifts (project type change)', () => {
305
300
  const sb = makeSandbox();
306
301
  try {
307
- adopt({ cwd: sb.cwd, home: sb.home });
308
- unadopt({ cwd: sb.cwd, home: sb.home });
309
- assert.strictEqual(isAdopted({ cwd: sb.cwd, home: sb.home }), false);
302
+ adopt({ cwd: sb.cwd }); // generic block
303
+ // Now the project gains a web framework — block should switch to web-rs.
304
+ fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[dependencies]\naxum = "0.7"\n');
305
+ assert.strictEqual(needsRefresh({ cwd: sb.cwd }), true);
310
306
  } finally { sb.cleanup(); }
311
307
  });
312
308
 
313
- test('isAdopted returns false when target file exists but index has no sentinel', () => {
309
+ test('needsRefresh: false when not adopted (nothing to refresh)', () => {
314
310
  const sb = makeSandbox();
315
311
  try {
316
- const indexPath = path.join(sb.dir, 'MEMORY.md');
317
- fs.writeFileSync(indexPath, '# Memory Index\n- [foo.md](foo.md) — unrelated\n');
318
- fs.writeFileSync(path.join(sb.dir, 'plugin_code_graph_mcp.md'), 'stale copy');
319
- assert.strictEqual(isAdopted({ cwd: sb.cwd, home: sb.home }), false);
312
+ assert.strictEqual(needsRefresh({ cwd: sb.cwd }), false);
320
313
  } finally { sb.cleanup(); }
321
314
  });
322
315
 
323
- test('isPluginModeInstall recognizes ~/.claude/plugins/... paths', () => {
324
- const pluginPath = '/home/user/.claude/plugins/cache/code-graph-mcp@0.9.0/scripts';
325
- assert.strictEqual(isPluginModeInstall(pluginPath), true);
326
- });
316
+ // ── maybeAutoAdopt ──────────────────────────────────────────────────────────
327
317
 
328
- test('isPluginModeInstall rejects npm global install paths', () => {
329
- const npmPath = '/usr/local/lib/node_modules/@sdsrs/code-graph/claude-plugin/scripts';
330
- assert.strictEqual(isPluginModeInstall(npmPath), false);
331
- });
318
+ const PLUGIN_SCRIPTS = '/home/u/.claude/plugins/cache/code-graph-mcp/scripts';
332
319
 
333
- test('isPluginModeInstall rejects dev-checkout paths', () => {
334
- const devPath = '/mnt/data_ssd/dev/projects/code-graph-mcp/claude-plugin/scripts';
335
- assert.strictEqual(isPluginModeInstall(devPath), false);
336
- });
337
-
338
- test('isPluginModeInstall rejects npx cache paths', () => {
339
- const npxPath = '/tmp/npx-abc123/node_modules/@sdsrs/code-graph/claude-plugin/scripts';
340
- assert.strictEqual(isPluginModeInstall(npxPath), false);
341
- });
342
-
343
- test('memoryDir honors CLAUDE_CONFIG_DIR override (multi-account isolation)', () => {
344
- const prev = process.env.CLAUDE_CONFIG_DIR;
345
- process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
320
+ test('maybeAutoAdopt skips when CODE_GRAPH_NO_AUTO_ADOPT=1', () => {
321
+ const sb = makeSandbox();
346
322
  try {
347
- // home arg is irrelevant when env var is set projects live under the
348
- // configured claude dir, not home/.claude.
349
- assert.strictEqual(
350
- memoryDir('/home/alice/proj', '/home/alice'),
351
- '/home/alice/work-claude/projects/-home-alice-proj/memory'
352
- );
353
- } finally {
354
- if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
355
- else process.env.CLAUDE_CONFIG_DIR = prev;
356
- }
323
+ const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: { CODE_GRAPH_NO_AUTO_ADOPT: '1' } });
324
+ assert.strictEqual(res.reason, 'opted-out');
325
+ assert.deepStrictEqual(res.migrated, { memoryIndexPruned: false, legacyDetailRemoved: false }, 'consistent migrated shape on early return');
326
+ assert.strictEqual(isAdopted({ cwd: sb.cwd }), false);
327
+ } finally { sb.cleanup(); }
357
328
  });
358
329
 
359
- test('isPluginModeInstall recognizes CLAUDE_CONFIG_DIR/plugins/... paths', () => {
360
- const prev = process.env.CLAUDE_CONFIG_DIR;
361
- process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
330
+ test('maybeAutoAdopt skips when not plugin-mode (npm install path)', () => {
331
+ const sb = makeSandbox();
362
332
  try {
363
- const pluginPath = '/home/alice/work-claude/plugins/cache/code-graph-mcp@0.31.0/scripts';
364
- assert.strictEqual(isPluginModeInstall(pluginPath), true);
365
- // Legacy ~/.claude/plugins/ path still works even with env var set.
366
- assert.strictEqual(
367
- isPluginModeInstall('/home/user/.claude/plugins/cache/code-graph-mcp/scripts'),
368
- true
369
- );
370
- // Unrelated path under same prefix is still rejected.
371
- assert.strictEqual(
372
- isPluginModeInstall('/home/alice/work-claude/projects/foo/memory'),
373
- false
374
- );
375
- } finally {
376
- if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
377
- else process.env.CLAUDE_CONFIG_DIR = prev;
378
- }
333
+ const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: '/usr/local/lib/node_modules/@sdsrs/code-graph/claude-plugin/scripts', env: {} });
334
+ assert.strictEqual(res.reason, 'not-plugin-mode');
335
+ assert.deepStrictEqual(res.migrated, { memoryIndexPruned: false, legacyDetailRemoved: false }, 'consistent migrated shape on early return');
336
+ assert.strictEqual(isAdopted({ cwd: sb.cwd }), false);
337
+ } finally { sb.cleanup(); }
379
338
  });
380
339
 
381
- test('maybeAutoAdopt skips when CODE_GRAPH_NO_AUTO_ADOPT=1', () => {
340
+ test('maybeAutoAdopt installs when plugin-mode + not-yet-adopted', () => {
382
341
  const sb = makeSandbox();
383
342
  try {
384
- const res = maybeAutoAdopt({
385
- cwd: sb.cwd, home: sb.home,
386
- scriptPath: '/home/u/.claude/plugins/cache/code-graph-mcp/scripts',
387
- env: { CODE_GRAPH_NO_AUTO_ADOPT: '1' },
388
- });
389
- assert.strictEqual(res.attempted, false);
390
- assert.strictEqual(res.reason, 'opted-out');
391
- assert.strictEqual(isAdopted({ cwd: sb.cwd, home: sb.home }), false);
343
+ const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
344
+ assert.strictEqual(res.attempted, true);
345
+ assert.strictEqual(res.reason, 'adopted');
346
+ assert.strictEqual(res.result.ok, true);
347
+ assert.strictEqual(isAdopted({ cwd: sb.cwd }), true);
392
348
  } finally { sb.cleanup(); }
393
349
  });
394
350
 
395
- test('maybeAutoAdopt skips when not plugin-mode (npm install)', () => {
351
+ test('maybeAutoAdopt is already-adopted when in sync (no gratuitous write)', () => {
396
352
  const sb = makeSandbox();
397
353
  try {
398
- const res = maybeAutoAdopt({
399
- cwd: sb.cwd, home: sb.home,
400
- scriptPath: '/usr/local/lib/node_modules/@sdsrs/code-graph/claude-plugin/scripts',
401
- env: {},
402
- });
403
- assert.strictEqual(res.attempted, false);
404
- assert.strictEqual(res.reason, 'not-plugin-mode');
405
- assert.strictEqual(isAdopted({ cwd: sb.cwd, home: sb.home }), false);
354
+ adopt({ cwd: sb.cwd });
355
+ const mtime = fs.statSync(sb.claudeMd).mtimeMs;
356
+ const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
357
+ assert.strictEqual(res.reason, 'already-adopted');
358
+ assert.strictEqual(fs.statSync(sb.claudeMd).mtimeMs, mtime, 'CLAUDE.md not touched');
406
359
  } finally { sb.cleanup(); }
407
360
  });
408
361
 
409
- test('maybeAutoAdopt skips when already adopted (idempotent)', () => {
362
+ test('maybeAutoAdopt refreshes a drifted detail doc (reason=refreshed)', () => {
410
363
  const sb = makeSandbox();
411
364
  try {
412
- adopt({ cwd: sb.cwd, home: sb.home });
413
- const res = maybeAutoAdopt({
414
- cwd: sb.cwd, home: sb.home,
415
- scriptPath: '/home/u/.claude/plugins/cache/code-graph-mcp/scripts',
416
- env: {},
417
- });
418
- assert.strictEqual(res.attempted, false);
419
- assert.strictEqual(res.reason, 'already-adopted');
365
+ adopt({ cwd: sb.cwd });
366
+ fs.writeFileSync(sb.detail, `${MANAGED_BY}\n# stale\n`);
367
+ const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
368
+ assert.strictEqual(res.reason, 'refreshed');
369
+ const shipped = fs.readFileSync(TEMPLATE_PATH);
370
+ const cur = fs.readFileSync(sb.detail);
371
+ const nl = cur.indexOf(0x0a);
372
+ assert.ok(shipped.equals(cur.subarray(nl + 1)), 'detail re-synced to shipped template');
420
373
  } finally { sb.cleanup(); }
421
374
  });
422
375
 
423
- test('maybeAutoAdopt runs adopt when plugin-mode + unadopted + no opt-out', () => {
376
+ test('maybeAutoAdopt skips refresh when CODE_GRAPH_NO_TEMPLATE_REFRESH=1 (locks edits)', () => {
424
377
  const sb = makeSandbox();
425
378
  try {
426
- const res = maybeAutoAdopt({
427
- cwd: sb.cwd, home: sb.home,
428
- scriptPath: '/home/u/.claude/plugins/cache/code-graph-mcp/scripts',
429
- env: {},
430
- });
431
- assert.strictEqual(res.attempted, true);
432
- assert.strictEqual(res.result.ok, true);
433
- assert.strictEqual(res.result.indexed, true);
434
- assert.strictEqual(isAdopted({ cwd: sb.cwd, home: sb.home }), true);
379
+ adopt({ cwd: sb.cwd });
380
+ const userEdit = `${MANAGED_BY}\n# my hand-edited table\n`;
381
+ fs.writeFileSync(sb.detail, userEdit);
382
+ const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: { CODE_GRAPH_NO_TEMPLATE_REFRESH: '1' } });
383
+ assert.strictEqual(res.reason, 'already-adopted');
384
+ assert.strictEqual(fs.readFileSync(sb.detail, 'utf8'), userEdit, 'user edit preserved');
435
385
  } finally { sb.cleanup(); }
436
386
  });
437
387
 
438
- test('maybeAutoAdopt fails with not-a-project when cwd has no project marker', () => {
439
- // v0.16.9: bare mkdtemp cwd without .git/Cargo.toml/etc. surfaces
440
- // 'not-a-project' so plugin-mode auto-adopt doesn't litter ~/.claude/projects/
441
- // with bogus slugs from non-project working directories.
388
+ test('maybeAutoAdopt surfaces not-a-project for a bare cwd', () => {
442
389
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
443
390
  const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
444
391
  try {
445
- const res = maybeAutoAdopt({
446
- cwd, home,
447
- scriptPath: '/home/u/.claude/plugins/cache/code-graph-mcp/scripts',
448
- env: {},
449
- });
450
- assert.strictEqual(res.attempted, true);
392
+ const res = maybeAutoAdopt({ cwd, home, scriptPath: PLUGIN_SCRIPTS, env: {} });
451
393
  assert.strictEqual(res.result.ok, false);
452
394
  assert.strictEqual(res.result.reason, 'not-a-project');
453
395
  } finally {
@@ -456,154 +398,153 @@ test('maybeAutoAdopt fails with not-a-project when cwd has no project marker', (
456
398
  }
457
399
  });
458
400
 
459
- // v0.11.0template-refresh on drift
401
+ // ── migrateLegacyMemoryDir auto-upgrade cleanup of the pre-v0.74 scheme ────
402
+
403
+ function seedLegacy(sb) {
404
+ const dir = memoryDir(sb.cwd, sb.home);
405
+ fs.mkdirSync(dir, { recursive: true });
406
+ fs.writeFileSync(path.join(dir, TARGET_NAME), `<!-- adopted-by: ${sb.cwd} -->\nold detail table\n`);
407
+ fs.writeFileSync(path.join(dir, 'MEMORY.md'),
408
+ `# Memory Index\n\n- [user_note.md](user_note.md) — keep me\n\n${SENTINEL_BEGIN_V1}\n- old code-graph router line\n${SENTINEL_END}\n`);
409
+ return { dir, memIndex: path.join(dir, 'MEMORY.md'), legacyDetail: path.join(dir, TARGET_NAME) };
410
+ }
460
411
 
461
- test('needsRefresh returns false when target matches shipped template + INDEX_LINE', () => {
412
+ test('migrate strips the legacy v1 MEMORY.md block + deletes the adopted-by detail file', () => {
462
413
  const sb = makeSandbox();
463
414
  try {
464
- adopt({ cwd: sb.cwd, home: sb.home });
465
- assert.strictEqual(needsRefresh({ cwd: sb.cwd, home: sb.home }), false);
415
+ const L = seedLegacy(sb);
416
+ const res = migrateLegacyMemoryDir({ cwd: sb.cwd, home: sb.home });
417
+ assert.strictEqual(res.memoryIndexPruned, true);
418
+ assert.strictEqual(res.legacyDetailRemoved, true);
419
+ assert.ok(!fs.existsSync(L.legacyDetail), 'legacy detail deleted');
420
+ const mem = fs.readFileSync(L.memIndex, 'utf8');
421
+ assert.ok(!mem.includes(SENTINEL_BEGIN_V1), 'v1 sentinel removed');
422
+ assert.ok(mem.includes('keep me'), "user's other memory preserved");
466
423
  } finally { sb.cleanup(); }
467
424
  });
468
425
 
469
- test('needsRefresh returns true when target content drifted from shipped template', () => {
426
+ test('migrate will NOT delete a legacy detail file lacking the adopted-by marker', () => {
470
427
  const sb = makeSandbox();
471
428
  try {
472
- adopt({ cwd: sb.cwd, home: sb.home });
473
- const target = path.join(sb.dir, TARGET_NAME);
474
- fs.writeFileSync(target, '# stale content from earlier plugin version\n');
475
- assert.strictEqual(needsRefresh({ cwd: sb.cwd, home: sb.home }), true);
429
+ const dir = memoryDir(sb.cwd, sb.home);
430
+ fs.mkdirSync(dir, { recursive: true });
431
+ const userFile = path.join(dir, TARGET_NAME);
432
+ fs.writeFileSync(userFile, 'a user file that happens to share the name\n');
433
+ const res = migrateLegacyMemoryDir({ cwd: sb.cwd, home: sb.home });
434
+ assert.strictEqual(res.legacyDetailRemoved, false);
435
+ assert.ok(fs.existsSync(userFile), 'unmarked file survives');
476
436
  } finally { sb.cleanup(); }
477
437
  });
478
438
 
479
- test('needsRefresh returns true when MEMORY.md INDEX_LINE drifted', () => {
439
+ test('migrate is a no-op when there is nothing to clean', () => {
480
440
  const sb = makeSandbox();
481
441
  try {
482
- adopt({ cwd: sb.cwd, home: sb.home });
483
- const indexPath = path.join(sb.dir, 'MEMORY.md');
484
- const stale = `# Memory Index\n\n${SENTINEL_BEGIN}\n- old 12-tool index line\n${SENTINEL_END}\n`;
485
- fs.writeFileSync(indexPath, stale);
486
- assert.strictEqual(needsRefresh({ cwd: sb.cwd, home: sb.home }), true);
442
+ const res = migrateLegacyMemoryDir({ cwd: sb.cwd, home: sb.home });
443
+ assert.deepStrictEqual(res, { memoryIndexPruned: false, legacyDetailRemoved: false });
487
444
  } finally { sb.cleanup(); }
488
445
  });
489
446
 
490
- test('needsRefresh returns false when not adopted (nothing to refresh)', () => {
447
+ test('maybeAutoAdopt runs the legacy migration then installs the new scheme', () => {
491
448
  const sb = makeSandbox();
492
449
  try {
493
- assert.strictEqual(needsRefresh({ cwd: sb.cwd, home: sb.home }), false);
450
+ const L = seedLegacy(sb);
451
+ const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
452
+ assert.ok(res.migrated.memoryIndexPruned && res.migrated.legacyDetailRemoved, 'legacy cleaned');
453
+ assert.ok(!fs.existsSync(L.legacyDetail), 'legacy detail gone');
454
+ assert.ok(!fs.readFileSync(L.memIndex, 'utf8').includes(SENTINEL_BEGIN_V1), 'v1 block gone');
455
+ assert.strictEqual(isAdopted({ cwd: sb.cwd }), true, 'new CLAUDE.md scheme installed');
494
456
  } finally { sb.cleanup(); }
495
457
  });
496
458
 
497
- test('maybeAutoAdopt refreshes drifted target on re-run (reason=refreshed)', () => {
498
- const sb = makeSandbox();
499
- try {
500
- adopt({ cwd: sb.cwd, home: sb.home });
501
- const target = path.join(sb.dir, TARGET_NAME);
502
- fs.writeFileSync(target, '# stale\n');
503
- const res = maybeAutoAdopt({
504
- cwd: sb.cwd, home: sb.home,
505
- scriptPath: '/home/u/.claude/plugins/cache/code-graph-mcp/scripts',
506
- env: {},
507
- });
508
- assert.strictEqual(res.attempted, true);
509
- assert.strictEqual(res.reason, 'refreshed');
510
- assert.strictEqual(res.result.ok, true);
511
- // Target now matches shipped template (after stripping the leading
512
- // "<!-- adopted-by: ... -->\n" collision marker added by adopt v0.16.9).
513
- const shipped = fs.readFileSync(TEMPLATE_PATH);
514
- const current = fs.readFileSync(target);
515
- const nl = current.indexOf(0x0a);
516
- const body = nl > 0 && /^<!-- adopted-by: /.test(current.subarray(0, nl).toString())
517
- ? current.subarray(nl + 1) : current;
518
- assert.ok(shipped.equals(body), 'target re-synced to shipped template');
519
- // Sentinel preserved in MEMORY.md
520
- assert.strictEqual(isAdopted({ cwd: sb.cwd, home: sb.home }), true);
521
- } finally { sb.cleanup(); }
459
+ // ── stripSentinelBlock (matches v1 + v2) ────────────────────────────────────
460
+
461
+ test('stripSentinelBlock removes a well-formed v2 block, preserving neighbors', () => {
462
+ const before = `# Index\nKeep.\n\n${SENTINEL_BEGIN}\nbody\n${SENTINEL_END}\n\n- [x.md](x.md)\n`;
463
+ const after = stripSentinelBlock(before);
464
+ assert.ok(!after.includes(SENTINEL_BEGIN) && !after.includes(SENTINEL_END));
465
+ assert.ok(after.includes('Keep.') && after.includes('- [x.md](x.md)'));
522
466
  });
523
467
 
524
- test('maybeAutoAdopt refreshes drifted INDEX_LINE in MEMORY.md', () => {
525
- const sb = makeSandbox();
526
- try {
527
- adopt({ cwd: sb.cwd, home: sb.home });
528
- const indexPath = path.join(sb.dir, 'MEMORY.md');
529
- const stale = `# Memory Index\n\n${SENTINEL_BEGIN}\n- old 12-tool index line\n${SENTINEL_END}\n`;
530
- fs.writeFileSync(indexPath, stale);
531
- const res = maybeAutoAdopt({
532
- cwd: sb.cwd, home: sb.home,
533
- scriptPath: '/home/u/.claude/plugins/cache/code-graph-mcp/scripts',
534
- env: {},
535
- });
536
- assert.strictEqual(res.attempted, true);
537
- assert.strictEqual(res.reason, 'refreshed');
538
- const index = fs.readFileSync(indexPath, 'utf8');
539
- assert.ok(index.includes(INDEX_LINE), 'INDEX_LINE restored from current constant');
540
- assert.ok(!index.includes('old 12-tool index line'), 'stale line removed');
541
- } finally { sb.cleanup(); }
468
+ test('stripSentinelBlock removes a legacy v1 block (version-agnostic match)', () => {
469
+ const before = `# Index\n${SENTINEL_BEGIN_V1}\n- old line\n${SENTINEL_END}\n- [keep.md](keep.md)\n`;
470
+ const after = stripSentinelBlock(before);
471
+ assert.ok(!after.includes(SENTINEL_BEGIN_V1), 'v1 begin removed');
472
+ assert.ok(after.includes('- [keep.md](keep.md)'), 'neighbor preserved');
542
473
  });
543
474
 
544
- test('maybeAutoAdopt skips refresh when CODE_GRAPH_NO_TEMPLATE_REFRESH=1 (locks manual edits)', () => {
545
- const sb = makeSandbox();
546
- try {
547
- adopt({ cwd: sb.cwd, home: sb.home });
548
- const target = path.join(sb.dir, TARGET_NAME);
549
- const userEdit = '# my hand-edited decision table\n';
550
- fs.writeFileSync(target, userEdit);
551
- const res = maybeAutoAdopt({
552
- cwd: sb.cwd, home: sb.home,
553
- scriptPath: '/home/u/.claude/plugins/cache/code-graph-mcp/scripts',
554
- env: { CODE_GRAPH_NO_TEMPLATE_REFRESH: '1' },
555
- });
556
- assert.strictEqual(res.attempted, false);
557
- assert.strictEqual(res.reason, 'already-adopted');
558
- assert.strictEqual(fs.readFileSync(target, 'utf8'), userEdit, 'user edit preserved');
559
- } finally { sb.cleanup(); }
475
+ test('stripSentinelBlock self-heals orphan BEGIN without END', () => {
476
+ const before = `# Index\n- [a.md](a.md) — entry\n${SENTINEL_BEGIN}\nbody\n\n- [b.md](b.md) — survivor\n`;
477
+ const after = stripSentinelBlock(before);
478
+ assert.ok(!after.includes(SENTINEL_BEGIN), 'orphan BEGIN removed');
479
+ assert.ok(after.includes('survivor') && after.includes('entry'));
560
480
  });
561
481
 
562
- test('maybeAutoAdopt stays already-adopted when in sync (no gratuitous refresh)', () => {
563
- const sb = makeSandbox();
564
- try {
565
- adopt({ cwd: sb.cwd, home: sb.home });
566
- const target = path.join(sb.dir, TARGET_NAME);
567
- const mtimeBefore = fs.statSync(target).mtimeMs;
568
- const res = maybeAutoAdopt({
569
- cwd: sb.cwd, home: sb.home,
570
- scriptPath: '/home/u/.claude/plugins/cache/code-graph-mcp/scripts',
571
- env: {},
572
- });
573
- assert.strictEqual(res.attempted, false);
574
- assert.strictEqual(res.reason, 'already-adopted');
575
- const mtimeAfter = fs.statSync(target).mtimeMs;
576
- assert.strictEqual(mtimeAfter, mtimeBefore, 'target file not touched when in sync');
577
- } finally { sb.cleanup(); }
482
+ test('stripSentinelBlock self-heals orphan END line', () => {
483
+ const before = `# Index\n- [a.md](a.md)\n${SENTINEL_END}\n- [b.md](b.md)\n`;
484
+ const after = stripSentinelBlock(before);
485
+ assert.ok(!after.includes(SENTINEL_END));
486
+ assert.ok(after.includes('- [a.md](a.md)') && after.includes('- [b.md](b.md)'));
578
487
  });
579
488
 
489
+ // ── platform guard ──────────────────────────────────────────────────────────
490
+
580
491
  test('Windows platform is rejected with clear reason', { skip: process.platform === 'win32' }, () => {
581
492
  const orig = process.platform;
582
493
  Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
583
494
  try {
584
495
  const sb = makeSandbox();
585
496
  try {
586
- const adoptRes = adopt({ cwd: sb.cwd, home: sb.home });
587
- assert.strictEqual(adoptRes.ok, false);
588
- assert.strictEqual(adoptRes.reason, 'windows-not-supported');
589
- const unadoptRes = unadopt({ cwd: sb.cwd, home: sb.home });
590
- assert.strictEqual(unadoptRes.ok, false);
591
- assert.strictEqual(unadoptRes.reason, 'windows-not-supported');
497
+ assert.strictEqual(adopt({ cwd: sb.cwd }).reason, 'windows-not-supported');
498
+ assert.strictEqual(unadopt({ cwd: sb.cwd, home: sb.home }).reason, 'windows-not-supported');
592
499
  } finally { sb.cleanup(); }
593
500
  } finally {
594
501
  Object.defineProperty(process, 'platform', { value: orig, configurable: true });
595
502
  }
596
503
  });
597
504
 
598
- // ─── C fix: project-marker mkdir ─────────────────────────────────────────
505
+ // ── template integrity ──────────────────────────────────────────────────────
506
+
507
+ test('template file exists and contains the decision table', () => {
508
+ assert.ok(fs.existsSync(TEMPLATE_PATH), `template at ${TEMPLATE_PATH}`);
509
+ const content = fs.readFileSync(TEMPLATE_PATH, 'utf8');
510
+ assert.ok(content.includes('get_call_graph'), 'mentions get_call_graph');
511
+ assert.ok(content.includes('CODE_GRAPH_QUIET_HOOKS'), 'mentions env gate');
512
+ assert.ok(content.includes('.claude/plugin_code_graph_mcp.md'), 'describes the new layout');
513
+ });
514
+
515
+ // ── isPluginModeInstall ─────────────────────────────────────────────────────
516
+
517
+ test('isPluginModeInstall recognizes ~/.claude/plugins/... paths', () => {
518
+ assert.strictEqual(isPluginModeInstall('/home/user/.claude/plugins/cache/code-graph-mcp@0.9.0/scripts'), true);
519
+ });
520
+
521
+ test('isPluginModeInstall rejects npm global / dev / npx paths', () => {
522
+ assert.strictEqual(isPluginModeInstall('/usr/local/lib/node_modules/@sdsrs/code-graph/claude-plugin/scripts'), false);
523
+ assert.strictEqual(isPluginModeInstall('/mnt/data_ssd/dev/projects/code-graph-mcp/claude-plugin/scripts'), false);
524
+ assert.strictEqual(isPluginModeInstall('/tmp/npx-abc123/node_modules/@sdsrs/code-graph/claude-plugin/scripts'), false);
525
+ });
526
+
527
+ test('isPluginModeInstall recognizes CLAUDE_CONFIG_DIR/plugins/... paths', () => {
528
+ const prev = process.env.CLAUDE_CONFIG_DIR;
529
+ process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
530
+ try {
531
+ assert.strictEqual(isPluginModeInstall('/home/alice/work-claude/plugins/cache/code-graph-mcp@0.31.0/scripts'), true);
532
+ assert.strictEqual(isPluginModeInstall('/home/user/.claude/plugins/cache/code-graph-mcp/scripts'), true);
533
+ assert.strictEqual(isPluginModeInstall('/home/alice/work-claude/projects/foo/memory'), false);
534
+ } finally {
535
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
536
+ else process.env.CLAUDE_CONFIG_DIR = prev;
537
+ }
538
+ });
539
+
540
+ // ── isProjectRoot markers ───────────────────────────────────────────────────
599
541
 
600
542
  test('isProjectRoot detects each marker', () => {
601
543
  for (const marker of PROJECT_MARKERS) {
602
544
  const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-marker-'));
603
545
  try {
604
- assert.strictEqual(isProjectRoot(cwd), false, `bare cwd should not be a project`);
546
+ assert.strictEqual(isProjectRoot(cwd), false, 'bare cwd should not be a project');
605
547
  const markerPath = path.join(cwd, marker);
606
- // Some markers are directories (.git, .code-graph), others are files.
607
548
  if (marker.startsWith('.')) fs.mkdirSync(markerPath);
608
549
  else fs.writeFileSync(markerPath, '');
609
550
  assert.strictEqual(isProjectRoot(cwd), true, `${marker} should make cwd a project`);
@@ -613,87 +554,11 @@ test('isProjectRoot detects each marker', () => {
613
554
  }
614
555
  });
615
556
 
616
- test('adopt auto-creates memory dir when cwd has a project marker', () => {
617
- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
618
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
619
- try {
620
- // Add a project marker so adopt is allowed to create the memory dir.
621
- fs.writeFileSync(path.join(cwd, 'package.json'), '{}');
622
- // The memory dir does NOT exist yet — pre-fix behavior errored 'no-memory-dir'.
623
- const dir = memoryDir(cwd, home);
624
- assert.strictEqual(fs.existsSync(dir), false);
625
-
626
- const res = adopt({ cwd, home });
627
- assert.strictEqual(res.ok, true, `expected ok, got ${JSON.stringify(res)}`);
628
- assert.strictEqual(fs.existsSync(dir), true, 'memory dir auto-created');
629
- assert.strictEqual(fs.existsSync(path.join(dir, TARGET_NAME)), true, 'plugin file written');
630
- } finally {
631
- fs.rmSync(home, { recursive: true, force: true });
632
- fs.rmSync(cwd, { recursive: true, force: true });
633
- }
634
- });
635
-
636
- // ─── D fix: slug collision marker ────────────────────────────────────────
637
-
638
- test('adopt writes adopted-by marker as first line of plugin file', () => {
639
- const sb = makeSandbox();
640
- try {
641
- adopt({ cwd: sb.cwd, home: sb.home });
642
- const target = path.join(sb.dir, TARGET_NAME);
643
- const firstLine = fs.readFileSync(target, 'utf8').split('\n', 1)[0];
644
- assert.match(firstLine, /^<!-- adopted-by: .* -->$/, `expected adopted-by marker, got: ${firstLine}`);
645
- assert.ok(firstLine.includes(sb.cwd), `marker should embed absolute cwd: ${firstLine}`);
646
- } finally { sb.cleanup(); }
647
- });
648
-
649
- test('adopt detects slug collision when same memory dir is re-adopted from a different cwd', () => {
650
- // Simulate two real cwds whose paths slugify to the same string. Here we
651
- // skip real path encoding and just write a file pretending it came from a
652
- // different cwd, then re-adopt — collision detection reads the prior marker.
653
- const sb = makeSandbox();
654
- try {
655
- adopt({ cwd: sb.cwd, home: sb.home });
656
- const target = path.join(sb.dir, TARGET_NAME);
657
- // Tamper: rewrite the marker to look like a different cwd adopted first.
658
- const body = fs.readFileSync(target, 'utf8').split('\n').slice(1).join('\n');
659
- fs.writeFileSync(target, '<!-- adopted-by: /imaginary/other-project -->\n' + body);
660
-
661
- const res = adopt({ cwd: sb.cwd, home: sb.home });
662
- assert.strictEqual(res.ok, true);
663
- assert.strictEqual(res.collisionWith, '/imaginary/other-project',
664
- `expected collisionWith to surface prior cwd, got ${res.collisionWith}`);
665
- } finally { sb.cleanup(); }
666
- });
667
-
668
- test('adopt collisionWith is null when re-adopting from same cwd (idempotent)', () => {
669
- const sb = makeSandbox();
670
- try {
671
- adopt({ cwd: sb.cwd, home: sb.home });
672
- const res = adopt({ cwd: sb.cwd, home: sb.home });
673
- assert.strictEqual(res.ok, true);
674
- assert.strictEqual(res.collisionWith, null);
675
- } finally { sb.cleanup(); }
676
- });
677
-
678
- test('needsRefresh ignores the adopted-by marker when bytewise comparing', () => {
679
- // Critical: the marker we add to target makes target ≠ template byte-for-byte.
680
- // needsRefresh must skip the leading marker line before compare; otherwise
681
- // every SessionStart would re-write the file and burn IO on a no-op.
682
- const sb = makeSandbox();
683
- try {
684
- adopt({ cwd: sb.cwd, home: sb.home });
685
- assert.strictEqual(needsRefresh({ cwd: sb.cwd, home: sb.home }), false,
686
- 'needsRefresh should be false right after adopt — marker must not trigger drift');
687
- } finally { sb.cleanup(); }
688
- });
689
-
690
- // memdir L1 升格 — project-typed INDEX_LINE coverage.
557
+ // ── detectProjectType (unchanged logic; tailoring still feeds buildBlock) ────
691
558
 
692
559
  test('detectProjectType returns generic for an empty cwd', () => {
693
560
  const sb = makeSandbox();
694
- try {
695
- assert.strictEqual(detectProjectType(sb.cwd), 'generic');
696
- } finally { sb.cleanup(); }
561
+ try { assert.strictEqual(detectProjectType(sb.cwd), 'generic'); } finally { sb.cleanup(); }
697
562
  });
698
563
 
699
564
  test('detectProjectType returns rust for a Cargo.toml without web framework', () => {
@@ -704,7 +569,7 @@ test('detectProjectType returns rust for a Cargo.toml without web framework', ()
704
569
  } finally { sb.cleanup(); }
705
570
  });
706
571
 
707
- test('detectProjectType returns web-rs when Cargo.toml has axum/actix/etc', () => {
572
+ test('detectProjectType returns web-rs when Cargo.toml has axum', () => {
708
573
  const sb = makeSandbox();
709
574
  try {
710
575
  fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[dependencies]\naxum = "0.7"\n');
@@ -712,20 +577,18 @@ test('detectProjectType returns web-rs when Cargo.toml has axum/actix/etc', () =
712
577
  } finally { sb.cleanup(); }
713
578
  });
714
579
 
715
- test('detectProjectType returns frontend for package.json with React/Next/Vue', () => {
580
+ test('detectProjectType returns frontend for React/Next deps', () => {
716
581
  const sb = makeSandbox();
717
582
  try {
718
- fs.writeFileSync(path.join(sb.cwd, 'package.json'),
719
- '{"dependencies":{"next":"^14","react":"^18"}}');
583
+ fs.writeFileSync(path.join(sb.cwd, 'package.json'), '{"dependencies":{"next":"^14","react":"^18"}}');
720
584
  assert.strictEqual(detectProjectType(sb.cwd), 'frontend');
721
585
  } finally { sb.cleanup(); }
722
586
  });
723
587
 
724
- test('detectProjectType returns web-node for package.json with express/fastify', () => {
588
+ test('detectProjectType returns web-node for express', () => {
725
589
  const sb = makeSandbox();
726
590
  try {
727
- fs.writeFileSync(path.join(sb.cwd, 'package.json'),
728
- '{"dependencies":{"express":"^4"}}');
591
+ fs.writeFileSync(path.join(sb.cwd, 'package.json'), '{"dependencies":{"express":"^4"}}');
729
592
  assert.strictEqual(detectProjectType(sb.cwd), 'web-node');
730
593
  } finally { sb.cleanup(); }
731
594
  });
@@ -733,146 +596,35 @@ test('detectProjectType returns web-node for package.json with express/fastify',
733
596
  test('detectProjectType returns web-py for FastAPI in pyproject.toml', () => {
734
597
  const sb = makeSandbox();
735
598
  try {
736
- fs.writeFileSync(path.join(sb.cwd, 'pyproject.toml'),
737
- '[tool.poetry.dependencies]\nfastapi = "^0.115"\n');
599
+ fs.writeFileSync(path.join(sb.cwd, 'pyproject.toml'), '[tool.poetry.dependencies]\nfastapi = "^0.115"\n');
738
600
  assert.strictEqual(detectProjectType(sb.cwd), 'web-py');
739
601
  } finally { sb.cleanup(); }
740
602
  });
741
603
 
742
- test('buildIndexLine generic returns the canonical INDEX_LINE byte-for-byte', () => {
743
- // Critical: keeps backward compatibility with adopted projects that have no
744
- // markers. Any drift here invalidates needsRefresh's idempotency assumption.
745
- assert.strictEqual(buildIndexLine('generic'), INDEX_LINE);
746
- assert.strictEqual(buildIndexLine(undefined), INDEX_LINE);
747
- });
748
-
749
- test('buildIndexLine web-rs prepends route/trace tags + handler-focused lead', () => {
750
- const line = buildIndexLine('web-rs');
751
- assert.match(line, /\[trace-http-chain, http-route,/, 'web-rs index line should lead with trace-http-chain/http-route tags');
752
- assert.match(line, /HTTP 路由/, 'lead sentence should mention HTTP routes');
753
- });
754
-
755
- test('buildIndexLine frontend emphasizes refs/overview, drops HTTP route priming', () => {
756
- const line = buildIndexLine('frontend');
757
- assert.match(line, /组件重命名|find_references/, 'frontend should mention rename audit / refs');
758
- assert.match(line, /HTTP route 通常不适用/, 'frontend should explicitly demote HTTP route tracing');
759
- });
760
-
761
- test('adopt + needsRefresh agree on typed INDEX_LINE — no spurious refresh in a Rust project', () => {
762
- // The detection function is deterministic + adopt and needsRefresh both call
763
- // it; together they must produce a consistent indexLine, otherwise every
764
- // SessionStart triggers a rewrite.
765
- const sb = makeSandbox();
766
- try {
767
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
768
- adopt({ cwd: sb.cwd, home: sb.home });
769
- assert.strictEqual(needsRefresh({ cwd: sb.cwd, home: sb.home }), false,
770
- 'needsRefresh must be false right after adopt for a Rust project');
771
- const indexPath = path.join(sb.dir, 'MEMORY.md');
772
- const index = fs.readFileSync(indexPath, 'utf8');
773
- assert.ok(index.includes('优先于 Grep'),
774
- 'MEMORY.md should contain the rust-typed index line');
775
- } finally { sb.cleanup(); }
776
- });
777
-
778
- test('stale INDEX_LINE → adopt rewrites in place without duplicating sentinel blocks', () => {
779
- // Regression for the v0.24+ tag-rename fix (feedback_adoption_tag_specificity).
780
- // If a user's MEMORY.md was written by an older code-graph-mcp (pre-rename),
781
- // SessionStart → maybeAutoAdopt → needsRefresh must detect the drift,
782
- // stripSentinelBlock must locate the old v1 block by sentinel marker, and
783
- // the rewrite must end with exactly one BEGIN/END pair. Breaks if anyone
784
- // bumps SENTINEL_BEGIN without teaching stripSentinelBlock to also match
785
- // the prior version — would leave an orphan v1 block plus a new v2 block.
786
- const sb = makeSandbox();
787
- try {
788
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
789
- // Plant pre-rename MEMORY.md: well-formed v1 sentinel + an obsolete tag
790
- // line that no current buildIndexLine variant produces. Exact prior bytes
791
- // don't matter — only that it differs from today's desiredBlock.
792
- const stalePayload =
793
- '- [code-graph-mcp](plugin_code_graph_mcp.md) [obsolete-tag, another-stale] — stale lead sentence kept for drift detection.';
794
- const indexPath = path.join(sb.dir, 'MEMORY.md');
795
- const neighbor = '- [user_profile.md](user_profile.md) — neighbor (must survive)';
796
- fs.writeFileSync(
797
- indexPath,
798
- `# Memory Index\n\n${neighbor}\n\n${SENTINEL_BEGIN}\n${stalePayload}\n${SENTINEL_END}\n`
799
- );
800
- // Plant target file so isAdopted/needsRefresh treat this as a real prior
801
- // adoption (body bytewise-identical to shipped template — drift is only
802
- // in MEMORY.md, not the template body).
803
- const tplBody = fs.readFileSync(TEMPLATE_PATH);
804
- const marker = Buffer.from(`<!-- adopted-by: ${sb.cwd} -->\n`);
805
- fs.writeFileSync(path.join(sb.dir, TARGET_NAME), Buffer.concat([marker, tplBody]));
806
-
807
- assert.strictEqual(isAdopted({ cwd: sb.cwd, home: sb.home }), true,
808
- 'precondition: planted state should look adopted');
809
- assert.strictEqual(needsRefresh({ cwd: sb.cwd, home: sb.home }), true,
810
- 'tag-list drift must trigger refresh');
811
-
812
- adopt({ cwd: sb.cwd, home: sb.home });
813
-
814
- const after = fs.readFileSync(indexPath, 'utf8');
815
- const escBegin = SENTINEL_BEGIN.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
816
- const escEnd = SENTINEL_END.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
817
- const beginHits = (after.match(new RegExp(escBegin, 'g')) || []).length;
818
- const endHits = (after.match(new RegExp(escEnd, 'g')) || []).length;
819
- assert.strictEqual(beginHits, 1, 'exactly one sentinel BEGIN after rewrite (no duplicate blocks)');
820
- assert.strictEqual(endHits, 1, 'exactly one sentinel END after rewrite');
821
- assert.ok(!after.includes('obsolete-tag'), 'stale tag list must be gone');
822
- assert.ok(!after.includes('another-stale'), 'stale tag list must be gone');
823
- assert.ok(after.includes(neighbor), 'neighbor entry preserved');
824
- assert.strictEqual(needsRefresh({ cwd: sb.cwd, home: sb.home }), false,
825
- 'post-refresh needsRefresh must be false (no SessionStart refresh loop)');
826
- } finally { sb.cleanup(); }
827
- });
828
-
829
- // 2A — false-positive hardening: comment-strip + section-aware scan.
830
-
831
604
  test('detectProjectType ignores commented-out web-framework deps in Cargo.toml', () => {
832
- // Pre-fix: `# axum = "0.7"` substring-matched and falsely promoted to web-rs.
833
- // Post-fix: comment stripping happens before section scan.
834
605
  const sb = makeSandbox();
835
606
  try {
836
607
  fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
837
- '[package]\nname="x"\n[dependencies]\n# axum = "0.7" # disabled, was for prototype\nserde = "1"\n');
838
- assert.strictEqual(detectProjectType(sb.cwd), 'rust',
839
- 'commented dep must not promote to web-rs');
608
+ '[package]\nname="x"\n[dependencies]\n# axum = "0.7" # disabled\nserde = "1"\n');
609
+ assert.strictEqual(detectProjectType(sb.cwd), 'rust');
840
610
  } finally { sb.cleanup(); }
841
611
  });
842
612
 
843
613
  test('detectProjectType ignores axum in [dev-dependencies] only', () => {
844
- // axum used solely for tests does not make this a web project.
845
614
  const sb = makeSandbox();
846
615
  try {
847
616
  fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
848
617
  '[package]\nname="x"\n[dependencies]\nserde = "1"\n[dev-dependencies]\naxum = "0.7"\n');
849
- assert.strictEqual(detectProjectType(sb.cwd), 'rust',
850
- 'axum in dev-dependencies must not promote to web-rs');
851
- } finally { sb.cleanup(); }
852
- });
853
-
854
- test('detectProjectType ignores axum in [build-dependencies] only', () => {
855
- const sb = makeSandbox();
856
- try {
857
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
858
- '[package]\nname="x"\n[dependencies]\nserde = "1"\n[build-dependencies]\naxum = "0.7"\n');
859
- assert.strictEqual(detectProjectType(sb.cwd), 'rust',
860
- 'axum in build-dependencies must not promote to web-rs');
618
+ assert.strictEqual(detectProjectType(sb.cwd), 'rust');
861
619
  } finally { sb.cleanup(); }
862
620
  });
863
621
 
864
- test('detectProjectType ignores react in devDependencies of package.json', () => {
865
- // A library that lists react in devDependencies for testing should not
866
- // be classified as a frontend app.
622
+ test('detectProjectType ignores react in devDependencies', () => {
867
623
  const sb = makeSandbox();
868
624
  try {
869
625
  fs.writeFileSync(path.join(sb.cwd, 'package.json'),
870
- JSON.stringify({
871
- dependencies: { lodash: '^4' },
872
- devDependencies: { react: '^18', 'react-dom': '^18' },
873
- }));
874
- assert.strictEqual(detectProjectType(sb.cwd), 'node',
875
- 'react in devDependencies must not promote to frontend');
626
+ JSON.stringify({ dependencies: { lodash: '^4' }, devDependencies: { react: '^18' } }));
627
+ assert.strictEqual(detectProjectType(sb.cwd), 'node');
876
628
  } finally { sb.cleanup(); }
877
629
  });
878
630
 
@@ -881,25 +633,19 @@ test('detectProjectType ignores // indirect deps in go.mod', () => {
881
633
  try {
882
634
  fs.writeFileSync(path.join(sb.cwd, 'go.mod'),
883
635
  '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');
884
- assert.strictEqual(detectProjectType(sb.cwd), 'go',
885
- 'indirect gin must not promote to web-go');
636
+ assert.strictEqual(detectProjectType(sb.cwd), 'go');
886
637
  } finally { sb.cleanup(); }
887
638
  });
888
639
 
889
640
  test('detectProjectType handles malformed package.json without throwing', () => {
890
- // JSON.parse failure must not crash detection; falls back to 'node' since
891
- // package.json exists but is unreadable.
892
641
  const sb = makeSandbox();
893
642
  try {
894
643
  fs.writeFileSync(path.join(sb.cwd, 'package.json'), '{not valid json');
895
- assert.strictEqual(detectProjectType(sb.cwd), 'node',
896
- 'malformed package.json should fall back to node bucket');
644
+ assert.strictEqual(detectProjectType(sb.cwd), 'node');
897
645
  } finally { sb.cleanup(); }
898
646
  });
899
647
 
900
648
  test('detectProjectType detects PEP 621 [project] dependencies block', () => {
901
- // PEP 621 puts `dependencies = [...]` inside [project], not a separate
902
- // [project.dependencies] section — our state machine accepts both.
903
649
  const sb = makeSandbox();
904
650
  try {
905
651
  fs.writeFileSync(path.join(sb.cwd, 'pyproject.toml'),
@@ -911,60 +657,23 @@ test('detectProjectType detects PEP 621 [project] dependencies block', () => {
911
657
  test('detectProjectType reads requirements.txt as fallback', () => {
912
658
  const sb = makeSandbox();
913
659
  try {
914
- fs.writeFileSync(path.join(sb.cwd, 'requirements.txt'),
915
- '# web stack\nflask>=3.0\ngunicorn\n');
660
+ fs.writeFileSync(path.join(sb.cwd, 'requirements.txt'), '# web stack\nflask>=3.0\ngunicorn\n');
916
661
  assert.strictEqual(detectProjectType(sb.cwd), 'web-py');
917
662
  } finally { sb.cleanup(); }
918
663
  });
919
664
 
920
- // 2D — env override.
921
-
922
665
  test('CODE_GRAPH_PROJECT_TYPE env override beats file-based detection', () => {
923
666
  const sb = makeSandbox();
924
667
  try {
925
- // Cargo.toml says rust — env says web-rs. Env wins.
926
668
  fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
927
- assert.strictEqual(
928
- detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'web-rs' }),
929
- 'web-rs',
930
- );
669
+ assert.strictEqual(detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'web-rs' }), 'web-rs');
931
670
  } finally { sb.cleanup(); }
932
671
  });
933
672
 
934
673
  test('CODE_GRAPH_PROJECT_TYPE env override falls through on invalid value', () => {
935
- // Typo'd / unknown bucket name should not silently classify everything as
936
- // 'generic' — fall through to file-based detection so the project still
937
- // gets a meaningful index line.
938
- const sb = makeSandbox();
939
- try {
940
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
941
- assert.strictEqual(
942
- detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'web-rust' /* typo */ }),
943
- 'rust',
944
- 'invalid override must fall through to file detection',
945
- );
946
- } finally { sb.cleanup(); }
947
- });
948
-
949
- test('CODE_GRAPH_PROJECT_TYPE env override unset uses file detection', () => {
950
- // Empty env reaches file-based detection unchanged.
951
674
  const sb = makeSandbox();
952
675
  try {
953
676
  fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
954
- assert.strictEqual(detectProjectType(sb.cwd, {}), 'rust');
955
- } finally { sb.cleanup(); }
956
- });
957
-
958
- test('CODE_GRAPH_PROJECT_TYPE env override forces generic in a Rust project', () => {
959
- // Power-user case: explicit opt-out of typed routing for a project that
960
- // would otherwise be auto-classified.
961
- const sb = makeSandbox();
962
- try {
963
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
964
- '[package]\nname="x"\n[dependencies]\naxum = "0.7"\n');
965
- assert.strictEqual(
966
- detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'generic' }),
967
- 'generic',
968
- );
677
+ assert.strictEqual(detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'web-rust' }), 'rust');
969
678
  } finally { sb.cleanup(); }
970
679
  });