@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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +268 -223
- package/claude-plugin/scripts/adopt.test.js +357 -648
- package/claude-plugin/scripts/session-init.js +18 -10
- package/claude-plugin/templates/plugin_code_graph_mcp.md +12 -9
- package/package.json +6 -6
|
@@ -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,
|
|
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.
|
|
350
|
-
|
|
351
|
-
|
|
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('
|
|
360
|
-
const
|
|
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
|
|
364
|
-
assert.strictEqual(
|
|
365
|
-
|
|
366
|
-
assert.strictEqual(
|
|
367
|
-
|
|
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
|
|
340
|
+
test('maybeAutoAdopt installs when plugin-mode + not-yet-adopted', () => {
|
|
382
341
|
const sb = makeSandbox();
|
|
383
342
|
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);
|
|
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
|
|
351
|
+
test('maybeAutoAdopt is already-adopted when in sync (no gratuitous write)', () => {
|
|
396
352
|
const sb = makeSandbox();
|
|
397
353
|
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);
|
|
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
|
|
362
|
+
test('maybeAutoAdopt refreshes a drifted detail doc (reason=refreshed)', () => {
|
|
410
363
|
const sb = makeSandbox();
|
|
411
364
|
try {
|
|
412
|
-
adopt({ cwd: sb.cwd
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
assert.
|
|
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
|
|
376
|
+
test('maybeAutoAdopt skips refresh when CODE_GRAPH_NO_TEMPLATE_REFRESH=1 (locks edits)', () => {
|
|
424
377
|
const sb = makeSandbox();
|
|
425
378
|
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);
|
|
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
|
|
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
|
-
//
|
|
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('
|
|
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
|
-
|
|
465
|
-
|
|
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('
|
|
426
|
+
test('migrate will NOT delete a legacy detail file lacking the adopted-by marker', () => {
|
|
470
427
|
const sb = makeSandbox();
|
|
471
428
|
try {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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('
|
|
439
|
+
test('migrate is a no-op when there is nothing to clean', () => {
|
|
480
440
|
const sb = makeSandbox();
|
|
481
441
|
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);
|
|
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('
|
|
447
|
+
test('maybeAutoAdopt runs the legacy migration then installs the new scheme', () => {
|
|
491
448
|
const sb = makeSandbox();
|
|
492
449
|
try {
|
|
493
|
-
|
|
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
|
-
|
|
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(); }
|
|
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('
|
|
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(); }
|
|
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('
|
|
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(); }
|
|
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('
|
|
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(); }
|
|
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
|
-
|
|
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');
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
});
|