@sdsrs/code-graph 0.94.0 → 0.95.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/auto-update.js +69 -9
- package/package.json +8 -7
- package/claude-plugin/scripts/adopt.test.js +0 -679
- package/claude-plugin/scripts/auto-update.test.js +0 -515
- package/claude-plugin/scripts/cg-answer.test.js +0 -309
- package/claude-plugin/scripts/claude-config.test.js +0 -58
- package/claude-plugin/scripts/covering-tests.test.js +0 -78
- package/claude-plugin/scripts/doctor.test.js +0 -215
- package/claude-plugin/scripts/find-binary.test.js +0 -246
- package/claude-plugin/scripts/hook-fire.test.js +0 -117
- package/claude-plugin/scripts/hooks.test.js +0 -230
- package/claude-plugin/scripts/incremental-index.test.js +0 -102
- package/claude-plugin/scripts/lifecycle.e2e.test.js +0 -179
- package/claude-plugin/scripts/lifecycle.test.js +0 -786
- package/claude-plugin/scripts/mcp-launcher.test.js +0 -162
- package/claude-plugin/scripts/mcp-stub.test.js +0 -207
- package/claude-plugin/scripts/post-grep-inject.test.js +0 -531
- package/claude-plugin/scripts/pr-impact-comment.test.js +0 -110
- package/claude-plugin/scripts/pre-edit-guide.test.js +0 -218
- package/claude-plugin/scripts/pre-grep-guide.test.js +0 -1682
- package/claude-plugin/scripts/pre-read-guide.test.js +0 -363
- package/claude-plugin/scripts/project-detect.test.js +0 -95
- package/claude-plugin/scripts/recommendation-log.test.js +0 -79
- package/claude-plugin/scripts/session-init.test.js +0 -479
- package/claude-plugin/scripts/statusline-composite.test.js +0 -65
- package/claude-plugin/scripts/statusline.test.js +0 -235
- package/claude-plugin/scripts/tmp-dir.test.js +0 -50
- package/claude-plugin/scripts/user-prompt-context.test.js +0 -743
- package/claude-plugin/scripts/version-utils.test.js +0 -141
|
@@ -1,786 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
const test = require('node:test');
|
|
3
|
-
const assert = require('node:assert/strict');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const { execFileSync } = require('child_process');
|
|
8
|
-
|
|
9
|
-
const lifecyclePath = path.join(__dirname, 'lifecycle.js');
|
|
10
|
-
const statuslinePath = path.join(__dirname, 'statusline.js');
|
|
11
|
-
|
|
12
|
-
function mkHome(t) {
|
|
13
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-home-'));
|
|
14
|
-
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
15
|
-
return dir;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function writeJson(filePath, value) {
|
|
19
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
20
|
-
fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function seedDisabledComposite(homeDir) {
|
|
24
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
25
|
-
const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
|
|
26
|
-
writeJson(settingsPath, {
|
|
27
|
-
statusLine: { type: 'command', command: 'node "/plugin/statusline-composite.js"' },
|
|
28
|
-
enabledPlugins: { 'code-graph-mcp@code-graph-mcp': false },
|
|
29
|
-
});
|
|
30
|
-
writeJson(registryPath, [
|
|
31
|
-
{ id: '_previous', command: 'echo previous-status', needsStdin: true },
|
|
32
|
-
{ id: 'code-graph', command: 'node "/plugin/statusline.js"', needsStdin: false },
|
|
33
|
-
]);
|
|
34
|
-
return { settingsPath, registryPath };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function seedOrphanedComposite(homeDir) {
|
|
38
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
39
|
-
const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
|
|
40
|
-
const installedPath = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
|
|
41
|
-
writeJson(settingsPath, {
|
|
42
|
-
statusLine: { type: 'command', command: 'node "/plugin/statusline-composite.js"' },
|
|
43
|
-
enabledPlugins: {},
|
|
44
|
-
});
|
|
45
|
-
writeJson(installedPath, { plugins: {} });
|
|
46
|
-
writeJson(registryPath, [
|
|
47
|
-
{ id: '_previous', command: 'echo previous-status', needsStdin: true },
|
|
48
|
-
{ id: 'code-graph', command: 'node "/plugin/statusline.js"', needsStdin: false },
|
|
49
|
-
]);
|
|
50
|
-
return { settingsPath, registryPath };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
test('cleanupDisabledStatusline restores previous statusline and removes registry', (t) => {
|
|
54
|
-
const homeDir = mkHome(t);
|
|
55
|
-
const { settingsPath, registryPath } = seedDisabledComposite(homeDir);
|
|
56
|
-
|
|
57
|
-
const out = execFileSync(process.execPath, ['-e', `
|
|
58
|
-
const { cleanupDisabledStatusline } = require(${JSON.stringify(lifecyclePath)});
|
|
59
|
-
process.stdout.write(JSON.stringify(cleanupDisabledStatusline()));
|
|
60
|
-
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
61
|
-
|
|
62
|
-
assert.deepEqual(JSON.parse(out), { cleaned: true, settingsChanged: true });
|
|
63
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
64
|
-
assert.equal(settings.statusLine.command, 'echo previous-status');
|
|
65
|
-
assert.equal(fs.existsSync(registryPath), false);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('statusline exits cleanly and self-heals when plugin is disabled', (t) => {
|
|
69
|
-
const homeDir = mkHome(t);
|
|
70
|
-
const { settingsPath, registryPath } = seedDisabledComposite(homeDir);
|
|
71
|
-
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-project-'));
|
|
72
|
-
t.after(() => fs.rmSync(projectDir, { recursive: true, force: true }));
|
|
73
|
-
fs.mkdirSync(path.join(projectDir, '.code-graph'), { recursive: true });
|
|
74
|
-
fs.writeFileSync(path.join(projectDir, '.code-graph', 'index.db'), '');
|
|
75
|
-
|
|
76
|
-
const stdout = execFileSync(process.execPath, [statuslinePath], {
|
|
77
|
-
env: { ...process.env, HOME: homeDir },
|
|
78
|
-
cwd: projectDir,
|
|
79
|
-
}).toString();
|
|
80
|
-
|
|
81
|
-
assert.equal(stdout, '');
|
|
82
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
83
|
-
assert.equal(settings.statusLine.command, 'echo previous-status');
|
|
84
|
-
assert.equal(fs.existsSync(registryPath), false);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test('cleanupDisabledStatusline also heals orphaned statusline after uninstall', (t) => {
|
|
88
|
-
const homeDir = mkHome(t);
|
|
89
|
-
const { settingsPath, registryPath } = seedOrphanedComposite(homeDir);
|
|
90
|
-
|
|
91
|
-
const out = execFileSync(process.execPath, ['-e', `
|
|
92
|
-
const { cleanupDisabledStatusline } = require(${JSON.stringify(lifecyclePath)});
|
|
93
|
-
process.stdout.write(JSON.stringify(cleanupDisabledStatusline()));
|
|
94
|
-
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
95
|
-
|
|
96
|
-
assert.deepEqual(JSON.parse(out), { cleaned: true, settingsChanged: true });
|
|
97
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
98
|
-
assert.equal(settings.statusLine.command, 'echo previous-status');
|
|
99
|
-
assert.equal(fs.existsSync(registryPath), false);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('isPluginUninstalled distinguishes a genuine uninstall from a temporary disable', (t) => {
|
|
103
|
-
// Orphaned composite (installed_plugins exists, no code-graph record) = uninstalled.
|
|
104
|
-
const uninstalledHome = mkHome(t);
|
|
105
|
-
seedOrphanedComposite(uninstalledHome);
|
|
106
|
-
// enabledPlugins[id]=false = user toggled it off; may re-enable → NOT uninstalled.
|
|
107
|
-
const disabledHome = mkHome(t);
|
|
108
|
-
seedDisabledComposite(disabledHome);
|
|
109
|
-
|
|
110
|
-
const probe = (home) => JSON.parse(execFileSync(process.execPath, ['-e', `
|
|
111
|
-
const { isPluginUninstalled } = require(${JSON.stringify(lifecyclePath)});
|
|
112
|
-
process.stdout.write(JSON.stringify(isPluginUninstalled()));
|
|
113
|
-
`], { env: { ...process.env, HOME: home } }).toString());
|
|
114
|
-
|
|
115
|
-
assert.equal(probe(uninstalledHome), true, 'orphaned/no-record → uninstalled');
|
|
116
|
-
assert.equal(probe(disabledHome), false, 'explicit disable → not uninstalled (re-enable safe)');
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test('removeCacheResidue deletes ~/.cache/code-graph and is idempotent', (t) => {
|
|
120
|
-
const homeDir = mkHome(t);
|
|
121
|
-
const cacheDir = path.join(homeDir, '.cache', 'code-graph');
|
|
122
|
-
writeJson(path.join(cacheDir, 'bin', 'marker.json'), { v: 1 });
|
|
123
|
-
fs.writeFileSync(path.join(cacheDir, 'update-state.json'), '{}');
|
|
124
|
-
|
|
125
|
-
const run = () => execFileSync(process.execPath, ['-e', `
|
|
126
|
-
const { removeCacheResidue } = require(${JSON.stringify(lifecyclePath)});
|
|
127
|
-
process.stdout.write(JSON.stringify(removeCacheResidue()));
|
|
128
|
-
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
129
|
-
|
|
130
|
-
assert.equal(run(), 'true');
|
|
131
|
-
assert.equal(fs.existsSync(cacheDir), false, 'cache dir removed');
|
|
132
|
-
assert.equal(run(), 'true', 'second call is a no-op success (idempotent force-rm)');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
function legacyHooksFromPlugin() {
|
|
136
|
-
return {
|
|
137
|
-
SessionStart: [{
|
|
138
|
-
matcher: 'startup|clear|compact',
|
|
139
|
-
description: 'StatusLine self-heal, lifecycle sync, project map injection',
|
|
140
|
-
hooks: [{ type: 'command', command: 'node "/stale/cache/0.8.2/claude-plugin/scripts/session-init.js"', timeout: 5 }],
|
|
141
|
-
}],
|
|
142
|
-
PostToolUse: [{
|
|
143
|
-
matcher: 'tool == "Write" || tool == "Edit"',
|
|
144
|
-
description: 'Auto-update code graph index after file edits',
|
|
145
|
-
hooks: [{ type: 'command', command: 'node "/stale/code-graph/incremental-index.js"', timeout: 10 }],
|
|
146
|
-
}],
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
test('isOurHookEntry matches legacy description-tagged entries', () => {
|
|
151
|
-
const entry = legacyHooksFromPlugin().SessionStart[0];
|
|
152
|
-
const out = execFileSync(process.execPath, ['-e', `
|
|
153
|
-
const { isOurHookEntry } = require(${JSON.stringify(lifecyclePath)});
|
|
154
|
-
process.stdout.write(JSON.stringify(isOurHookEntry(${JSON.stringify(entry)})));
|
|
155
|
-
`]).toString();
|
|
156
|
-
assert.equal(JSON.parse(out), true);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test('isOurHookEntry matches script-name + path fallback (missing description)', () => {
|
|
160
|
-
const entry = {
|
|
161
|
-
matcher: 'tool == "Edit"',
|
|
162
|
-
hooks: [{ type: 'command', command: 'node "/cache/code-graph-mcp/scripts/pre-edit-guide.js"' }],
|
|
163
|
-
};
|
|
164
|
-
const out = execFileSync(process.execPath, ['-e', `
|
|
165
|
-
const { isOurHookEntry } = require(${JSON.stringify(lifecyclePath)});
|
|
166
|
-
process.stdout.write(JSON.stringify(isOurHookEntry(${JSON.stringify(entry)})));
|
|
167
|
-
`]).toString();
|
|
168
|
-
assert.equal(JSON.parse(out), true);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test('isOurHookEntry leaves unrelated entries alone', () => {
|
|
172
|
-
const entry = {
|
|
173
|
-
matcher: 'startup',
|
|
174
|
-
description: 'some other plugin hook',
|
|
175
|
-
hooks: [{ type: 'command', command: 'node /some/other/script.js' }],
|
|
176
|
-
};
|
|
177
|
-
const out = execFileSync(process.execPath, ['-e', `
|
|
178
|
-
const { isOurHookEntry } = require(${JSON.stringify(lifecyclePath)});
|
|
179
|
-
process.stdout.write(JSON.stringify(isOurHookEntry(${JSON.stringify(entry)})));
|
|
180
|
-
`]).toString();
|
|
181
|
-
assert.equal(JSON.parse(out), false);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test('removeHooksFromSettings strips our entries but keeps unrelated hooks', () => {
|
|
185
|
-
const settings = {
|
|
186
|
-
hooks: {
|
|
187
|
-
SessionStart: [
|
|
188
|
-
legacyHooksFromPlugin().SessionStart[0],
|
|
189
|
-
{
|
|
190
|
-
matcher: 'startup',
|
|
191
|
-
description: 'some other plugin hook',
|
|
192
|
-
hooks: [{ type: 'command', command: 'node /some/other/script.js' }],
|
|
193
|
-
},
|
|
194
|
-
],
|
|
195
|
-
PostToolUse: [legacyHooksFromPlugin().PostToolUse[0]],
|
|
196
|
-
},
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
const out = execFileSync(process.execPath, ['-e', `
|
|
200
|
-
const { removeHooksFromSettings } = require(${JSON.stringify(lifecyclePath)});
|
|
201
|
-
const s = ${JSON.stringify(settings)};
|
|
202
|
-
const changed = removeHooksFromSettings(s);
|
|
203
|
-
process.stdout.write(JSON.stringify({ changed, s }));
|
|
204
|
-
`]).toString();
|
|
205
|
-
|
|
206
|
-
const { changed, s } = JSON.parse(out);
|
|
207
|
-
assert.equal(changed, true);
|
|
208
|
-
// Only the unrelated SessionStart entry remains; PostToolUse removed entirely.
|
|
209
|
-
assert.equal(s.hooks.SessionStart.length, 1);
|
|
210
|
-
assert.equal(s.hooks.SessionStart[0].description, 'some other plugin hook');
|
|
211
|
-
assert.ok(!s.hooks.PostToolUse, 'empty event key should be deleted');
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test('writeRegistry mirrors entries to durable backup outside ~/.cache/', (t) => {
|
|
215
|
-
const homeDir = mkHome(t);
|
|
216
|
-
const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
|
|
217
|
-
const backupPath = path.join(homeDir, '.claude', 'statusline-providers.json');
|
|
218
|
-
|
|
219
|
-
execFileSync(process.execPath, ['-e', `
|
|
220
|
-
const { registerStatuslineProvider } = require(${JSON.stringify(lifecyclePath)});
|
|
221
|
-
registerStatuslineProvider('_previous', 'echo prev', true);
|
|
222
|
-
registerStatuslineProvider('code-graph', 'node /cg.js', false);
|
|
223
|
-
`], { env: { ...process.env, HOME: homeDir } });
|
|
224
|
-
|
|
225
|
-
const primary = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
226
|
-
const backup = JSON.parse(fs.readFileSync(backupPath, 'utf8'));
|
|
227
|
-
assert.deepEqual(primary, backup);
|
|
228
|
-
assert.equal(primary.length, 2);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
test('readRegistry self-heals primary from durable backup after cache wipe', (t) => {
|
|
232
|
-
const homeDir = mkHome(t);
|
|
233
|
-
const cacheDir = path.join(homeDir, '.cache', 'code-graph');
|
|
234
|
-
const registryPath = path.join(cacheDir, 'statusline-registry.json');
|
|
235
|
-
const backupPath = path.join(homeDir, '.claude', 'statusline-providers.json');
|
|
236
|
-
|
|
237
|
-
// Seed both files, then simulate user wiping ~/.cache/code-graph/
|
|
238
|
-
writeJson(registryPath, [
|
|
239
|
-
{ id: '_previous', command: 'echo gsd', needsStdin: true },
|
|
240
|
-
{ id: 'code-graph', command: 'node /cg.js', needsStdin: false },
|
|
241
|
-
]);
|
|
242
|
-
writeJson(backupPath, [
|
|
243
|
-
{ id: '_previous', command: 'echo gsd', needsStdin: true },
|
|
244
|
-
{ id: 'code-graph', command: 'node /cg.js', needsStdin: false },
|
|
245
|
-
]);
|
|
246
|
-
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
247
|
-
assert.equal(fs.existsSync(registryPath), false);
|
|
248
|
-
|
|
249
|
-
const out = execFileSync(process.execPath, ['-e', `
|
|
250
|
-
const { readRegistry } = require(${JSON.stringify(lifecyclePath)});
|
|
251
|
-
process.stdout.write(JSON.stringify(readRegistry()));
|
|
252
|
-
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
253
|
-
|
|
254
|
-
const restored = JSON.parse(out);
|
|
255
|
-
assert.equal(restored.length, 2);
|
|
256
|
-
assert.equal(restored[0].id, '_previous');
|
|
257
|
-
// Primary file rebuilt from backup
|
|
258
|
-
assert.equal(fs.existsSync(registryPath), true);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
test('writeRegistry([]) clears both primary and backup', (t) => {
|
|
262
|
-
const homeDir = mkHome(t);
|
|
263
|
-
const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
|
|
264
|
-
const backupPath = path.join(homeDir, '.claude', 'statusline-providers.json');
|
|
265
|
-
|
|
266
|
-
execFileSync(process.execPath, ['-e', `
|
|
267
|
-
const { registerStatuslineProvider, unregisterStatuslineProvider } = require(${JSON.stringify(lifecyclePath)});
|
|
268
|
-
registerStatuslineProvider('code-graph', 'node /cg.js', false);
|
|
269
|
-
unregisterStatuslineProvider('code-graph');
|
|
270
|
-
`], { env: { ...process.env, HOME: homeDir } });
|
|
271
|
-
|
|
272
|
-
assert.equal(fs.existsSync(registryPath), false);
|
|
273
|
-
assert.equal(fs.existsSync(backupPath), false);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test('statusline-chain CLI register/unregister/list + reserved-id guard', (t) => {
|
|
277
|
-
const homeDir = mkHome(t);
|
|
278
|
-
const chainPath = path.join(__dirname, 'statusline-chain.js');
|
|
279
|
-
const env = { ...process.env, HOME: homeDir };
|
|
280
|
-
|
|
281
|
-
const reg = execFileSync(process.execPath, [chainPath, 'register', 'gsd', 'node /gsd.cjs', '--stdin'], { env }).toString();
|
|
282
|
-
assert.match(reg, /registered gsd/);
|
|
283
|
-
|
|
284
|
-
const reRun = execFileSync(process.execPath, [chainPath, 'register', 'gsd', 'node /gsd.cjs', '--stdin'], { env }).toString();
|
|
285
|
-
assert.match(reRun, /unchanged gsd/);
|
|
286
|
-
|
|
287
|
-
const list = execFileSync(process.execPath, [chainPath, 'list'], { env }).toString();
|
|
288
|
-
assert.match(list, /gsd \[stdin\]: node \/gsd\.cjs/);
|
|
289
|
-
|
|
290
|
-
// Reserved ids rejected — both should exit 2 with stderr "reserved"
|
|
291
|
-
const { spawnSync } = require('child_process');
|
|
292
|
-
for (const rid of ['_previous', 'code-graph']) {
|
|
293
|
-
const r = spawnSync(process.execPath, [chainPath, 'register', rid, 'x'], { env });
|
|
294
|
-
assert.equal(r.status, 2, `${rid} should exit 2`);
|
|
295
|
-
assert.match(r.stderr.toString(), /reserved/);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const un = execFileSync(process.execPath, [chainPath, 'unregister', 'gsd'], { env }).toString();
|
|
299
|
-
assert.match(un, /unregistered gsd/);
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
// ════════════════════════════════════════════════════════════════════
|
|
303
|
-
// v0.32.0 — settings.json hook registration (replaces the v0.8.3 strip)
|
|
304
|
-
// ════════════════════════════════════════════════════════════════════
|
|
305
|
-
|
|
306
|
-
test('install() registers PreToolUse/PostToolUse/UserPromptSubmit hooks in settings.json', (t) => {
|
|
307
|
-
const homeDir = mkHome(t);
|
|
308
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
309
|
-
writeJson(settingsPath, {
|
|
310
|
-
statusLine: { type: 'command', command: 'echo previous-status' },
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
314
|
-
env: { ...process.env, HOME: homeDir },
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
318
|
-
assert.ok(after.hooks, 'install() must add hooks block');
|
|
319
|
-
assert.ok(after.hooks.PreToolUse, 'PreToolUse must be registered');
|
|
320
|
-
assert.ok(after.hooks.PostToolUse, 'PostToolUse must be registered');
|
|
321
|
-
assert.ok(after.hooks.UserPromptSubmit, 'UserPromptSubmit must be registered');
|
|
322
|
-
|
|
323
|
-
// Verify the matchers we promised exist
|
|
324
|
-
const ptuMatchers = after.hooks.PreToolUse.map(e => e.matcher);
|
|
325
|
-
for (const m of ['Edit', 'Bash', 'Read']) {
|
|
326
|
-
assert.ok(ptuMatchers.includes(m), `PreToolUse matcher ${m} missing; got ${JSON.stringify(ptuMatchers)}`);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Every registered entry must carry the description marker for cleanup
|
|
330
|
-
for (const entries of Object.values(after.hooks)) {
|
|
331
|
-
for (const e of entries) {
|
|
332
|
-
if (e.description) {
|
|
333
|
-
assert.ok(e.description.includes('[code-graph-mcp'),
|
|
334
|
-
`entry without our marker leaked through: ${JSON.stringify(e.description)}`);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// statusLine composite still set
|
|
340
|
-
assert.match(after.statusLine.command, /statusline-composite/);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
test('install() strips legacy code-graph hooks AND writes fresh ones (migration path)', (t) => {
|
|
344
|
-
const homeDir = mkHome(t);
|
|
345
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
346
|
-
// Seed with v0.8.2-era legacy entries that should be cleaned up
|
|
347
|
-
writeJson(settingsPath, {
|
|
348
|
-
hooks: legacyHooksFromPlugin(),
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
352
|
-
env: { ...process.env, HOME: homeDir },
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
356
|
-
// Legacy stale paths should be gone — no `/stale/cache/0.8.2/` survivors
|
|
357
|
-
const serialized = JSON.stringify(after.hooks || {});
|
|
358
|
-
assert.ok(!serialized.includes('/stale/cache/'),
|
|
359
|
-
'legacy stale paths must be evicted: ' + serialized);
|
|
360
|
-
// BUT fresh entries (v0.32.0 markers) should be present
|
|
361
|
-
assert.ok(serialized.includes('[code-graph-mcp v0.32+]'),
|
|
362
|
-
'fresh v0.32+ entries should be installed');
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
test('install() is idempotent on settings.json (second call no-op)', (t) => {
|
|
366
|
-
const homeDir = mkHome(t);
|
|
367
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
368
|
-
|
|
369
|
-
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
370
|
-
env: { ...process.env, HOME: homeDir },
|
|
371
|
-
});
|
|
372
|
-
const first = fs.readFileSync(settingsPath, 'utf8');
|
|
373
|
-
|
|
374
|
-
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
375
|
-
env: { ...process.env, HOME: homeDir },
|
|
376
|
-
});
|
|
377
|
-
const second = fs.readFileSync(settingsPath, 'utf8');
|
|
378
|
-
|
|
379
|
-
assert.equal(first, second, 'second install() must produce byte-identical settings.json');
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
test('install() preserves foreign plugin hooks (other plugins\' entries survive)', (t) => {
|
|
383
|
-
const homeDir = mkHome(t);
|
|
384
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
385
|
-
// Seed with an unrelated plugin's hooks alongside ours
|
|
386
|
-
writeJson(settingsPath, {
|
|
387
|
-
hooks: {
|
|
388
|
-
PreToolUse: [{
|
|
389
|
-
matcher: 'Bash',
|
|
390
|
-
description: 'some-other-plugin Bash inspector',
|
|
391
|
-
hooks: [{ type: 'command', command: 'node /opt/other-plugin/bash-check.js', timeout: 3 }],
|
|
392
|
-
}],
|
|
393
|
-
PostToolUse: [{
|
|
394
|
-
matcher: '*',
|
|
395
|
-
description: 'foreign post-tool logger',
|
|
396
|
-
hooks: [{ type: 'command', command: 'bash /opt/foreign/post.sh', timeout: 5 }],
|
|
397
|
-
}],
|
|
398
|
-
},
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
402
|
-
env: { ...process.env, HOME: homeDir },
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
406
|
-
// Foreign entries must still be there
|
|
407
|
-
const ptu = after.hooks.PreToolUse;
|
|
408
|
-
const otherBash = ptu.find(e => e.description === 'some-other-plugin Bash inspector');
|
|
409
|
-
assert.ok(otherBash, 'foreign Bash hook was stripped — never strip non-code-graph entries');
|
|
410
|
-
|
|
411
|
-
const ptoFor = after.hooks.PostToolUse.find(e => e.description === 'foreign post-tool logger');
|
|
412
|
-
assert.ok(ptoFor, 'foreign PostToolUse hook was stripped');
|
|
413
|
-
|
|
414
|
-
// Ours are also there
|
|
415
|
-
assert.ok(after.hooks.PreToolUse.some(e => e.matcher === 'Edit' && e.description?.includes('[code-graph-mcp')));
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
test('registerHooksToSettings is idempotent when called directly', () => {
|
|
419
|
-
// Pure-function direct call, no process spawn
|
|
420
|
-
const { registerHooksToSettings } = require('./lifecycle.js');
|
|
421
|
-
const settings = {};
|
|
422
|
-
const changed1 = registerHooksToSettings(settings);
|
|
423
|
-
const snapshot1 = JSON.stringify(settings);
|
|
424
|
-
const changed2 = registerHooksToSettings(settings);
|
|
425
|
-
const snapshot2 = JSON.stringify(settings);
|
|
426
|
-
assert.equal(changed1, true, 'first call must report change');
|
|
427
|
-
assert.equal(changed2, false, 'second call must report no-change (idempotent)');
|
|
428
|
-
assert.equal(snapshot1, snapshot2, 'settings must be byte-identical after second call');
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
test('removeHooksFromSettings cleans up v0.32+ entries (uninstall path)', () => {
|
|
432
|
-
const { registerHooksToSettings, removeHooksFromSettings } = require('./lifecycle.js');
|
|
433
|
-
const settings = {};
|
|
434
|
-
registerHooksToSettings(settings);
|
|
435
|
-
// Sanity: have entries
|
|
436
|
-
assert.ok(settings.hooks.PreToolUse && settings.hooks.PreToolUse.length > 0);
|
|
437
|
-
|
|
438
|
-
const changed = removeHooksFromSettings(settings);
|
|
439
|
-
assert.equal(changed, true);
|
|
440
|
-
assert.ok(!settings.hooks || Object.keys(settings.hooks).length === 0,
|
|
441
|
-
'all our entries must be removed; got: ' + JSON.stringify(settings.hooks));
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
test('uninstall() removes settings.json hook entries end-to-end', (t) => {
|
|
445
|
-
const homeDir = mkHome(t);
|
|
446
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
447
|
-
|
|
448
|
-
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
449
|
-
env: { ...process.env, HOME: homeDir },
|
|
450
|
-
});
|
|
451
|
-
const afterInstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
452
|
-
assert.ok(afterInstall.hooks?.PreToolUse, 'install must have created hooks');
|
|
453
|
-
|
|
454
|
-
execFileSync(process.execPath, [lifecyclePath, 'uninstall'], {
|
|
455
|
-
env: { ...process.env, HOME: homeDir },
|
|
456
|
-
});
|
|
457
|
-
const afterUninstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
458
|
-
// Our hooks should be gone (foreign ones would survive but we didn't seed any)
|
|
459
|
-
const serialized = JSON.stringify(afterUninstall.hooks || {});
|
|
460
|
-
assert.ok(!serialized.includes('[code-graph-mcp'),
|
|
461
|
-
'uninstall must strip all our entries; got: ' + serialized);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
test('hook commands use absolute paths (no ${CLAUDE_PLUGIN_ROOT} in settings.json)', (t) => {
|
|
465
|
-
// settings.json hook commands run with env-pollution risk per
|
|
466
|
-
// feedback_plugin_env_isolation.md — they must NOT depend on
|
|
467
|
-
// ${CLAUDE_PLUGIN_ROOT} (different plugins overwrite each other's value).
|
|
468
|
-
const homeDir = mkHome(t);
|
|
469
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
470
|
-
|
|
471
|
-
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
472
|
-
env: { ...process.env, HOME: homeDir },
|
|
473
|
-
});
|
|
474
|
-
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
475
|
-
const serialized = JSON.stringify(after.hooks || {});
|
|
476
|
-
assert.ok(!serialized.includes('${CLAUDE_PLUGIN_ROOT}'),
|
|
477
|
-
'settings.json hook commands must not reference ${CLAUDE_PLUGIN_ROOT}: ' + serialized);
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
// ════════════════════════════════════════════════════════════════════
|
|
481
|
-
// v0.32.2 — update() upgrade-path integration tests (reviewer Rec #2)
|
|
482
|
-
// ════════════════════════════════════════════════════════════════════
|
|
483
|
-
// Covers the actual v0.31.x → v0.32.x migration path that runs in
|
|
484
|
-
// production via session-init.js syncLifecycleConfig detecting a manifest
|
|
485
|
-
// version mismatch and calling update(). Previously only install() was
|
|
486
|
-
// tested end-to-end; the upgrade path shared the registerHooksToSettings
|
|
487
|
-
// code internally but had no integration test exercising the wiring.
|
|
488
|
-
|
|
489
|
-
test('update() from v0.31.x manifest registers fresh hooks in empty settings.json', (t) => {
|
|
490
|
-
const homeDir = mkHome(t);
|
|
491
|
-
const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
|
|
492
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
493
|
-
|
|
494
|
-
// Seed v0.31.2 manifest state. updatedAt is the v0.31.2 release date.
|
|
495
|
-
writeJson(manifestPath, {
|
|
496
|
-
version: '0.31.2',
|
|
497
|
-
installedAt: '2026-03-16T18:56:17.656Z',
|
|
498
|
-
updatedAt: '2026-05-23T16:46:39.353Z',
|
|
499
|
-
config: { statusLine: false },
|
|
500
|
-
});
|
|
501
|
-
// settings.json empty (mirrors real v0.31.x state — pre-v0.32.0 strategy
|
|
502
|
-
// was "strip from settings.json, rely on plugin-cache hooks.json").
|
|
503
|
-
writeJson(settingsPath, {});
|
|
504
|
-
|
|
505
|
-
const out = execFileSync(process.execPath, [lifecyclePath, 'update'], {
|
|
506
|
-
env: { ...process.env, HOME: homeDir },
|
|
507
|
-
}).toString();
|
|
508
|
-
assert.match(out, /Updated 0\.31\.2 → /, 'CLI output must show version transition');
|
|
509
|
-
|
|
510
|
-
// Manifest version was bumped to current
|
|
511
|
-
const manifestAfter = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
512
|
-
assert.notEqual(manifestAfter.version, '0.31.2', 'manifest version must advance');
|
|
513
|
-
assert.ok(/^\d+\.\d+\.\d+$/.test(manifestAfter.version),
|
|
514
|
-
`manifest version must be semver, got ${manifestAfter.version}`);
|
|
515
|
-
|
|
516
|
-
// settings.json got the v0.32+ hook entries
|
|
517
|
-
const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
518
|
-
assert.ok(settingsAfter.hooks, 'update() must populate hooks block');
|
|
519
|
-
assert.ok(settingsAfter.hooks.PreToolUse, 'PreToolUse must be registered');
|
|
520
|
-
assert.ok(settingsAfter.hooks.PostToolUse, 'PostToolUse must be registered');
|
|
521
|
-
assert.ok(settingsAfter.hooks.UserPromptSubmit, 'UserPromptSubmit must be registered');
|
|
522
|
-
|
|
523
|
-
// Every entry must carry the v0.32+ marker
|
|
524
|
-
for (const entries of Object.values(settingsAfter.hooks)) {
|
|
525
|
-
for (const e of entries) {
|
|
526
|
-
assert.ok(e.description && e.description.includes('[code-graph-mcp v0.32+'),
|
|
527
|
-
`update() entry without v0.32+ marker: ${JSON.stringify(e.description)}`);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
test('update() from v0.31.x evicts legacy v0.7/v0.8 entries with stale paths', (t) => {
|
|
533
|
-
const homeDir = mkHome(t);
|
|
534
|
-
const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
|
|
535
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
536
|
-
|
|
537
|
-
writeJson(manifestPath, {
|
|
538
|
-
version: '0.31.2',
|
|
539
|
-
installedAt: '2026-03-16T18:56:17.656Z',
|
|
540
|
-
config: { statusLine: false },
|
|
541
|
-
});
|
|
542
|
-
// Seed with legacy v0.8.2-era entries that should be evicted on update.
|
|
543
|
-
writeJson(settingsPath, {
|
|
544
|
-
hooks: legacyHooksFromPlugin(),
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
execFileSync(process.execPath, [lifecyclePath, 'update'], {
|
|
548
|
-
env: { ...process.env, HOME: homeDir },
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
552
|
-
const serialized = JSON.stringify(settingsAfter.hooks || {});
|
|
553
|
-
// Stale paths must be gone
|
|
554
|
-
assert.ok(!serialized.includes('/stale/cache/'),
|
|
555
|
-
'legacy stale paths must be evicted by update(): ' + serialized);
|
|
556
|
-
// Fresh v0.32+ entries must be present
|
|
557
|
-
assert.ok(serialized.includes('[code-graph-mcp v0.32+'),
|
|
558
|
-
'fresh v0.32+ entries must be installed by update()');
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
test('update() preserves foreign plugin hooks during upgrade', (t) => {
|
|
562
|
-
const homeDir = mkHome(t);
|
|
563
|
-
const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
|
|
564
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
565
|
-
|
|
566
|
-
writeJson(manifestPath, {
|
|
567
|
-
version: '0.31.2',
|
|
568
|
-
config: { statusLine: false },
|
|
569
|
-
});
|
|
570
|
-
// Seed with an unrelated plugin's hooks — must survive our update().
|
|
571
|
-
writeJson(settingsPath, {
|
|
572
|
-
hooks: {
|
|
573
|
-
PreToolUse: [{
|
|
574
|
-
matcher: 'Bash',
|
|
575
|
-
description: 'foreign-plugin Bash watcher',
|
|
576
|
-
hooks: [{ type: 'command', command: 'node /opt/foreign/bash.js', timeout: 3 }],
|
|
577
|
-
}],
|
|
578
|
-
},
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
execFileSync(process.execPath, [lifecyclePath, 'update'], {
|
|
582
|
-
env: { ...process.env, HOME: homeDir },
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
586
|
-
const ptu = settingsAfter.hooks.PreToolUse;
|
|
587
|
-
assert.ok(ptu.some(e => e.description === 'foreign-plugin Bash watcher'),
|
|
588
|
-
'foreign Bash hook must survive update() — never strip non-code-graph entries');
|
|
589
|
-
// And our own entries must coexist
|
|
590
|
-
assert.ok(ptu.some(e => e.description && e.description.includes('[code-graph-mcp v0.32+')),
|
|
591
|
-
'update() must add our v0.32+ entries alongside the foreign one');
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
// ════════════════════════════════════════════════════════════════════
|
|
595
|
-
// v0.32.2 — healthCheck post-repair re-verification
|
|
596
|
-
// (Reviewer M3: repaired:true was set blindly after install() without
|
|
597
|
-
// re-scanning to confirm the issues actually resolved.)
|
|
598
|
-
// ════════════════════════════════════════════════════════════════════
|
|
599
|
-
|
|
600
|
-
function runHealthCheckInChild(homeDir) {
|
|
601
|
-
const code = `
|
|
602
|
-
const lc = require(${JSON.stringify(lifecyclePath)});
|
|
603
|
-
process.stdout.write(JSON.stringify(lc.healthCheck()));
|
|
604
|
-
`;
|
|
605
|
-
const out = execFileSync(process.execPath, ['-e', code], {
|
|
606
|
-
env: { ...process.env, HOME: homeDir },
|
|
607
|
-
encoding: 'utf8',
|
|
608
|
-
});
|
|
609
|
-
return JSON.parse(out);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
test('healthCheck on a clean state returns healthy:true and never sets remaining', (t) => {
|
|
613
|
-
const homeDir = mkHome(t);
|
|
614
|
-
// No settings.json, no registry — clean slate.
|
|
615
|
-
const r = runHealthCheckInChild(homeDir);
|
|
616
|
-
assert.equal(r.healthy, true, 'fresh empty state must be healthy');
|
|
617
|
-
assert.deepEqual(r.issues, [], 'no issues on empty state');
|
|
618
|
-
assert.equal(r.repaired, false, 'no repair runs when nothing was broken');
|
|
619
|
-
assert.equal(r.remaining, undefined, 'no remaining field when no repair attempted');
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
test('healthCheck repaired:true ONLY after post-repair re-scan returns clean', (t) => {
|
|
623
|
-
const homeDir = mkHome(t);
|
|
624
|
-
// Seed a hook entry whose path is broken AND carries our marker. install()
|
|
625
|
-
// will overwrite our entries with fresh absolute paths derived from
|
|
626
|
-
// __dirname (which is real in the test env), so the re-scan should be clean.
|
|
627
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
628
|
-
writeJson(settingsPath, {
|
|
629
|
-
hooks: {
|
|
630
|
-
PreToolUse: [{
|
|
631
|
-
matcher: 'Edit',
|
|
632
|
-
description: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)',
|
|
633
|
-
hooks: [{ type: 'command', command: 'node "/nonexistent/code-graph-mcp/pre-edit-guide.js"' }],
|
|
634
|
-
}],
|
|
635
|
-
},
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
const r = runHealthCheckInChild(homeDir);
|
|
639
|
-
assert.equal(r.healthy, false, 'pre-repair scan must have flagged the broken path');
|
|
640
|
-
assert.ok(r.issues.length >= 1, 'pre-repair issues must list the broken hook');
|
|
641
|
-
assert.equal(r.repaired, true, 'install() rewrote our entry → post-scan clean → repaired:true');
|
|
642
|
-
assert.deepEqual(r.remaining, [], 'remaining must be empty when repair succeeded');
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
test('healthCheck repaired:false when install() cannot resolve a flagged path', (t) => {
|
|
646
|
-
const homeDir = mkHome(t);
|
|
647
|
-
// Seed the registry with a non-`_previous` third-party provider whose path
|
|
648
|
-
// is broken. install() only manages the 'code-graph' registry entry, so
|
|
649
|
-
// the third-party entry survives untouched and the post-repair re-scan
|
|
650
|
-
// still flags it. This is the canonical "auto-repair could not fix it"
|
|
651
|
-
// path — previously the function lied and returned repaired:true anyway.
|
|
652
|
-
const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
|
|
653
|
-
writeJson(registryPath, [
|
|
654
|
-
{ id: 'third-party-statusline', command: 'node "/nonexistent/foreign/sl.js"', needsStdin: false },
|
|
655
|
-
]);
|
|
656
|
-
|
|
657
|
-
const r = runHealthCheckInChild(homeDir);
|
|
658
|
-
assert.equal(r.healthy, false, 'broken third-party path must be flagged on entry');
|
|
659
|
-
assert.ok(r.issues.some(i => i.type === 'registry' && i.id === 'third-party-statusline'),
|
|
660
|
-
'pre-repair issue list must contain the third-party entry');
|
|
661
|
-
assert.equal(r.repaired, false,
|
|
662
|
-
'install() does not touch third-party providers → re-scan still broken → repaired must be false');
|
|
663
|
-
assert.ok(Array.isArray(r.remaining), 'remaining must be present when install() was attempted');
|
|
664
|
-
assert.ok(r.remaining.some(i => i.id === 'third-party-statusline'),
|
|
665
|
-
'remaining must still contain the un-fixable third-party entry');
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
test('scanForBrokenPaths is exported and returns the issue structure', (t) => {
|
|
669
|
-
// Direct unit test of the extracted scanner — no install() side effects.
|
|
670
|
-
// Verifies the contract M3 relies on: a pure function whose return
|
|
671
|
-
// shape is what healthCheck composes its result from.
|
|
672
|
-
const homeDir = mkHome(t);
|
|
673
|
-
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
674
|
-
writeJson(settingsPath, {
|
|
675
|
-
hooks: {
|
|
676
|
-
PreToolUse: [{
|
|
677
|
-
matcher: 'Edit',
|
|
678
|
-
description: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)',
|
|
679
|
-
hooks: [{ type: 'command', command: 'node "/nonexistent/code-graph-mcp/pre-edit-guide.js"' }],
|
|
680
|
-
}],
|
|
681
|
-
},
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
const code = `
|
|
685
|
-
const lc = require(${JSON.stringify(lifecyclePath)});
|
|
686
|
-
process.stdout.write(JSON.stringify(lc.scanForBrokenPaths()));
|
|
687
|
-
`;
|
|
688
|
-
const out = execFileSync(process.execPath, ['-e', code], {
|
|
689
|
-
env: { ...process.env, HOME: homeDir },
|
|
690
|
-
encoding: 'utf8',
|
|
691
|
-
});
|
|
692
|
-
const issues = JSON.parse(out);
|
|
693
|
-
assert.ok(Array.isArray(issues));
|
|
694
|
-
assert.ok(issues.some(i => i.type === 'hook' && i.event === 'PreToolUse' && i.path.includes('/nonexistent/')),
|
|
695
|
-
'scanForBrokenPaths must report the seeded broken hook entry');
|
|
696
|
-
});
|
|
697
|
-
// ── isStaleRelicContext (v0.49.1 downgrade-war guard) ──────────────────────
|
|
698
|
-
|
|
699
|
-
test('isStaleRelicContext: relic in plugins cache defers to a different active install', (t) => {
|
|
700
|
-
const { isStaleRelicContext } = require('./lifecycle');
|
|
701
|
-
const cacheRoot = '/home/u/.claude/plugins/cache';
|
|
702
|
-
const relicRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.48.0`;
|
|
703
|
-
const activeRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.49.0`;
|
|
704
|
-
|
|
705
|
-
// The downgrade-war case: running from old cache dir, active points elsewhere.
|
|
706
|
-
assert.equal(isStaleRelicContext({
|
|
707
|
-
pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
|
|
708
|
-
existsSync: () => true,
|
|
709
|
-
}), true);
|
|
710
|
-
|
|
711
|
-
// Running FROM the active install → full self-heal rights.
|
|
712
|
-
assert.equal(isStaleRelicContext({
|
|
713
|
-
pluginRoot: activeRoot, cacheRoot, activePath: activeRoot,
|
|
714
|
-
existsSync: () => true,
|
|
715
|
-
}), false);
|
|
716
|
-
|
|
717
|
-
// Dev checkout / npm install (pluginRoot outside the plugins cache) → exempt.
|
|
718
|
-
assert.equal(isStaleRelicContext({
|
|
719
|
-
pluginRoot: '/repo/code-graph-mcp/claude-plugin', cacheRoot, activePath: activeRoot,
|
|
720
|
-
existsSync: () => true,
|
|
721
|
-
}), false);
|
|
722
|
-
|
|
723
|
-
// No installed_plugins record → exempt (nothing authoritative to defer to).
|
|
724
|
-
assert.equal(isStaleRelicContext({
|
|
725
|
-
pluginRoot: relicRoot, cacheRoot, activePath: null,
|
|
726
|
-
existsSync: () => true,
|
|
727
|
-
}), false);
|
|
728
|
-
|
|
729
|
-
// Active path recorded but its lifecycle.js is gone (cache wiped) → the
|
|
730
|
-
// relic is the only working copy left; keep self-heal rights.
|
|
731
|
-
assert.equal(isStaleRelicContext({
|
|
732
|
-
pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
|
|
733
|
-
existsSync: () => false,
|
|
734
|
-
}), false);
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
test('cleanupOldCacheVersions keeps an in-use version even beyond the keep window', (t) => {
|
|
738
|
-
const { cleanupOldCacheVersions } = require('./lifecycle.js');
|
|
739
|
-
const cacheParent = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-cache-'));
|
|
740
|
-
t.after(() => fs.rmSync(cacheParent, { recursive: true, force: true }));
|
|
741
|
-
const pluginDir = path.join(cacheParent, 'code-graph-mcp');
|
|
742
|
-
// Seven versions, oldest -> newest by mtime.
|
|
743
|
-
const vers = ['0.78.0', '0.80.2', '0.80.3', '0.81.0', '0.81.1', '0.81.2', '0.81.3'];
|
|
744
|
-
vers.forEach((v, i) => {
|
|
745
|
-
const scripts = path.join(pluginDir, v, 'scripts');
|
|
746
|
-
fs.mkdirSync(scripts, { recursive: true });
|
|
747
|
-
fs.writeFileSync(path.join(scripts, 'mcp-launcher.js'), '// stub');
|
|
748
|
-
const ts = (i + 1) * 3600; // distinct, increasing mtimes
|
|
749
|
-
fs.utimesSync(path.join(pluginDir, v), ts, ts);
|
|
750
|
-
});
|
|
751
|
-
// A live MCP server is running from the OLDEST version (beyond keep=5) — this
|
|
752
|
-
// is the v0.80.2 reconnect-(-32000) scenario.
|
|
753
|
-
const inUse = path.join(pluginDir, '0.78.0');
|
|
754
|
-
const fakeCmdlines = [`node ${path.join(inUse, 'scripts', 'mcp-launcher.js')} `];
|
|
755
|
-
|
|
756
|
-
cleanupOldCacheVersions(5, () => fakeCmdlines, cacheParent);
|
|
757
|
-
|
|
758
|
-
assert.equal(fs.existsSync(inUse), true,
|
|
759
|
-
'in-use version must survive prune even when it is the oldest');
|
|
760
|
-
assert.equal(fs.existsSync(path.join(pluginDir, '0.80.2')), false,
|
|
761
|
-
'a non-in-use version beyond the keep window is still pruned');
|
|
762
|
-
assert.equal(fs.existsSync(path.join(pluginDir, '0.81.3')), true,
|
|
763
|
-
'newest version (within keep window) is kept');
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
test('cleanupOldCacheVersions prunes beyond keep when nothing is in use', (t) => {
|
|
767
|
-
const { cleanupOldCacheVersions } = require('./lifecycle.js');
|
|
768
|
-
const cacheParent = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-cache-'));
|
|
769
|
-
t.after(() => fs.rmSync(cacheParent, { recursive: true, force: true }));
|
|
770
|
-
const pluginDir = path.join(cacheParent, 'code-graph-mcp');
|
|
771
|
-
const vers = ['0.78.0', '0.80.2', '0.80.3', '0.81.0', '0.81.1', '0.81.2', '0.81.3'];
|
|
772
|
-
vers.forEach((v, i) => {
|
|
773
|
-
fs.mkdirSync(path.join(pluginDir, v), { recursive: true });
|
|
774
|
-
const ts = (i + 1) * 3600;
|
|
775
|
-
fs.utimesSync(path.join(pluginDir, v), ts, ts);
|
|
776
|
-
});
|
|
777
|
-
// No live process references any version → recency-only pruning (pre-guard).
|
|
778
|
-
cleanupOldCacheVersions(5, () => [], cacheParent);
|
|
779
|
-
|
|
780
|
-
assert.equal(fs.existsSync(path.join(pluginDir, '0.78.0')), false, 'oldest pruned');
|
|
781
|
-
assert.equal(fs.existsSync(path.join(pluginDir, '0.80.2')), false, '2nd-oldest pruned');
|
|
782
|
-
assert.equal(fs.existsSync(path.join(pluginDir, '0.80.3')), true, 'within keep window kept');
|
|
783
|
-
assert.equal(
|
|
784
|
-
fs.readdirSync(pluginDir).filter(n => fs.statSync(path.join(pluginDir, n)).isDirectory()).length,
|
|
785
|
-
5, 'exactly keep=5 versions remain');
|
|
786
|
-
});
|