@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.
@@ -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) → hint only', () => {
318
- assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), false);
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 → user already filtering, hint only', () => {
322
- assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), false);
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-dir=tests → hint only', () => {
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: -A 3 context flag → hint only', () => {
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 context flag → hint only', () => {
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 context flag → hint only', () => {
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 env var', () => {
403
- assert.match(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP=1/);
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 → HINT only after normalization (precision flag)', () => {
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.equal(shouldBlock(norm), false); // -l cluster disqualifies block
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, command, and escape hatch', () => {
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
- const cwd = process.cwd();
131
- const dbPath = path.join(cwd, '.code-graph', 'index.db');
132
- if (!fs.existsSync(dbPath)) return;
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
- input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
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 cwd-relative path. If the file is outside cwd, skip —
143
- // a hint pointing at an unrelated dir helps no one.
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(cwd, filePath);
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
- const now = Date.now();
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.47.1",
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.47.1",
39
- "@sdsrs/code-graph-linux-arm64": "0.47.1",
40
- "@sdsrs/code-graph-darwin-x64": "0.47.1",
41
- "@sdsrs/code-graph-darwin-arm64": "0.47.1",
42
- "@sdsrs/code-graph-win32-x64": "0.47.1"
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
  }