@sdsrs/code-graph 0.94.0 → 0.95.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.
Files changed (30) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +1 -1
  2. package/claude-plugin/scripts/auto-update.js +69 -9
  3. package/package.json +8 -7
  4. package/claude-plugin/scripts/adopt.test.js +0 -679
  5. package/claude-plugin/scripts/auto-update.test.js +0 -515
  6. package/claude-plugin/scripts/cg-answer.test.js +0 -309
  7. package/claude-plugin/scripts/claude-config.test.js +0 -58
  8. package/claude-plugin/scripts/covering-tests.test.js +0 -78
  9. package/claude-plugin/scripts/doctor.test.js +0 -215
  10. package/claude-plugin/scripts/find-binary.test.js +0 -246
  11. package/claude-plugin/scripts/hook-fire.test.js +0 -117
  12. package/claude-plugin/scripts/hooks.test.js +0 -230
  13. package/claude-plugin/scripts/incremental-index.test.js +0 -102
  14. package/claude-plugin/scripts/lifecycle.e2e.test.js +0 -179
  15. package/claude-plugin/scripts/lifecycle.test.js +0 -786
  16. package/claude-plugin/scripts/mcp-launcher.test.js +0 -162
  17. package/claude-plugin/scripts/mcp-stub.test.js +0 -207
  18. package/claude-plugin/scripts/post-grep-inject.test.js +0 -531
  19. package/claude-plugin/scripts/pr-impact-comment.test.js +0 -110
  20. package/claude-plugin/scripts/pre-edit-guide.test.js +0 -218
  21. package/claude-plugin/scripts/pre-grep-guide.test.js +0 -1682
  22. package/claude-plugin/scripts/pre-read-guide.test.js +0 -363
  23. package/claude-plugin/scripts/project-detect.test.js +0 -95
  24. package/claude-plugin/scripts/recommendation-log.test.js +0 -79
  25. package/claude-plugin/scripts/session-init.test.js +0 -479
  26. package/claude-plugin/scripts/statusline-composite.test.js +0 -65
  27. package/claude-plugin/scripts/statusline.test.js +0 -235
  28. package/claude-plugin/scripts/tmp-dir.test.js +0 -50
  29. package/claude-plugin/scripts/user-prompt-context.test.js +0 -743
  30. package/claude-plugin/scripts/version-utils.test.js +0 -141
@@ -1,531 +0,0 @@
1
- 'use strict';
2
- const test = require('node:test');
3
- const assert = require('node:assert/strict');
4
- const { spawnSync } = require('child_process');
5
- const fs = require('fs');
6
- const os = require('os');
7
- const path = require('path');
8
- const { cgTmpDir } = require('./tmp-dir');
9
-
10
- const {
11
- findFoldableGrepSegment,
12
- extractCallgraphSymbols,
13
- extractGrepOutput,
14
- grepFoundPattern,
15
- isSilenced,
16
- isInjectDisabled,
17
- buildInjectText,
18
- commandHash,
19
- } = require('./post-grep-inject');
20
-
21
- // ── grep-response gate ──────────────────────────────────────────────
22
- // 2026-07-03 audit: 18/18 injects were 0 CONSUMED — they re-stated hits the model
23
- // already had in its OWN grep output. PostToolUse hands the hook the command's
24
- // actual output (tool_response); skip the inject when the grep already surfaced the
25
- // symbol (redundant), inject only when it found nothing (cg's structural answer is
26
- // then genuinely additive: "it's actually here / who calls it").
27
-
28
- test('extractGrepOutput: reads top-level tool_output string (doc-stated shape; forward-compat)', () => {
29
- assert.equal(extractGrepOutput({ tool_output: 'src/a.rs:1 hit' }), 'src/a.rs:1 hit');
30
- });
31
-
32
- test('extractGrepOutput: reads tool_response.stdout (VERIFIED real CC runtime shape — Bash result obj)', () => {
33
- // CC v2.1.198 binary: hook input = {tool_response:{stdout,stderr,interrupted,...}}.
34
- // This is the load-bearing path the gate actually fires on in production.
35
- assert.equal(extractGrepOutput({ tool_response: { stdout: 'src/a.rs:1 hit' } }), 'src/a.rs:1 hit');
36
- });
37
-
38
- test('extractGrepOutput: defensive fallback — tool_response as a bare string', () => {
39
- assert.equal(extractGrepOutput({ tool_response: 'raw output' }), 'raw output');
40
- });
41
-
42
- test('extractGrepOutput: defensive fallback — tool_response.output field', () => {
43
- assert.equal(extractGrepOutput({ tool_response: { output: 'out text' } }), 'out text');
44
- });
45
-
46
- test('extractGrepOutput: absent output → null (unknown, caller injects — no regression)', () => {
47
- assert.equal(extractGrepOutput({}), null);
48
- assert.equal(extractGrepOutput({ tool_response: {} }), null);
49
- assert.equal(extractGrepOutput(null), null);
50
- });
51
-
52
- test('grepFoundPattern: output line containing the symbol → true (grep hit)', () => {
53
- assert.equal(grepFoundPattern('src/foo.rs:7 fn EmbeddingModel()', 'EmbeddingModel'), true);
54
- });
55
-
56
- test('grepFoundPattern: no line contains the symbol → false (grep found nothing)', () => {
57
- // e.g. `echo "===" && grep Sym f` where grep matched nothing — only the echo lands.
58
- assert.equal(grepFoundPattern('===\n', 'EmbeddingModel'), false);
59
- });
60
-
61
- test('grepFoundPattern: alternation — ANY alternand present → true', () => {
62
- assert.equal(grepFoundPattern('src/x.rs:3 created_at', 'markSuperseded|created_at'), true);
63
- });
64
-
65
- test('grepFoundPattern: null / empty output or pattern → false', () => {
66
- assert.equal(grepFoundPattern(null, 'Sym'), false);
67
- assert.equal(grepFoundPattern('', 'Sym'), false);
68
- assert.equal(grepFoundPattern('anything', ''), false);
69
- assert.equal(grepFoundPattern('anything', null), false);
70
- });
71
-
72
- test('grepFoundPattern: sibling echo mentions the symbol but grep MISSED → false (no hit-shaped line)', () => {
73
- // `echo "search for EmbeddingModel" && grep EmbeddingModel wrongpath/` where grep
74
- // found nothing → stdout is just the echo prose. Must NOT count as a hit, or the
75
- // additive grep-empty inject is unreachable for this common shape (review MEDIUM).
76
- assert.equal(grepFoundPattern('search for EmbeddingModel', 'EmbeddingModel'), false);
77
- assert.equal(grepFoundPattern('=== callers of EmbeddingModel ===', 'EmbeddingModel'), false);
78
- });
79
-
80
- test('grepFoundPattern: identifier matched as a WHOLE WORD, not a substring', () => {
81
- // `date` must not be swallowed by `update`/`validate` on a real hit line (review LOW#2).
82
- assert.equal(grepFoundPattern('src/x.rs:3 updated the row and validated it', 'TaskState|date'), false);
83
- // …but a genuine whole-word hit on a hit-shaped line still counts.
84
- assert.equal(grepFoundPattern('src/x.rs:3 const date = now()', 'TaskState|date'), true);
85
- });
86
-
87
- test('grepFoundPattern: bare path line (grep -l output) with the symbol → true', () => {
88
- assert.equal(grepFoundPattern('src/getVocabulary.rs', 'getVocabulary'), true);
89
- });
90
-
91
- test('grepFoundPattern: single-file `grep -n` linenum:content hit (no path prefix) → true', () => {
92
- // Real shape from a compound `grep -n Sym onefile.mjs` — the hit line is
93
- // `2:import { parseGitHubUrl }` with NO path token. Must still count as a hit.
94
- assert.equal(grepFoundPattern('2:import { parseGitHubUrl } from "../x.mjs";', 'parseGitHubUrl'), true);
95
- });
96
-
97
- test('grepFoundPattern: long colon-free line does NOT ReDoS (bounded prefix scan)', () => {
98
- // GREP_HIT_LINE's two `[^\s:]*` stars backtrack O(n²) on a long line carrying `/`.`
99
- // but no colon (~33s on 400KB pre-fix). The prefix cap must keep it O(1)/line.
100
- const huge = '/x.'.repeat(200000) + ' EmbeddingModel'; // ~600KB, has /. but no colon
101
- const t0 = process.hrtime.bigint();
102
- const r = grepFoundPattern(huge, 'EmbeddingModel');
103
- const ms = Number(process.hrtime.bigint() - t0) / 1e6;
104
- assert.ok(ms < 200, `grepFoundPattern took ${ms.toFixed(0)}ms on a 600KB line — ReDoS regressed`);
105
- // Not a grep-hit-shaped line (no colon in the prefix, too long for a bare path) → false.
106
- assert.equal(r, false);
107
- });
108
-
109
- test('grepFoundPattern: symbol present only in prose (no path token on the line) → false', () => {
110
- // Defends the hit-line requirement: a plain content line without a path:col prefix
111
- // (e.g. a `grep` on a single unnamed file, or non-grep sibling output) → inject
112
- // (safe over-inject) rather than a false-skip.
113
- assert.equal(grepFoundPattern('the EmbeddingModel struct is here', 'EmbeddingModel'), false);
114
- });
115
-
116
- // ── extractCallgraphSymbols ─────────────────────────────────────────
117
- // Widen callgraph eligibility: an alternation / multi-symbol grep pattern
118
- // used to fall to the redundant grep-echo because the WHOLE pattern wasn't a lone
119
- // identifier. Extract the identifier tokens (callgraph self-filters non-symbols).
120
-
121
- test('extractCallgraphSymbols: a lone identifier → [itself] (prior behavior)', () => {
122
- assert.deepEqual(extractCallgraphSymbols('markSuperseded'), ['markSuperseded']);
123
- });
124
-
125
- test('extractCallgraphSymbols: a lone SHORT identifier is preserved (no length filter on the fast path)', () => {
126
- // The <3-char length filter applies ONLY to multi-token extraction; a grep for
127
- // a lone 2-char symbol must still get its callgraph, exactly as before.
128
- assert.deepEqual(extractCallgraphSymbols('ok'), ['ok']);
129
- });
130
-
131
- test('extractCallgraphSymbols: alternation → each identifier in order', () => {
132
- assert.deepEqual(
133
- extractCallgraphSymbols('markSuperseded|created_at'),
134
- ['markSuperseded', 'created_at']);
135
- });
136
-
137
- test('extractCallgraphSymbols: strips regex escapes so `\\bdate` yields `date`, not `bdate`', () => {
138
- // The letter after \b/\d/\w is a regex metachar, not part of the symbol.
139
- assert.deepEqual(
140
- extractCallgraphSymbols('markSuperseded|\\bdate:|created_at'),
141
- ['markSuperseded', 'date', 'created_at']);
142
- });
143
-
144
- test('extractCallgraphSymbols: drops <3-char noise tokens in multi mode', () => {
145
- // `a|bb|ccc` → only `ccc` survives (a=1, bb=2 filtered).
146
- assert.deepEqual(extractCallgraphSymbols('a|bb|ccc'), ['ccc']);
147
- });
148
-
149
- test('extractCallgraphSymbols: dedups repeated tokens, order-preserving', () => {
150
- assert.deepEqual(extractCallgraphSymbols('foo|bar|foo'), ['foo', 'bar']);
151
- });
152
-
153
- test('extractCallgraphSymbols: caps attempts at 3', () => {
154
- assert.deepEqual(
155
- extractCallgraphSymbols('aaa|bbb|ccc|ddd|eee'),
156
- ['aaa', 'bbb', 'ccc']);
157
- });
158
-
159
- test('extractCallgraphSymbols: non-string / empty → []', () => {
160
- assert.deepEqual(extractCallgraphSymbols(null), []);
161
- assert.deepEqual(extractCallgraphSymbols(''), []);
162
- assert.deepEqual(extractCallgraphSymbols(undefined), []);
163
- });
164
-
165
- test('extractCallgraphSymbols: pattern with no identifier token → []', () => {
166
- assert.deepEqual(extractCallgraphSymbols('\\d+\\.\\d+'), []);
167
- });
168
-
169
- // ── Pure logic: findFoldableGrepSegment ─────────────────────────────
170
- // Reuses splitTopLevelSegments + classifyBlock from pre-grep-guide. The FIRST
171
- // segment whose head is grep AND whose classifyBlock is non-null is the foldable
172
- // grep to answer. Leading-grep foldable commands were DENIED in PreToolUse and
173
- // never ran → never reach PostToolUse, so no dedup is needed here.
174
-
175
- test('findFoldableGrepSegment: compound `echo && grep "Sym" tests/` → the grep segment', () => {
176
- // classifyBlock requires a QUOTED, identifier-like pattern (the deny gate's
177
- // contract); `EmbeddingModel` stands for the spec's illustrative `Sym`.
178
- const seg = findFoldableGrepSegment('echo "x" && grep "EmbeddingModel" tests/');
179
- assert.ok(seg, 'expected a foldable grep segment');
180
- assert.equal(seg.segment, 'grep "EmbeddingModel" tests/');
181
- assert.equal(seg.block.mode, 'grep');
182
- });
183
-
184
- test('findFoldableGrepSegment: `git diff && grep "Sym" src/` → the grep segment', () => {
185
- const seg = findFoldableGrepSegment('git diff && grep "EmbeddingModel" src/');
186
- assert.ok(seg);
187
- assert.equal(seg.segment, 'grep "EmbeddingModel" src/');
188
- });
189
-
190
- test('findFoldableGrepSegment: `cargo test | grep FAIL` is an output filter → null', () => {
191
- // single pipe is NOT a split → head stays `cargo`, not a foldable grep.
192
- assert.equal(findFoldableGrepSegment('cargo test | grep FAIL'), null);
193
- });
194
-
195
- test('findFoldableGrepSegment: a leading non-compound grep is NOT folded here (PreToolUse denies it)', () => {
196
- // A bare leading foldable grep is handled by PreToolUse deny; if it somehow
197
- // reaches PostToolUse it still classifies, but the typical compound case is the
198
- // target. We DO answer a lone classifyBlock-positive segment when present.
199
- const seg = findFoldableGrepSegment('grep "EmbeddingModel" src/');
200
- assert.ok(seg, 'a classifyBlock-positive grep segment is foldable');
201
- assert.equal(seg.block.mode, 'grep');
202
- });
203
-
204
- test('findFoldableGrepSegment: non-foldable hint-tier grep (marker) → null', () => {
205
- // bare TODO marker passes shouldHint but classifyBlock is null → not foldable.
206
- assert.equal(findFoldableGrepSegment('echo hi && grep "TODO" src/'), null);
207
- });
208
-
209
- test('findFoldableGrepSegment: no grep anywhere → null', () => {
210
- assert.equal(findFoldableGrepSegment('cargo build && cargo test'), null);
211
- });
212
-
213
- test('findFoldableGrepSegment: for-loop body grep is isolated and folded', () => {
214
- const seg = findFoldableGrepSegment('for s in a b; do grep "EmbeddingModel" src/; done');
215
- assert.ok(seg, 'loop-body grep must be foldable');
216
- assert.match(seg.segment, /grep "EmbeddingModel" src\//);
217
- });
218
-
219
- test('findFoldableGrepSegment: empty / non-string → null', () => {
220
- assert.equal(findFoldableGrepSegment(''), null);
221
- assert.equal(findFoldableGrepSegment(null), null);
222
- });
223
-
224
- test('findFoldableGrepSegment: show-mode (decl anchor + context flag) classifies as show', () => {
225
- const seg = findFoldableGrepSegment('echo go && grep "fn handle_message" -A 5 src/');
226
- assert.ok(seg);
227
- assert.equal(seg.block.mode, 'show');
228
- assert.deepEqual(seg.block.symbols, ['handle_message']);
229
- });
230
-
231
- // ── buildInjectText ─────────────────────────────────────────────────
232
-
233
- test('buildInjectText: carries a header + the answer text', () => {
234
- const out = buildInjectText({ text: 'src/foo.rs:7 fn x()', truncated: false }, 'grep');
235
- assert.match(out, /AST-aware view of your grep/);
236
- assert.match(out, /src\/foo\.rs:7/);
237
- });
238
-
239
- test('buildInjectText: truncation note appended when truncated', () => {
240
- const out = buildInjectText({ text: 'hit', truncated: true }, 'grep');
241
- assert.match(out, /truncated/);
242
- });
243
-
244
- test('buildInjectText: no truncation note when not truncated', () => {
245
- const out = buildInjectText({ text: 'hit', truncated: false }, 'grep');
246
- assert.doesNotMatch(out, /truncated/);
247
- });
248
-
249
- test('buildInjectText: callgraph mode uses the cross-file header (not the grep-echo header)', () => {
250
- const out = buildInjectText({ text: ' ← called by: alpha (src/b.rs)', truncated: false }, 'callgraph');
251
- assert.match(out, /Cross-file call graph/);
252
- assert.match(out, /grep can't show this/);
253
- assert.doesNotMatch(out, /AST-aware view of your grep/);
254
- assert.match(out, /← called by` = callers/);
255
- });
256
-
257
- test('buildInjectText: callgraph truncation note points at the callgraph command', () => {
258
- const out = buildInjectText({ text: 'tree', truncated: true }, 'callgraph');
259
- assert.match(out, /code-graph-mcp callgraph <symbol>/);
260
- });
261
-
262
- // ── opt-out / kill switch ───────────────────────────────────────────
263
-
264
- test('isSilenced: CODE_GRAPH_QUIET_HOOKS=1 → silenced; default not', () => {
265
- assert.equal(isSilenced({ CODE_GRAPH_QUIET_HOOKS: '1' }), true);
266
- assert.equal(isSilenced({}), false);
267
- });
268
-
269
- test('isInjectDisabled: CODE_GRAPH_NO_INJECT=1 → disabled; default not', () => {
270
- assert.equal(isInjectDisabled({ CODE_GRAPH_NO_INJECT: '1' }), true);
271
- assert.equal(isInjectDisabled({ CODE_GRAPH_NO_INJECT: '0' }), false);
272
- assert.equal(isInjectDisabled({}), false);
273
- });
274
-
275
- // ── e2e: real spawn with stub binary (mirrors pre-grep-guide harness) ──
276
- // PostToolUse-shaped stdin {tool_input:{command:"..."}}; assert on
277
- // hookSpecificOutput.additionalContext.
278
-
279
- function e2eFixture(stubBody) {
280
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'post-grep-e2e-'));
281
- fs.mkdirSync(path.join(dir, '.code-graph'), { recursive: true });
282
- fs.writeFileSync(path.join(dir, '.code-graph', 'index.db'), '');
283
- const stub = path.join(dir, 'cg-stub.js');
284
- fs.writeFileSync(stub, '#!/usr/bin/env node\n' + stubBody);
285
- fs.chmodSync(stub, 0o755);
286
- return { dir, stub };
287
- }
288
-
289
- function runHook(cmd, fixture, extraEnv = {}, cwdOverride, toolOutput) {
290
- const payload = { tool_input: { command: cmd } };
291
- // Drive the REAL CC runtime shape (verified against the v2.1.198 binary): the Bash
292
- // result reaches the hook as `tool_response.stdout`. Absent → unknown → the gate
293
- // injects (pre-gate behavior; no regression).
294
- if (toolOutput !== undefined) payload.tool_response = { stdout: toolOutput };
295
- return spawnSync(process.execPath, [path.join(__dirname, 'post-grep-inject.js')], {
296
- cwd: cwdOverride || fixture.dir,
297
- input: JSON.stringify(payload),
298
- encoding: 'utf8',
299
- env: {
300
- ...process.env,
301
- _CG_ANSWER_BINARY: fixture.stub,
302
- CODE_GRAPH_QUIET_HOOKS: '0',
303
- CODE_GRAPH_NO_INJECT: '0',
304
- ...extraEnv,
305
- },
306
- });
307
- }
308
-
309
- function cleanupFixture(fixture, cmd) {
310
- fs.rmSync(fixture.dir, { recursive: true, force: true });
311
- try {
312
- fs.unlinkSync(path.join(cgTmpDir(), `.code-graph-postinject-${commandHash(cmd)}`));
313
- } catch { /* ok */ }
314
- }
315
-
316
- test('e2e: `echo "x" && grep Sym tests/` → injects additionalContext with the stub hits + records inject', () => {
317
- const uniq = `PostHit${Date.now()}`;
318
- const fixture = e2eFixture(
319
- `process.stdout.write('tests/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
320
- const cmd = `echo "x" && grep "${uniq}" tests/`;
321
- try {
322
- const res = runHook(cmd, fixture);
323
- assert.equal(res.status, 0);
324
- const out = JSON.parse(res.stdout);
325
- assert.equal(out.hookSpecificOutput.hookEventName, 'PostToolUse');
326
- assert.equal(out.hookSpecificOutput.permissionDecision, undefined,
327
- 'PostToolUse inject must be permission-neutral (no permissionDecision)');
328
- assert.match(out.hookSpecificOutput.additionalContext, new RegExp(uniq));
329
- assert.match(out.hookSpecificOutput.additionalContext, /tests\/foo\.rs:7/);
330
- const recs = fs.readFileSync(
331
- path.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
332
- const rec = JSON.parse(recs.trim().split('\n').pop());
333
- assert.equal(rec.action, 'inject');
334
- assert.equal(rec.answered, true);
335
- assert.equal(rec.hook, 'grep');
336
- assert.equal(rec.pattern, uniq);
337
- } finally {
338
- cleanupFixture(fixture, cmd);
339
- }
340
- });
341
-
342
- test('e2e: `git diff && grep Sym src/` → inject', () => {
343
- const uniq = `GitDiffHit${Date.now()}`;
344
- const fixture = e2eFixture(
345
- `process.stdout.write('src/foo.rs:9 fn ' + process.argv[3] + '()\\n');`);
346
- const cmd = `git diff && grep "${uniq}" src/`;
347
- try {
348
- const res = runHook(cmd, fixture);
349
- assert.equal(res.status, 0);
350
- const out = JSON.parse(res.stdout);
351
- assert.match(out.hookSpecificOutput.additionalContext, new RegExp(uniq));
352
- } finally {
353
- cleanupFixture(fixture, cmd);
354
- }
355
- });
356
-
357
- test('e2e: `cargo test | grep FAIL` → no inject (output filter)', () => {
358
- const fixture = e2eFixture(`process.stdout.write('should not run\\n');`);
359
- const cmd = `cargo test | grep FAIL`;
360
- try {
361
- const res = runHook(cmd, fixture);
362
- assert.equal(res.status, 0);
363
- assert.equal(res.stdout.trim(), '', 'an output-filter pipe must not inject');
364
- } finally {
365
- cleanupFixture(fixture, cmd);
366
- }
367
- });
368
-
369
- test('e2e: stub reports no hits → silent (no inject)', () => {
370
- const uniq = `PostMiss${Date.now()}`;
371
- const fixture = e2eFixture(
372
- `process.stdout.write('[code-graph] No matches\\n');`);
373
- const cmd = `echo go && grep "${uniq}" src/`;
374
- try {
375
- const res = runHook(cmd, fixture);
376
- assert.equal(res.status, 0);
377
- assert.equal(res.stdout.trim(), '', 'no-hits must inject nothing');
378
- } finally {
379
- cleanupFixture(fixture, cmd);
380
- }
381
- });
382
-
383
- test('e2e: CODE_GRAPH_NO_INJECT=1 silences the hook', () => {
384
- const uniq = `PostOptout${Date.now()}`;
385
- const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
386
- const cmd = `echo go && grep "${uniq}" src/`;
387
- try {
388
- const res = runHook(cmd, fixture, { CODE_GRAPH_NO_INJECT: '1' });
389
- assert.equal(res.status, 0);
390
- assert.equal(res.stdout.trim(), '', 'opt-out must silence the inject');
391
- } finally {
392
- cleanupFixture(fixture, cmd);
393
- }
394
- });
395
-
396
- test('e2e: per-command cooldown — verbatim re-run within window injects only once', () => {
397
- const uniq = `PostCool${Date.now()}`;
398
- const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
399
- const cmd = `echo go && grep "${uniq}" src/`;
400
- try {
401
- const r1 = runHook(cmd, fixture);
402
- assert.notEqual(r1.stdout.trim(), '', 'first run injects');
403
- const r2 = runHook(cmd, fixture);
404
- assert.equal(r2.stdout.trim(), '', 'second run within cooldown is silent');
405
- } finally {
406
- cleanupFixture(fixture, cmd);
407
- }
408
- });
409
-
410
- test('e2e: alternation grep `Alpha|Beta` → callgraph mode when a symbol has edges', () => {
411
- // The whole pattern is not a lone identifier, but the FIRST alternand resolves
412
- // to a symbol with cross-file edges → callgraph payload, not the grep echo.
413
- const uniq = `AltCg${Date.now()}`;
414
- const fixture = e2eFixture(
415
- // stub: argv = [node, stub, subcmd, sym/pattern, ...]. callgraph → edge-bearing
416
- // tree; anything else (grep) → a plain hit line.
417
- `const sub = process.argv[2], arg = process.argv[3];\n` +
418
- `if (sub === 'callgraph') { process.stdout.write(arg + '\\n \\u2190 called by: someCaller (src/x.rs:3)\\n'); process.exit(0); }\n` +
419
- `process.stdout.write('src/foo.rs:7 fn ' + arg + '()\\n');`);
420
- const cmd = `echo "x" && grep "${uniq}|OtherSym" src/`;
421
- try {
422
- const res = runHook(cmd, fixture);
423
- assert.equal(res.status, 0);
424
- const out = JSON.parse(res.stdout);
425
- assert.match(out.hookSpecificOutput.additionalContext, /Cross-file call graph/,
426
- 'a resolving alternand must produce the callgraph payload, not the grep echo');
427
- assert.match(out.hookSpecificOutput.additionalContext, /called by: someCaller/);
428
- const recs = fs.readFileSync(
429
- path.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
430
- const rec = JSON.parse(recs.trim().split('\n').pop());
431
- assert.equal(rec.action, 'inject');
432
- assert.equal(rec.mode, 'callgraph', 'inject rec must record mode:callgraph');
433
- } finally {
434
- cleanupFixture(fixture, cmd);
435
- }
436
- });
437
-
438
- test('e2e: alternation grep, no symbol has edges → falls back to grep echo (grep mode)', () => {
439
- // callgraph returns exit 1 (no node) for every alternand → the grep-echo path
440
- // still delivers, mode:grep. Guards that widening never LOSES the echo fallback.
441
- const uniq = `AltEcho${Date.now()}`;
442
- const fixture = e2eFixture(
443
- `const sub = process.argv[2], arg = process.argv[3];\n` +
444
- `if (sub === 'callgraph') { process.exit(1); }\n` +
445
- `process.stdout.write('src/foo.rs:7 fn matched()\\n');`);
446
- const cmd = `echo "x" && grep "${uniq}|OtherSym" src/`;
447
- try {
448
- const res = runHook(cmd, fixture);
449
- assert.equal(res.status, 0);
450
- const out = JSON.parse(res.stdout);
451
- assert.match(out.hookSpecificOutput.additionalContext, /AST-aware view of your grep/);
452
- const recs = fs.readFileSync(
453
- path.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
454
- const rec = JSON.parse(recs.trim().split('\n').pop());
455
- assert.equal(rec.mode, 'grep');
456
- } finally {
457
- cleanupFixture(fixture, cmd);
458
- }
459
- });
460
-
461
- test('e2e: grep-response gate — grep ALREADY showed the symbol → skip inject (redundant)', () => {
462
- // The model's own grep output contains the symbol → inject would re-state hits it
463
- // already has (the 18/18-CONSUMED=0 case). Even though the stub WOULD answer, the
464
- // gate suppresses the redundant inject.
465
- const uniq = `GateHit${Date.now()}`;
466
- const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
467
- const cmd = `echo "x" && grep "${uniq}" src/`;
468
- const grepOutput = `src/real.rs:42 fn ${uniq}() { // the model's own grep already found it`;
469
- try {
470
- const res = runHook(cmd, fixture, {}, undefined, grepOutput);
471
- assert.equal(res.status, 0);
472
- assert.equal(res.stdout.trim(), '', 'a grep that already surfaced the symbol must NOT trigger a redundant inject');
473
- } finally {
474
- cleanupFixture(fixture, cmd);
475
- }
476
- });
477
-
478
- test('e2e: grep-response gate — grep found NOTHING → inject (cg answer is additive)', () => {
479
- // The grep produced no hit for the symbol (dialect/scope miss) → cg's structural
480
- // answer is genuinely new info → inject fires.
481
- const uniq = `GateMiss${Date.now()}`;
482
- const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
483
- const cmd = `echo "===" && grep "${uniq}" src/`;
484
- const grepOutput = `===\n`; // only the echo landed; grep matched nothing
485
- try {
486
- const res = runHook(cmd, fixture, {}, undefined, grepOutput);
487
- assert.equal(res.status, 0);
488
- const out = JSON.parse(res.stdout);
489
- assert.match(out.hookSpecificOutput.additionalContext, new RegExp(uniq),
490
- 'a grep that found nothing must still get the additive cg answer');
491
- } finally {
492
- cleanupFixture(fixture, cmd);
493
- }
494
- });
495
-
496
- test('e2e: grep-response gate — absent output field → inject (no regression on unknown)', () => {
497
- // No tool_response (older CC, or unreadable) → the gate can't confirm redundancy →
498
- // it injects, exactly as before the gate existed.
499
- const uniq = `GateUnknown${Date.now()}`;
500
- const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
501
- const cmd = `echo "x" && grep "${uniq}" src/`;
502
- try {
503
- const res = runHook(cmd, fixture); // no toolOutput arg
504
- assert.equal(res.status, 0);
505
- const out = JSON.parse(res.stdout);
506
- assert.match(out.hookSpecificOutput.additionalContext, new RegExp(uniq));
507
- } finally {
508
- cleanupFixture(fixture, cmd);
509
- }
510
- });
511
-
512
- test('e2e: no index up to $HOME → silent exit 0', () => {
513
- // A cwd with no .code-graph anywhere up the tree resolves to null root → exit.
514
- const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'post-grep-noidx-'));
515
- const stub = path.join(bare, 'cg-stub.js');
516
- fs.writeFileSync(stub, '#!/usr/bin/env node\nprocess.stdout.write("hit\\n");');
517
- fs.chmodSync(stub, 0o755);
518
- const cmd = `echo go && grep "FooBar" src/`;
519
- try {
520
- const res = spawnSync(process.execPath, [path.join(__dirname, 'post-grep-inject.js')], {
521
- cwd: bare,
522
- input: JSON.stringify({ tool_input: { command: cmd } }),
523
- encoding: 'utf8',
524
- env: { ...process.env, _CG_ANSWER_BINARY: stub, HOME: bare, CODE_GRAPH_QUIET_HOOKS: '0' },
525
- });
526
- assert.equal(res.status, 0);
527
- assert.equal(res.stdout.trim(), '');
528
- } finally {
529
- fs.rmSync(bare, { recursive: true, force: true });
530
- }
531
- });
@@ -1,110 +0,0 @@
1
- 'use strict';
2
- const { test } = require('node:test');
3
- const assert = require('node:assert');
4
- const fs = require('fs');
5
- const os = require('os');
6
- const path = require('path');
7
-
8
- const { isTestPath, renderMarkdown, computeReview, MARKER } = require('./pr-impact-comment');
9
-
10
- test('isTestPath mirrors domain::is_test_path patterns', () => {
11
- for (const p of [
12
- 'tests/integration.rs', 'test/foo.js', 'benches/indexing.rs', 'bench/x.rs',
13
- 'src/__tests__/a.ts', 'src/foo/tests.rs', 'pkg/x_test.go', 'src/y_test.rs',
14
- 'a.test.ts', 'a.test.js', 'a.test.tsx', 'a.test.jsx',
15
- 'a.spec.ts', 'a.spec.js', 'a.spec.tsx', 'a.spec.jsx',
16
- ]) {
17
- assert.ok(isTestPath(p), `${p} should be a test path`);
18
- }
19
- for (const p of ['src/lib.rs', 'src/graph/centrality.rs', 'README.md', 'src/testing.rs']) {
20
- assert.ok(!isTestPath(p), `${p} should NOT be a test path`);
21
- }
22
- });
23
-
24
- test('renderMarkdown: empty diff', () => {
25
- const md = renderMarkdown({ changed: [], not_indexed: [], tests: [], blast_radius: 0, top_affected: [], uncovered: [] });
26
- assert.ok(md.startsWith(MARKER), 'must start with marker');
27
- assert.match(md, /No code changes detected/);
28
- });
29
-
30
- test('renderMarkdown: only non-indexed changes', () => {
31
- const md = renderMarkdown({ changed: [], not_indexed: ['docs/x.md', 'new.rs'], tests: [], blast_radius: 0, top_affected: [], uncovered: [] });
32
- assert.match(md, /No \*\*indexed\*\* code changed \(2 changed file/);
33
- });
34
-
35
- test('renderMarkdown: full review with test gaps', () => {
36
- const md = renderMarkdown({
37
- changed: ['src/a.rs', 'src/b.rs'],
38
- not_indexed: ['NEW.md'],
39
- tests: ['tests/a_test.rs'],
40
- blast_radius: 20,
41
- top_affected: [{ path: 'src/c.rs', depth: 1 }, { path: 'src/d.rs', depth: 2 }],
42
- uncovered: ['src/b.rs'],
43
- });
44
- assert.ok(md.startsWith(MARKER));
45
- assert.match(md, /2\*\* changed indexed file/);
46
- assert.match(md, /blast radius \*\*20\*\*/);
47
- assert.match(md, /Test gaps \(1\)/);
48
- assert.match(md, /- `src\/b\.rs`/);
49
- assert.match(md, /Tests to re-run/);
50
- assert.match(md, /- `tests\/a_test\.rs`/);
51
- // 20 blast radius but only 2 shown → "top 2 of 20" + "…and 18 more"
52
- assert.match(md, /top 2 of 20/);
53
- assert.match(md, /…and 18 more/);
54
- assert.match(md, /1 changed file\(s\) not in index/);
55
- });
56
-
57
- // Stub binary: a node script that emulates `code-graph-mcp affected`.
58
- // `affected --stdin --json` → aggregate; `affected <file> --json` → per-file.
59
- function writeStubBinary(dir) {
60
- const stub = path.join(dir, 'stub-cg.js');
61
- fs.writeFileSync(stub, `#!/usr/bin/env node
62
- 'use strict';
63
- const args = process.argv.slice(2);
64
- // args[0] === 'affected'
65
- if (args.includes('--stdin')) {
66
- process.stdout.write(JSON.stringify({
67
- changed: ['src/a.rs', 'src/b.rs', 'tests/a_test.rs'],
68
- tests: ['tests/a_test.rs'],
69
- affected_files: [{path:'tests/a_test.rs',depth:1,is_test:true},{path:'src/x.rs',depth:1,is_test:false}],
70
- not_indexed: ['NEW.md'],
71
- }));
72
- process.exit(0);
73
- }
74
- const file = args[1];
75
- // src/a.rs is covered (has a test); src/b.rs is uncovered (no tests).
76
- if (file === 'src/a.rs') {
77
- process.stdout.write(JSON.stringify({ changed:[file], tests:['tests/a_test.rs'], affected_files:[], not_indexed:[] }));
78
- } else {
79
- process.stdout.write(JSON.stringify({ changed:[file], tests:[], affected_files:[], not_indexed:[] }));
80
- }
81
- process.exit(0);
82
- `);
83
- fs.chmodSync(stub, 0o755);
84
- // Wrap so it's executable as a single binary path: use `node stub.js` via a shell shim.
85
- const shim = path.join(dir, 'cg');
86
- fs.writeFileSync(shim, `#!/usr/bin/env bash\nexec node "${stub}" "$@"\n`);
87
- fs.chmodSync(shim, 0o755);
88
- return shim;
89
- }
90
-
91
- test('computeReview: aggregate + per-file test-gap detection', () => {
92
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-prreview-'));
93
- try {
94
- const binary = writeStubBinary(dir);
95
- const review = computeReview(binary, ['src/a.rs', 'src/b.rs', 'tests/a_test.rs', 'NEW.md'], dir);
96
- assert.ok(review, 'review computed');
97
- assert.deepStrictEqual(review.tests, ['tests/a_test.rs']);
98
- assert.strictEqual(review.blast_radius, 2);
99
- assert.deepStrictEqual(review.not_indexed, ['NEW.md']);
100
- // src/b.rs has no covering test → uncovered; src/a.rs covered; test file skipped.
101
- assert.deepStrictEqual(review.uncovered, ['src/b.rs']);
102
- } finally {
103
- fs.rmSync(dir, { recursive: true, force: true });
104
- }
105
- });
106
-
107
- test('computeReview: returns null when binary unavailable', () => {
108
- const review = computeReview('/nonexistent/cg-binary-xyz', ['src/a.rs'], os.tmpdir());
109
- assert.strictEqual(review, null);
110
- });