@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,1682 +0,0 @@
1
- 'use strict';
2
- const test = require('node:test');
3
- const assert = require('node:assert/strict');
4
- const {
5
- shouldHint,
6
- shouldBlock,
7
- classifyBlock,
8
- splitTopLevelSegments,
9
- countNamedPaths,
10
- extractDeclSymbols,
11
- translateBreToRg,
12
- buildShowDenyReason,
13
- extractSedReadTargets,
14
- extractUnansweredTail,
15
- extractPatterns,
16
- extractSearchPath,
17
- normalizeCommandPaths,
18
- resolveProjectRoot,
19
- rebaseRelativePaths,
20
- commandHasBypass,
21
- pickBlockPattern,
22
- buildHint,
23
- buildBlockReason,
24
- buildBlockReasonWithAnswer,
25
- buildNoHitsFyi,
26
- commandHash,
27
- isSilenced,
28
- isBlockDisabled,
29
- isAnswerDisabled,
30
- } = require('./pre-grep-guide');
31
-
32
- // ── Should fire: bare grep/rg/ag on indexed source tree ─────────────
33
-
34
- test('shouldHint: grep -rn on src/', () => {
35
- assert.equal(shouldHint('grep -rn "fn fts5_search" src/storage/'), true);
36
- });
37
-
38
- test('shouldHint: rg on tests/', () => {
39
- assert.equal(shouldHint('rg "expand_acronym" tests/'), true);
40
- });
41
-
42
- test('shouldHint: grep -n on single file in src/', () => {
43
- assert.equal(shouldHint('grep -n "fn split_identifier" src/search/tokenizer.rs'), true);
44
- });
45
-
46
- test('shouldHint: grep -rn on claude-plugin/', () => {
47
- assert.equal(shouldHint('grep -rn "computeQuietHooks" claude-plugin/scripts/'), true);
48
- });
49
-
50
- test('shouldHint: grep with alternation against src/', () => {
51
- assert.equal(shouldHint('grep -rn "set_hook\\|panic_handler" src/main.rs src/lib.rs'), true);
52
- });
53
-
54
- test('shouldHint: grep with stderr redirect + head pipe (still a source search)', () => {
55
- // head/tail/sort pipes don't disqualify — the SEARCH operation is grep on src/
56
- assert.equal(shouldHint('grep -rn "fn fts5_search\\|MATCH" src/storage/ 2>&1 | head -10'), true);
57
- });
58
-
59
- test('shouldHint: ag on lib/', () => {
60
- assert.equal(shouldHint('ag "TODO" lib/'), true);
61
- });
62
-
63
- test('shouldHint: env-prefixed grep on src/', () => {
64
- assert.equal(shouldHint('env LANG=C grep -rn "Foo" src/'), true);
65
- });
66
-
67
- // ── git grep coverage (v0.71): `git grep` is raw BRE search on the tracked
68
- // source tree — same foldable intent as `grep`, but its command HEAD is
69
- // `git`, so it leaked past GREP_HEAD until v0.71. cg grep is a superset
70
- // (tracked AND gitignored), so folding `git grep` into it is sound. The verb
71
- // set is shared across GREP_HEAD / VERB_STRIP / PIPE_INTO_GREP — these lock
72
- // each parse site that touches the verb.
73
-
74
- test('git grep: shouldHint fires on `git grep` against src/', () => {
75
- assert.equal(shouldHint('git grep -n "fts5_search" src/storage/'), true);
76
- });
77
-
78
- test('git grep: shouldHint fires with the `--` pathspec separator', () => {
79
- assert.equal(shouldHint('git grep "FooBar" -- src/lib.rs'), true);
80
- });
81
-
82
- test('git grep: identifier search is a deny (block tier, same as grep)', () => {
83
- assert.equal(shouldBlock('git grep "FooBar" src/'), true);
84
- });
85
-
86
- test('git grep: context flag + decl anchor → show mode', () => {
87
- assert.deepEqual(
88
- classifyBlock('git grep "fn handle_message" -A 5 src/'),
89
- { mode: 'show', symbols: ['handle_message'] });
90
- });
91
-
92
- test('git grep: multi-file named search downgrades to hint (v0.70 parity)', () => {
93
- // inline answer scopes to ONE path; ≥2 named files → hint so the full grep runs.
94
- assert.equal(classifyBlock('git grep "FooBar" src/a.rs src/b.rs'), null);
95
- });
96
-
97
- test('git grep: BRE alternation is translated to rust-regex dialect', () => {
98
- // git grep speaks BRE like plain grep → an escaped \| must unescape for cg grep.
99
- assert.equal(translateBreToRg('git grep "a\\|b" src/', 'a\\|b'), 'a|b');
100
- });
101
-
102
- test('git grep: `| git grep` is an output-filter pipe (no fire)', () => {
103
- assert.equal(shouldHint('grep -rn "Foo" src/ | git grep "Bar"'), false);
104
- });
105
-
106
- test('git grep: rebaseRelativePaths rebases the real subdir path, not the `grep` word', () => {
107
- // shell sits in backend/; `app` is subdir-relative → rebased. `grep` is the
108
- // git subcommand and is existence-gated so it never masquerades as a path.
109
- const exists = (p) => p.endsWith('/root/backend/app');
110
- const out = rebaseRelativePaths('git grep "Foo" app', 'backend', '/root', exists);
111
- assert.match(out, /git grep "Foo" backend\/app/);
112
- });
113
-
114
- // v0.71 — git grep at a scope the working-tree cg answer can't honor (staged
115
- // index / another revision) must NOT deny: folding it would substitute
116
- // current-tree hits for a different revision. The hook stays out entirely.
117
- test('git grep: --cached (staged index) is not denied — cg cannot honor that scope', () => {
118
- assert.equal(shouldHint('git grep --cached "FooBar" src/'), false);
119
- assert.equal(shouldBlock('git grep --cached "FooBar" src/'), false);
120
- });
121
-
122
- test('git grep: a treeish ref before `--` (another revision) is not denied', () => {
123
- assert.equal(shouldHint('git grep "FooBar" HEAD~3 -- src/'), false);
124
- assert.equal(shouldBlock('git grep "cascade_failure" main -- src/'), false);
125
- });
126
-
127
- test('git grep: a bare `-- path` (no ref, working-tree scope) STILL denies', () => {
128
- // guard: the revision-scope exclusion must not over-catch a plain pathspec sep.
129
- assert.equal(shouldBlock('git grep "FooBar" -- src/lib.rs'), true);
130
- });
131
-
132
- // ── Should NOT fire: pipe-grep (output filter, not search) ──────────
133
-
134
- test('shouldHint: pipe-grep on cargo test output', () => {
135
- assert.equal(shouldHint('cargo test 2>&1 | grep "test result"'), false);
136
- });
137
-
138
- test('shouldHint: pipe-grep with -E flag', () => {
139
- assert.equal(shouldHint("cargo test --no-default-features 2>&1 | grep -E 'test result|FAILED'"), false);
140
- });
141
-
142
- test('shouldHint: pipe-rg', () => {
143
- assert.equal(shouldHint("cargo build 2>&1 | rg 'warning|error'"), false);
144
- });
145
-
146
- test('shouldHint: pipe-grep with src/ in pattern (still output filter)', () => {
147
- assert.equal(shouldHint("cargo build 2>&1 | grep 'src/main.rs'"), false);
148
- });
149
-
150
- // ── Should NOT fire: already using code-graph-mcp ───────────────────
151
-
152
- test('shouldHint: code-graph-mcp grep itself', () => {
153
- assert.equal(shouldHint('code-graph-mcp grep "fn parse" src/'), false);
154
- });
155
-
156
- test('shouldHint: pipe through code-graph-mcp', () => {
157
- assert.equal(shouldHint('code-graph-mcp show foo | grep src/'), false);
158
- });
159
-
160
- // ── Should NOT fire: not source-tree paths ──────────────────────────
161
-
162
- test('shouldHint: grep on Cargo.toml only', () => {
163
- assert.equal(shouldHint('grep "^version" Cargo.toml'), false);
164
- });
165
-
166
- test('shouldHint: grep -i docs on .gitignore', () => {
167
- assert.equal(shouldHint('grep -i docs .gitignore'), false);
168
- });
169
-
170
- test('shouldHint: grep on package.json', () => {
171
- assert.equal(shouldHint('grep "version" package.json'), false);
172
- });
173
-
174
- test('shouldHint: grep on a markdown changelog', () => {
175
- assert.equal(shouldHint('grep "v0.24" CHANGELOG.md'), false);
176
- });
177
-
178
- // ── Floor (v0.69 hardening): non-foldable greps must NEVER deny/hint ──
179
- // cg has no structural answer for these → a deny is friction-without-value that teaches
180
- // CODE_GRAPH_NO_BLOCK_GREP bypass. 2026-06-23 reach audit: foldability (~24%) ≈
181
- // interception (24%), so the floor (precision) is the lever — not reach expansion.
182
-
183
- test('floor: grep on an external / non-indexed dir (/tmp clone) never fires', () => {
184
- assert.equal(shouldHint('grep -rn "FooBar" /tmp/openwolf-analysis'), false);
185
- assert.equal(shouldBlock('grep -rn "FooBar" /tmp/openwolf-analysis'), false);
186
- });
187
-
188
- test('floor: external path with an embedded src/ segment never fires', () => {
189
- // SRC_PATH only matches a prefix at ^|\s|quote — `/tmp/clone/src/` is not a project path.
190
- assert.equal(shouldHint('grep -rn "FooBar" /tmp/clone/src/'), false);
191
- });
192
-
193
- test('floor: a non-source data file (.log) under src/ never fires', () => {
194
- assert.equal(shouldHint('grep "ErrorHandler" src/fixtures/app.log'), false);
195
- assert.equal(shouldBlock('grep "ErrorHandler" src/fixtures/app.log'), false);
196
- });
197
-
198
- test('floor: ini/conf/xml/csv data files under src/ never fire', () => {
199
- assert.equal(shouldHint('grep "FooBar" src/config.ini'), false);
200
- assert.equal(shouldHint('grep "FooBar" src/app.conf'), false);
201
- assert.equal(shouldHint('grep "FooBar" src/data.xml'), false);
202
- assert.equal(shouldHint('grep "FooBar" src/rows.csv'), false);
203
- });
204
-
205
- test('floor: multiple config files under a src prefix all peel off → skip', () => {
206
- // global strip (v0.69): pre-fix only the first .json peeled, the 2nd false-matched SRC_PATH.
207
- assert.equal(shouldHint('grep "FooBar" src/a.json src/b.json'), false);
208
- });
209
-
210
- test('floor: mixed target (data file + real source file) STILL fires (no foldable miss)', () => {
211
- assert.equal(shouldHint('grep -rn "FooBar" src/app.log src/handler.rs'), true);
212
- });
213
-
214
- // ── Should NOT fire: not search tools ───────────────────────────────
215
-
216
- test('shouldHint: ls src/', () => {
217
- assert.equal(shouldHint('ls src/storage/'), false);
218
- });
219
-
220
- test('shouldHint: cat src/main.rs', () => {
221
- assert.equal(shouldHint('cat src/main.rs'), false);
222
- });
223
-
224
- test('shouldHint: git log on src/', () => {
225
- assert.equal(shouldHint('git log --oneline -10 src/'), false);
226
- });
227
-
228
- test('shouldHint: find on src/ (file path tool, not content search)', () => {
229
- // find is path-based, not pattern-based. Out of scope for this hook.
230
- assert.equal(shouldHint('find src/ -name "*.rs"'), false);
231
- });
232
-
233
- // ── Edge cases ──────────────────────────────────────────────────────
234
-
235
- test('shouldHint: empty command', () => {
236
- assert.equal(shouldHint(''), false);
237
- });
238
-
239
- test('shouldHint: non-string input', () => {
240
- assert.equal(shouldHint(null), false);
241
- assert.equal(shouldHint(undefined), false);
242
- assert.equal(shouldHint(42), false);
243
- });
244
-
245
- test('shouldHint: oversize command (>1000 chars)', () => {
246
- assert.equal(shouldHint('grep -rn "x" src/ ' + 'y'.repeat(1100)), false);
247
- });
248
-
249
- // ── Hint content ────────────────────────────────────────────────────
250
-
251
- test('buildHint: includes all four code-graph subcommands', () => {
252
- const out = buildHint();
253
- assert.match(out, /code-graph-mcp grep/);
254
- assert.match(out, /code-graph-mcp ast-search/);
255
- assert.match(out, /code-graph-mcp callgraph/);
256
- assert.match(out, /code-graph-mcp show/);
257
- });
258
-
259
- test('buildHint: stays under 700-byte budget (~175 tokens)', () => {
260
- const out = buildHint();
261
- assert.ok(out.length < 700, `hint length ${out.length} exceeds budget`);
262
- });
263
-
264
- test('buildHint: mentions repo-wide / LSP boundary', () => {
265
- assert.match(buildHint(), /Repo-wide index|LSP/);
266
- });
267
-
268
- // ── Cooldown hash ───────────────────────────────────────────────────
269
-
270
- test('commandHash: deterministic + 12-char', () => {
271
- const h1 = commandHash('grep -rn "foo" src/');
272
- const h2 = commandHash('grep -rn "foo" src/');
273
- assert.equal(h1, h2);
274
- assert.equal(h1.length, 12);
275
- });
276
-
277
- test('commandHash: different commands → different hashes', () => {
278
- assert.notEqual(commandHash('grep -rn "foo" src/'), commandHash('grep -rn "bar" src/'));
279
- });
280
-
281
- // ── Kill switch ─────────────────────────────────────────────────────
282
-
283
- test('isSilenced: default (no env) → not silenced (noisy)', () => {
284
- assert.equal(isSilenced({}), false);
285
- });
286
-
287
- test('isSilenced: CODE_GRAPH_QUIET_HOOKS=1 → silenced', () => {
288
- assert.equal(isSilenced({ CODE_GRAPH_QUIET_HOOKS: '1' }), true);
289
- });
290
-
291
- test('isSilenced: CODE_GRAPH_QUIET_HOOKS=0 → not silenced', () => {
292
- assert.equal(isSilenced({ CODE_GRAPH_QUIET_HOOKS: '0' }), false);
293
- });
294
-
295
- test('isSilenced: VERBOSE_HOOKS=1 alone → not silenced (noisy by default already)', () => {
296
- // pre-grep-guide is noisy-by-default; VERBOSE is irrelevant here.
297
- assert.equal(isSilenced({ CODE_GRAPH_VERBOSE_HOOKS: '1' }), false);
298
- });
299
-
300
- // ── Phase C: extended prefixes (real-world backend / DDD / web conventions) ──
301
-
302
- // daagu pattern: `backend/app/services/...` — `app/` is preceded by `backend/`,
303
- // which doesn't satisfy the `(?:^|\s|["'])` lookbehind in the old SRC_PATH.
304
- // 7d audit found 5 of the worst missed sessions used exactly this layout.
305
- test('shouldHint: grep -rn on backend/app/services/ (daagu)', () => {
306
- assert.equal(
307
- shouldHint('grep -rn "pct_chg|pct_change" backend/app/services/context_builder.py'),
308
- true
309
- );
310
- });
311
-
312
- test('shouldHint: grep -rn on backend/app/services/scheduler/', () => {
313
- assert.equal(
314
- shouldHint('grep -rn "TASK_ZOMBIE|zombie recovery|reason=age" backend/app/services/scheduler/'),
315
- true
316
- );
317
- });
318
-
319
- test('shouldHint: grep on services/ (no backend prefix)', () => {
320
- assert.equal(shouldHint('grep -rn "fetchUser" services/auth/'), true);
321
- });
322
-
323
- test('shouldHint: grep on models/ (Rails / Django)', () => {
324
- assert.equal(shouldHint('grep -rn "before_save" models/user.rb'), true);
325
- });
326
-
327
- test('shouldHint: grep on controllers/ (Rails / ASP.NET)', () => {
328
- assert.equal(shouldHint('grep -rn "def index" controllers/UsersController.rb'), true);
329
- });
330
-
331
- test('shouldHint: grep on domain/ (DDD architecture)', () => {
332
- assert.equal(shouldHint('grep -rn "Aggregate" domain/orders/'), true);
333
- });
334
-
335
- test('shouldHint: grep on handlers/ (web server)', () => {
336
- assert.equal(shouldHint('grep -rn "func New" handlers/api/'), true);
337
- });
338
-
339
- test('shouldHint: grep on migrations/ (db schema)', () => {
340
- assert.equal(shouldHint('grep -rn "add_column" migrations/'), true);
341
- });
342
-
343
- test('shouldHint: grep on features/ (modular monolith)', () => {
344
- assert.equal(shouldHint('grep -rn "useFeature" features/billing/'), true);
345
- });
346
-
347
- test('shouldHint: grep on api/ + frontend/', () => {
348
- assert.equal(shouldHint('grep -rn "POST" api/v1/'), true);
349
- assert.equal(shouldHint('grep -rn "import React" frontend/'), true);
350
- });
351
-
352
- // Precision guards — these MUST still NOT fire after the expansion.
353
-
354
- test('shouldHint: grep on web.config (config file ext keeps suppression)', () => {
355
- assert.equal(shouldHint('grep "<connectionStrings" web.config'), false);
356
- });
357
-
358
- test('shouldHint: grep on node_modules/ (NOT in src list)', () => {
359
- assert.equal(shouldHint('grep -rn "deprecated" node_modules/some-pkg/'), false);
360
- });
361
-
362
- test('shouldHint: grep on docs/ (docs trees stay out)', () => {
363
- // We deliberately did NOT add `docs` to the prefix list — docs are typically
364
- // markdown and the existing CONFIG_TARGET_ONLY already filters `.md`-only
365
- // greps. A bare `grep "X" docs/foo.md` would be CONFIG_TARGET_ONLY-suppressed.
366
- assert.equal(shouldHint('grep "v0.24" docs/CHANGELOG.md'), false);
367
- });
368
-
369
- // ── Regression cases from real session telemetry (2026-05-11) ───────
370
-
371
- test('regression: grep -n "Error\\|anyhow" src/main.rs (sess 5052e2a1)', () => {
372
- assert.equal(shouldHint('grep -n "Error\\|anyhow\\|context" src/main.rs'), true);
373
- });
374
-
375
- test('regression: grep -rn "fn fts5_search" src/storage/ (sess 25fa8050)', () => {
376
- assert.equal(shouldHint('grep -rn "fn fts5_search\\|MATCH\\|fts.*tokenize" src/storage/'), true);
377
- });
378
-
379
- test('regression: grep multi-extension MEMORY.md tag search (sess 5052e2a1)', () => {
380
- // This one targets MEMORY.md files — should NOT fire because the --include flags
381
- // are for non-source extensions and there's no `src/` etc. in the args.
382
- assert.equal(shouldHint("grep -rn 'callgraph, impact' --include='*.md'"), false);
383
- });
384
-
385
- test('regression: cargo test pipe filter NOT fires (sess 45691293)', () => {
386
- assert.equal(shouldHint('cargo test --no-default-features 2>&1 | grep -E "test result|FAILED|error\\[" | tail -15'), false);
387
- });
388
-
389
- test('regression: grep -m1 "^version" Cargo.toml NOT fires', () => {
390
- assert.equal(shouldHint('grep -m1 "^version" Cargo.toml'), false);
391
- });
392
-
393
- // ════════════════════════════════════════════════════════════════════
394
- // v0.32.0 — Block tier (shouldBlock, buildBlockReason, isBlockDisabled)
395
- // ════════════════════════════════════════════════════════════════════
396
-
397
- // ── shouldBlock: SHOULD block — identifier-shaped symbol scan ───────
398
-
399
- test('shouldBlock: CamelCase identifier on src/', () => {
400
- assert.equal(shouldBlock('grep -rn "EmbeddingModel" src/'), true);
401
- });
402
-
403
- test('shouldBlock: snake_case identifier on src/', () => {
404
- assert.equal(shouldBlock('grep -rn "fts5_search" src/storage/'), true);
405
- });
406
-
407
- test('shouldBlock: fn declaration anchor on src/', () => {
408
- assert.equal(shouldBlock('grep -rn "fn fts5_search" src/storage/'), true);
409
- });
410
-
411
- test('shouldBlock: alternation with identifiers on src/', () => {
412
- assert.equal(shouldBlock('grep -rn "fn fts5_search\\|MATCH" src/storage/'), true);
413
- });
414
-
415
- test('shouldBlock: class declaration on src/', () => {
416
- assert.equal(shouldBlock('grep -rn "class UserService" src/'), true);
417
- });
418
-
419
- test('shouldBlock: def declaration on backend/app/', () => {
420
- assert.equal(shouldBlock('grep -rn "def fetch_user" backend/app/services/'), true);
421
- });
422
-
423
- test('shouldBlock: rg with CamelCase on lib/', () => {
424
- assert.equal(shouldBlock('rg "AuthHandler" lib/'), true);
425
- });
426
-
427
- // ── shouldBlock: should NOT block (downgrade to hint) — precision flags ─
428
-
429
- test('shouldBlock: grep -l (files-with-matches) → deny, grep answer covers file lists (v0.49)', () => {
430
- assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), true);
431
- assert.deepEqual(classifyBlock('grep -rl "EmbeddingModel" src/'), { mode: 'grep' });
432
- });
433
-
434
- test('shouldBlock: --include=*.rs → deny, path-scoped grep answer covers it (v0.49)', () => {
435
- assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), true);
436
- });
437
-
438
- test('shouldBlock: --exclude=tests → hint only (answer cannot honor exclusion)', () => {
439
- assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false);
440
- });
441
-
442
- // ── B (v0.70): deny only when the inline answer covers the FULL scope ──
443
- // The deny scopes to ONE path (extractSearchPath = first src-prefixed token). A grep naming
444
- // ≥2 file paths would get a first-path-only answer (the rest silently dropped) — an incomplete
445
- // substitute that rationally teaches CODE_GRAPH_NO_BLOCK_GREP bypass. Downgrade those to HINT;
446
- // single file / directory greps (which the answer fully covers) still deny.
447
-
448
- test('B: ≥2 named files downgrade deny→hint (deny would drop all but the first)', () => {
449
- const cmd = 'grep -n "CLAUDE_MEM_DIR" scripts/setup.sh hook-shared.mjs';
450
- assert.equal(shouldHint(cmd), true); // still nudges
451
- assert.equal(shouldBlock(cmd), false); // but does NOT deny (answer can't cover hook-shared.mjs)
452
- assert.equal(classifyBlock(cmd), null);
453
- });
454
-
455
- test('B: two source files also downgrade (deny would cover only the first)', () => {
456
- assert.equal(shouldBlock('grep -rn "set_hook" src/main.rs src/lib.rs'), false);
457
- assert.equal(shouldHint('grep -rn "set_hook" src/main.rs src/lib.rs'), true);
458
- });
459
-
460
- test('B: single file still DENIES (inline answer fully covers it)', () => {
461
- assert.deepEqual(classifyBlock('grep -n "handleMessage" src/server.mjs'), { mode: 'grep' });
462
- });
463
-
464
- test('B: single directory (recursive) still DENIES (cg grep covers the whole dir)', () => {
465
- assert.equal(shouldBlock('grep -rn "EmbeddingModel" src/'), true);
466
- });
467
-
468
- test('B: --include on a single dir still DENIES (one path, fully scoped)', () => {
469
- assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), true);
470
- });
471
-
472
- test('countNamedPaths: counts paths, excludes flags and the quoted pattern', () => {
473
- assert.equal(countNamedPaths('grep -n "Foo" src/a.rs src/b.rs', ['Foo']), 2);
474
- assert.equal(countNamedPaths('grep -rn "Foo" src/', ['Foo']), 1);
475
- // a path-shaped pattern is the pattern, not a second path token
476
- assert.equal(countNamedPaths('grep "config.json" src/app.rs', ['config.json']), 1);
477
- // a path in a compound tail (sed/pipe target) is NOT a 2nd grep target → stays 1 (deny)
478
- assert.equal(countNamedPaths("grep -n \"Foo\" src/foo.rs | head; sed -n '1,5p' src/bar.rs", ['Foo']), 1);
479
- });
480
-
481
- test('shouldBlock: -L / -v inverted intents → hint only', () => {
482
- assert.equal(shouldBlock('grep -rL "EmbeddingModel" src/'), false);
483
- assert.equal(shouldBlock('grep -rnv "EmbeddingModel" src/'), false);
484
- });
485
-
486
- test('shouldBlock: -A 3 with bare identifier → hint only (cannot honor ±N lines)', () => {
487
- assert.equal(shouldBlock('grep -rn -A 3 "EmbeddingModel" src/'), false);
488
- });
489
-
490
- test('shouldBlock: -B 2 with bare identifier → hint only', () => {
491
- assert.equal(shouldBlock('grep -rn -B 2 "EmbeddingModel" src/'), false);
492
- });
493
-
494
- test('shouldBlock: -C 5 with bare identifier → hint only', () => {
495
- assert.equal(shouldBlock('grep -rn -C 5 "EmbeddingModel" src/'), false);
496
- });
497
-
498
- // ── translateBreToRg (v0.49) — BRE→rust-regex dialect bridge ─────────
499
-
500
- test('translateBreToRg: plain grep BRE alternation unescaped', () => {
501
- assert.equal(
502
- translateBreToRg('grep -rn "UnifiedPickerEngine\\|engine.run" src/', 'UnifiedPickerEngine\\|engine.run'),
503
- 'UnifiedPickerEngine|engine.run');
504
- });
505
-
506
- test('translateBreToRg: rg patterns untouched (already extended)', () => {
507
- assert.equal(translateBreToRg('rg "a\\|b" src/', 'a\\|b'), 'a\\|b');
508
- });
509
-
510
- test('translateBreToRg: grep -E untouched', () => {
511
- assert.equal(translateBreToRg('grep -rnE "a\\|b" src/', 'a\\|b'), 'a\\|b');
512
- });
513
-
514
- test('translateBreToRg: unescapes groups/braces/quantifiers for plain grep', () => {
515
- assert.equal(translateBreToRg('grep "fn \\(x\\)\\+" src/', 'fn \\(x\\)\\+'), 'fn (x)+');
516
- });
517
-
518
- // ── classifyBlock: show mode (v0.49) — the daagu 22/128 function-body reads ──
519
-
520
- test('classifyBlock: declaration anchor + -A → show mode with symbols', () => {
521
- assert.deepEqual(
522
- classifyBlock('rg -n "def cascade_failure|def reset_task" -A 25 backend/app/'),
523
- { mode: 'show', symbols: ['cascade_failure', 'reset_task'] });
524
- });
525
-
526
- test('classifyBlock: multi-decl alternation caps at 3 symbols', () => {
527
- const c = classifyBlock('rg -n "def a_one|def b_two|class CThree|fn d_four" -A 10 src/');
528
- assert.equal(c.mode, 'show');
529
- assert.deepEqual(c.symbols, ['a_one', 'b_two', 'CThree']);
530
- });
531
-
532
- test('classifyBlock: declaration anchor WITHOUT context flag → plain grep deny', () => {
533
- assert.deepEqual(classifyBlock('grep -rn "def fetch_user" backend/app/services/'),
534
- { mode: 'grep' });
535
- });
536
-
537
- test('extractDeclSymbols: dedupes and spans fn/def/class/struct anchors', () => {
538
- assert.deepEqual(
539
- extractDeclSymbols(['fn alpha_one', 'struct BetaTwo', 'fn alpha_one']),
540
- ['alpha_one', 'BetaTwo']);
541
- });
542
-
543
- // ── shouldBlock: should NOT block — marker-only patterns ────────────
544
-
545
- test('shouldBlock: bare TODO marker → hint only (no cg equivalent)', () => {
546
- assert.equal(shouldBlock('grep -rn "TODO" src/'), false);
547
- });
548
-
549
- test('shouldBlock: bare FIXME marker → hint only', () => {
550
- assert.equal(shouldBlock('grep -rn "FIXME" src/'), false);
551
- });
552
-
553
- test('shouldBlock: bare XXX marker → hint only', () => {
554
- assert.equal(shouldBlock('grep -rn "XXX" src/'), false);
555
- });
556
-
557
- test('shouldBlock: bare HACK marker → hint only', () => {
558
- assert.equal(shouldBlock('grep -rn "HACK" src/'), false);
559
- });
560
-
561
- // ── shouldBlock: should NOT block — non-identifier text ─────────────
562
-
563
- test('shouldBlock: short lowercase word "foo" → hint only', () => {
564
- // No CamelCase, no _, no declaration anchor → not symbol-shaped
565
- assert.equal(shouldBlock('grep -rn "foo" src/'), false);
566
- });
567
-
568
- test('shouldBlock: short alphanumeric "v1" → hint only', () => {
569
- assert.equal(shouldBlock('grep -rn "v1" src/'), false);
570
- });
571
-
572
- // ── shouldBlock: should NOT block — inherits shouldHint=false ──────
573
-
574
- test('shouldBlock: pipe-grep → false (already shouldHint=false)', () => {
575
- assert.equal(shouldBlock('cargo test 2>&1 | grep "EmbeddingModel"'), false);
576
- });
577
-
578
- test('shouldBlock: code-graph-mcp already used → false', () => {
579
- assert.equal(shouldBlock('code-graph-mcp grep "EmbeddingModel" src/'), false);
580
- });
581
-
582
- test('shouldBlock: empty / non-string → false', () => {
583
- assert.equal(shouldBlock(''), false);
584
- assert.equal(shouldBlock(null), false);
585
- });
586
-
587
- test('shouldBlock: grep on Cargo.toml only → false', () => {
588
- assert.equal(shouldBlock('grep "EmbeddingModel" Cargo.toml'), false);
589
- });
590
-
591
- // ── buildBlockReason content ────────────────────────────────────────
592
-
593
- test('buildBlockReason: includes "denied"', () => {
594
- assert.match(buildBlockReason(), /denied/i);
595
- });
596
-
597
- test('buildBlockReason: lists cg grep + ast-search + callgraph', () => {
598
- const out = buildBlockReason();
599
- assert.match(out, /code-graph-mcp grep/);
600
- assert.match(out, /code-graph-mcp ast-search/);
601
- assert.match(out, /code-graph-mcp callgraph/);
602
- });
603
-
604
- test('buildBlockReason: NEVER documents the escape hatch (v0.49 — the "THIS command only" scoping was adopted as a permanent prefix in 8s on 2026-06-12)', () => {
605
- assert.doesNotMatch(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP/);
606
- });
607
-
608
- test('buildBlockReason: under 700-byte budget (single CC message)', () => {
609
- const out = buildBlockReason();
610
- assert.ok(out.length < 700, `reason length ${out.length} exceeds budget`);
611
- });
612
-
613
- // ── isBlockDisabled escape hatch ────────────────────────────────────
614
-
615
- test('isBlockDisabled: default (no env) → false (block enabled)', () => {
616
- assert.equal(isBlockDisabled({}), false);
617
- });
618
-
619
- test('isBlockDisabled: CODE_GRAPH_NO_BLOCK_GREP=1 → true', () => {
620
- assert.equal(isBlockDisabled({ CODE_GRAPH_NO_BLOCK_GREP: '1' }), true);
621
- });
622
-
623
- test('isBlockDisabled: CODE_GRAPH_NO_BLOCK_GREP=0 → false', () => {
624
- assert.equal(isBlockDisabled({ CODE_GRAPH_NO_BLOCK_GREP: '0' }), false);
625
- });
626
-
627
- test('isBlockDisabled: independent of CODE_GRAPH_QUIET_HOOKS', () => {
628
- // QUIET_HOOKS=1 silences entirely (no block, no hint).
629
- // NO_BLOCK_GREP=1 downgrades block to hint only.
630
- // The two flags must be orthogonal — neither implies the other.
631
- assert.equal(isBlockDisabled({ CODE_GRAPH_QUIET_HOOKS: '1' }), false);
632
- assert.equal(isSilenced({ CODE_GRAPH_NO_BLOCK_GREP: '1' }), false);
633
- });
634
-
635
- // ════════════════════════════════════════════════════════════════════
636
- // v0.32.1 — extractPatterns + I1/I4 false-positive regressions
637
- // ════════════════════════════════════════════════════════════════════
638
-
639
- // ── extractPatterns: pulls quoted args from grep/rg/ag commands ──────
640
-
641
- test('extractPatterns: single double-quoted pattern', () => {
642
- assert.deepEqual(extractPatterns('grep -rn "EmbeddingModel" src/'), ['EmbeddingModel']);
643
- });
644
-
645
- test('extractPatterns: single-quoted pattern', () => {
646
- assert.deepEqual(extractPatterns("grep -rn 'fts5_search' src/"), ['fts5_search']);
647
- });
648
-
649
- test('extractPatterns: env-prefixed verb', () => {
650
- assert.deepEqual(extractPatterns('env LANG=C grep -rn "Foo" src/'), ['Foo']);
651
- });
652
-
653
- test('extractPatterns: multiple -e patterns', () => {
654
- // Multi-pattern grep: both quoted args should be returned.
655
- const got = extractPatterns('grep -rn -e "first" -e "second" src/');
656
- assert.deepEqual(got, ['first', 'second']);
657
- });
658
-
659
- test('extractPatterns: pattern with alternation', () => {
660
- assert.deepEqual(
661
- extractPatterns('grep -rn "fn fts5_search\\|MATCH" src/storage/'),
662
- ['fn fts5_search\\|MATCH']
663
- );
664
- });
665
-
666
- test('extractPatterns: no quotes at all → empty array', () => {
667
- // Unquoted pattern (`grep foo src/`) — we deliberately don't try to parse
668
- // shell tokenization; shouldBlock falls back to hint in this case.
669
- assert.deepEqual(extractPatterns('grep -rn foo src/'), []);
670
- });
671
-
672
- test('extractPatterns: empty / non-string → empty array', () => {
673
- assert.deepEqual(extractPatterns(''), []);
674
- assert.deepEqual(extractPatterns(null), []);
675
- assert.deepEqual(extractPatterns(undefined), []);
676
- });
677
-
678
- test('extractPatterns: rg / ag head also stripped', () => {
679
- assert.deepEqual(extractPatterns('rg "Foo" lib/'), ['Foo']);
680
- assert.deepEqual(extractPatterns('ag "Bar" src/'), ['Bar']);
681
- });
682
-
683
- // ── I1 regression: identifier-shaped PATHS no longer trigger block ──
684
-
685
- test('I1: grep -rn "abc" src/EmbeddingModel.rs → HINT (path has CamelCase, pattern doesn\'t)', () => {
686
- // CamelCase is in the FILENAME, not the pattern. v0.32.0 false-blocked
687
- // this. Pattern "abc" has no identifier shape → must downgrade to hint.
688
- assert.equal(shouldBlock('grep -rn "abc" src/EmbeddingModel.rs'), false);
689
- });
690
-
691
- test('I1: grep -rn "x" src/some_module/file.rs → HINT (path has snake_case)', () => {
692
- assert.equal(shouldBlock('grep -rn "x" src/some_module/file.rs'), false);
693
- });
694
-
695
- test('I1: grep -rn "the quick brown fox" src/EmbeddingModel.rs → HINT (English prose pattern)', () => {
696
- assert.equal(shouldBlock('grep -rn "the quick brown fox" src/EmbeddingModel.rs'), false);
697
- });
698
-
699
- test('I1: unquoted pattern grep -rn foo src/ → HINT (conservative fallback)', () => {
700
- // Without quotes we can't safely identify the pattern arg via shell rules
701
- // alone. Conservative: hint only.
702
- assert.equal(shouldBlock('grep -rn foo src/'), false);
703
- });
704
-
705
- test('I1: identifier pattern still blocks even with non-identifier path', () => {
706
- // Sanity check the inverse — block tier shouldn't get over-relaxed.
707
- // Path is plain `src/` but pattern is CamelCase → still block.
708
- assert.equal(shouldBlock('grep -rn "EmbeddingModel" src/'), true);
709
- });
710
-
711
- // ── I4 regression: declaration-anchor + `type` keyword fixes ─────────
712
-
713
- test('I4: grep -rn "# type checking" src/ → HINT (comment scan, "type" not a decl keyword anymore)', () => {
714
- assert.equal(shouldBlock('grep -rn "# type checking" src/'), false);
715
- });
716
-
717
- test('I4: grep -rn "some type X" src/ → HINT (type not at pattern start, no longer over-matches)', () => {
718
- assert.equal(shouldBlock('grep -rn "some type X" src/'), false);
719
- });
720
-
721
- test('I4: grep -rn "the def keyword" src/ → HINT (def not at pattern start)', () => {
722
- // "the def keyword" had `\bdef\s+\w` match `def k` previously.
723
- // ^\s*(?:fn|def|...) anchor stops this.
724
- assert.equal(shouldBlock('grep -rn "the def keyword" src/'), false);
725
- });
726
-
727
- test('I4: grep -rn "def calc_total" src/ → BLOCK (def at start + snake_case)', () => {
728
- // Real declaration search — still blocks correctly.
729
- assert.equal(shouldBlock('grep -rn "def calc_total" src/'), true);
730
- });
731
-
732
- test('I4: grep -rn "fn render" src/ → BLOCK (decl anchor at start)', () => {
733
- assert.equal(shouldBlock('grep -rn "fn render" src/'), true);
734
- });
735
-
736
- // ── v0.47.1 abs-path matcher fix: normalizeCommandPaths ─────────────
737
- // CC harness steers Bash toward ABSOLUTE paths (cd in compound commands
738
- // triggers permission prompts), so `grep -rn "X" /abs/root/backend/...` is
739
- // the dominant real-world shape. SRC_PATH's lookbehind (^|\s|quote) never
740
- // matched it: daagu 2026-06-11 replay — 42/42 head-greps absolute, 1 hint /
741
- // 0 block as-is vs 30 hint / 16 block after cwd-strip.
742
-
743
- test('normalizeCommandPaths: strips cwd prefix from path args', () => {
744
- assert.equal(
745
- normalizeCommandPaths('grep -rn "X" /proj/root/src/storage/', '/proj/root'),
746
- 'grep -rn "X" src/storage/');
747
- });
748
-
749
- test('normalizeCommandPaths: strips every occurrence', () => {
750
- assert.equal(
751
- normalizeCommandPaths('grep -rn "X" /proj/root/src/a.rs /proj/root/tests/', '/proj/root'),
752
- 'grep -rn "X" src/a.rs tests/');
753
- });
754
-
755
- test('normalizeCommandPaths: strips inside quotes', () => {
756
- assert.equal(
757
- normalizeCommandPaths('grep -rn "X" "/proj/root/backend/app/"', '/proj/root'),
758
- 'grep -rn "X" "backend/app/"');
759
- });
760
-
761
- test('normalizeCommandPaths: leaves foreign absolute paths alone', () => {
762
- assert.equal(
763
- normalizeCommandPaths('grep -rn "X" /other/place/src/', '/proj/root'),
764
- 'grep -rn "X" /other/place/src/');
765
- });
766
-
767
- test('normalizeCommandPaths: no-op when cwd absent / falsy inputs', () => {
768
- assert.equal(normalizeCommandPaths('grep -rn "X" src/', '/proj/root'), 'grep -rn "X" src/');
769
- assert.equal(normalizeCommandPaths('', '/proj/root'), '');
770
- assert.equal(normalizeCommandPaths('grep "X" src/', ''), 'grep "X" src/');
771
- });
772
-
773
- // Real daagu transcript commands (2026-06-11 session 23f149f0…), the exact
774
- // shape that was invisible to v0.47.0. Replay must fire post-normalization.
775
- const DAAGU = '/mnt/data_ssd/dev/projects/daagu';
776
-
777
- test('replay: real abs-path symbol grep → BLOCK after normalization', () => {
778
- const cmd = `grep -n "_parse_finish_reason\\|_last_finish_reason\\|class OpenRouterProvider" ${DAAGU}/backend/app/services/llm_engine/openrouter.py`;
779
- assert.equal(shouldHint(cmd), false); // documents the v0.47.0 blindspot
780
- const norm = normalizeCommandPaths(cmd, DAAGU);
781
- assert.equal(shouldHint(norm), true);
782
- assert.equal(shouldBlock(norm), true);
783
- });
784
-
785
- test('replay: real abs-path -rln grep → DENY after normalization (v0.49: file lists answerable)', () => {
786
- const cmd = `grep -rln "load_active_config_standalone" ${DAAGU}/backend/tests/ | head -5`;
787
- const norm = normalizeCommandPaths(cmd, DAAGU);
788
- assert.equal(shouldHint(norm), true);
789
- assert.deepEqual(classifyBlock(norm), { mode: 'grep' }); // grep answer lists files per hit
790
- });
791
-
792
- test('replay: abs-path config-only grep stays silent after normalization', () => {
793
- const cmd = `grep -n '"typecheck"\\|"type-check"\\|vue-tsc' ${DAAGU}/frontend/package.json`;
794
- assert.equal(shouldHint(normalizeCommandPaths(cmd, DAAGU)), false);
795
- });
796
-
797
- test('replay: extractSearchPath gets relative path from normalized abs command', () => {
798
- const cmd = `grep -rn "config_version" ${DAAGU}/backend/app/services/stock_picker/data_providers.py 2>/dev/null | head -5`;
799
- assert.equal(
800
- extractSearchPath(normalizeCommandPaths(cmd, DAAGU)),
801
- 'backend/app/services/stock_picker/data_providers.py');
802
- });
803
-
804
- // ── v0.47.0 deny-with-answer: extractSearchPath / pickBlockPattern ──
805
-
806
- test('extractSearchPath: dir path after pattern', () => {
807
- assert.equal(extractSearchPath('grep -rn "fts5_search" src/storage/'), 'src/storage/');
808
- });
809
-
810
- test('extractSearchPath: single file in src/', () => {
811
- assert.equal(
812
- extractSearchPath('grep -n "split_identifier" src/search/tokenizer.rs'),
813
- 'src/search/tokenizer.rs');
814
- });
815
-
816
- test('extractSearchPath: first of multiple paths wins', () => {
817
- assert.equal(extractSearchPath('grep -rn "set_hook" src/main.rs src/lib.rs'), 'src/main.rs');
818
- });
819
-
820
- test('extractSearchPath: quoted path is unwrapped', () => {
821
- assert.equal(extractSearchPath('grep -rn "Foo" "claude-plugin/scripts/"'), 'claude-plugin/scripts/');
822
- });
823
-
824
- test('extractSearchPath: flags and redirects are skipped', () => {
825
- assert.equal(extractSearchPath('grep -rn "Foo" src/ 2>&1'), 'src/');
826
- });
827
-
828
- test('extractSearchPath: ./-prefixed path is accepted', () => {
829
- assert.equal(extractSearchPath('grep -rn "Foo" ./src/parser/'), './src/parser/');
830
- });
831
-
832
- test('extractSearchPath: path traversal is rejected', () => {
833
- assert.equal(extractSearchPath('grep -rn "Foo" src/../../etc/'), undefined);
834
- });
835
-
836
- test('extractSearchPath: no source path → undefined', () => {
837
- assert.equal(extractSearchPath('grep -rn "Foo"'), undefined);
838
- });
839
-
840
- test('pickBlockPattern: returns the identifier-like pattern', () => {
841
- assert.equal(pickBlockPattern('grep -rn "EmbeddingModel" src/'), 'EmbeddingModel');
842
- });
843
-
844
- test('pickBlockPattern: skips non-identifier, picks identifier from -e args', () => {
845
- assert.equal(
846
- pickBlockPattern('grep -rn -e "some words" -e "fts5_search" src/'),
847
- 'fts5_search');
848
- });
849
-
850
- test('pickBlockPattern: no identifier-like pattern → undefined', () => {
851
- assert.equal(pickBlockPattern('grep -rn "no ident here" src/'), undefined);
852
- });
853
-
854
- // ── v0.47.0 deny-with-answer: message builders + env gate ───────────
855
-
856
- test('buildBlockReasonWithAnswer: embeds results and command', () => {
857
- const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
858
- status: 'hits', text: 'src/storage/db.rs:42 fn fts5_search()', truncated: false,
859
- });
860
- assert.match(reason, /already ran/);
861
- assert.match(reason, /code-graph-mcp grep "fts5_search" src\/storage\//);
862
- assert.match(reason, /src\/storage\/db\.rs:42/);
863
- assert.doesNotMatch(reason, /truncated/);
864
- });
865
-
866
- test('buildBlockReasonWithAnswer: NEVER advertises the bypass (v0.48 — one deny taught a 14-grep permanent prefix)', () => {
867
- const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
868
- status: 'hits', text: 'hit', truncated: false,
869
- });
870
- assert.doesNotMatch(reason, /CODE_GRAPH_NO_BLOCK_GREP/);
871
- });
872
-
873
- test('buildBlockReasonWithAnswer: no salience restatement — answer is already in context (v0.63 removed)', () => {
874
- // A forced "name the hit you will act on" line was trialed and removed: the
875
- // answer is delivered, so restatement is performative friction. Keep the deny
876
- // copy to delivery + the plain "use directly" nudge only.
877
- const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
878
- status: 'hits', text: 'hit', truncated: false,
879
- });
880
- assert.doesNotMatch(reason, /name the hit you will act on/i);
881
- assert.match(reason, /use these results directly/i);
882
- });
883
-
884
- test('buildShowDenyReason: no salience restatement (v0.63 removed)', () => {
885
- const reason = buildShowDenyReason({ status: 'hits', text: 'fn body', truncated: false });
886
- assert.doesNotMatch(reason, /name which definition above you will change/i);
887
- });
888
-
889
- test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
890
- const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
891
- status: 'hits', text: 'hit', truncated: false,
892
- });
893
- assert.match(reason, /code-graph-mcp grep "fts5_search"\n/);
894
- });
895
-
896
- test('buildBlockReasonWithAnswer: truncated flag adds marker', () => {
897
- const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
898
- status: 'hits', text: 'hit', truncated: true,
899
- });
900
- assert.match(reason, /truncated/);
901
- });
902
-
903
- // ── v0.50 compound-command tail: deny answers the grep, NOT the rest ─
904
-
905
- test('extractUnansweredTail: `; sed` tail after piped grep (2026-06-13 real deny shape)', () => {
906
- assert.equal(
907
- extractUnansweredTail(
908
- 'grep -n "mem_update\\|registerTool" tests/server.test.mjs | head -20; sed -n \'1,60p\' tests/server.test.mjs'),
909
- "sed -n '1,60p' tests/server.test.mjs");
910
- });
911
-
912
- test('extractUnansweredTail: && tail is unanswered (would have run on grep success)', () => {
913
- assert.equal(
914
- extractUnansweredTail('grep -rn "fts5_search" src/ && wc -l src/storage/db.rs'),
915
- 'wc -l src/storage/db.rs');
916
- });
917
-
918
- test('extractUnansweredTail: quoted separators are pattern text, not a tail', () => {
919
- assert.equal(extractUnansweredTail('grep -rn "a;b" src/'), null);
920
- assert.equal(extractUnansweredTail("grep -rn 'a && b' src/"), null);
921
- });
922
-
923
- test('extractUnansweredTail: pipes and redirects are the same pipeline, not a tail', () => {
924
- assert.equal(extractUnansweredTail('grep -rn "Foo" src/ 2>&1 | head -10'), null);
925
- });
926
-
927
- test('extractUnansweredTail: || branch would NOT have run on hits — no tail', () => {
928
- assert.equal(extractUnansweredTail('grep -rn "Foo" src/ || echo none'), null);
929
- });
930
-
931
- test('extractUnansweredTail: trailing separator with nothing after → no tail', () => {
932
- assert.equal(extractUnansweredTail('grep -rn "Foo" src/;'), null);
933
- });
934
-
935
- test('buildBlockReasonWithAnswer: compound tail → note says the rest did NOT run', () => {
936
- const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
937
- status: 'hits', text: 'hit', truncated: false,
938
- }, "sed -n '1,60p' tests/server.test.mjs");
939
- assert.match(reason, /did NOT run/);
940
- assert.match(reason, /sed -n '1,60p' tests\/server\.test\.mjs/);
941
- });
942
-
943
- test('buildBlockReasonWithAnswer: no tail → no compound note', () => {
944
- const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
945
- status: 'hits', text: 'hit', truncated: false,
946
- });
947
- assert.doesNotMatch(reason, /did NOT run/);
948
- });
949
-
950
- test('buildShowDenyReason: compound tail → note says the rest did NOT run', () => {
951
- const reason = buildShowDenyReason(
952
- { status: 'hits', text: 'fn body', truncated: false },
953
- 'cargo test -q');
954
- assert.match(reason, /did NOT run/);
955
- assert.match(reason, /cargo test -q/);
956
- });
957
-
958
- test('buildShowDenyReason: no tail → no compound note', () => {
959
- const reason = buildShowDenyReason({ status: 'hits', text: 'fn body', truncated: false });
960
- assert.doesNotMatch(reason, /did NOT run/);
961
- });
962
-
963
- test('buildBlockReason: compound tail → static deny also flags the unanswered tail', () => {
964
- const reason = buildBlockReason("sed -n '1,60p' tests/server.test.mjs");
965
- assert.match(reason, /did NOT run/);
966
- assert.match(reason, /sed -n '1,60p' tests\/server\.test\.mjs/);
967
- });
968
-
969
- test('buildBlockReason: no tail → unchanged static deny', () => {
970
- const reason = buildBlockReason();
971
- assert.match(reason, /denied by code-graph hook/);
972
- assert.doesNotMatch(reason, /did NOT run/);
973
- });
974
-
975
- test('buildNoHitsFyi: names the pattern and says raw grep proceeds', () => {
976
- const fyi = buildNoHitsFyi('GhostSymbol');
977
- assert.match(fyi, /GhostSymbol/);
978
- assert.match(fyi, /[Nn]o matches/);
979
- });
980
-
981
- test('isAnswerDisabled: only env=1 disables', () => {
982
- assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '1' }), true);
983
- assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '0' }), false);
984
- assert.equal(isAnswerDisabled({}), false);
985
- });
986
-
987
- // ── v0.47.0 deny-with-answer: stdin-spawn e2e with stub binary ──────
988
-
989
- const { spawnSync: spawnHook } = require('child_process');
990
- const fsE2e = require('fs');
991
- const osE2e = require('os');
992
- const pathE2e = require('path');
993
- const { cgTmpDir } = require('./tmp-dir');
994
-
995
- function e2eFixture(stubBody) {
996
- const dir = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'pre-grep-e2e-'));
997
- fsE2e.mkdirSync(pathE2e.join(dir, '.code-graph'), { recursive: true });
998
- fsE2e.writeFileSync(pathE2e.join(dir, '.code-graph', 'index.db'), '');
999
- const stub = pathE2e.join(dir, 'cg-stub.js');
1000
- fsE2e.writeFileSync(stub, '#!/usr/bin/env node\n' + stubBody);
1001
- fsE2e.chmodSync(stub, 0o755);
1002
- return { dir, stub };
1003
- }
1004
-
1005
- function runHook(cmd, fixture, cwdOverride) {
1006
- const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
1007
- cwd: cwdOverride || fixture.dir,
1008
- input: JSON.stringify({ tool_input: { command: cmd } }),
1009
- encoding: 'utf8',
1010
- env: {
1011
- ...process.env,
1012
- _CG_ANSWER_BINARY: fixture.stub,
1013
- CODE_GRAPH_QUIET_HOOKS: '0',
1014
- CODE_GRAPH_NO_BLOCK_GREP: '0',
1015
- CODE_GRAPH_NO_ANSWER_IN_DENY: '0',
1016
- },
1017
- });
1018
- return res;
1019
- }
1020
-
1021
- function cleanupFixture(fixture, cmd) {
1022
- fsE2e.rmSync(fixture.dir, { recursive: true, force: true });
1023
- // cooldown flag for this command lives in cgTmpDir — remove so reruns stay deterministic
1024
- try {
1025
- fsE2e.unlinkSync(pathE2e.join(cgTmpDir(), `.code-graph-bash-${commandHash(cmd)}`));
1026
- } catch { /* ok */ }
1027
- }
1028
-
1029
- test('e2e: denied grep with stub hits → deny JSON embeds the answer + records answered:true', () => {
1030
- const uniq = `StubHit${Date.now()}`;
1031
- const fixture = e2eFixture(
1032
- `process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
1033
- const cmd = `grep -rn "${uniq}" src/`;
1034
- try {
1035
- const res = runHook(cmd, fixture);
1036
- assert.equal(res.status, 0);
1037
- const out = JSON.parse(res.stdout);
1038
- assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1039
- assert.match(out.hookSpecificOutput.permissionDecisionReason, /src\/foo\.rs:7/);
1040
- assert.match(out.hookSpecificOutput.permissionDecisionReason, new RegExp(uniq));
1041
- const recs = fsE2e.readFileSync(
1042
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
1043
- const rec = JSON.parse(recs.trim().split('\n').pop());
1044
- assert.equal(rec.action, 'deny');
1045
- assert.equal(rec.answered, true);
1046
- // An answered deny carries no failure reason — the field is reserved for
1047
- // the not-answered fallback so 'no-binary' vs 'unavailable' stays legible.
1048
- assert.equal(rec.reason, undefined);
1049
- } finally {
1050
- cleanupFixture(fixture, cmd);
1051
- }
1052
- });
1053
-
1054
- test('e2e: `git grep` identifier on src/ → deny with the embedded answer', () => {
1055
- const uniq = `GitHit${Date.now()}`;
1056
- const fixture = e2eFixture(
1057
- `process.stdout.write('src/foo.rs:9 fn ' + process.argv[3] + '()\\n');`);
1058
- const cmd = `git grep -n "${uniq}" src/`;
1059
- try {
1060
- const res = runHook(cmd, fixture);
1061
- assert.equal(res.status, 0);
1062
- const out = JSON.parse(res.stdout);
1063
- assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1064
- assert.match(out.hookSpecificOutput.permissionDecisionReason, new RegExp(uniq));
1065
- } finally {
1066
- cleanupFixture(fixture, cmd);
1067
- }
1068
- });
1069
-
1070
- test('e2e: denied grep records the denied pattern (fingerprint for verbatim re-grep detection)', () => {
1071
- // The Rust funnel (aggregate_recommendations_jsonl) scores a follow-up search
1072
- // carrying the SAME pattern as the armed answered deny as fall-through, not a
1073
- // sustained drill-down. That needs the pattern on the deny event.
1074
- const uniq = `StubPat${Date.now()}`;
1075
- const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
1076
- const cmd = `grep -rn "${uniq}" src/`;
1077
- try {
1078
- const res = runHook(cmd, fixture);
1079
- assert.equal(res.status, 0);
1080
- const rec = JSON.parse(fsE2e.readFileSync(
1081
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim().split('\n').pop());
1082
- assert.equal(rec.action, 'deny');
1083
- assert.equal(rec.pattern, uniq, 'deny event carries the denied pattern as a fingerprint');
1084
- } finally {
1085
- cleanupFixture(fixture, cmd);
1086
- }
1087
- });
1088
-
1089
- test('e2e: re-grep within cooldown → observe carries the same pattern (answer-ignored fingerprint)', () => {
1090
- // First grep denies + marks the cooldown; the verbatim re-grep within the
1091
- // window runs silently as an observe. It must carry the same pattern so the
1092
- // funnel can tell "ignored the inline answer" from "drilled into something new".
1093
- const uniq = `StubCool${Date.now()}`;
1094
- const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
1095
- const cmd = `grep -rn "${uniq}" src/`;
1096
- try {
1097
- runHook(cmd, fixture); // 1st → deny + markCooldown
1098
- const res2 = runHook(cmd, fixture); // 2nd within window → observe
1099
- assert.equal(res2.status, 0);
1100
- const last = JSON.parse(fsE2e.readFileSync(
1101
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim().split('\n').pop());
1102
- assert.equal(last.action, 'observe');
1103
- assert.equal(last.pattern, uniq, 'cooldown observe carries the re-grepped pattern');
1104
- } finally {
1105
- cleanupFixture(fixture, cmd);
1106
- }
1107
- });
1108
-
1109
- test('e2e: stub reports no matches → grep allowed with FYI + records fallthrough', () => {
1110
- const uniq = `StubMiss${Date.now()}`;
1111
- const fixture = e2eFixture(
1112
- `process.stdout.write('[code-graph] No matches for: ' + process.argv[3] + '\\n');`);
1113
- const cmd = `grep -rn "${uniq}" src/`;
1114
- try {
1115
- const res = runHook(cmd, fixture);
1116
- assert.equal(res.status, 0);
1117
- // No deny JSON — plain FYI text means the grep proceeds
1118
- assert.throws(() => JSON.parse(res.stdout));
1119
- assert.match(res.stdout, /[Nn]o matches/);
1120
- const recs = fsE2e.readFileSync(
1121
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
1122
- const rec = JSON.parse(recs.trim().split('\n').pop());
1123
- assert.equal(rec.action, 'hint');
1124
- assert.equal(rec.fallthrough, 'no-hits');
1125
- } finally {
1126
- cleanupFixture(fixture, cmd);
1127
- }
1128
- });
1129
-
1130
- test('e2e: stub ran-and-failed → grep ALLOWED (no static deny) + records fallthrough:unavailable', () => {
1131
- // v0.92 — a binary that ran but failed (exit 3) can't answer, so a static deny
1132
- // would hand the model nothing (pure friction that teaches the bypass). ALLOW
1133
- // the raw grep instead; the funnel still distinguishes this from no-hits /
1134
- // no-binary via the `fallthrough` field on the recorded hint event.
1135
- const uniq = `StubBoom${Date.now()}`;
1136
- const fixture = e2eFixture(`process.exit(3);`);
1137
- const cmd = `grep -rn "${uniq}" src/`;
1138
- try {
1139
- const res = runHook(cmd, fixture);
1140
- assert.equal(res.status, 0);
1141
- // No deny JSON — plain FYI text means the grep proceeds.
1142
- assert.throws(() => JSON.parse(res.stdout));
1143
- assert.match(res.stdout, /unavailable \(ran but failed\)/);
1144
- const rec = JSON.parse(fsE2e.readFileSync(
1145
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
1146
- assert.equal(rec.action, 'hint');
1147
- // Runtime-fail must stay distinguishable from a missing-binary fallthrough.
1148
- assert.equal(rec.fallthrough, 'unavailable');
1149
- } finally {
1150
- cleanupFixture(fixture, cmd);
1151
- }
1152
- });
1153
-
1154
- test('e2e: ABS-path grep under fixture root → deny fires, CLI argv gets relative path', () => {
1155
- const uniq = `StubAbs${Date.now()}`;
1156
- const fixture = e2eFixture(
1157
- `process.stdout.write('args=' + JSON.stringify(process.argv.slice(2)) + '\\n');`);
1158
- // fs.realpathSync: on macOS/Linux tmpdir may be a symlink; the hook sees the
1159
- // resolved cwd, so build the command from the same resolved form.
1160
- const realDir = fsE2e.realpathSync(fixture.dir);
1161
- const cmd = `grep -rn "${uniq}" ${realDir}/src/storage/`;
1162
- try {
1163
- const res = runHook(cmd, fixture);
1164
- assert.equal(res.status, 0);
1165
- const out = JSON.parse(res.stdout);
1166
- assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1167
- assert.match(out.hookSpecificOutput.permissionDecisionReason,
1168
- /args=\["grep","StubAbs\d+","src\/storage\/"\]/);
1169
- } finally {
1170
- cleanupFixture(fixture, cmd);
1171
- }
1172
- });
1173
-
1174
- test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would hit', () => {
1175
- const uniq = `StubOptout${Date.now()}`;
1176
- const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
1177
- const cmd = `grep -rn "${uniq}" src/`;
1178
- try {
1179
- const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
1180
- cwd: fixture.dir,
1181
- input: JSON.stringify({ tool_input: { command: cmd } }),
1182
- encoding: 'utf8',
1183
- env: {
1184
- ...process.env,
1185
- _CG_ANSWER_BINARY: fixture.stub,
1186
- CODE_GRAPH_QUIET_HOOKS: '0',
1187
- CODE_GRAPH_NO_BLOCK_GREP: '0',
1188
- CODE_GRAPH_NO_ANSWER_IN_DENY: '1',
1189
- },
1190
- });
1191
- const out = JSON.parse(res.stdout);
1192
- assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1193
- assert.doesNotMatch(out.hookSpecificOutput.permissionDecisionReason, /src\/foo\.rs:7/);
1194
- } finally {
1195
- cleanupFixture(fixture, cmd);
1196
- }
1197
- });
1198
-
1199
- test('e2e: compound `grep …; sed` → deny answers grep AND flags the unanswered sed tail', () => {
1200
- const uniq = `StubTail${Date.now()}`;
1201
- const fixture = e2eFixture(
1202
- `process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
1203
- const cmd = `grep -n "${uniq}" src/foo.rs | head -20; sed -n '100,160p' src/foo.rs`;
1204
- try {
1205
- fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
1206
- fsE2e.writeFileSync(pathE2e.join(fixture.dir, 'src', 'foo.rs'), 'fn x() {}\n');
1207
- const res = runHook(cmd, fixture);
1208
- assert.equal(res.status, 0);
1209
- const out = JSON.parse(res.stdout);
1210
- assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1211
- const reason = out.hookSpecificOutput.permissionDecisionReason;
1212
- assert.match(reason, /src\/foo\.rs:7/); // grep half answered
1213
- assert.match(reason, /did NOT run/); // tail flagged honestly
1214
- assert.match(reason, /sed -n '100,160p' src\/foo\.rs/); // verbatim re-issue line
1215
- // funnel: the deny record marks that a tail note was carried
1216
- const recs = fsE2e.readFileSync(
1217
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
1218
- const rec = JSON.parse(recs.trim().split('\n').pop());
1219
- assert.equal(rec.action, 'deny');
1220
- assert.equal(rec.tail, true);
1221
- } finally {
1222
- cleanupFixture(fixture, cmd);
1223
- }
1224
- });
1225
-
1226
- test('e2e: compound cmd + answer failure → grep ALLOWED, whole command runs intact (no half-run tail drop)', () => {
1227
- // v0.92 — the marquee fix: when cg can't answer, a static deny used to block
1228
- // the grep AND drop the `&& cargo test` tail, leaving the model with nothing +
1229
- // a re-issue chore (ubuntu-sec: the `grep "def render" …; python3 …` case).
1230
- // Now the whole compound command is ALLOWED to run intact — no deny, no tail
1231
- // note, no half-run. Recorded as a hint/fallthrough so the funnel still sees it.
1232
- const uniq = `StubTailBoom${Date.now()}`;
1233
- const fixture = e2eFixture(`process.exit(3);`);
1234
- const cmd = `grep -n "${uniq}" src/foo.rs && cargo test -q`;
1235
- try {
1236
- fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
1237
- fsE2e.writeFileSync(pathE2e.join(fixture.dir, 'src', 'foo.rs'), 'fn x() {}\n');
1238
- const res = runHook(cmd, fixture);
1239
- assert.equal(res.status, 0);
1240
- // No deny JSON — the whole compound command proceeds, tail included.
1241
- assert.throws(() => JSON.parse(res.stdout));
1242
- assert.match(res.stdout, /unavailable \(ran but failed\)/);
1243
- const rec = JSON.parse(fsE2e.readFileSync(
1244
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
1245
- assert.equal(rec.action, 'hint');
1246
- assert.equal(rec.fallthrough, 'unavailable');
1247
- assert.equal(rec.tail, undefined); // nothing dropped → no tail flag
1248
- } finally {
1249
- cleanupFixture(fixture, cmd);
1250
- }
1251
- });
1252
-
1253
- test('e2e: simple (non-compound) denied grep → no tail field in the deny record', () => {
1254
- const uniq = `StubNoTail${Date.now()}`;
1255
- const fixture = e2eFixture(
1256
- `process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
1257
- const cmd = `grep -rn "${uniq}" src/`;
1258
- try {
1259
- const res = runHook(cmd, fixture);
1260
- const out = JSON.parse(res.stdout);
1261
- assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1262
- const rec = JSON.parse(fsE2e.readFileSync(
1263
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
1264
- assert.equal(rec.action, 'deny');
1265
- assert.equal('tail' in rec, false);
1266
- } finally {
1267
- cleanupFixture(fixture, cmd);
1268
- }
1269
- });
1270
-
1271
- test('e2e: missing binary → grep ALLOWED (no static deny) records fallthrough:no-binary (distinct from no-hits & unavailable)', () => {
1272
- // v0.92 — when the binary can't be found the hook can't answer AND denying
1273
- // would block the user's only search tool, so it ALLOWS the raw grep. The
1274
- // `fallthrough` field still keeps a missing-binary case distinguishable in the
1275
- // funnel from no-hits / runtime-unavailable. We can't make findBinary() return
1276
- // null in-repo (dev target/release is always there), so run the child with a
1277
- // `--require` shim that forces it null — and DON'T set _CG_ANSWER_BINARY (it
1278
- // would short-circuit before findBinary()).
1279
- const uniq = `StubGone${Date.now()}`;
1280
- const fixture = e2eFixture(`process.stdout.write('unused\\n');`);
1281
- const shim = pathE2e.join(fixture.dir, 'no-binary-shim.js');
1282
- fsE2e.writeFileSync(shim, `
1283
- const Module = require('module');
1284
- const orig = Module.prototype.require;
1285
- Module.prototype.require = function (id) {
1286
- const m = orig.apply(this, arguments);
1287
- if (id === './find-binary') {
1288
- return new Proxy(m, { get(t, p) { return p === 'findBinary' ? () => null : t[p]; } });
1289
- }
1290
- return m;
1291
- };
1292
- `);
1293
- const cmd = `grep -rn "${uniq}" src/`;
1294
- try {
1295
- const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
1296
- cwd: fixture.dir,
1297
- input: JSON.stringify({ tool_input: { command: cmd } }),
1298
- encoding: 'utf8',
1299
- env: {
1300
- ...process.env,
1301
- // _CG_ANSWER_BINARY intentionally UNSET so the shimmed findBinary() runs.
1302
- _CG_ANSWER_BINARY: '',
1303
- NODE_OPTIONS: `--require ${shim}`,
1304
- CODE_GRAPH_QUIET_HOOKS: '0',
1305
- CODE_GRAPH_NO_BLOCK_GREP: '0',
1306
- CODE_GRAPH_NO_ANSWER_IN_DENY: '0',
1307
- },
1308
- });
1309
- assert.equal(res.status, 0);
1310
- // No deny JSON — the raw grep proceeds; FYI names the missing-binary cause.
1311
- assert.throws(() => JSON.parse(res.stdout));
1312
- assert.match(res.stdout, /unavailable \(binary not found\)/);
1313
- const rec = JSON.parse(fsE2e.readFileSync(
1314
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
1315
- assert.equal(rec.action, 'hint');
1316
- assert.equal(rec.fallthrough, 'no-binary',
1317
- 'a missing-binary fallthrough must be distinguishable from an unavailable (runtime-fail) one');
1318
- } finally {
1319
- cleanupFixture(fixture, cmd);
1320
- }
1321
- });
1322
-
1323
- // ── v0.48 subdir-cwd dark fix: resolveProjectRoot / rebaseRelativePaths ──
1324
- // daagu 2026-06-11: the persistent shell `cd backend/` darkened 38/40
1325
- // head-greps for the rest of the night — gate 5 checked process.cwd() only.
1326
-
1327
- const { sanitizeSearchPath } = require('./cg-answer');
1328
-
1329
- test('resolveProjectRoot: index at start dir', () => {
1330
- const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
1331
- try {
1332
- fsE2e.mkdirSync(pathE2e.join(base, 'proj', '.code-graph'), { recursive: true });
1333
- fsE2e.writeFileSync(pathE2e.join(base, 'proj', '.code-graph', 'index.db'), '');
1334
- assert.equal(
1335
- resolveProjectRoot(pathE2e.join(base, 'proj'), { home: base }),
1336
- pathE2e.join(base, 'proj'));
1337
- } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
1338
- });
1339
-
1340
- test('resolveProjectRoot: walks up from nested subdir to the indexed root', () => {
1341
- const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
1342
- try {
1343
- const proj = pathE2e.join(base, 'proj');
1344
- fsE2e.mkdirSync(pathE2e.join(proj, '.code-graph'), { recursive: true });
1345
- fsE2e.writeFileSync(pathE2e.join(proj, '.code-graph', 'index.db'), '');
1346
- const deep = pathE2e.join(proj, 'backend', 'app', 'services');
1347
- fsE2e.mkdirSync(deep, { recursive: true });
1348
- assert.equal(resolveProjectRoot(deep, { home: base }), proj);
1349
- } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
1350
- });
1351
-
1352
- test('resolveProjectRoot: no index up to $HOME → null (home itself still checked)', () => {
1353
- const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
1354
- try {
1355
- const deep = pathE2e.join(base, 'somewhere', 'deep');
1356
- fsE2e.mkdirSync(deep, { recursive: true });
1357
- assert.equal(resolveProjectRoot(deep, { home: base }), null);
1358
- // home itself holding an index is honored
1359
- fsE2e.mkdirSync(pathE2e.join(base, '.code-graph'), { recursive: true });
1360
- fsE2e.writeFileSync(pathE2e.join(base, '.code-graph', 'index.db'), '');
1361
- assert.equal(resolveProjectRoot(deep, { home: base }), base);
1362
- } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
1363
- });
1364
-
1365
- test('resolveProjectRoot: skips a STRAY nested subdir index, prefers the .git root', () => {
1366
- // monorepo (daagu shape): root has .git + index; a subdir carries a stray
1367
- // index relic but no .git. Resolving from the subdir must climb to the root,
1368
- // not pin the stray nested index (the statusline "oscillation" root cause).
1369
- const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
1370
- try {
1371
- const proj = pathE2e.join(base, 'proj');
1372
- fsE2e.mkdirSync(pathE2e.join(proj, '.git'), { recursive: true });
1373
- fsE2e.mkdirSync(pathE2e.join(proj, '.code-graph'), { recursive: true });
1374
- fsE2e.writeFileSync(pathE2e.join(proj, '.code-graph', 'index.db'), '');
1375
- const sub = pathE2e.join(proj, 'backend');
1376
- fsE2e.mkdirSync(pathE2e.join(sub, '.code-graph'), { recursive: true });
1377
- fsE2e.writeFileSync(pathE2e.join(sub, '.code-graph', 'index.db'), '');
1378
- assert.equal(resolveProjectRoot(sub, { home: base }), proj);
1379
- } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
1380
- });
1381
-
1382
- test('resolveProjectRoot: a nested index with its OWN .git (submodule) still wins', () => {
1383
- const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
1384
- try {
1385
- const proj = pathE2e.join(base, 'proj');
1386
- fsE2e.mkdirSync(pathE2e.join(proj, '.git'), { recursive: true });
1387
- fsE2e.mkdirSync(pathE2e.join(proj, '.code-graph'), { recursive: true });
1388
- fsE2e.writeFileSync(pathE2e.join(proj, '.code-graph', 'index.db'), '');
1389
- const sub = pathE2e.join(proj, 'vendored');
1390
- fsE2e.mkdirSync(pathE2e.join(sub, '.git'), { recursive: true });
1391
- fsE2e.mkdirSync(pathE2e.join(sub, '.code-graph'), { recursive: true });
1392
- fsE2e.writeFileSync(pathE2e.join(sub, '.code-graph', 'index.db'), '');
1393
- assert.equal(resolveProjectRoot(sub, { home: base }), sub);
1394
- } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
1395
- });
1396
-
1397
- test('resolveProjectRoot: start with its OWN .git but no index → null (boundary, no escape)', () => {
1398
- const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
1399
- try {
1400
- const proj = pathE2e.join(base, 'proj'); // indexed parent
1401
- fsE2e.mkdirSync(pathE2e.join(proj, '.code-graph'), { recursive: true });
1402
- fsE2e.writeFileSync(pathE2e.join(proj, '.code-graph', 'index.db'), '');
1403
- const sub = pathE2e.join(proj, 'sub'); // own .git, no index
1404
- fsE2e.mkdirSync(pathE2e.join(sub, '.git'), { recursive: true });
1405
- assert.equal(resolveProjectRoot(sub, { home: base }), null);
1406
- } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
1407
- });
1408
-
1409
- test('resolveProjectRoot: non-git monorepo — stray subdir index resolves to indexed ancestor', () => {
1410
- const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
1411
- try {
1412
- const root = pathE2e.join(base, 'mono'); // indexed, NO .git
1413
- fsE2e.mkdirSync(pathE2e.join(root, '.code-graph'), { recursive: true });
1414
- fsE2e.writeFileSync(pathE2e.join(root, '.code-graph', 'index.db'), '');
1415
- const sub = pathE2e.join(root, 'backend'); // stray index, no .git
1416
- fsE2e.mkdirSync(pathE2e.join(sub, '.code-graph'), { recursive: true });
1417
- fsE2e.writeFileSync(pathE2e.join(sub, '.code-graph', 'index.db'), '');
1418
- assert.equal(resolveProjectRoot(sub, { home: base }), root);
1419
- } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
1420
- });
1421
-
1422
- test('rebaseRelativePaths: daagu shape — bare `app` from backend/ cwd', () => {
1423
- const exists = (p) => p.endsWith(pathE2e.join('backend', 'app'));
1424
- const cmd = 'grep -rn "rr_source\\|max_retries" app --include=*.py';
1425
- const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
1426
- assert.equal(rebased, 'grep -rn "rr_source\\|max_retries" backend/app --include=*.py');
1427
- assert.equal(shouldHint(rebased), true);
1428
- assert.equal(extractSearchPath(rebased), 'backend/app');
1429
- });
1430
-
1431
- test('rebaseRelativePaths: deep relPrefix, multiple file args', () => {
1432
- const exists = (p) => p.endsWith('.py');
1433
- const rel = 'backend/app/services/scheduler/tasks';
1434
- const cmd = 'grep -n "except Exception" asr_preload.py xuanlun_pro_scan.py';
1435
- const rebased = rebaseRelativePaths(cmd, rel, '/proj', exists);
1436
- assert.match(rebased, /backend\/app\/services\/scheduler\/tasks\/asr_preload\.py/);
1437
- assert.match(rebased, /backend\/app\/services\/scheduler\/tasks\/xuanlun_pro_scan\.py/);
1438
- assert.equal(shouldHint(rebased), true);
1439
- });
1440
-
1441
- test('rebaseRelativePaths: quoted patterns are never rebased even if a same-named path exists', () => {
1442
- const exists = () => true; // adversarial: everything "exists"
1443
- const cmd = 'grep -rn "retry" app';
1444
- const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
1445
- assert.equal(rebased, 'grep -rn "retry" backend/app');
1446
- });
1447
-
1448
- test('rebaseRelativePaths: flags, absolute, traversal, operators untouched', () => {
1449
- const exists = () => true;
1450
- const cmd = 'grep -rn "X" /etc/hosts ../up --include=*.py 2>/dev/null';
1451
- assert.equal(rebaseRelativePaths(cmd, 'backend', '/proj', exists), cmd);
1452
- });
1453
-
1454
- test('rebaseRelativePaths: non-source relPrefix (docs/) → unchanged', () => {
1455
- const exists = () => true;
1456
- const cmd = 'grep -rn "X" app';
1457
- assert.equal(rebaseRelativePaths(cmd, 'docs', '/proj', exists), cmd);
1458
- });
1459
-
1460
- test('rebaseRelativePaths: unquoted pattern word does not exist → untouched', () => {
1461
- const exists = (p) => p.endsWith('/backend/app');
1462
- const cmd = 'grep -rn retry_count app';
1463
- const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
1464
- assert.equal(rebased, 'grep -rn retry_count backend/app');
1465
- });
1466
-
1467
- // ── v0.48 bypass visibility: GREP_HEAD bare-prefix + commandHasBypass ──
1468
-
1469
- test('shouldHint: bare KEY=VALUE prefixed grep now matches GREP_HEAD', () => {
1470
- assert.equal(shouldHint('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "fts5_search" src/'), true);
1471
- });
1472
-
1473
- test('extractPatterns: bare KEY=VALUE prefix stripped with the verb', () => {
1474
- assert.deepEqual(
1475
- extractPatterns('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "split_identifier" src/'),
1476
- ['split_identifier']);
1477
- });
1478
-
1479
- test('commandHasBypass: =1 prefix detected, other values / absence are not', () => {
1480
- assert.equal(commandHasBypass('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "X" src/'), true);
1481
- assert.equal(commandHasBypass('FOO=1 CODE_GRAPH_NO_BLOCK_GREP=1 grep "X" src/'), true);
1482
- assert.equal(commandHasBypass('CODE_GRAPH_NO_BLOCK_GREP=0 grep -rn "X" src/'), false);
1483
- assert.equal(commandHasBypass('grep -rn "CODE_GRAPH_NO_BLOCK_GREP=1" src/'), false);
1484
- assert.equal(commandHasBypass('grep -rn "X" src/'), false);
1485
- });
1486
-
1487
- // ── v0.48 replay: the exact command behind the night's only deny ──
1488
- // (answered:false — glob path reached rg literally and exited 1)
1489
-
1490
- test('replay: daagu denied glob command → block + sanitized search path', () => {
1491
- const cmd = 'grep -rn "async def chat\\|def chat\\|retry\\|rate.limit\\|rate-limit\\|RateLimit\\|429\\|max_retries\\|backoff\\|fallback_model\\|temporarily" backend/app/services/llm_engine/*.py | head -40';
1492
- assert.equal(shouldHint(cmd), true);
1493
- assert.equal(shouldBlock(cmd), true);
1494
- const raw = extractSearchPath(cmd);
1495
- assert.equal(raw, 'backend/app/services/llm_engine/*.py');
1496
- assert.equal(sanitizeSearchPath(raw), 'backend/app/services/llm_engine');
1497
- });
1498
-
1499
- // ── v0.48 e2e: hook process spawned exactly as CC does ──
1500
-
1501
- test('e2e: subdir cwd — hook resolves root, rebases path, records at root', () => {
1502
- const uniq = `sub_dir_fix_${Date.now()}`;
1503
- const fixture = e2eFixture(
1504
- `process.stdout.write('backend/app/x.py:1 fn hit()\\n');`);
1505
- const cmd = `grep -rn "${uniq}\\|max_retries" app`;
1506
- try {
1507
- fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'backend', 'app'), { recursive: true });
1508
- const res = runHook(cmd, fixture, pathE2e.join(fixture.dir, 'backend'));
1509
- const out = JSON.parse(res.stdout);
1510
- assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1511
- const recs = fsE2e.readFileSync(
1512
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
1513
- assert.match(recs, /"action":"deny"/);
1514
- // never creates .code-graph in the subdir
1515
- assert.equal(fsE2e.existsSync(pathE2e.join(fixture.dir, 'backend', '.code-graph')), false);
1516
- } finally {
1517
- cleanupFixture(fixture, cmd);
1518
- }
1519
- });
1520
-
1521
- test('e2e: bypassed grep is silent but recorded as action:bypass', () => {
1522
- const uniq = `bypass_vis_${Date.now()}`;
1523
- const fixture = e2eFixture(`process.stdout.write('never called\\n');`);
1524
- const cmd = `CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "${uniq}\\|fts5_search" src/`;
1525
- try {
1526
- fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
1527
- const res = runHook(cmd, fixture);
1528
- assert.equal(res.stdout, '');
1529
- const recs = fsE2e.readFileSync(
1530
- pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
1531
- assert.match(recs, /"action":"bypass"/);
1532
- } finally {
1533
- cleanupFixture(fixture, cmd);
1534
- }
1535
- });
1536
-
1537
- test('e2e: glob path arg → answer runs against the glob-truncated dir', () => {
1538
- const uniq = `GlobTrunc${Date.now()}`;
1539
- const fixture = e2eFixture(
1540
- `process.stdout.write('args=' + JSON.stringify(process.argv.slice(2)) + '\\n');`);
1541
- const cmd = `grep -rn "${uniq}" src/storage/*.rs`;
1542
- try {
1543
- fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src', 'storage'), { recursive: true });
1544
- const res = runHook(cmd, fixture);
1545
- const out = JSON.parse(res.stdout);
1546
- assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1547
- assert.match(out.hookSpecificOutput.permissionDecisionReason,
1548
- new RegExp(`args=\\["grep","${uniq}","src/storage"\\]`));
1549
- } finally {
1550
- cleanupFixture(fixture, cmd);
1551
- }
1552
- });
1553
-
1554
- test('rebaseRelativePaths: glob token rebases when its glob-truncated dir exists', () => {
1555
- // daagu shape: shell in backend/, command scopes a glob under it. Without
1556
- // the truncated probe the token stayed subdir-relative while the answer ran
1557
- // from the root → rg ENOENT → answered:false (the original night bug).
1558
- const exists = (p) => p.endsWith(pathE2e.join('backend', 'app', 'services', 'llm_engine'));
1559
- const cmd = 'grep -rn "def chat\\|max_retries" app/services/llm_engine/*.py';
1560
- const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
1561
- assert.equal(
1562
- extractSearchPath(rebased), 'backend/app/services/llm_engine/*.py');
1563
- assert.equal(
1564
- sanitizeSearchPath(extractSearchPath(rebased)), 'backend/app/services/llm_engine');
1565
- });
1566
-
1567
- // ── extractSedReadTargets (v0.49) — sed-range reads feed read-fanout ──
1568
-
1569
- test('extractSedReadTargets: plain and quoted ranges, abs and rel paths', () => {
1570
- assert.deepEqual(
1571
- extractSedReadTargets('sed -n 620,700p /abs/proj/backend/app/services/market.py'),
1572
- ['/abs/proj/backend/app/services/market.py']);
1573
- assert.deepEqual(
1574
- extractSedReadTargets("sed -n '230,310p' backend/app/services/tushare.py"),
1575
- ['backend/app/services/tushare.py']);
1576
- });
1577
-
1578
- test('extractSedReadTargets: multiple segments in one command, deduped', () => {
1579
- const cmd = 'sed -n 60,200p src/a.py; echo ===; sed -n 250,300p src/b.py && sed -n 250,300p src/b.py';
1580
- assert.deepEqual(extractSedReadTargets(cmd), ['src/a.py', 'src/b.py']);
1581
- });
1582
-
1583
- test('extractSedReadTargets: non-range sed (substitution) ignored', () => {
1584
- assert.deepEqual(extractSedReadTargets("sed -i 's/a/b/' src/a.py"), []);
1585
- assert.deepEqual(extractSedReadTargets('sed -n /pattern/p src/a.py'), []);
1586
- });
1587
-
1588
- test('extractSedReadTargets: pipeline sed after grep still extracted', () => {
1589
- assert.deepEqual(
1590
- extractSedReadTargets('grep -n "x" src/a.py | sed -n 1,5p src/b.py'),
1591
- ['src/b.py']);
1592
- });
1593
-
1594
- // ── splitTopLevelSegments (compound-grep PostToolUse §1) ─────────────
1595
- // Quote-aware top-level splitter shared by post-grep-inject. Splits on &&, ||,
1596
- // ;, newline, and for…in / do / done boundaries — NOT on a single `|` (so a
1597
- // pipe-into-grep keeps head=cargo and is recognized as an output filter).
1598
-
1599
- test('splitTopLevelSegments: && joins two commands → two segments', () => {
1600
- assert.deepEqual(
1601
- splitTopLevelSegments('echo "x" && grep Sym tests/'),
1602
- ['echo "x"', 'grep Sym tests/']);
1603
- });
1604
-
1605
- test('splitTopLevelSegments: ; and || are top-level separators', () => {
1606
- assert.deepEqual(
1607
- splitTopLevelSegments('git diff; grep Sym src/ || echo none'),
1608
- ['git diff', 'grep Sym src/', 'echo none']);
1609
- });
1610
-
1611
- test('splitTopLevelSegments: a single pipe is NOT a separator (output filter)', () => {
1612
- // cargo test | grep X must keep head=cargo so it reads as an output filter,
1613
- // NOT a foldable grep segment.
1614
- assert.deepEqual(
1615
- splitTopLevelSegments('cargo test | grep FAIL'),
1616
- ['cargo test | grep FAIL']);
1617
- });
1618
-
1619
- test('splitTopLevelSegments: for … in / do / done are segment boundaries', () => {
1620
- const segs = splitTopLevelSegments('for s in a b; do grep "$s" src/; done');
1621
- // the grep body is isolated as its own segment
1622
- assert.ok(segs.some(seg => /grep "\$s" src\//.test(seg)),
1623
- `grep body not isolated: ${JSON.stringify(segs)}`);
1624
- // the for-header / do / done keywords are not glued onto the grep
1625
- assert.ok(!segs.some(seg => /for s in/.test(seg) && /grep/.test(seg)),
1626
- `for-header glued to grep: ${JSON.stringify(segs)}`);
1627
- });
1628
-
1629
- test('splitTopLevelSegments: separators inside quotes are literal, not splits', () => {
1630
- assert.deepEqual(
1631
- splitTopLevelSegments('grep "a && b; c" src/'),
1632
- ['grep "a && b; c" src/']);
1633
- assert.deepEqual(
1634
- splitTopLevelSegments("grep 'x || y' src/"),
1635
- ["grep 'x || y' src/"]);
1636
- });
1637
-
1638
- test('splitTopLevelSegments: backslash-escaped quote inside double quotes does NOT close (no phantom segment)', () => {
1639
- // One literal echo arg — the \" must not close the quote, so && stays inside
1640
- // the string and no foldable `grep` segment is split out. (review L1)
1641
- assert.deepEqual(
1642
- splitTopLevelSegments('echo "x\\" && grep \\"Y\\" src/ rest"'),
1643
- ['echo "x\\" && grep \\"Y\\" src/ rest"']);
1644
- // Single quotes do NOT process backslashes (POSIX): a real separator after a
1645
- // closed single-quoted string still splits.
1646
- assert.deepEqual(
1647
- splitTopLevelSegments("echo 'a\\' && grep Sym src/"),
1648
- ["echo 'a\\'", 'grep Sym src/']);
1649
- });
1650
-
1651
- test('splitTopLevelSegments: newline is a separator', () => {
1652
- assert.deepEqual(
1653
- splitTopLevelSegments('echo hi\ngrep Sym src/'),
1654
- ['echo hi', 'grep Sym src/']);
1655
- });
1656
-
1657
- test('splitTopLevelSegments: empty / non-string → empty array', () => {
1658
- assert.deepEqual(splitTopLevelSegments(''), []);
1659
- assert.deepEqual(splitTopLevelSegments(null), []);
1660
- assert.deepEqual(splitTopLevelSegments(undefined), []);
1661
- });
1662
-
1663
- test('splitTopLevelSegments: trims and drops empty segments', () => {
1664
- assert.deepEqual(
1665
- splitTopLevelSegments(' echo a ;; grep Sym src/ '),
1666
- ['echo a', 'grep Sym src/']);
1667
- });
1668
-
1669
- // The dark-hint fallthrough (action:'hint' + stdout buildHint) was DELETED in
1670
- // the compound-grep change: a grep that passes shouldHint but not classifyBlock
1671
- // now exits silently from PreToolUse (PostToolUse handles only classifyBlock
1672
- // non-null cases). buildHint stays exported (referenced above) but is never
1673
- // emitted by the runMain hint tier.
1674
- test('source-text: PreToolUse no longer emits the dark stdout hint fallthrough', () => {
1675
- const fs = require('node:fs');
1676
- const path = require('node:path');
1677
- const src = fs.readFileSync(path.join(__dirname, 'pre-grep-guide.js'), 'utf8');
1678
- assert.doesNotMatch(src, /process\.stdout\.write\(buildHint\(\)/,
1679
- 'the dark hint stdout emission must be removed (PreToolUse exit-0 stdout is debug-log-only)');
1680
- assert.doesNotMatch(src, /action:\s*'hint'\s*\}\);\s*\n\s*process\.stdout\.write\(buildHint/,
1681
- 'the hint-tier recordRecommendation + buildHint pair must be removed');
1682
- });