@sdsrs/code-graph 0.47.1 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +7 -4
- package/claude-plugin/scripts/cg-answer.js +117 -2
- package/claude-plugin/scripts/cg-answer.test.js +72 -1
- package/claude-plugin/scripts/pre-edit-guide.js +21 -5
- package/claude-plugin/scripts/pre-grep-guide.js +255 -49
- package/claude-plugin/scripts/pre-grep-guide.test.js +295 -16
- package/claude-plugin/scripts/pre-read-guide.js +65 -25
- package/claude-plugin/scripts/pre-read-guide.test.js +59 -1
- package/claude-plugin/scripts/project-root.js +30 -0
- package/claude-plugin/templates/plugin_code_graph_mcp.md +5 -0
- package/package.json +6 -6
|
@@ -4,9 +4,17 @@ const assert = require('node:assert/strict');
|
|
|
4
4
|
const {
|
|
5
5
|
shouldHint,
|
|
6
6
|
shouldBlock,
|
|
7
|
+
classifyBlock,
|
|
8
|
+
extractDeclSymbols,
|
|
9
|
+
translateBreToRg,
|
|
10
|
+
buildShowDenyReason,
|
|
11
|
+
extractSedReadTargets,
|
|
7
12
|
extractPatterns,
|
|
8
13
|
extractSearchPath,
|
|
9
14
|
normalizeCommandPaths,
|
|
15
|
+
resolveProjectRoot,
|
|
16
|
+
rebaseRelativePaths,
|
|
17
|
+
commandHasBypass,
|
|
10
18
|
pickBlockPattern,
|
|
11
19
|
buildHint,
|
|
12
20
|
buildBlockReason,
|
|
@@ -314,30 +322,81 @@ test('shouldBlock: rg with CamelCase on lib/', () => {
|
|
|
314
322
|
|
|
315
323
|
// ── shouldBlock: should NOT block (downgrade to hint) — precision flags ─
|
|
316
324
|
|
|
317
|
-
test('shouldBlock: grep -l (files-with-matches) →
|
|
318
|
-
assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'),
|
|
325
|
+
test('shouldBlock: grep -l (files-with-matches) → deny, grep answer covers file lists (v0.49)', () => {
|
|
326
|
+
assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), true);
|
|
327
|
+
assert.deepEqual(classifyBlock('grep -rl "EmbeddingModel" src/'), { mode: 'grep' });
|
|
319
328
|
});
|
|
320
329
|
|
|
321
|
-
test('shouldBlock: --include=*.rs →
|
|
322
|
-
assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'),
|
|
330
|
+
test('shouldBlock: --include=*.rs → deny, path-scoped grep answer covers it (v0.49)', () => {
|
|
331
|
+
assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), true);
|
|
323
332
|
});
|
|
324
333
|
|
|
325
|
-
test('shouldBlock: --exclude
|
|
334
|
+
test('shouldBlock: --exclude=tests → hint only (answer cannot honor exclusion)', () => {
|
|
326
335
|
assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false);
|
|
327
336
|
});
|
|
328
337
|
|
|
329
|
-
test('shouldBlock: -
|
|
338
|
+
test('shouldBlock: -L / -v inverted intents → hint only', () => {
|
|
339
|
+
assert.equal(shouldBlock('grep -rL "EmbeddingModel" src/'), false);
|
|
340
|
+
assert.equal(shouldBlock('grep -rnv "EmbeddingModel" src/'), false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('shouldBlock: -A 3 with bare identifier → hint only (cannot honor ±N lines)', () => {
|
|
330
344
|
assert.equal(shouldBlock('grep -rn -A 3 "EmbeddingModel" src/'), false);
|
|
331
345
|
});
|
|
332
346
|
|
|
333
|
-
test('shouldBlock: -B 2
|
|
347
|
+
test('shouldBlock: -B 2 with bare identifier → hint only', () => {
|
|
334
348
|
assert.equal(shouldBlock('grep -rn -B 2 "EmbeddingModel" src/'), false);
|
|
335
349
|
});
|
|
336
350
|
|
|
337
|
-
test('shouldBlock: -C 5
|
|
351
|
+
test('shouldBlock: -C 5 with bare identifier → hint only', () => {
|
|
338
352
|
assert.equal(shouldBlock('grep -rn -C 5 "EmbeddingModel" src/'), false);
|
|
339
353
|
});
|
|
340
354
|
|
|
355
|
+
// ── translateBreToRg (v0.49) — BRE→rust-regex dialect bridge ─────────
|
|
356
|
+
|
|
357
|
+
test('translateBreToRg: plain grep BRE alternation unescaped', () => {
|
|
358
|
+
assert.equal(
|
|
359
|
+
translateBreToRg('grep -rn "UnifiedPickerEngine\\|engine.run" src/', 'UnifiedPickerEngine\\|engine.run'),
|
|
360
|
+
'UnifiedPickerEngine|engine.run');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('translateBreToRg: rg patterns untouched (already extended)', () => {
|
|
364
|
+
assert.equal(translateBreToRg('rg "a\\|b" src/', 'a\\|b'), 'a\\|b');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('translateBreToRg: grep -E untouched', () => {
|
|
368
|
+
assert.equal(translateBreToRg('grep -rnE "a\\|b" src/', 'a\\|b'), 'a\\|b');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('translateBreToRg: unescapes groups/braces/quantifiers for plain grep', () => {
|
|
372
|
+
assert.equal(translateBreToRg('grep "fn \\(x\\)\\+" src/', 'fn \\(x\\)\\+'), 'fn (x)+');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ── classifyBlock: show mode (v0.49) — the daagu 22/128 function-body reads ──
|
|
376
|
+
|
|
377
|
+
test('classifyBlock: declaration anchor + -A → show mode with symbols', () => {
|
|
378
|
+
assert.deepEqual(
|
|
379
|
+
classifyBlock('rg -n "def cascade_failure|def reset_task" -A 25 backend/app/'),
|
|
380
|
+
{ mode: 'show', symbols: ['cascade_failure', 'reset_task'] });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('classifyBlock: multi-decl alternation caps at 3 symbols', () => {
|
|
384
|
+
const c = classifyBlock('rg -n "def a_one|def b_two|class CThree|fn d_four" -A 10 src/');
|
|
385
|
+
assert.equal(c.mode, 'show');
|
|
386
|
+
assert.deepEqual(c.symbols, ['a_one', 'b_two', 'CThree']);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('classifyBlock: declaration anchor WITHOUT context flag → plain grep deny', () => {
|
|
390
|
+
assert.deepEqual(classifyBlock('grep -rn "def fetch_user" backend/app/services/'),
|
|
391
|
+
{ mode: 'grep' });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('extractDeclSymbols: dedupes and spans fn/def/class/struct anchors', () => {
|
|
395
|
+
assert.deepEqual(
|
|
396
|
+
extractDeclSymbols(['fn alpha_one', 'struct BetaTwo', 'fn alpha_one']),
|
|
397
|
+
['alpha_one', 'BetaTwo']);
|
|
398
|
+
});
|
|
399
|
+
|
|
341
400
|
// ── shouldBlock: should NOT block — marker-only patterns ────────────
|
|
342
401
|
|
|
343
402
|
test('shouldBlock: bare TODO marker → hint only (no cg equivalent)', () => {
|
|
@@ -399,8 +458,8 @@ test('buildBlockReason: lists cg grep + ast-search + callgraph', () => {
|
|
|
399
458
|
assert.match(out, /code-graph-mcp callgraph/);
|
|
400
459
|
});
|
|
401
460
|
|
|
402
|
-
test('buildBlockReason: documents the escape hatch
|
|
403
|
-
assert.
|
|
461
|
+
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)', () => {
|
|
462
|
+
assert.doesNotMatch(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP/);
|
|
404
463
|
});
|
|
405
464
|
|
|
406
465
|
test('buildBlockReason: under 700-byte budget (single CC message)', () => {
|
|
@@ -580,11 +639,11 @@ test('replay: real abs-path symbol grep → BLOCK after normalization', () => {
|
|
|
580
639
|
assert.equal(shouldBlock(norm), true);
|
|
581
640
|
});
|
|
582
641
|
|
|
583
|
-
test('replay: real abs-path -rln grep →
|
|
642
|
+
test('replay: real abs-path -rln grep → DENY after normalization (v0.49: file lists answerable)', () => {
|
|
584
643
|
const cmd = `grep -rln "load_active_config_standalone" ${DAAGU}/backend/tests/ | head -5`;
|
|
585
644
|
const norm = normalizeCommandPaths(cmd, DAAGU);
|
|
586
645
|
assert.equal(shouldHint(norm), true);
|
|
587
|
-
assert.
|
|
646
|
+
assert.deepEqual(classifyBlock(norm), { mode: 'grep' }); // grep answer lists files per hit
|
|
588
647
|
});
|
|
589
648
|
|
|
590
649
|
test('replay: abs-path config-only grep stays silent after normalization', () => {
|
|
@@ -651,17 +710,23 @@ test('pickBlockPattern: no identifier-like pattern → undefined', () => {
|
|
|
651
710
|
|
|
652
711
|
// ── v0.47.0 deny-with-answer: message builders + env gate ───────────
|
|
653
712
|
|
|
654
|
-
test('buildBlockReasonWithAnswer: embeds results
|
|
713
|
+
test('buildBlockReasonWithAnswer: embeds results and command', () => {
|
|
655
714
|
const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
|
|
656
715
|
status: 'hits', text: 'src/storage/db.rs:42 fn fts5_search()', truncated: false,
|
|
657
716
|
});
|
|
658
717
|
assert.match(reason, /already ran/);
|
|
659
718
|
assert.match(reason, /code-graph-mcp grep "fts5_search" src\/storage\//);
|
|
660
719
|
assert.match(reason, /src\/storage\/db\.rs:42/);
|
|
661
|
-
assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
|
|
662
720
|
assert.doesNotMatch(reason, /truncated/);
|
|
663
721
|
});
|
|
664
722
|
|
|
723
|
+
test('buildBlockReasonWithAnswer: NEVER advertises the bypass (v0.48 — one deny taught a 14-grep permanent prefix)', () => {
|
|
724
|
+
const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
|
|
725
|
+
status: 'hits', text: 'hit', truncated: false,
|
|
726
|
+
});
|
|
727
|
+
assert.doesNotMatch(reason, /CODE_GRAPH_NO_BLOCK_GREP/);
|
|
728
|
+
});
|
|
729
|
+
|
|
665
730
|
test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
|
|
666
731
|
const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
|
|
667
732
|
status: 'hits', text: 'hit', truncated: false,
|
|
@@ -706,9 +771,9 @@ function e2eFixture(stubBody) {
|
|
|
706
771
|
return { dir, stub };
|
|
707
772
|
}
|
|
708
773
|
|
|
709
|
-
function runHook(cmd, fixture) {
|
|
774
|
+
function runHook(cmd, fixture, cwdOverride) {
|
|
710
775
|
const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
|
|
711
|
-
cwd: fixture.dir,
|
|
776
|
+
cwd: cwdOverride || fixture.dir,
|
|
712
777
|
input: JSON.stringify({ tool_input: { command: cmd } }),
|
|
713
778
|
encoding: 'utf8',
|
|
714
779
|
env: {
|
|
@@ -837,3 +902,217 @@ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would h
|
|
|
837
902
|
cleanupFixture(fixture, cmd);
|
|
838
903
|
}
|
|
839
904
|
});
|
|
905
|
+
|
|
906
|
+
// ── v0.48 subdir-cwd dark fix: resolveProjectRoot / rebaseRelativePaths ──
|
|
907
|
+
// daagu 2026-06-11: the persistent shell `cd backend/` darkened 38/40
|
|
908
|
+
// head-greps for the rest of the night — gate 5 checked process.cwd() only.
|
|
909
|
+
|
|
910
|
+
const { sanitizeSearchPath } = require('./cg-answer');
|
|
911
|
+
|
|
912
|
+
test('resolveProjectRoot: index at start dir', () => {
|
|
913
|
+
const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
|
|
914
|
+
try {
|
|
915
|
+
fsE2e.mkdirSync(pathE2e.join(base, 'proj', '.code-graph'), { recursive: true });
|
|
916
|
+
fsE2e.writeFileSync(pathE2e.join(base, 'proj', '.code-graph', 'index.db'), '');
|
|
917
|
+
assert.equal(
|
|
918
|
+
resolveProjectRoot(pathE2e.join(base, 'proj'), { home: base }),
|
|
919
|
+
pathE2e.join(base, 'proj'));
|
|
920
|
+
} finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
test('resolveProjectRoot: walks up from nested subdir to the indexed root', () => {
|
|
924
|
+
const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
|
|
925
|
+
try {
|
|
926
|
+
const proj = pathE2e.join(base, 'proj');
|
|
927
|
+
fsE2e.mkdirSync(pathE2e.join(proj, '.code-graph'), { recursive: true });
|
|
928
|
+
fsE2e.writeFileSync(pathE2e.join(proj, '.code-graph', 'index.db'), '');
|
|
929
|
+
const deep = pathE2e.join(proj, 'backend', 'app', 'services');
|
|
930
|
+
fsE2e.mkdirSync(deep, { recursive: true });
|
|
931
|
+
assert.equal(resolveProjectRoot(deep, { home: base }), proj);
|
|
932
|
+
} finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test('resolveProjectRoot: no index up to $HOME → null (home itself still checked)', () => {
|
|
936
|
+
const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
|
|
937
|
+
try {
|
|
938
|
+
const deep = pathE2e.join(base, 'somewhere', 'deep');
|
|
939
|
+
fsE2e.mkdirSync(deep, { recursive: true });
|
|
940
|
+
assert.equal(resolveProjectRoot(deep, { home: base }), null);
|
|
941
|
+
// home itself holding an index is honored
|
|
942
|
+
fsE2e.mkdirSync(pathE2e.join(base, '.code-graph'), { recursive: true });
|
|
943
|
+
fsE2e.writeFileSync(pathE2e.join(base, '.code-graph', 'index.db'), '');
|
|
944
|
+
assert.equal(resolveProjectRoot(deep, { home: base }), base);
|
|
945
|
+
} finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
test('rebaseRelativePaths: daagu shape — bare `app` from backend/ cwd', () => {
|
|
949
|
+
const exists = (p) => p.endsWith(pathE2e.join('backend', 'app'));
|
|
950
|
+
const cmd = 'grep -rn "rr_source\\|max_retries" app --include=*.py';
|
|
951
|
+
const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
|
|
952
|
+
assert.equal(rebased, 'grep -rn "rr_source\\|max_retries" backend/app --include=*.py');
|
|
953
|
+
assert.equal(shouldHint(rebased), true);
|
|
954
|
+
assert.equal(extractSearchPath(rebased), 'backend/app');
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test('rebaseRelativePaths: deep relPrefix, multiple file args', () => {
|
|
958
|
+
const exists = (p) => p.endsWith('.py');
|
|
959
|
+
const rel = 'backend/app/services/scheduler/tasks';
|
|
960
|
+
const cmd = 'grep -n "except Exception" asr_preload.py xuanlun_pro_scan.py';
|
|
961
|
+
const rebased = rebaseRelativePaths(cmd, rel, '/proj', exists);
|
|
962
|
+
assert.match(rebased, /backend\/app\/services\/scheduler\/tasks\/asr_preload\.py/);
|
|
963
|
+
assert.match(rebased, /backend\/app\/services\/scheduler\/tasks\/xuanlun_pro_scan\.py/);
|
|
964
|
+
assert.equal(shouldHint(rebased), true);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
test('rebaseRelativePaths: quoted patterns are never rebased even if a same-named path exists', () => {
|
|
968
|
+
const exists = () => true; // adversarial: everything "exists"
|
|
969
|
+
const cmd = 'grep -rn "retry" app';
|
|
970
|
+
const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
|
|
971
|
+
assert.equal(rebased, 'grep -rn "retry" backend/app');
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test('rebaseRelativePaths: flags, absolute, traversal, operators untouched', () => {
|
|
975
|
+
const exists = () => true;
|
|
976
|
+
const cmd = 'grep -rn "X" /etc/hosts ../up --include=*.py 2>/dev/null';
|
|
977
|
+
assert.equal(rebaseRelativePaths(cmd, 'backend', '/proj', exists), cmd);
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test('rebaseRelativePaths: non-source relPrefix (docs/) → unchanged', () => {
|
|
981
|
+
const exists = () => true;
|
|
982
|
+
const cmd = 'grep -rn "X" app';
|
|
983
|
+
assert.equal(rebaseRelativePaths(cmd, 'docs', '/proj', exists), cmd);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test('rebaseRelativePaths: unquoted pattern word does not exist → untouched', () => {
|
|
987
|
+
const exists = (p) => p.endsWith('/backend/app');
|
|
988
|
+
const cmd = 'grep -rn retry_count app';
|
|
989
|
+
const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
|
|
990
|
+
assert.equal(rebased, 'grep -rn retry_count backend/app');
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
// ── v0.48 bypass visibility: GREP_HEAD bare-prefix + commandHasBypass ──
|
|
994
|
+
|
|
995
|
+
test('shouldHint: bare KEY=VALUE prefixed grep now matches GREP_HEAD', () => {
|
|
996
|
+
assert.equal(shouldHint('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "fts5_search" src/'), true);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
test('extractPatterns: bare KEY=VALUE prefix stripped with the verb', () => {
|
|
1000
|
+
assert.deepEqual(
|
|
1001
|
+
extractPatterns('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "split_identifier" src/'),
|
|
1002
|
+
['split_identifier']);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test('commandHasBypass: =1 prefix detected, other values / absence are not', () => {
|
|
1006
|
+
assert.equal(commandHasBypass('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "X" src/'), true);
|
|
1007
|
+
assert.equal(commandHasBypass('FOO=1 CODE_GRAPH_NO_BLOCK_GREP=1 grep "X" src/'), true);
|
|
1008
|
+
assert.equal(commandHasBypass('CODE_GRAPH_NO_BLOCK_GREP=0 grep -rn "X" src/'), false);
|
|
1009
|
+
assert.equal(commandHasBypass('grep -rn "CODE_GRAPH_NO_BLOCK_GREP=1" src/'), false);
|
|
1010
|
+
assert.equal(commandHasBypass('grep -rn "X" src/'), false);
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// ── v0.48 replay: the exact command behind the night's only deny ──
|
|
1014
|
+
// (answered:false — glob path reached rg literally and exited 1)
|
|
1015
|
+
|
|
1016
|
+
test('replay: daagu denied glob command → block + sanitized search path', () => {
|
|
1017
|
+
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';
|
|
1018
|
+
assert.equal(shouldHint(cmd), true);
|
|
1019
|
+
assert.equal(shouldBlock(cmd), true);
|
|
1020
|
+
const raw = extractSearchPath(cmd);
|
|
1021
|
+
assert.equal(raw, 'backend/app/services/llm_engine/*.py');
|
|
1022
|
+
assert.equal(sanitizeSearchPath(raw), 'backend/app/services/llm_engine');
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
// ── v0.48 e2e: hook process spawned exactly as CC does ──
|
|
1026
|
+
|
|
1027
|
+
test('e2e: subdir cwd — hook resolves root, rebases path, records at root', () => {
|
|
1028
|
+
const uniq = `sub_dir_fix_${Date.now()}`;
|
|
1029
|
+
const fixture = e2eFixture(
|
|
1030
|
+
`process.stdout.write('backend/app/x.py:1 fn hit()\\n');`);
|
|
1031
|
+
const cmd = `grep -rn "${uniq}\\|max_retries" app`;
|
|
1032
|
+
try {
|
|
1033
|
+
fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'backend', 'app'), { recursive: true });
|
|
1034
|
+
const res = runHook(cmd, fixture, pathE2e.join(fixture.dir, 'backend'));
|
|
1035
|
+
const out = JSON.parse(res.stdout);
|
|
1036
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
1037
|
+
const recs = fsE2e.readFileSync(
|
|
1038
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
|
|
1039
|
+
assert.match(recs, /"action":"deny"/);
|
|
1040
|
+
// never creates .code-graph in the subdir
|
|
1041
|
+
assert.equal(fsE2e.existsSync(pathE2e.join(fixture.dir, 'backend', '.code-graph')), false);
|
|
1042
|
+
} finally {
|
|
1043
|
+
cleanupFixture(fixture, cmd);
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
test('e2e: bypassed grep is silent but recorded as action:bypass', () => {
|
|
1048
|
+
const uniq = `bypass_vis_${Date.now()}`;
|
|
1049
|
+
const fixture = e2eFixture(`process.stdout.write('never called\\n');`);
|
|
1050
|
+
const cmd = `CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "${uniq}\\|fts5_search" src/`;
|
|
1051
|
+
try {
|
|
1052
|
+
fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
|
|
1053
|
+
const res = runHook(cmd, fixture);
|
|
1054
|
+
assert.equal(res.stdout, '');
|
|
1055
|
+
const recs = fsE2e.readFileSync(
|
|
1056
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
|
|
1057
|
+
assert.match(recs, /"action":"bypass"/);
|
|
1058
|
+
} finally {
|
|
1059
|
+
cleanupFixture(fixture, cmd);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
test('e2e: glob path arg → answer runs against the glob-truncated dir', () => {
|
|
1064
|
+
const uniq = `GlobTrunc${Date.now()}`;
|
|
1065
|
+
const fixture = e2eFixture(
|
|
1066
|
+
`process.stdout.write('args=' + JSON.stringify(process.argv.slice(2)) + '\\n');`);
|
|
1067
|
+
const cmd = `grep -rn "${uniq}" src/storage/*.rs`;
|
|
1068
|
+
try {
|
|
1069
|
+
fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src', 'storage'), { recursive: true });
|
|
1070
|
+
const res = runHook(cmd, fixture);
|
|
1071
|
+
const out = JSON.parse(res.stdout);
|
|
1072
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
1073
|
+
assert.match(out.hookSpecificOutput.permissionDecisionReason,
|
|
1074
|
+
new RegExp(`args=\\["grep","${uniq}","src/storage"\\]`));
|
|
1075
|
+
} finally {
|
|
1076
|
+
cleanupFixture(fixture, cmd);
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test('rebaseRelativePaths: glob token rebases when its glob-truncated dir exists', () => {
|
|
1081
|
+
// daagu shape: shell in backend/, command scopes a glob under it. Without
|
|
1082
|
+
// the truncated probe the token stayed subdir-relative while the answer ran
|
|
1083
|
+
// from the root → rg ENOENT → answered:false (the original night bug).
|
|
1084
|
+
const exists = (p) => p.endsWith(pathE2e.join('backend', 'app', 'services', 'llm_engine'));
|
|
1085
|
+
const cmd = 'grep -rn "def chat\\|max_retries" app/services/llm_engine/*.py';
|
|
1086
|
+
const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
|
|
1087
|
+
assert.equal(
|
|
1088
|
+
extractSearchPath(rebased), 'backend/app/services/llm_engine/*.py');
|
|
1089
|
+
assert.equal(
|
|
1090
|
+
sanitizeSearchPath(extractSearchPath(rebased)), 'backend/app/services/llm_engine');
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// ── extractSedReadTargets (v0.49) — sed-range reads feed read-fanout ──
|
|
1094
|
+
|
|
1095
|
+
test('extractSedReadTargets: plain and quoted ranges, abs and rel paths', () => {
|
|
1096
|
+
assert.deepEqual(
|
|
1097
|
+
extractSedReadTargets('sed -n 620,700p /abs/proj/backend/app/services/market.py'),
|
|
1098
|
+
['/abs/proj/backend/app/services/market.py']);
|
|
1099
|
+
assert.deepEqual(
|
|
1100
|
+
extractSedReadTargets("sed -n '230,310p' backend/app/services/tushare.py"),
|
|
1101
|
+
['backend/app/services/tushare.py']);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
test('extractSedReadTargets: multiple segments in one command, deduped', () => {
|
|
1105
|
+
const cmd = 'sed -n 60,200p src/a.py; echo ===; sed -n 250,300p src/b.py && sed -n 250,300p src/b.py';
|
|
1106
|
+
assert.deepEqual(extractSedReadTargets(cmd), ['src/a.py', 'src/b.py']);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test('extractSedReadTargets: non-range sed (substitution) ignored', () => {
|
|
1110
|
+
assert.deepEqual(extractSedReadTargets("sed -i 's/a/b/' src/a.py"), []);
|
|
1111
|
+
assert.deepEqual(extractSedReadTargets('sed -n /pattern/p src/a.py'), []);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
test('extractSedReadTargets: pipeline sed after grep still extracted', () => {
|
|
1115
|
+
assert.deepEqual(
|
|
1116
|
+
extractSedReadTargets('grep -n "x" src/a.py | sed -n 1,5p src/b.py'),
|
|
1117
|
+
['src/b.py']);
|
|
1118
|
+
});
|
|
@@ -27,6 +27,8 @@ const path = require('path');
|
|
|
27
27
|
const crypto = require('crypto');
|
|
28
28
|
const { cgTmpDir } = require('./tmp-dir');
|
|
29
29
|
const { recordRecommendation } = require('./recommendation-log');
|
|
30
|
+
const { resolveProjectRoot } = require('./project-root');
|
|
31
|
+
const { runOverviewAnswer } = require('./cg-answer');
|
|
30
32
|
|
|
31
33
|
// --- Configuration ---
|
|
32
34
|
|
|
@@ -119,50 +121,87 @@ function buildHint(dir) {
|
|
|
119
121
|
return `[code-graph] 5+ Reads into ${dir}/ — \`code-graph-mcp overview ${dir}/\` gives symbols+callers in one call (MCP: \`module_overview path=${dir}\`). Skip if you need raw file contents.`;
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
// v0.49 — the hint DELIVERS the overview instead of advising a tool call
|
|
125
|
+
// (advice measured 0/40 transfer on 2026-06-12; delivered answers satisfied
|
|
126
|
+
// 5/5 in place). Falls back to the advice-only line when the CLI is
|
|
127
|
+
// unavailable or the dir has no overview.
|
|
128
|
+
function buildHintWithAnswer(dir, answer) {
|
|
129
|
+
const lines = [
|
|
130
|
+
`[code-graph] 5+ Reads into ${dir}/ — module overview from the AST index (saves the remaining file-by-file reads):`,
|
|
131
|
+
answer.text,
|
|
132
|
+
];
|
|
133
|
+
if (answer.truncated) {
|
|
134
|
+
lines.push(`(truncated — \`code-graph-mcp overview ${dir}/\` for the full map)`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
function isSilenced(env = process.env) {
|
|
123
140
|
return env.CODE_GRAPH_QUIET_HOOKS === '1';
|
|
124
141
|
}
|
|
125
142
|
|
|
143
|
+
// v0.49 — answer tier opt-out, shared name with the grep hook's deny-answer
|
|
144
|
+
// opt-out: =1 restores advice-only hints (no CLI run inside the hook).
|
|
145
|
+
function isAnswerDisabled(env = process.env) {
|
|
146
|
+
return env.CODE_GRAPH_NO_ANSWER_IN_DENY === '1';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- Shared tracking core (also driven by pre-grep-guide's sed-range path) ---
|
|
150
|
+
|
|
151
|
+
/// Record one read of `rel` (project-root-relative source path) and fire the
|
|
152
|
+
/// fanout hint when the threshold crosses. Emits to stdout + records the
|
|
153
|
+
/// recommendation. Returns true when a hint fired.
|
|
154
|
+
function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
155
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
|
156
|
+
const dir = path.dirname(rel);
|
|
157
|
+
if (!dir || dir === '.' || dir === '') return false; // top-level file: not fanout
|
|
158
|
+
|
|
159
|
+
const state = loadState(root, now);
|
|
160
|
+
recordRead(state, dir, now);
|
|
161
|
+
let fired = false;
|
|
162
|
+
if (shouldHint(state, dir, now)) {
|
|
163
|
+
markHint(state, dir, now);
|
|
164
|
+
fired = true;
|
|
165
|
+
}
|
|
166
|
+
saveState(root, state);
|
|
167
|
+
if (!fired) return false;
|
|
168
|
+
|
|
169
|
+
let answer = { status: 'unavailable' };
|
|
170
|
+
if (!isAnswerDisabled()) {
|
|
171
|
+
answer = runOverviewAnswer({ cwd: root, dir });
|
|
172
|
+
}
|
|
173
|
+
const answered = answer.status === 'hits';
|
|
174
|
+
recordRecommendation(root, { hook: 'read', action: 'hint', answered });
|
|
175
|
+
process.stdout.write((answered ? buildHintWithAnswer(dir, answer) : buildHint(dir)) + '\n');
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
126
179
|
// --- Main execution ---
|
|
127
180
|
|
|
128
181
|
function runMain() {
|
|
129
182
|
if (isSilenced()) return;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
183
|
+
// v0.49 — walk up from the shell cwd (subdir-cwd fix; the read hook had
|
|
184
|
+
// recorded NOTHING in daagu history because sessions sat in backend/).
|
|
185
|
+
const root = resolveProjectRoot(process.cwd());
|
|
186
|
+
if (root === null) return;
|
|
133
187
|
|
|
134
188
|
let input;
|
|
135
189
|
try {
|
|
136
|
-
|
|
190
|
+
// fd 0, not '/dev/stdin': the path form fails ENXIO on socketpair stdin.
|
|
191
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
137
192
|
} catch { return; }
|
|
138
193
|
|
|
139
194
|
const filePath = (input.tool_input && input.tool_input.file_path) || '';
|
|
140
195
|
if (!isSourceFile(filePath)) return;
|
|
141
196
|
|
|
142
|
-
// Normalize to a
|
|
143
|
-
//
|
|
197
|
+
// Normalize to a root-relative path. Read sends absolute paths; files
|
|
198
|
+
// outside the project (other repos, ~/.claude/) stay silent.
|
|
144
199
|
let rel;
|
|
145
200
|
try {
|
|
146
|
-
rel = path.relative(
|
|
201
|
+
rel = path.relative(root, filePath);
|
|
147
202
|
} catch { return; }
|
|
148
|
-
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
|
|
149
|
-
|
|
150
|
-
const dir = path.dirname(rel);
|
|
151
|
-
if (!dir || dir === '.' || dir === '') return; // top-level file: not fanout
|
|
152
203
|
|
|
153
|
-
|
|
154
|
-
const state = loadState(cwd, now);
|
|
155
|
-
recordRead(state, dir, now);
|
|
156
|
-
let fired = false;
|
|
157
|
-
if (shouldHint(state, dir, now)) {
|
|
158
|
-
markHint(state, dir, now);
|
|
159
|
-
fired = true;
|
|
160
|
-
}
|
|
161
|
-
saveState(cwd, state);
|
|
162
|
-
if (fired) {
|
|
163
|
-
recordRecommendation(cwd, { hook: 'read', action: 'hint' });
|
|
164
|
-
process.stdout.write(buildHint(dir) + '\n');
|
|
165
|
-
}
|
|
204
|
+
trackReadAndMaybeHint(root, rel);
|
|
166
205
|
}
|
|
167
206
|
|
|
168
207
|
if (require.main === module) {
|
|
@@ -172,6 +211,7 @@ if (require.main === module) {
|
|
|
172
211
|
module.exports = {
|
|
173
212
|
isSourceFile, dirOf, cwdHash, statePath,
|
|
174
213
|
loadState, saveState, recordRead, shouldHint, markHint,
|
|
175
|
-
buildHint, isSilenced,
|
|
214
|
+
buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
|
|
215
|
+
trackReadAndMaybeHint, // v0.49 — shared with pre-grep-guide's sed-range path
|
|
176
216
|
FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS, SRC_EXT,
|
|
177
217
|
};
|
|
@@ -8,7 +8,8 @@ const crypto = require('crypto');
|
|
|
8
8
|
|
|
9
9
|
const {
|
|
10
10
|
isSourceFile, dirOf, recordRead, shouldHint, markHint,
|
|
11
|
-
buildHint, isSilenced,
|
|
11
|
+
buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
|
|
12
|
+
trackReadAndMaybeHint,
|
|
12
13
|
FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS,
|
|
13
14
|
loadState, saveState, statePath,
|
|
14
15
|
} = require('./pre-read-guide');
|
|
@@ -243,3 +244,60 @@ test('flow: 5 reads to same dir → hint, 6th read same dir → no hint (cooldow
|
|
|
243
244
|
recordRead(s, 'src/foo', 1005);
|
|
244
245
|
assert.equal(shouldHint(s, 'src/foo', 1005), false);
|
|
245
246
|
});
|
|
247
|
+
|
|
248
|
+
// ── v0.49: answer-in-hint + shared tracking core ─────────────────────
|
|
249
|
+
|
|
250
|
+
test('buildHintWithAnswer: embeds overview text and truncation pointer', () => {
|
|
251
|
+
const out = buildHintWithAnswer('src/storage', { text: 'Module src/storage\n conn (57 callers)', truncated: true });
|
|
252
|
+
assert.match(out, /^\[code-graph\] 5\+ Reads into src\/storage\//);
|
|
253
|
+
assert.match(out, /conn \(57 callers\)/);
|
|
254
|
+
assert.match(out, /truncated — `code-graph-mcp overview src\/storage\/`/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('isAnswerDisabled: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → advice-only hints', () => {
|
|
258
|
+
assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '1' }), true);
|
|
259
|
+
assert.equal(isAnswerDisabled({}), false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('trackReadAndMaybeHint: fires on 5th read with stubbed overview answer', () => {
|
|
263
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-track-'));
|
|
264
|
+
// Stub CLI: prints a fake overview (hook resolves binary via _CG_ANSWER_BINARY).
|
|
265
|
+
const stub = path.join(root, 'stub.js');
|
|
266
|
+
fs.writeFileSync(stub, '#!/usr/bin/env node\nprocess.stdout.write("Module overview stub: 3 symbols\\n");');
|
|
267
|
+
fs.chmodSync(stub, 0o755);
|
|
268
|
+
const oldEnv = process.env._CG_ANSWER_BINARY;
|
|
269
|
+
process.env._CG_ANSWER_BINARY = stub;
|
|
270
|
+
// .code-graph present so recordRecommendation appends.
|
|
271
|
+
fs.mkdirSync(path.join(root, '.code-graph'), { recursive: true });
|
|
272
|
+
|
|
273
|
+
const written = [];
|
|
274
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
275
|
+
process.stdout.write = (chunk) => { written.push(String(chunk)); return true; };
|
|
276
|
+
try {
|
|
277
|
+
let fired = false;
|
|
278
|
+
for (let i = 0; i < 5; i++) {
|
|
279
|
+
fired = trackReadAndMaybeHint(root, 'src/storage/file' + i + '.rs');
|
|
280
|
+
}
|
|
281
|
+
assert.equal(fired, true, '5th same-dir read must fire');
|
|
282
|
+
assert.match(written.join(''), /Module overview stub/, 'hint must EMBED the overview answer');
|
|
283
|
+
const recs = fs.readFileSync(path.join(root, '.code-graph', 'recommendations.jsonl'), 'utf8');
|
|
284
|
+
assert.match(recs, /"hook":"read"/);
|
|
285
|
+
assert.match(recs, /"answered":true/);
|
|
286
|
+
} finally {
|
|
287
|
+
process.stdout.write = origWrite;
|
|
288
|
+
if (oldEnv === undefined) delete process.env._CG_ANSWER_BINARY;
|
|
289
|
+
else process.env._CG_ANSWER_BINARY = oldEnv;
|
|
290
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('trackReadAndMaybeHint: top-level and outside-root paths never fire', () => {
|
|
295
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-skip-'));
|
|
296
|
+
try {
|
|
297
|
+
assert.equal(trackReadAndMaybeHint(root, 'main.rs'), false);
|
|
298
|
+
assert.equal(trackReadAndMaybeHint(root, '../other/file.rs'), false);
|
|
299
|
+
assert.equal(trackReadAndMaybeHint(root, '/abs/file.rs'), false);
|
|
300
|
+
} finally {
|
|
301
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
302
|
+
}
|
|
303
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Shared project-root resolution for the PreToolUse hooks (v0.49 — extracted
|
|
3
|
+
// from pre-grep-guide so pre-read-guide gets the same subdir-cwd fix without a
|
|
4
|
+
// circular require).
|
|
5
|
+
//
|
|
6
|
+
// The hook's process.cwd() follows the PERSISTENT shell, not the project root:
|
|
7
|
+
// after the model runs `cd backend/`, every per-cwd gate (index.db existence,
|
|
8
|
+
// relative-path matching) fails silently for the rest of the session (daagu
|
|
9
|
+
// 2026-06-11: 38/40 head-greps dark; the read hook never recorded AT ALL).
|
|
10
|
+
// Walk up to the nearest ancestor holding `.code-graph/index.db`; stop at
|
|
11
|
+
// $HOME (checked, not crossed) and fs root.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
function resolveProjectRoot(startDir, opts = {}) {
|
|
18
|
+
const home = opts.home !== undefined ? opts.home : os.homedir();
|
|
19
|
+
const exists = opts.exists || fs.existsSync;
|
|
20
|
+
let dir = path.resolve(startDir || '.');
|
|
21
|
+
for (;;) {
|
|
22
|
+
if (exists(path.join(dir, '.code-graph', 'index.db'))) return dir;
|
|
23
|
+
if (dir === home) return null;
|
|
24
|
+
const parent = path.dirname(dir);
|
|
25
|
+
if (parent === dir) return null;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { resolveProjectRoot };
|
|
@@ -38,6 +38,11 @@ type: reference
|
|
|
38
38
|
|
|
39
39
|
## 何时调用 MCP/CLI(替代多步 Grep/Read)
|
|
40
40
|
|
|
41
|
+
> **v0.49 起 CLI 优先**:Claude Code 里 MCP 工具是 deferred(首次调用前要
|
|
42
|
+
> ToolSearch 加载),而 Bash 永远在线——真实编程夜(2026-06-12)观测到的全部
|
|
43
|
+
> 转化都是 CLI 调用。结构化查询的最快路径是 Bash 直呼
|
|
44
|
+
> `code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`。
|
|
45
|
+
>
|
|
41
46
|
> v0.10.0 起:tools/list 默认只暴露 7 个核心工具;下表"进阶 5"中的工具
|
|
42
47
|
> 已从 tools/list 隐藏以节省 session 启动 tokens。**Claude Code 里请走 CLI
|
|
43
48
|
> 子命令**(MCP schema 不在 list,Claude Code 的 ToolSearch 不会加载,直接
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.49.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.49.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.49.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.49.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.49.0"
|
|
43
43
|
}
|
|
44
44
|
}
|