@sdsrs/code-graph 0.73.1 → 0.74.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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +263 -221
- package/claude-plugin/scripts/adopt.test.js +355 -648
- package/claude-plugin/scripts/session-init.js +13 -5
- package/claude-plugin/templates/plugin_code_graph_mcp.md +12 -9
- package/package.json +6 -6
|
@@ -7,447 +7,387 @@ const os = require('os');
|
|
|
7
7
|
const {
|
|
8
8
|
adopt, unadopt, memoryDir, stripSentinelBlock,
|
|
9
9
|
isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
|
|
10
|
-
detectProjectType,
|
|
11
|
-
SENTINEL_BEGIN, SENTINEL_END,
|
|
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()
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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('
|
|
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
|
|
104
|
+
const res = adopt({ cwd: sb.cwd });
|
|
58
105
|
assert.strictEqual(res.ok, true);
|
|
59
|
-
assert.strictEqual(res.
|
|
60
|
-
assert.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
assert.ok(
|
|
64
|
-
assert.ok(
|
|
65
|
-
assert.ok(
|
|
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
|
|
116
|
+
test('adopt injects the block into an existing CLAUDE.md, preserving user prose', () => {
|
|
70
117
|
const sb = makeSandbox();
|
|
71
118
|
try {
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
assert.strictEqual(
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
assert.
|
|
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
|
|
129
|
+
test('adopt is idempotent — no duplicate block, no write on re-run', () => {
|
|
82
130
|
const sb = makeSandbox();
|
|
83
131
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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('
|
|
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.
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
assert.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
|
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
|
|
170
|
+
const res = adopt({ cwd });
|
|
138
171
|
assert.strictEqual(res.ok, false);
|
|
139
172
|
assert.strictEqual(res.reason, 'not-a-project');
|
|
140
|
-
|
|
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
|
|
152
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
164
|
-
|
|
206
|
+
fs.renameSync = realRename;
|
|
207
|
+
sb.cleanup();
|
|
165
208
|
}
|
|
166
209
|
});
|
|
167
210
|
|
|
168
|
-
|
|
211
|
+
// ── unadopt ─────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
test('unadopt removes the block + detail file, preserving user prose', () => {
|
|
169
214
|
const sb = makeSandbox();
|
|
170
215
|
try {
|
|
171
|
-
|
|
172
|
-
|
|
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.
|
|
180
|
-
assert.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
assert.ok(
|
|
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
|
|
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.
|
|
192
|
-
assert.
|
|
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('
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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('
|
|
250
|
+
test('unadopt is a no-op when never adopted', () => {
|
|
229
251
|
const sb = makeSandbox();
|
|
230
252
|
try {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
259
|
+
// ── isAdopted ───────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
test('isAdopted: false fresh, true after adopt, false after unadopt', () => {
|
|
254
262
|
const sb = makeSandbox();
|
|
255
263
|
try {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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('
|
|
272
|
+
test('isAdopted: false when block present but detail file missing', () => {
|
|
270
273
|
const sb = makeSandbox();
|
|
271
274
|
try {
|
|
272
|
-
|
|
273
|
-
|
|
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('
|
|
282
|
+
test('needsRefresh: false right after adopt', () => {
|
|
290
283
|
const sb = makeSandbox();
|
|
291
284
|
try {
|
|
292
|
-
|
|
285
|
+
adopt({ cwd: sb.cwd });
|
|
286
|
+
assert.strictEqual(needsRefresh({ cwd: sb.cwd }), false);
|
|
293
287
|
} finally { sb.cleanup(); }
|
|
294
288
|
});
|
|
295
289
|
|
|
296
|
-
test('
|
|
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
|
|
300
|
-
|
|
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('
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
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('
|
|
309
|
+
test('needsRefresh: false when not adopted (nothing to refresh)', () => {
|
|
314
310
|
const sb = makeSandbox();
|
|
315
311
|
try {
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
334
|
-
const
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
assert.strictEqual(
|
|
350
|
-
|
|
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.strictEqual(isAdopted({ cwd: sb.cwd }), false);
|
|
326
|
+
} finally { sb.cleanup(); }
|
|
357
327
|
});
|
|
358
328
|
|
|
359
|
-
test('
|
|
360
|
-
const
|
|
361
|
-
process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
|
|
329
|
+
test('maybeAutoAdopt skips when not plugin-mode (npm install path)', () => {
|
|
330
|
+
const sb = makeSandbox();
|
|
362
331
|
try {
|
|
363
|
-
const
|
|
364
|
-
assert.strictEqual(
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
}
|
|
332
|
+
const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: '/usr/local/lib/node_modules/@sdsrs/code-graph/claude-plugin/scripts', env: {} });
|
|
333
|
+
assert.strictEqual(res.reason, 'not-plugin-mode');
|
|
334
|
+
assert.strictEqual(isAdopted({ cwd: sb.cwd }), false);
|
|
335
|
+
} finally { sb.cleanup(); }
|
|
379
336
|
});
|
|
380
337
|
|
|
381
|
-
test('maybeAutoAdopt
|
|
338
|
+
test('maybeAutoAdopt installs when plugin-mode + not-yet-adopted', () => {
|
|
382
339
|
const sb = makeSandbox();
|
|
383
340
|
try {
|
|
384
|
-
const res = maybeAutoAdopt({
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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);
|
|
341
|
+
const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
|
|
342
|
+
assert.strictEqual(res.attempted, true);
|
|
343
|
+
assert.strictEqual(res.reason, 'adopted');
|
|
344
|
+
assert.strictEqual(res.result.ok, true);
|
|
345
|
+
assert.strictEqual(isAdopted({ cwd: sb.cwd }), true);
|
|
392
346
|
} finally { sb.cleanup(); }
|
|
393
347
|
});
|
|
394
348
|
|
|
395
|
-
test('maybeAutoAdopt
|
|
349
|
+
test('maybeAutoAdopt is already-adopted when in sync (no gratuitous write)', () => {
|
|
396
350
|
const sb = makeSandbox();
|
|
397
351
|
try {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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);
|
|
352
|
+
adopt({ cwd: sb.cwd });
|
|
353
|
+
const mtime = fs.statSync(sb.claudeMd).mtimeMs;
|
|
354
|
+
const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
|
|
355
|
+
assert.strictEqual(res.reason, 'already-adopted');
|
|
356
|
+
assert.strictEqual(fs.statSync(sb.claudeMd).mtimeMs, mtime, 'CLAUDE.md not touched');
|
|
406
357
|
} finally { sb.cleanup(); }
|
|
407
358
|
});
|
|
408
359
|
|
|
409
|
-
test('maybeAutoAdopt
|
|
360
|
+
test('maybeAutoAdopt refreshes a drifted detail doc (reason=refreshed)', () => {
|
|
410
361
|
const sb = makeSandbox();
|
|
411
362
|
try {
|
|
412
|
-
adopt({ cwd: sb.cwd
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
assert.
|
|
363
|
+
adopt({ cwd: sb.cwd });
|
|
364
|
+
fs.writeFileSync(sb.detail, `${MANAGED_BY}\n# stale\n`);
|
|
365
|
+
const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
|
|
366
|
+
assert.strictEqual(res.reason, 'refreshed');
|
|
367
|
+
const shipped = fs.readFileSync(TEMPLATE_PATH);
|
|
368
|
+
const cur = fs.readFileSync(sb.detail);
|
|
369
|
+
const nl = cur.indexOf(0x0a);
|
|
370
|
+
assert.ok(shipped.equals(cur.subarray(nl + 1)), 'detail re-synced to shipped template');
|
|
420
371
|
} finally { sb.cleanup(); }
|
|
421
372
|
});
|
|
422
373
|
|
|
423
|
-
test('maybeAutoAdopt
|
|
374
|
+
test('maybeAutoAdopt skips refresh when CODE_GRAPH_NO_TEMPLATE_REFRESH=1 (locks edits)', () => {
|
|
424
375
|
const sb = makeSandbox();
|
|
425
376
|
try {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
assert.strictEqual(
|
|
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);
|
|
377
|
+
adopt({ cwd: sb.cwd });
|
|
378
|
+
const userEdit = `${MANAGED_BY}\n# my hand-edited table\n`;
|
|
379
|
+
fs.writeFileSync(sb.detail, userEdit);
|
|
380
|
+
const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: { CODE_GRAPH_NO_TEMPLATE_REFRESH: '1' } });
|
|
381
|
+
assert.strictEqual(res.reason, 'already-adopted');
|
|
382
|
+
assert.strictEqual(fs.readFileSync(sb.detail, 'utf8'), userEdit, 'user edit preserved');
|
|
435
383
|
} finally { sb.cleanup(); }
|
|
436
384
|
});
|
|
437
385
|
|
|
438
|
-
test('maybeAutoAdopt
|
|
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.
|
|
386
|
+
test('maybeAutoAdopt surfaces not-a-project for a bare cwd', () => {
|
|
442
387
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
|
|
443
388
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
|
|
444
389
|
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);
|
|
390
|
+
const res = maybeAutoAdopt({ cwd, home, scriptPath: PLUGIN_SCRIPTS, env: {} });
|
|
451
391
|
assert.strictEqual(res.result.ok, false);
|
|
452
392
|
assert.strictEqual(res.result.reason, 'not-a-project');
|
|
453
393
|
} finally {
|
|
@@ -456,154 +396,153 @@ test('maybeAutoAdopt fails with not-a-project when cwd has no project marker', (
|
|
|
456
396
|
}
|
|
457
397
|
});
|
|
458
398
|
|
|
459
|
-
//
|
|
399
|
+
// ── migrateLegacyMemoryDir — auto-upgrade cleanup of the pre-v0.74 scheme ────
|
|
400
|
+
|
|
401
|
+
function seedLegacy(sb) {
|
|
402
|
+
const dir = memoryDir(sb.cwd, sb.home);
|
|
403
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
404
|
+
fs.writeFileSync(path.join(dir, TARGET_NAME), `<!-- adopted-by: ${sb.cwd} -->\nold detail table\n`);
|
|
405
|
+
fs.writeFileSync(path.join(dir, 'MEMORY.md'),
|
|
406
|
+
`# 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`);
|
|
407
|
+
return { dir, memIndex: path.join(dir, 'MEMORY.md'), legacyDetail: path.join(dir, TARGET_NAME) };
|
|
408
|
+
}
|
|
460
409
|
|
|
461
|
-
test('
|
|
410
|
+
test('migrate strips the legacy v1 MEMORY.md block + deletes the adopted-by detail file', () => {
|
|
462
411
|
const sb = makeSandbox();
|
|
463
412
|
try {
|
|
464
|
-
|
|
465
|
-
|
|
413
|
+
const L = seedLegacy(sb);
|
|
414
|
+
const res = migrateLegacyMemoryDir({ cwd: sb.cwd, home: sb.home });
|
|
415
|
+
assert.strictEqual(res.memoryIndexPruned, true);
|
|
416
|
+
assert.strictEqual(res.legacyDetailRemoved, true);
|
|
417
|
+
assert.ok(!fs.existsSync(L.legacyDetail), 'legacy detail deleted');
|
|
418
|
+
const mem = fs.readFileSync(L.memIndex, 'utf8');
|
|
419
|
+
assert.ok(!mem.includes(SENTINEL_BEGIN_V1), 'v1 sentinel removed');
|
|
420
|
+
assert.ok(mem.includes('keep me'), "user's other memory preserved");
|
|
466
421
|
} finally { sb.cleanup(); }
|
|
467
422
|
});
|
|
468
423
|
|
|
469
|
-
test('
|
|
424
|
+
test('migrate will NOT delete a legacy detail file lacking the adopted-by marker', () => {
|
|
470
425
|
const sb = makeSandbox();
|
|
471
426
|
try {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
427
|
+
const dir = memoryDir(sb.cwd, sb.home);
|
|
428
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
429
|
+
const userFile = path.join(dir, TARGET_NAME);
|
|
430
|
+
fs.writeFileSync(userFile, 'a user file that happens to share the name\n');
|
|
431
|
+
const res = migrateLegacyMemoryDir({ cwd: sb.cwd, home: sb.home });
|
|
432
|
+
assert.strictEqual(res.legacyDetailRemoved, false);
|
|
433
|
+
assert.ok(fs.existsSync(userFile), 'unmarked file survives');
|
|
476
434
|
} finally { sb.cleanup(); }
|
|
477
435
|
});
|
|
478
436
|
|
|
479
|
-
test('
|
|
437
|
+
test('migrate is a no-op when there is nothing to clean', () => {
|
|
480
438
|
const sb = makeSandbox();
|
|
481
439
|
try {
|
|
482
|
-
|
|
483
|
-
|
|
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);
|
|
440
|
+
const res = migrateLegacyMemoryDir({ cwd: sb.cwd, home: sb.home });
|
|
441
|
+
assert.deepStrictEqual(res, { memoryIndexPruned: false, legacyDetailRemoved: false });
|
|
487
442
|
} finally { sb.cleanup(); }
|
|
488
443
|
});
|
|
489
444
|
|
|
490
|
-
test('
|
|
445
|
+
test('maybeAutoAdopt runs the legacy migration then installs the new scheme', () => {
|
|
491
446
|
const sb = makeSandbox();
|
|
492
447
|
try {
|
|
493
|
-
|
|
448
|
+
const L = seedLegacy(sb);
|
|
449
|
+
const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
|
|
450
|
+
assert.ok(res.migrated.memoryIndexPruned && res.migrated.legacyDetailRemoved, 'legacy cleaned');
|
|
451
|
+
assert.ok(!fs.existsSync(L.legacyDetail), 'legacy detail gone');
|
|
452
|
+
assert.ok(!fs.readFileSync(L.memIndex, 'utf8').includes(SENTINEL_BEGIN_V1), 'v1 block gone');
|
|
453
|
+
assert.strictEqual(isAdopted({ cwd: sb.cwd }), true, 'new CLAUDE.md scheme installed');
|
|
494
454
|
} finally { sb.cleanup(); }
|
|
495
455
|
});
|
|
496
456
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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(); }
|
|
457
|
+
// ── stripSentinelBlock (matches v1 + v2) ────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
test('stripSentinelBlock removes a well-formed v2 block, preserving neighbors', () => {
|
|
460
|
+
const before = `# Index\nKeep.\n\n${SENTINEL_BEGIN}\nbody\n${SENTINEL_END}\n\n- [x.md](x.md)\n`;
|
|
461
|
+
const after = stripSentinelBlock(before);
|
|
462
|
+
assert.ok(!after.includes(SENTINEL_BEGIN) && !after.includes(SENTINEL_END));
|
|
463
|
+
assert.ok(after.includes('Keep.') && after.includes('- [x.md](x.md)'));
|
|
522
464
|
});
|
|
523
465
|
|
|
524
|
-
test('
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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(); }
|
|
466
|
+
test('stripSentinelBlock removes a legacy v1 block (version-agnostic match)', () => {
|
|
467
|
+
const before = `# Index\n${SENTINEL_BEGIN_V1}\n- old line\n${SENTINEL_END}\n- [keep.md](keep.md)\n`;
|
|
468
|
+
const after = stripSentinelBlock(before);
|
|
469
|
+
assert.ok(!after.includes(SENTINEL_BEGIN_V1), 'v1 begin removed');
|
|
470
|
+
assert.ok(after.includes('- [keep.md](keep.md)'), 'neighbor preserved');
|
|
542
471
|
});
|
|
543
472
|
|
|
544
|
-
test('
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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(); }
|
|
473
|
+
test('stripSentinelBlock self-heals orphan BEGIN without END', () => {
|
|
474
|
+
const before = `# Index\n- [a.md](a.md) — entry\n${SENTINEL_BEGIN}\nbody\n\n- [b.md](b.md) — survivor\n`;
|
|
475
|
+
const after = stripSentinelBlock(before);
|
|
476
|
+
assert.ok(!after.includes(SENTINEL_BEGIN), 'orphan BEGIN removed');
|
|
477
|
+
assert.ok(after.includes('survivor') && after.includes('entry'));
|
|
560
478
|
});
|
|
561
479
|
|
|
562
|
-
test('
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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(); }
|
|
480
|
+
test('stripSentinelBlock self-heals orphan END line', () => {
|
|
481
|
+
const before = `# Index\n- [a.md](a.md)\n${SENTINEL_END}\n- [b.md](b.md)\n`;
|
|
482
|
+
const after = stripSentinelBlock(before);
|
|
483
|
+
assert.ok(!after.includes(SENTINEL_END));
|
|
484
|
+
assert.ok(after.includes('- [a.md](a.md)') && after.includes('- [b.md](b.md)'));
|
|
578
485
|
});
|
|
579
486
|
|
|
487
|
+
// ── platform guard ──────────────────────────────────────────────────────────
|
|
488
|
+
|
|
580
489
|
test('Windows platform is rejected with clear reason', { skip: process.platform === 'win32' }, () => {
|
|
581
490
|
const orig = process.platform;
|
|
582
491
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
583
492
|
try {
|
|
584
493
|
const sb = makeSandbox();
|
|
585
494
|
try {
|
|
586
|
-
|
|
587
|
-
assert.strictEqual(
|
|
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');
|
|
495
|
+
assert.strictEqual(adopt({ cwd: sb.cwd }).reason, 'windows-not-supported');
|
|
496
|
+
assert.strictEqual(unadopt({ cwd: sb.cwd, home: sb.home }).reason, 'windows-not-supported');
|
|
592
497
|
} finally { sb.cleanup(); }
|
|
593
498
|
} finally {
|
|
594
499
|
Object.defineProperty(process, 'platform', { value: orig, configurable: true });
|
|
595
500
|
}
|
|
596
501
|
});
|
|
597
502
|
|
|
598
|
-
//
|
|
503
|
+
// ── template integrity ──────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
test('template file exists and contains the decision table', () => {
|
|
506
|
+
assert.ok(fs.existsSync(TEMPLATE_PATH), `template at ${TEMPLATE_PATH}`);
|
|
507
|
+
const content = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
|
508
|
+
assert.ok(content.includes('get_call_graph'), 'mentions get_call_graph');
|
|
509
|
+
assert.ok(content.includes('CODE_GRAPH_QUIET_HOOKS'), 'mentions env gate');
|
|
510
|
+
assert.ok(content.includes('.claude/plugin_code_graph_mcp.md'), 'describes the new layout');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// ── isPluginModeInstall ─────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
test('isPluginModeInstall recognizes ~/.claude/plugins/... paths', () => {
|
|
516
|
+
assert.strictEqual(isPluginModeInstall('/home/user/.claude/plugins/cache/code-graph-mcp@0.9.0/scripts'), true);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('isPluginModeInstall rejects npm global / dev / npx paths', () => {
|
|
520
|
+
assert.strictEqual(isPluginModeInstall('/usr/local/lib/node_modules/@sdsrs/code-graph/claude-plugin/scripts'), false);
|
|
521
|
+
assert.strictEqual(isPluginModeInstall('/mnt/data_ssd/dev/projects/code-graph-mcp/claude-plugin/scripts'), false);
|
|
522
|
+
assert.strictEqual(isPluginModeInstall('/tmp/npx-abc123/node_modules/@sdsrs/code-graph/claude-plugin/scripts'), false);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('isPluginModeInstall recognizes CLAUDE_CONFIG_DIR/plugins/... paths', () => {
|
|
526
|
+
const prev = process.env.CLAUDE_CONFIG_DIR;
|
|
527
|
+
process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
|
|
528
|
+
try {
|
|
529
|
+
assert.strictEqual(isPluginModeInstall('/home/alice/work-claude/plugins/cache/code-graph-mcp@0.31.0/scripts'), true);
|
|
530
|
+
assert.strictEqual(isPluginModeInstall('/home/user/.claude/plugins/cache/code-graph-mcp/scripts'), true);
|
|
531
|
+
assert.strictEqual(isPluginModeInstall('/home/alice/work-claude/projects/foo/memory'), false);
|
|
532
|
+
} finally {
|
|
533
|
+
if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
534
|
+
else process.env.CLAUDE_CONFIG_DIR = prev;
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// ── isProjectRoot markers ───────────────────────────────────────────────────
|
|
599
539
|
|
|
600
540
|
test('isProjectRoot detects each marker', () => {
|
|
601
541
|
for (const marker of PROJECT_MARKERS) {
|
|
602
542
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-marker-'));
|
|
603
543
|
try {
|
|
604
|
-
assert.strictEqual(isProjectRoot(cwd), false,
|
|
544
|
+
assert.strictEqual(isProjectRoot(cwd), false, 'bare cwd should not be a project');
|
|
605
545
|
const markerPath = path.join(cwd, marker);
|
|
606
|
-
// Some markers are directories (.git, .code-graph), others are files.
|
|
607
546
|
if (marker.startsWith('.')) fs.mkdirSync(markerPath);
|
|
608
547
|
else fs.writeFileSync(markerPath, '');
|
|
609
548
|
assert.strictEqual(isProjectRoot(cwd), true, `${marker} should make cwd a project`);
|
|
@@ -613,87 +552,11 @@ test('isProjectRoot detects each marker', () => {
|
|
|
613
552
|
}
|
|
614
553
|
});
|
|
615
554
|
|
|
616
|
-
|
|
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.
|
|
555
|
+
// ── detectProjectType (unchanged logic; tailoring still feeds buildBlock) ────
|
|
691
556
|
|
|
692
557
|
test('detectProjectType returns generic for an empty cwd', () => {
|
|
693
558
|
const sb = makeSandbox();
|
|
694
|
-
try {
|
|
695
|
-
assert.strictEqual(detectProjectType(sb.cwd), 'generic');
|
|
696
|
-
} finally { sb.cleanup(); }
|
|
559
|
+
try { assert.strictEqual(detectProjectType(sb.cwd), 'generic'); } finally { sb.cleanup(); }
|
|
697
560
|
});
|
|
698
561
|
|
|
699
562
|
test('detectProjectType returns rust for a Cargo.toml without web framework', () => {
|
|
@@ -704,7 +567,7 @@ test('detectProjectType returns rust for a Cargo.toml without web framework', ()
|
|
|
704
567
|
} finally { sb.cleanup(); }
|
|
705
568
|
});
|
|
706
569
|
|
|
707
|
-
test('detectProjectType returns web-rs when Cargo.toml has axum
|
|
570
|
+
test('detectProjectType returns web-rs when Cargo.toml has axum', () => {
|
|
708
571
|
const sb = makeSandbox();
|
|
709
572
|
try {
|
|
710
573
|
fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[dependencies]\naxum = "0.7"\n');
|
|
@@ -712,20 +575,18 @@ test('detectProjectType returns web-rs when Cargo.toml has axum/actix/etc', () =
|
|
|
712
575
|
} finally { sb.cleanup(); }
|
|
713
576
|
});
|
|
714
577
|
|
|
715
|
-
test('detectProjectType returns frontend for
|
|
578
|
+
test('detectProjectType returns frontend for React/Next deps', () => {
|
|
716
579
|
const sb = makeSandbox();
|
|
717
580
|
try {
|
|
718
|
-
fs.writeFileSync(path.join(sb.cwd, 'package.json'),
|
|
719
|
-
'{"dependencies":{"next":"^14","react":"^18"}}');
|
|
581
|
+
fs.writeFileSync(path.join(sb.cwd, 'package.json'), '{"dependencies":{"next":"^14","react":"^18"}}');
|
|
720
582
|
assert.strictEqual(detectProjectType(sb.cwd), 'frontend');
|
|
721
583
|
} finally { sb.cleanup(); }
|
|
722
584
|
});
|
|
723
585
|
|
|
724
|
-
test('detectProjectType returns web-node for
|
|
586
|
+
test('detectProjectType returns web-node for express', () => {
|
|
725
587
|
const sb = makeSandbox();
|
|
726
588
|
try {
|
|
727
|
-
fs.writeFileSync(path.join(sb.cwd, 'package.json'),
|
|
728
|
-
'{"dependencies":{"express":"^4"}}');
|
|
589
|
+
fs.writeFileSync(path.join(sb.cwd, 'package.json'), '{"dependencies":{"express":"^4"}}');
|
|
729
590
|
assert.strictEqual(detectProjectType(sb.cwd), 'web-node');
|
|
730
591
|
} finally { sb.cleanup(); }
|
|
731
592
|
});
|
|
@@ -733,146 +594,35 @@ test('detectProjectType returns web-node for package.json with express/fastify',
|
|
|
733
594
|
test('detectProjectType returns web-py for FastAPI in pyproject.toml', () => {
|
|
734
595
|
const sb = makeSandbox();
|
|
735
596
|
try {
|
|
736
|
-
fs.writeFileSync(path.join(sb.cwd, 'pyproject.toml'),
|
|
737
|
-
'[tool.poetry.dependencies]\nfastapi = "^0.115"\n');
|
|
597
|
+
fs.writeFileSync(path.join(sb.cwd, 'pyproject.toml'), '[tool.poetry.dependencies]\nfastapi = "^0.115"\n');
|
|
738
598
|
assert.strictEqual(detectProjectType(sb.cwd), 'web-py');
|
|
739
599
|
} finally { sb.cleanup(); }
|
|
740
600
|
});
|
|
741
601
|
|
|
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
602
|
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
603
|
const sb = makeSandbox();
|
|
835
604
|
try {
|
|
836
605
|
fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
|
|
837
|
-
'[package]\nname="x"\n[dependencies]\n# axum = "0.7" # disabled
|
|
838
|
-
assert.strictEqual(detectProjectType(sb.cwd), 'rust'
|
|
839
|
-
'commented dep must not promote to web-rs');
|
|
606
|
+
'[package]\nname="x"\n[dependencies]\n# axum = "0.7" # disabled\nserde = "1"\n');
|
|
607
|
+
assert.strictEqual(detectProjectType(sb.cwd), 'rust');
|
|
840
608
|
} finally { sb.cleanup(); }
|
|
841
609
|
});
|
|
842
610
|
|
|
843
611
|
test('detectProjectType ignores axum in [dev-dependencies] only', () => {
|
|
844
|
-
// axum used solely for tests does not make this a web project.
|
|
845
612
|
const sb = makeSandbox();
|
|
846
613
|
try {
|
|
847
614
|
fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
|
|
848
615
|
'[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');
|
|
616
|
+
assert.strictEqual(detectProjectType(sb.cwd), 'rust');
|
|
861
617
|
} finally { sb.cleanup(); }
|
|
862
618
|
});
|
|
863
619
|
|
|
864
|
-
test('detectProjectType ignores react in devDependencies
|
|
865
|
-
// A library that lists react in devDependencies for testing should not
|
|
866
|
-
// be classified as a frontend app.
|
|
620
|
+
test('detectProjectType ignores react in devDependencies', () => {
|
|
867
621
|
const sb = makeSandbox();
|
|
868
622
|
try {
|
|
869
623
|
fs.writeFileSync(path.join(sb.cwd, 'package.json'),
|
|
870
|
-
JSON.stringify({
|
|
871
|
-
|
|
872
|
-
devDependencies: { react: '^18', 'react-dom': '^18' },
|
|
873
|
-
}));
|
|
874
|
-
assert.strictEqual(detectProjectType(sb.cwd), 'node',
|
|
875
|
-
'react in devDependencies must not promote to frontend');
|
|
624
|
+
JSON.stringify({ dependencies: { lodash: '^4' }, devDependencies: { react: '^18' } }));
|
|
625
|
+
assert.strictEqual(detectProjectType(sb.cwd), 'node');
|
|
876
626
|
} finally { sb.cleanup(); }
|
|
877
627
|
});
|
|
878
628
|
|
|
@@ -881,25 +631,19 @@ test('detectProjectType ignores // indirect deps in go.mod', () => {
|
|
|
881
631
|
try {
|
|
882
632
|
fs.writeFileSync(path.join(sb.cwd, 'go.mod'),
|
|
883
633
|
'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');
|
|
634
|
+
assert.strictEqual(detectProjectType(sb.cwd), 'go');
|
|
886
635
|
} finally { sb.cleanup(); }
|
|
887
636
|
});
|
|
888
637
|
|
|
889
638
|
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
639
|
const sb = makeSandbox();
|
|
893
640
|
try {
|
|
894
641
|
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');
|
|
642
|
+
assert.strictEqual(detectProjectType(sb.cwd), 'node');
|
|
897
643
|
} finally { sb.cleanup(); }
|
|
898
644
|
});
|
|
899
645
|
|
|
900
646
|
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
647
|
const sb = makeSandbox();
|
|
904
648
|
try {
|
|
905
649
|
fs.writeFileSync(path.join(sb.cwd, 'pyproject.toml'),
|
|
@@ -911,60 +655,23 @@ test('detectProjectType detects PEP 621 [project] dependencies block', () => {
|
|
|
911
655
|
test('detectProjectType reads requirements.txt as fallback', () => {
|
|
912
656
|
const sb = makeSandbox();
|
|
913
657
|
try {
|
|
914
|
-
fs.writeFileSync(path.join(sb.cwd, 'requirements.txt'),
|
|
915
|
-
'# web stack\nflask>=3.0\ngunicorn\n');
|
|
658
|
+
fs.writeFileSync(path.join(sb.cwd, 'requirements.txt'), '# web stack\nflask>=3.0\ngunicorn\n');
|
|
916
659
|
assert.strictEqual(detectProjectType(sb.cwd), 'web-py');
|
|
917
660
|
} finally { sb.cleanup(); }
|
|
918
661
|
});
|
|
919
662
|
|
|
920
|
-
// 2D — env override.
|
|
921
|
-
|
|
922
663
|
test('CODE_GRAPH_PROJECT_TYPE env override beats file-based detection', () => {
|
|
923
664
|
const sb = makeSandbox();
|
|
924
665
|
try {
|
|
925
|
-
// Cargo.toml says rust — env says web-rs. Env wins.
|
|
926
666
|
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
|
-
);
|
|
667
|
+
assert.strictEqual(detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'web-rs' }), 'web-rs');
|
|
931
668
|
} finally { sb.cleanup(); }
|
|
932
669
|
});
|
|
933
670
|
|
|
934
671
|
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
672
|
const sb = makeSandbox();
|
|
952
673
|
try {
|
|
953
674
|
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
|
-
);
|
|
675
|
+
assert.strictEqual(detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'web-rust' }), 'rust');
|
|
969
676
|
} finally { sb.cleanup(); }
|
|
970
677
|
});
|