@sdsrs/code-graph 0.93.1 → 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 -474
- 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,479 +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 path = require('path');
|
|
6
|
-
|
|
7
|
-
const os = require('os');
|
|
8
|
-
const { launchBackgroundAutoUpdate, isHighIntentSource, syncLifecycleConfig, ensureIndexFresh, indexNeedsRevalidation, verifyBinary, computeQuietHooks, shouldInjectMap, shouldInjectRecentImpact, recentImpactWorthShowing, filterSourceFiles, parseGitStatusPaths, formatRecentImpact } = require('./session-init');
|
|
9
|
-
|
|
10
|
-
// Write an executable stub named `code-graph-mcp` that emits `json` to stdout on
|
|
11
|
-
// `health-check` and exits with `exitCode`. Mirrors how the real binary behaves:
|
|
12
|
-
// non-zero exit on an unhealthy index, but the JSON report still goes to stdout.
|
|
13
|
-
function stubHealthBin(t, { json, exitCode = 0 }) {
|
|
14
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sessinit-'));
|
|
15
|
-
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
16
|
-
const bin = path.join(dir, 'code-graph-mcp');
|
|
17
|
-
const payload = String(json).replace(/'/g, `'\\''`);
|
|
18
|
-
fs.writeFileSync(bin, [
|
|
19
|
-
'#!/usr/bin/env bash',
|
|
20
|
-
`printf '%s' '${payload}'`,
|
|
21
|
-
`exit ${exitCode}`,
|
|
22
|
-
'',
|
|
23
|
-
].join('\n'));
|
|
24
|
-
fs.chmodSync(bin, 0o755);
|
|
25
|
-
return { bin, cwd: dir };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
test('syncLifecycleConfig is exported as a callable helper', () => {
|
|
29
|
-
assert.equal(typeof syncLifecycleConfig, 'function');
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test('ensureIndexFresh is exported as a callable helper', () => {
|
|
33
|
-
assert.equal(typeof ensureIndexFresh, 'function');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('ensureIndexFresh returns skipped when no index exists', () => {
|
|
37
|
-
const origCwd = process.cwd();
|
|
38
|
-
const tmpDir = require('node:os').tmpdir();
|
|
39
|
-
process.chdir(tmpDir);
|
|
40
|
-
try {
|
|
41
|
-
const result = ensureIndexFresh();
|
|
42
|
-
assert.equal(result, 'skipped');
|
|
43
|
-
} finally {
|
|
44
|
-
process.chdir(origCwd);
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('indexNeedsRevalidation true when health-check reports index_version_stale', (t) => {
|
|
49
|
-
const { bin, cwd } = stubHealthBin(t, {
|
|
50
|
-
json: JSON.stringify({ healthy: true, nodes: 5, index_version_stale: true }),
|
|
51
|
-
exitCode: 0,
|
|
52
|
-
});
|
|
53
|
-
assert.equal(indexNeedsRevalidation(bin, cwd), true);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('indexNeedsRevalidation false when index is current', (t) => {
|
|
57
|
-
const { bin, cwd } = stubHealthBin(t, {
|
|
58
|
-
json: JSON.stringify({ healthy: true, nodes: 5, index_version_stale: false }),
|
|
59
|
-
exitCode: 0,
|
|
60
|
-
});
|
|
61
|
-
assert.equal(indexNeedsRevalidation(bin, cwd), false);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('indexNeedsRevalidation recovers JSON from a non-zero exit (unhealthy index)', (t) => {
|
|
65
|
-
// health-check exits 1 on an empty/unhealthy index but still emits the report.
|
|
66
|
-
const { bin, cwd } = stubHealthBin(t, {
|
|
67
|
-
json: JSON.stringify({ healthy: false, nodes: 0, index_version_stale: true }),
|
|
68
|
-
exitCode: 1,
|
|
69
|
-
});
|
|
70
|
-
assert.equal(indexNeedsRevalidation(bin, cwd), true);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test('indexNeedsRevalidation false on garbage output (never forces work off a bad probe)', (t) => {
|
|
74
|
-
const { bin, cwd } = stubHealthBin(t, { json: 'not json at all', exitCode: 0 });
|
|
75
|
-
assert.equal(indexNeedsRevalidation(bin, cwd), false);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('verifyBinary returns available:true when binary is found and executable', () => {
|
|
79
|
-
const result = verifyBinary();
|
|
80
|
-
// In dev repo, binary should be found (target/release/code-graph-mcp)
|
|
81
|
-
if (result.available) {
|
|
82
|
-
assert.equal(typeof result.binary, 'string');
|
|
83
|
-
assert.ok(result.binary.length > 0);
|
|
84
|
-
} else {
|
|
85
|
-
// Binary not built — still verify the return shape
|
|
86
|
-
assert.equal(result.available, false);
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test('verifyBinary returns structured result with expected shape', () => {
|
|
91
|
-
const result = verifyBinary();
|
|
92
|
-
assert.equal(typeof result.available, 'boolean');
|
|
93
|
-
assert.ok('binary' in result);
|
|
94
|
-
if (!result.available && result.binary) {
|
|
95
|
-
assert.ok('issue' in result);
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test('launchBackgroundAutoUpdate spawns detached silent updater', () => {
|
|
100
|
-
const calls = [];
|
|
101
|
-
|
|
102
|
-
const ok = launchBackgroundAutoUpdate((command, args, options) => {
|
|
103
|
-
const record = { command, args, options, unrefCalled: false };
|
|
104
|
-
calls.push(record);
|
|
105
|
-
return {
|
|
106
|
-
unref() {
|
|
107
|
-
record.unrefCalled = true;
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
}, { HOME: '/tmp/fake-home' });
|
|
111
|
-
|
|
112
|
-
assert.equal(ok, true);
|
|
113
|
-
assert.equal(calls.length, 1);
|
|
114
|
-
assert.equal(calls[0].command, process.execPath);
|
|
115
|
-
assert.match(calls[0].args[0], /auto-update\.js$/);
|
|
116
|
-
assert.equal(calls[0].args[1], 'check');
|
|
117
|
-
assert.equal(calls[0].args[2], '--silent');
|
|
118
|
-
assert.equal(calls[0].options.detached, true);
|
|
119
|
-
assert.equal(calls[0].options.stdio, 'ignore');
|
|
120
|
-
assert.equal(calls[0].options.env.CODE_GRAPH_AUTO_UPDATE_SILENT, '1');
|
|
121
|
-
assert.equal(calls[0].unrefCalled, true);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test('launchBackgroundAutoUpdate forwards --force only when asked (session-start bypass)', () => {
|
|
125
|
-
const calls = [];
|
|
126
|
-
const capture = (_command, args) => {
|
|
127
|
-
calls.push({ args });
|
|
128
|
-
return { unref() {} };
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
launchBackgroundAutoUpdate(capture, {}, { force: true });
|
|
132
|
-
assert.deepEqual(calls[0].args.slice(1), ['check', '--silent', '--force']);
|
|
133
|
-
|
|
134
|
-
launchBackgroundAutoUpdate(capture, {}); // default → no --force
|
|
135
|
-
assert.deepEqual(calls[1].args.slice(1), ['check', '--silent']);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test('isHighIntentSource forces on session start/resume/clear but not automatic compaction', () => {
|
|
139
|
-
assert.equal(isHighIntentSource('startup'), true);
|
|
140
|
-
assert.equal(isHighIntentSource('resume'), true);
|
|
141
|
-
assert.equal(isHighIntentSource('clear'), true);
|
|
142
|
-
assert.equal(isHighIntentSource(undefined), true); // direct call / unknown → high intent
|
|
143
|
-
assert.equal(isHighIntentSource('compact'), false); // frequent + automatic → gentle cadence
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
const { consistencyCheck, runSessionInit } = require('./session-init');
|
|
147
|
-
|
|
148
|
-
test('consistencyCheck is exported as a function', () => {
|
|
149
|
-
assert.equal(typeof consistencyCheck, 'function');
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test('runSessionInit no-ops (nonProject) in a non-project cwd', (t) => {
|
|
153
|
-
// /tmp-style cwd (no .git/manifest) → the gate returns BEFORE
|
|
154
|
-
// syncLifecycleConfig / verifyBinary / ensureIndexFresh / maybeAutoAdopt /
|
|
155
|
-
// injectProjectMap, leaving zero footprint. Safe to call: the early return
|
|
156
|
-
// precedes every side-effectful step.
|
|
157
|
-
const os = require('os');
|
|
158
|
-
const origCwd = process.cwd();
|
|
159
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-si-nonproj-'));
|
|
160
|
-
process.chdir(tmp);
|
|
161
|
-
try {
|
|
162
|
-
const res = runSessionInit();
|
|
163
|
-
if (res.inactive) { t.skip('plugin seen inactive in this env — gate not reached'); return; }
|
|
164
|
-
assert.equal(res.nonProject, true);
|
|
165
|
-
assert.equal(res.lifecycle, 'noop');
|
|
166
|
-
assert.equal(res.autoUpdateLaunched, false);
|
|
167
|
-
} finally {
|
|
168
|
-
process.chdir(origCwd);
|
|
169
|
-
fs.rmSync(tmp, { recursive: true, force: true });
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test('runSessionInit tears down cache + adoption on a genuine uninstall (order regression)', (t) => {
|
|
174
|
-
// Subprocess isolation: lifecycle.js evaluates CACHE_DIR from os.homedir() at
|
|
175
|
-
// MODULE LOAD, so HOME/CLAUDE_CONFIG_DIR must be set before require — only a
|
|
176
|
-
// fresh child honors them. This locks the order bug: isPluginUninstalled() MUST be
|
|
177
|
-
// read BEFORE cleanupDisabledStatusline() wipes the composite/registry signals it
|
|
178
|
-
// depends on — otherwise teardown is skipped (was null pre-fix).
|
|
179
|
-
const os = require('os');
|
|
180
|
-
const { execFileSync } = require('child_process');
|
|
181
|
-
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-si-teardown-'));
|
|
182
|
-
t.after(() => fs.rmSync(sb, { recursive: true, force: true }));
|
|
183
|
-
const home = sb, cfg = path.join(sb, '.claude'), proj = path.join(sb, 'proj');
|
|
184
|
-
fs.mkdirSync(path.join(cfg, 'plugins'), { recursive: true });
|
|
185
|
-
fs.mkdirSync(proj, { recursive: true });
|
|
186
|
-
fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"1.0.0"}');
|
|
187
|
-
fs.writeFileSync(path.join(proj, 'CLAUDE.md'), '# P\n\nKEEP THIS USER LINE.\n');
|
|
188
|
-
fs.writeFileSync(path.join(cfg, 'settings.json'), '{"statusLine":{"type":"command","command":"/bin/prior.sh"}}');
|
|
189
|
-
const env = { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: cfg };
|
|
190
|
-
const lc = path.join(__dirname, 'lifecycle.js'), ad = path.join(__dirname, 'adopt.js');
|
|
191
|
-
const si = path.join(__dirname, 'session-init.js');
|
|
192
|
-
|
|
193
|
-
// install + adopt, then simulate a downloaded binary + a post-/plugin-uninstall
|
|
194
|
-
// installed_plugins.json (record for some OTHER plugin, none for code-graph).
|
|
195
|
-
execFileSync(process.execPath, [lc, 'install'], { env, cwd: proj, stdio: 'ignore' });
|
|
196
|
-
execFileSync(process.execPath, ['-e',
|
|
197
|
-
`require(${JSON.stringify(ad)}).adopt({cwd:process.cwd()})`], { env, cwd: proj, stdio: 'ignore' });
|
|
198
|
-
fs.mkdirSync(path.join(home, '.cache', 'code-graph', 'bin'), { recursive: true });
|
|
199
|
-
fs.writeFileSync(path.join(home, '.cache', 'code-graph', 'bin', 'code-graph-mcp'), 'x');
|
|
200
|
-
fs.writeFileSync(path.join(cfg, 'plugins', 'installed_plugins.json'),
|
|
201
|
-
JSON.stringify({ plugins: { 'other@mkt': [{ version: '1.0.0', installPath: '/x' }] } }));
|
|
202
|
-
assert.ok(fs.readFileSync(path.join(proj, 'CLAUDE.md'), 'utf8').includes('code-graph'), 'adopt injected block');
|
|
203
|
-
|
|
204
|
-
const res = JSON.parse(execFileSync(process.execPath, ['-e',
|
|
205
|
-
`process.stdout.write(JSON.stringify(require(${JSON.stringify(si)}).runSessionInit({source:'startup'})))`],
|
|
206
|
-
{ env, cwd: proj }).toString());
|
|
207
|
-
|
|
208
|
-
assert.equal(res.inactive, true);
|
|
209
|
-
assert.ok(res.teardown, 'teardown ran (null pre-fix = order bug)');
|
|
210
|
-
assert.equal(res.teardown.cacheRemoved, true);
|
|
211
|
-
assert.equal(res.teardown.unadopted, true);
|
|
212
|
-
assert.equal(fs.existsSync(path.join(home, '.cache', 'code-graph')), false, 'cache residue gone');
|
|
213
|
-
const md = fs.readFileSync(path.join(proj, 'CLAUDE.md'), 'utf8');
|
|
214
|
-
assert.ok(!md.includes('code-graph'), 'adopt block removed');
|
|
215
|
-
assert.ok(md.includes('KEEP THIS USER LINE'), 'user content preserved');
|
|
216
|
-
const settings = JSON.parse(fs.readFileSync(path.join(cfg, 'settings.json'), 'utf8'));
|
|
217
|
-
assert.equal(settings.statusLine.command, '/bin/prior.sh', 'prior statusline restored');
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
test('consistencyCheck returns empty array when binary version matches plugin', () => {
|
|
221
|
-
const result = consistencyCheck('/tmp/nonexistent-binary');
|
|
222
|
-
assert.ok(Array.isArray(result));
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
226
|
-
// v0.17.0 — quietHooks: unconditional quiet default
|
|
227
|
-
// Priority: legacy QUIET_HOOKS=0/1 > new VERBOSE_HOOKS=1 > default true.
|
|
228
|
-
// `adopted` param is dead (unconditional default does not consult it) but
|
|
229
|
-
// the destructured signature still accepts it for backward compat.
|
|
230
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
231
|
-
|
|
232
|
-
test('computeQuietHooks: legacy QUIET_HOOKS="0" forces noisy', () => {
|
|
233
|
-
assert.equal(computeQuietHooks({ env: { CODE_GRAPH_QUIET_HOOKS: '0' } }), false);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test('computeQuietHooks: legacy QUIET_HOOKS="1" forces quiet', () => {
|
|
237
|
-
assert.equal(computeQuietHooks({ env: { CODE_GRAPH_QUIET_HOOKS: '1' } }), true);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
test('computeQuietHooks: VERBOSE_HOOKS="1" opts in to noisy', () => {
|
|
241
|
-
assert.equal(computeQuietHooks({ env: { CODE_GRAPH_VERBOSE_HOOKS: '1' } }), false);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
test('computeQuietHooks: legacy QUIET_HOOKS="1" wins over VERBOSE_HOOKS="1"', () => {
|
|
245
|
-
// Conflicting opt-ins: legacy explicit-quiet wins over new verbose opt-in.
|
|
246
|
-
// (Legacy QUIET_HOOKS="0" + VERBOSE_HOOKS="1" both mean noisy — no conflict.)
|
|
247
|
-
assert.equal(
|
|
248
|
-
computeQuietHooks({ env: { CODE_GRAPH_QUIET_HOOKS: '1', CODE_GRAPH_VERBOSE_HOOKS: '1' } }),
|
|
249
|
-
true
|
|
250
|
-
);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test('computeQuietHooks: env unset → quiet by default', () => {
|
|
254
|
-
assert.equal(computeQuietHooks({ env: {} }), true);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test('computeQuietHooks: no args → quiet by default', () => {
|
|
258
|
-
assert.equal(computeQuietHooks(), true);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
test('computeQuietHooks: legacy `adopted` param is ignored under new default', () => {
|
|
262
|
-
// adopted=true used to imply quiet; now quiet is unconditional.
|
|
263
|
-
// adopted=false used to imply noisy; now still quiet by default.
|
|
264
|
-
assert.equal(computeQuietHooks({ adopted: true, env: {} }), true);
|
|
265
|
-
assert.equal(computeQuietHooks({ adopted: false, env: {} }), true);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
test('shouldInjectMap: only injects when available + not-quiet + adopted', () => {
|
|
269
|
-
// The single positive case: opted into verbose AND adopted.
|
|
270
|
-
assert.equal(shouldInjectMap({ available: true, quietHooks: false, adopted: true }), true);
|
|
271
|
-
// Adopted-only gate: verbose but unadopted → no injection (the zero-referenced
|
|
272
|
-
// case cross-project-interference flagged).
|
|
273
|
-
assert.equal(shouldInjectMap({ available: true, quietHooks: false, adopted: false }), false);
|
|
274
|
-
// Quiet default suppresses regardless of adoption.
|
|
275
|
-
assert.equal(shouldInjectMap({ available: true, quietHooks: true, adopted: true }), false);
|
|
276
|
-
// No binary → nothing to inject.
|
|
277
|
-
assert.equal(shouldInjectMap({ available: false, quietHooks: false, adopted: true }), false);
|
|
278
|
-
// Missing args default to falsey → no injection.
|
|
279
|
-
assert.equal(shouldInjectMap(), false);
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
283
|
-
// v0.63 — SessionStart "live context": recent-change blast radius injection.
|
|
284
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
285
|
-
|
|
286
|
-
test('shouldInjectRecentImpact: default-ON for adopted projects (separate gate from the static map)', () => {
|
|
287
|
-
// Unlike shouldInjectMap, this does NOT require the verbose opt-in — it earns
|
|
288
|
-
// standing context because it's git-delta-derived, not duplicative of MEMORY.md.
|
|
289
|
-
assert.equal(shouldInjectRecentImpact({ available: true, adopted: true, env: {} }), true);
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
test('shouldInjectRecentImpact: hard kill-switch and dedicated opt-out suppress it', () => {
|
|
293
|
-
assert.equal(shouldInjectRecentImpact({ available: true, adopted: true, env: { CODE_GRAPH_QUIET_HOOKS: '1' } }), false);
|
|
294
|
-
assert.equal(shouldInjectRecentImpact({ available: true, adopted: true, env: { CODE_GRAPH_NO_RECENT_IMPACT: '1' } }), false);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test('shouldInjectRecentImpact: needs binary + adoption', () => {
|
|
298
|
-
assert.equal(shouldInjectRecentImpact({ available: false, adopted: true, env: {} }), false);
|
|
299
|
-
assert.equal(shouldInjectRecentImpact({ available: true, adopted: false, env: {} }), false);
|
|
300
|
-
assert.equal(shouldInjectRecentImpact(), false);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
test('filterSourceFiles: keeps AST-bearing source, drops config/lock/doc', () => {
|
|
304
|
-
const diff = [
|
|
305
|
-
'src/domain.rs', 'Cargo.lock', 'Cargo.toml', 'CHANGELOG.md',
|
|
306
|
-
'package.json', 'src/parser/relations/mod.rs', 'claude-plugin/scripts/session-init.js',
|
|
307
|
-
'npm/linux-x64/package.json',
|
|
308
|
-
].join('\n');
|
|
309
|
-
assert.deepEqual(filterSourceFiles(diff), [
|
|
310
|
-
'src/domain.rs', 'src/parser/relations/mod.rs', 'claude-plugin/scripts/session-init.js',
|
|
311
|
-
]);
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
test('parseGitStatusPaths: extracts paths from modified / staged / untracked lines (finding #3)', () => {
|
|
315
|
-
// `git status --porcelain` columns: " M" unstaged-mod, "M " staged, "??" untracked,
|
|
316
|
-
// "A " added. The untracked line is exactly what diff-only missed.
|
|
317
|
-
const out = [
|
|
318
|
-
' M src/domain.rs',
|
|
319
|
-
'M src/cli.rs',
|
|
320
|
-
'?? src/brand_new.rs',
|
|
321
|
-
'A src/staged_new.rs',
|
|
322
|
-
'D src/gone.rs',
|
|
323
|
-
].join('\n');
|
|
324
|
-
assert.deepEqual(parseGitStatusPaths(out), [
|
|
325
|
-
'src/domain.rs', 'src/cli.rs', 'src/brand_new.rs', 'src/staged_new.rs', 'src/gone.rs',
|
|
326
|
-
]);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test('parseGitStatusPaths: rename takes the NEW path; quoted path is unquoted', () => {
|
|
330
|
-
assert.deepEqual(parseGitStatusPaths('R src/old.rs -> src/new.rs'), ['src/new.rs']);
|
|
331
|
-
assert.deepEqual(parseGitStatusPaths('?? "src/with space.rs"'), ['src/with space.rs']);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
test('parseGitStatusPaths: blank / too-short / non-string input → []', () => {
|
|
335
|
-
assert.deepEqual(parseGitStatusPaths(''), []);
|
|
336
|
-
assert.deepEqual(parseGitStatusPaths(null), []);
|
|
337
|
-
assert.deepEqual(parseGitStatusPaths('\n\n'), []);
|
|
338
|
-
assert.deepEqual(parseGitStatusPaths('??'), []); // no path after status
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
test('parseGitStatusPaths composes with filterSourceFiles: untracked source kept, config dropped', () => {
|
|
342
|
-
const out = [' M Cargo.toml', '?? src/new_feature.rs', '?? notes.txt'].join('\n');
|
|
343
|
-
assert.deepEqual(filterSourceFiles(parseGitStatusPaths(out)), ['src/new_feature.rs']);
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
test('formatRecentImpact: re-run command is runnable verbatim when ≤4 changed (finding #4)', () => {
|
|
347
|
-
const affected = { affected_files: [{ depth: 1, is_test: false, path: 'src/a.rs' }], tests: [] };
|
|
348
|
-
const text = formatRecentImpact(['src/x.rs', 'src/y.rs'], affected);
|
|
349
|
-
assert.match(text, /Re-run impacted tests: code-graph-mcp affected src\/x\.rs src\/y\.rs$/m);
|
|
350
|
-
assert.doesNotMatch(text, /more changed file/);
|
|
351
|
-
assert.doesNotMatch(text, / …/); // no bare ellipsis
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
test('formatRecentImpact: >4 changed → explicit "+N more", not a bare ellipsis (finding #4)', () => {
|
|
355
|
-
const affected = { affected_files: [{ depth: 1, is_test: false, path: 'src/a.rs' }], tests: [] };
|
|
356
|
-
const changed = ['s/1.rs', 's/2.rs', 's/3.rs', 's/4.rs', 's/5.rs', 's/6.rs'];
|
|
357
|
-
const text = formatRecentImpact(changed, affected);
|
|
358
|
-
assert.match(text, /code-graph-mcp affected s\/1\.rs s\/2\.rs s\/3\.rs s\/4\.rs {2}\(\+2 more changed file\(s\)/);
|
|
359
|
-
assert.doesNotMatch(text, / …/); // the misleading bare ellipsis is gone
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
test('filterSourceFiles: caps the list and tolerates blank/garbage input', () => {
|
|
363
|
-
assert.deepEqual(filterSourceFiles(''), []);
|
|
364
|
-
assert.deepEqual(filterSourceFiles(null), []);
|
|
365
|
-
const many = Array.from({ length: 40 }, (_, i) => `src/m${i}.rs`).join('\n');
|
|
366
|
-
assert.equal(filterSourceFiles(many).length, 25);
|
|
367
|
-
assert.equal(filterSourceFiles(many, 3).length, 3);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test('formatRecentImpact: renders changed + blast radius + direct dependents', () => {
|
|
371
|
-
const affected = {
|
|
372
|
-
affected_files: [
|
|
373
|
-
{ depth: 1, is_test: false, path: 'src/cli.rs' },
|
|
374
|
-
{ depth: 1, is_test: false, path: 'src/graph/impact.rs' },
|
|
375
|
-
{ depth: 1, is_test: true, path: 'src/parser/relations/tests.rs' },
|
|
376
|
-
{ depth: 2, is_test: false, path: 'src/main.rs' },
|
|
377
|
-
],
|
|
378
|
-
changed: ['src/domain.rs'],
|
|
379
|
-
tests: ['src/parser/relations/tests.rs', 'tests/integration.rs'],
|
|
380
|
-
};
|
|
381
|
-
const text = formatRecentImpact(['src/domain.rs'], affected);
|
|
382
|
-
assert.match(text, /Recent changes/);
|
|
383
|
-
assert.match(text, /Changed: src\/domain\.rs/);
|
|
384
|
-
assert.match(text, /Impacts 4 file\(s\) \(2 direct dependent\(s\)\), 2 test file\(s\)/);
|
|
385
|
-
assert.match(text, /Direct dependents: src\/cli\.rs, src\/graph\/impact\.rs/);
|
|
386
|
-
assert.match(text, /code-graph-mcp affected src\/domain\.rs/);
|
|
387
|
-
// It is graph-unique — the copy says so (the whole point vs the static map).
|
|
388
|
-
assert.match(text, /not in MEMORY\.md/);
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
test('recentImpactWorthShowing: WIP always shows, regardless of source', () => {
|
|
392
|
-
assert.equal(recentImpactWorthShowing({ isWip: true, source: 'startup' }), true);
|
|
393
|
-
assert.equal(recentImpactWorthShowing({ isWip: true, source: 'compact' }), true);
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
test('recentImpactWorthShowing: clean tree (last-commit fallback) suppressed on cold startup, shown on resume', () => {
|
|
397
|
-
assert.equal(recentImpactWorthShowing({ isWip: false, source: 'startup' }), false);
|
|
398
|
-
assert.equal(recentImpactWorthShowing({ isWip: false, source: 'clear' }), true);
|
|
399
|
-
assert.equal(recentImpactWorthShowing({ isWip: false, source: 'compact' }), true);
|
|
400
|
-
assert.equal(recentImpactWorthShowing({ isWip: false, source: 'resume' }), true);
|
|
401
|
-
// Unknown source (direct call / test) defaults to showing — only explicit
|
|
402
|
-
// cold startup is the suppressed case.
|
|
403
|
-
assert.equal(recentImpactWorthShowing({ isWip: false }), true);
|
|
404
|
-
assert.equal(recentImpactWorthShowing(), true);
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
test('formatRecentImpact: high-fanout change drops the noisy name list, keeps risk + test scope', () => {
|
|
408
|
-
// >15 direct dependents = a constants/util node "touches everything"; the
|
|
409
|
-
// first-N names are arbitrary noise, so only risk + test count is surfaced.
|
|
410
|
-
const affected = {
|
|
411
|
-
affected_files: Array.from({ length: 20 }, (_, i) => ({ depth: 1, is_test: false, path: `src/f${i}.rs` })),
|
|
412
|
-
tests: ['tests/a.rs', 'tests/b.rs'],
|
|
413
|
-
};
|
|
414
|
-
const text = formatRecentImpact(['src/domain.rs'], affected);
|
|
415
|
-
assert.match(text, /High-fanout change/);
|
|
416
|
-
assert.match(text, /run the full suite \(2 test file\(s\)\)/);
|
|
417
|
-
assert.doesNotMatch(text, /Direct dependents:/); // name list suppressed
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
test('formatRecentImpact: at/under the fanout threshold the name list IS the signal', () => {
|
|
421
|
-
const affected = {
|
|
422
|
-
affected_files: Array.from({ length: 15 }, (_, i) => ({ depth: 1, is_test: false, path: `src/f${i}.rs` })),
|
|
423
|
-
tests: [],
|
|
424
|
-
};
|
|
425
|
-
const text = formatRecentImpact(['src/x.rs'], affected);
|
|
426
|
-
assert.doesNotMatch(text, /High-fanout/);
|
|
427
|
-
assert.match(text, /Direct dependents:/);
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
test('formatRecentImpact: caps direct-dependent list with a "+N more" overflow', () => {
|
|
431
|
-
const affected = {
|
|
432
|
-
affected_files: Array.from({ length: 10 }, (_, i) => ({ depth: 1, is_test: false, path: `src/f${i}.rs` })),
|
|
433
|
-
tests: [],
|
|
434
|
-
};
|
|
435
|
-
const text = formatRecentImpact(['src/domain.rs'], affected);
|
|
436
|
-
assert.match(text, /\+4 more/); // 10 direct, cap 6 → 4 hidden
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
test('formatRecentImpact: returns null when nothing graph-relevant (no dependents / no changes)', () => {
|
|
440
|
-
// A deps-only commit: changed files filtered to empty upstream → caller skips.
|
|
441
|
-
assert.equal(formatRecentImpact([], { affected_files: [] }), null);
|
|
442
|
-
// Changed source but zero indexed dependents → nothing actionable to say.
|
|
443
|
-
assert.equal(formatRecentImpact(['src/x.rs'], { affected_files: [], tests: [] }), null);
|
|
444
|
-
assert.equal(formatRecentImpact(['src/x.rs'], {}), null);
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
test('consistencyCheck returns version-mismatch when versions differ', (t) => {
|
|
448
|
-
const os = require('os');
|
|
449
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-'));
|
|
450
|
-
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
451
|
-
const bin = path.join(dir, 'code-graph-mcp');
|
|
452
|
-
fs.writeFileSync(bin, [
|
|
453
|
-
'#!/usr/bin/env bash',
|
|
454
|
-
'if [ "$1" = "--version" ]; then',
|
|
455
|
-
' echo "code-graph-mcp 0.0.1"',
|
|
456
|
-
' exit 0',
|
|
457
|
-
'fi',
|
|
458
|
-
'exit 0',
|
|
459
|
-
].join('\n'));
|
|
460
|
-
fs.chmodSync(bin, 0o755);
|
|
461
|
-
|
|
462
|
-
const issues = consistencyCheck(bin);
|
|
463
|
-
const versionIssue = issues.find(i => i.id === 'version-mismatch');
|
|
464
|
-
assert.ok(versionIssue, 'should detect version mismatch');
|
|
465
|
-
assert.ok(versionIssue.msg.includes('0.0.1'));
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
test('injectProjectMap map call carries CODE_GRAPH_INTERNAL (delivery, not a model conversion)', () => {
|
|
469
|
-
// injectProjectMap runs `code-graph-mcp map --compact` to inject the project map.
|
|
470
|
-
// That run is a hook-internal delivery — it must carry the internal marker so
|
|
471
|
-
// record_cli_use (src/cli.rs) does not log it as a phantom model `use` event
|
|
472
|
-
// (the 2026-06-23 mem audit found this leak class; the sibling affected call was
|
|
473
|
-
// already guarded). Asserted at source level because injectProjectMap is not exported.
|
|
474
|
-
const src = fs.readFileSync(path.join(__dirname, 'session-init.js'), 'utf8');
|
|
475
|
-
const i = src.indexOf("['map', '--compact']");
|
|
476
|
-
assert.ok(i >= 0, 'map injection present');
|
|
477
|
-
assert.match(src.slice(i, i + 420), /CODE_GRAPH_INTERNAL:\s*'1'/);
|
|
478
|
-
});
|
|
479
|
-
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
// The composite is the registered statusLine command: it receives Claude Code's
|
|
3
|
-
// JSON context on stdin and fans out to each provider. This pins the cwd bridge:
|
|
4
|
-
// the code-graph provider keys its gate on process.cwd(), but Claude Code may
|
|
5
|
-
// spawn the statusline from a cwd unrelated to the session. The composite must
|
|
6
|
-
// extract the authoritative cwd from stdin and forward it (CODE_GRAPH_STATUSLINE_CWD)
|
|
7
|
-
// so the provider resolves the right project regardless of the spawn's cwd.
|
|
8
|
-
const test = require('node:test');
|
|
9
|
-
const assert = require('node:assert/strict');
|
|
10
|
-
const fs = require('fs');
|
|
11
|
-
const os = require('os');
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const { cwdFromStdin, runProvider } = require('./statusline-composite');
|
|
14
|
-
|
|
15
|
-
test('cwdFromStdin reads the top-level cwd field', () => {
|
|
16
|
-
assert.equal(cwdFromStdin('{"cwd":"/a/b"}'), '/a/b');
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test('cwdFromStdin falls back to workspace.current_dir', () => {
|
|
20
|
-
assert.equal(cwdFromStdin('{"workspace":{"current_dir":"/c/d"}}'), '/c/d');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test('cwdFromStdin prefers top-level cwd over workspace.current_dir', () => {
|
|
24
|
-
assert.equal(cwdFromStdin('{"cwd":"/a","workspace":{"current_dir":"/c"}}'), '/a');
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('cwdFromStdin returns null for empty / non-JSON / cwd-less payloads', () => {
|
|
28
|
-
assert.equal(cwdFromStdin(''), null);
|
|
29
|
-
assert.equal(cwdFromStdin('not json'), null);
|
|
30
|
-
assert.equal(cwdFromStdin('{}'), null);
|
|
31
|
-
assert.equal(cwdFromStdin('{"workspace":{}}'), null);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('cwdFromStdin returns null for a non-string cwd (no bogus env path)', () => {
|
|
35
|
-
// A malformed payload must not coerce a number/object into an env path that
|
|
36
|
-
// resolves to nowhere and silently blanks the segment. Only a real string wins.
|
|
37
|
-
assert.equal(cwdFromStdin('{"cwd":123}'), null);
|
|
38
|
-
assert.equal(cwdFromStdin('{"cwd":{"x":1}}'), null);
|
|
39
|
-
assert.equal(cwdFromStdin('{"cwd":""}'), null);
|
|
40
|
-
assert.equal(cwdFromStdin('{"workspace":{"current_dir":42}}'), null);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('runProvider forwards the stdin cwd to the provider as CODE_GRAPH_STATUSLINE_CWD', (t) => {
|
|
44
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-composite-'));
|
|
45
|
-
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
46
|
-
const fixture = path.join(dir, 'echo-cwd.js');
|
|
47
|
-
fs.writeFileSync(fixture, "process.stdout.write('CWD='+(process.env.CODE_GRAPH_STATUSLINE_CWD||'NONE'));");
|
|
48
|
-
const out = runProvider(`node ${JSON.stringify(fixture)}`, false, '{"cwd":"/x/y"}');
|
|
49
|
-
assert.equal(out, 'CWD=/x/y');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('runProvider leaves CODE_GRAPH_STATUSLINE_CWD unset when stdin carries no cwd', (t) => {
|
|
53
|
-
// Hermetic against an ambient var: with no stdin cwd, runProvider passes
|
|
54
|
-
// process.env through unchanged, so a value inherited by the test runner would
|
|
55
|
-
// leak into the child. Clear it for this case, restore after.
|
|
56
|
-
const saved = process.env.CODE_GRAPH_STATUSLINE_CWD;
|
|
57
|
-
delete process.env.CODE_GRAPH_STATUSLINE_CWD;
|
|
58
|
-
t.after(() => { if (saved !== undefined) process.env.CODE_GRAPH_STATUSLINE_CWD = saved; });
|
|
59
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-composite-'));
|
|
60
|
-
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
61
|
-
const fixture = path.join(dir, 'echo-cwd.js');
|
|
62
|
-
fs.writeFileSync(fixture, "process.stdout.write('CWD='+(process.env.CODE_GRAPH_STATUSLINE_CWD||'NONE'));");
|
|
63
|
-
const out = runProvider(`node ${JSON.stringify(fixture)}`, false, '');
|
|
64
|
-
assert.equal(out, 'CWD=NONE');
|
|
65
|
-
});
|