@sdsrs/code-graph 0.48.0 → 0.50.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/auto-update.js +49 -7
- package/claude-plugin/scripts/auto-update.test.js +93 -1
- package/claude-plugin/scripts/cg-answer.js +105 -2
- package/claude-plugin/scripts/cg-answer.test.js +55 -2
- package/claude-plugin/scripts/doctor.js +24 -45
- package/claude-plugin/scripts/doctor.test.js +13 -0
- package/claude-plugin/scripts/lifecycle.js +88 -0
- package/claude-plugin/scripts/lifecycle.test.js +40 -1
- package/claude-plugin/scripts/pre-edit-guide.js +21 -5
- package/claude-plugin/scripts/pre-grep-guide.js +219 -61
- package/claude-plugin/scripts/pre-grep-guide.test.js +237 -19
- 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/scripts/session-init.js +38 -14
- package/claude-plugin/templates/plugin_code_graph_mcp.md +8 -0
- package/package.json +6 -6
|
@@ -4,6 +4,12 @@ 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,
|
|
12
|
+
extractUnansweredTail,
|
|
7
13
|
extractPatterns,
|
|
8
14
|
extractSearchPath,
|
|
9
15
|
normalizeCommandPaths,
|
|
@@ -317,30 +323,81 @@ test('shouldBlock: rg with CamelCase on lib/', () => {
|
|
|
317
323
|
|
|
318
324
|
// ── shouldBlock: should NOT block (downgrade to hint) — precision flags ─
|
|
319
325
|
|
|
320
|
-
test('shouldBlock: grep -l (files-with-matches) →
|
|
321
|
-
assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'),
|
|
326
|
+
test('shouldBlock: grep -l (files-with-matches) → deny, grep answer covers file lists (v0.49)', () => {
|
|
327
|
+
assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), true);
|
|
328
|
+
assert.deepEqual(classifyBlock('grep -rl "EmbeddingModel" src/'), { mode: 'grep' });
|
|
322
329
|
});
|
|
323
330
|
|
|
324
|
-
test('shouldBlock: --include=*.rs →
|
|
325
|
-
assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'),
|
|
331
|
+
test('shouldBlock: --include=*.rs → deny, path-scoped grep answer covers it (v0.49)', () => {
|
|
332
|
+
assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), true);
|
|
326
333
|
});
|
|
327
334
|
|
|
328
|
-
test('shouldBlock: --exclude
|
|
335
|
+
test('shouldBlock: --exclude=tests → hint only (answer cannot honor exclusion)', () => {
|
|
329
336
|
assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false);
|
|
330
337
|
});
|
|
331
338
|
|
|
332
|
-
test('shouldBlock: -
|
|
339
|
+
test('shouldBlock: -L / -v inverted intents → hint only', () => {
|
|
340
|
+
assert.equal(shouldBlock('grep -rL "EmbeddingModel" src/'), false);
|
|
341
|
+
assert.equal(shouldBlock('grep -rnv "EmbeddingModel" src/'), false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('shouldBlock: -A 3 with bare identifier → hint only (cannot honor ±N lines)', () => {
|
|
333
345
|
assert.equal(shouldBlock('grep -rn -A 3 "EmbeddingModel" src/'), false);
|
|
334
346
|
});
|
|
335
347
|
|
|
336
|
-
test('shouldBlock: -B 2
|
|
348
|
+
test('shouldBlock: -B 2 with bare identifier → hint only', () => {
|
|
337
349
|
assert.equal(shouldBlock('grep -rn -B 2 "EmbeddingModel" src/'), false);
|
|
338
350
|
});
|
|
339
351
|
|
|
340
|
-
test('shouldBlock: -C 5
|
|
352
|
+
test('shouldBlock: -C 5 with bare identifier → hint only', () => {
|
|
341
353
|
assert.equal(shouldBlock('grep -rn -C 5 "EmbeddingModel" src/'), false);
|
|
342
354
|
});
|
|
343
355
|
|
|
356
|
+
// ── translateBreToRg (v0.49) — BRE→rust-regex dialect bridge ─────────
|
|
357
|
+
|
|
358
|
+
test('translateBreToRg: plain grep BRE alternation unescaped', () => {
|
|
359
|
+
assert.equal(
|
|
360
|
+
translateBreToRg('grep -rn "UnifiedPickerEngine\\|engine.run" src/', 'UnifiedPickerEngine\\|engine.run'),
|
|
361
|
+
'UnifiedPickerEngine|engine.run');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('translateBreToRg: rg patterns untouched (already extended)', () => {
|
|
365
|
+
assert.equal(translateBreToRg('rg "a\\|b" src/', 'a\\|b'), 'a\\|b');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('translateBreToRg: grep -E untouched', () => {
|
|
369
|
+
assert.equal(translateBreToRg('grep -rnE "a\\|b" src/', 'a\\|b'), 'a\\|b');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('translateBreToRg: unescapes groups/braces/quantifiers for plain grep', () => {
|
|
373
|
+
assert.equal(translateBreToRg('grep "fn \\(x\\)\\+" src/', 'fn \\(x\\)\\+'), 'fn (x)+');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ── classifyBlock: show mode (v0.49) — the daagu 22/128 function-body reads ──
|
|
377
|
+
|
|
378
|
+
test('classifyBlock: declaration anchor + -A → show mode with symbols', () => {
|
|
379
|
+
assert.deepEqual(
|
|
380
|
+
classifyBlock('rg -n "def cascade_failure|def reset_task" -A 25 backend/app/'),
|
|
381
|
+
{ mode: 'show', symbols: ['cascade_failure', 'reset_task'] });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('classifyBlock: multi-decl alternation caps at 3 symbols', () => {
|
|
385
|
+
const c = classifyBlock('rg -n "def a_one|def b_two|class CThree|fn d_four" -A 10 src/');
|
|
386
|
+
assert.equal(c.mode, 'show');
|
|
387
|
+
assert.deepEqual(c.symbols, ['a_one', 'b_two', 'CThree']);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('classifyBlock: declaration anchor WITHOUT context flag → plain grep deny', () => {
|
|
391
|
+
assert.deepEqual(classifyBlock('grep -rn "def fetch_user" backend/app/services/'),
|
|
392
|
+
{ mode: 'grep' });
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('extractDeclSymbols: dedupes and spans fn/def/class/struct anchors', () => {
|
|
396
|
+
assert.deepEqual(
|
|
397
|
+
extractDeclSymbols(['fn alpha_one', 'struct BetaTwo', 'fn alpha_one']),
|
|
398
|
+
['alpha_one', 'BetaTwo']);
|
|
399
|
+
});
|
|
400
|
+
|
|
344
401
|
// ── shouldBlock: should NOT block — marker-only patterns ────────────
|
|
345
402
|
|
|
346
403
|
test('shouldBlock: bare TODO marker → hint only (no cg equivalent)', () => {
|
|
@@ -402,8 +459,8 @@ test('buildBlockReason: lists cg grep + ast-search + callgraph', () => {
|
|
|
402
459
|
assert.match(out, /code-graph-mcp callgraph/);
|
|
403
460
|
});
|
|
404
461
|
|
|
405
|
-
test('buildBlockReason: documents the escape hatch
|
|
406
|
-
assert.
|
|
462
|
+
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)', () => {
|
|
463
|
+
assert.doesNotMatch(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP/);
|
|
407
464
|
});
|
|
408
465
|
|
|
409
466
|
test('buildBlockReason: under 700-byte budget (single CC message)', () => {
|
|
@@ -583,11 +640,11 @@ test('replay: real abs-path symbol grep → BLOCK after normalization', () => {
|
|
|
583
640
|
assert.equal(shouldBlock(norm), true);
|
|
584
641
|
});
|
|
585
642
|
|
|
586
|
-
test('replay: real abs-path -rln grep →
|
|
643
|
+
test('replay: real abs-path -rln grep → DENY after normalization (v0.49: file lists answerable)', () => {
|
|
587
644
|
const cmd = `grep -rln "load_active_config_standalone" ${DAAGU}/backend/tests/ | head -5`;
|
|
588
645
|
const norm = normalizeCommandPaths(cmd, DAAGU);
|
|
589
646
|
assert.equal(shouldHint(norm), true);
|
|
590
|
-
assert.
|
|
647
|
+
assert.deepEqual(classifyBlock(norm), { mode: 'grep' }); // grep answer lists files per hit
|
|
591
648
|
});
|
|
592
649
|
|
|
593
650
|
test('replay: abs-path config-only grep stays silent after normalization', () => {
|
|
@@ -671,13 +728,6 @@ test('buildBlockReasonWithAnswer: NEVER advertises the bypass (v0.48 — one den
|
|
|
671
728
|
assert.doesNotMatch(reason, /CODE_GRAPH_NO_BLOCK_GREP/);
|
|
672
729
|
});
|
|
673
730
|
|
|
674
|
-
test('buildBlockReason: scopes the escape to THIS command only', () => {
|
|
675
|
-
const reason = buildBlockReason();
|
|
676
|
-
assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
|
|
677
|
-
assert.match(reason, /THIS command only/);
|
|
678
|
-
assert.match(reason, /not a default/);
|
|
679
|
-
});
|
|
680
|
-
|
|
681
731
|
test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
|
|
682
732
|
const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
|
|
683
733
|
status: 'hits', text: 'hit', truncated: false,
|
|
@@ -692,6 +742,78 @@ test('buildBlockReasonWithAnswer: truncated flag adds marker', () => {
|
|
|
692
742
|
assert.match(reason, /truncated/);
|
|
693
743
|
});
|
|
694
744
|
|
|
745
|
+
// ── v0.50 compound-command tail: deny answers the grep, NOT the rest ─
|
|
746
|
+
|
|
747
|
+
test('extractUnansweredTail: `; sed` tail after piped grep (2026-06-13 real deny shape)', () => {
|
|
748
|
+
assert.equal(
|
|
749
|
+
extractUnansweredTail(
|
|
750
|
+
'grep -n "mem_update\\|registerTool" tests/server.test.mjs | head -20; sed -n \'1,60p\' tests/server.test.mjs'),
|
|
751
|
+
"sed -n '1,60p' tests/server.test.mjs");
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test('extractUnansweredTail: && tail is unanswered (would have run on grep success)', () => {
|
|
755
|
+
assert.equal(
|
|
756
|
+
extractUnansweredTail('grep -rn "fts5_search" src/ && wc -l src/storage/db.rs'),
|
|
757
|
+
'wc -l src/storage/db.rs');
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test('extractUnansweredTail: quoted separators are pattern text, not a tail', () => {
|
|
761
|
+
assert.equal(extractUnansweredTail('grep -rn "a;b" src/'), null);
|
|
762
|
+
assert.equal(extractUnansweredTail("grep -rn 'a && b' src/"), null);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test('extractUnansweredTail: pipes and redirects are the same pipeline, not a tail', () => {
|
|
766
|
+
assert.equal(extractUnansweredTail('grep -rn "Foo" src/ 2>&1 | head -10'), null);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test('extractUnansweredTail: || branch would NOT have run on hits — no tail', () => {
|
|
770
|
+
assert.equal(extractUnansweredTail('grep -rn "Foo" src/ || echo none'), null);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test('extractUnansweredTail: trailing separator with nothing after → no tail', () => {
|
|
774
|
+
assert.equal(extractUnansweredTail('grep -rn "Foo" src/;'), null);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
test('buildBlockReasonWithAnswer: compound tail → note says the rest did NOT run', () => {
|
|
778
|
+
const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
|
|
779
|
+
status: 'hits', text: 'hit', truncated: false,
|
|
780
|
+
}, "sed -n '1,60p' tests/server.test.mjs");
|
|
781
|
+
assert.match(reason, /did NOT run/);
|
|
782
|
+
assert.match(reason, /sed -n '1,60p' tests\/server\.test\.mjs/);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test('buildBlockReasonWithAnswer: no tail → no compound note', () => {
|
|
786
|
+
const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
|
|
787
|
+
status: 'hits', text: 'hit', truncated: false,
|
|
788
|
+
});
|
|
789
|
+
assert.doesNotMatch(reason, /did NOT run/);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('buildShowDenyReason: compound tail → note says the rest did NOT run', () => {
|
|
793
|
+
const reason = buildShowDenyReason(
|
|
794
|
+
{ status: 'hits', text: 'fn body', truncated: false },
|
|
795
|
+
'cargo test -q');
|
|
796
|
+
assert.match(reason, /did NOT run/);
|
|
797
|
+
assert.match(reason, /cargo test -q/);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test('buildShowDenyReason: no tail → no compound note', () => {
|
|
801
|
+
const reason = buildShowDenyReason({ status: 'hits', text: 'fn body', truncated: false });
|
|
802
|
+
assert.doesNotMatch(reason, /did NOT run/);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test('buildBlockReason: compound tail → static deny also flags the unanswered tail', () => {
|
|
806
|
+
const reason = buildBlockReason("sed -n '1,60p' tests/server.test.mjs");
|
|
807
|
+
assert.match(reason, /did NOT run/);
|
|
808
|
+
assert.match(reason, /sed -n '1,60p' tests\/server\.test\.mjs/);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test('buildBlockReason: no tail → unchanged static deny', () => {
|
|
812
|
+
const reason = buildBlockReason();
|
|
813
|
+
assert.match(reason, /denied by code-graph hook/);
|
|
814
|
+
assert.doesNotMatch(reason, /did NOT run/);
|
|
815
|
+
});
|
|
816
|
+
|
|
695
817
|
test('buildNoHitsFyi: names the pattern and says raw grep proceeds', () => {
|
|
696
818
|
const fyi = buildNoHitsFyi('GhostSymbol');
|
|
697
819
|
assert.match(fyi, /GhostSymbol/);
|
|
@@ -854,6 +976,75 @@ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would h
|
|
|
854
976
|
}
|
|
855
977
|
});
|
|
856
978
|
|
|
979
|
+
test('e2e: compound `grep …; sed` → deny answers grep AND flags the unanswered sed tail', () => {
|
|
980
|
+
const uniq = `StubTail${Date.now()}`;
|
|
981
|
+
const fixture = e2eFixture(
|
|
982
|
+
`process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
|
|
983
|
+
const cmd = `grep -n "${uniq}" src/foo.rs | head -20; sed -n '100,160p' src/foo.rs`;
|
|
984
|
+
try {
|
|
985
|
+
fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
|
|
986
|
+
fsE2e.writeFileSync(pathE2e.join(fixture.dir, 'src', 'foo.rs'), 'fn x() {}\n');
|
|
987
|
+
const res = runHook(cmd, fixture);
|
|
988
|
+
assert.equal(res.status, 0);
|
|
989
|
+
const out = JSON.parse(res.stdout);
|
|
990
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
991
|
+
const reason = out.hookSpecificOutput.permissionDecisionReason;
|
|
992
|
+
assert.match(reason, /src\/foo\.rs:7/); // grep half answered
|
|
993
|
+
assert.match(reason, /did NOT run/); // tail flagged honestly
|
|
994
|
+
assert.match(reason, /sed -n '100,160p' src\/foo\.rs/); // verbatim re-issue line
|
|
995
|
+
// funnel: the deny record marks that a tail note was carried
|
|
996
|
+
const recs = fsE2e.readFileSync(
|
|
997
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
|
|
998
|
+
const rec = JSON.parse(recs.trim().split('\n').pop());
|
|
999
|
+
assert.equal(rec.action, 'deny');
|
|
1000
|
+
assert.equal(rec.tail, true);
|
|
1001
|
+
} finally {
|
|
1002
|
+
cleanupFixture(fixture, cmd);
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test('e2e: compound cmd + answer failure → STATIC deny still flags tail + records tail:true', () => {
|
|
1007
|
+
const uniq = `StubTailBoom${Date.now()}`;
|
|
1008
|
+
const fixture = e2eFixture(`process.exit(3);`);
|
|
1009
|
+
const cmd = `grep -n "${uniq}" src/foo.rs && cargo test -q`;
|
|
1010
|
+
try {
|
|
1011
|
+
fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
|
|
1012
|
+
fsE2e.writeFileSync(pathE2e.join(fixture.dir, 'src', 'foo.rs'), 'fn x() {}\n');
|
|
1013
|
+
const res = runHook(cmd, fixture);
|
|
1014
|
+
assert.equal(res.status, 0);
|
|
1015
|
+
const out = JSON.parse(res.stdout);
|
|
1016
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
1017
|
+
const reason = out.hookSpecificOutput.permissionDecisionReason;
|
|
1018
|
+
assert.match(reason, /denied by code-graph hook/); // static fallback path
|
|
1019
|
+
assert.match(reason, /did NOT run/);
|
|
1020
|
+
assert.match(reason, /cargo test -q/);
|
|
1021
|
+
const rec = JSON.parse(fsE2e.readFileSync(
|
|
1022
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
|
|
1023
|
+
assert.equal(rec.answered, false);
|
|
1024
|
+
assert.equal(rec.tail, true);
|
|
1025
|
+
} finally {
|
|
1026
|
+
cleanupFixture(fixture, cmd);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test('e2e: simple (non-compound) denied grep → no tail field in the deny record', () => {
|
|
1031
|
+
const uniq = `StubNoTail${Date.now()}`;
|
|
1032
|
+
const fixture = e2eFixture(
|
|
1033
|
+
`process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
|
|
1034
|
+
const cmd = `grep -rn "${uniq}" src/`;
|
|
1035
|
+
try {
|
|
1036
|
+
const res = runHook(cmd, fixture);
|
|
1037
|
+
const out = JSON.parse(res.stdout);
|
|
1038
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
1039
|
+
const rec = JSON.parse(fsE2e.readFileSync(
|
|
1040
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
|
|
1041
|
+
assert.equal(rec.action, 'deny');
|
|
1042
|
+
assert.equal('tail' in rec, false);
|
|
1043
|
+
} finally {
|
|
1044
|
+
cleanupFixture(fixture, cmd);
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
|
|
857
1048
|
// ── v0.48 subdir-cwd dark fix: resolveProjectRoot / rebaseRelativePaths ──
|
|
858
1049
|
// daagu 2026-06-11: the persistent shell `cd backend/` darkened 38/40
|
|
859
1050
|
// head-greps for the rest of the night — gate 5 checked process.cwd() only.
|
|
@@ -1040,3 +1231,30 @@ test('rebaseRelativePaths: glob token rebases when its glob-truncated dir exists
|
|
|
1040
1231
|
assert.equal(
|
|
1041
1232
|
sanitizeSearchPath(extractSearchPath(rebased)), 'backend/app/services/llm_engine');
|
|
1042
1233
|
});
|
|
1234
|
+
|
|
1235
|
+
// ── extractSedReadTargets (v0.49) — sed-range reads feed read-fanout ──
|
|
1236
|
+
|
|
1237
|
+
test('extractSedReadTargets: plain and quoted ranges, abs and rel paths', () => {
|
|
1238
|
+
assert.deepEqual(
|
|
1239
|
+
extractSedReadTargets('sed -n 620,700p /abs/proj/backend/app/services/market.py'),
|
|
1240
|
+
['/abs/proj/backend/app/services/market.py']);
|
|
1241
|
+
assert.deepEqual(
|
|
1242
|
+
extractSedReadTargets("sed -n '230,310p' backend/app/services/tushare.py"),
|
|
1243
|
+
['backend/app/services/tushare.py']);
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
test('extractSedReadTargets: multiple segments in one command, deduped', () => {
|
|
1247
|
+
const cmd = 'sed -n 60,200p src/a.py; echo ===; sed -n 250,300p src/b.py && sed -n 250,300p src/b.py';
|
|
1248
|
+
assert.deepEqual(extractSedReadTargets(cmd), ['src/a.py', 'src/b.py']);
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
test('extractSedReadTargets: non-range sed (substitution) ignored', () => {
|
|
1252
|
+
assert.deepEqual(extractSedReadTargets("sed -i 's/a/b/' src/a.py"), []);
|
|
1253
|
+
assert.deepEqual(extractSedReadTargets('sed -n /pattern/p src/a.py'), []);
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
test('extractSedReadTargets: pipeline sed after grep still extracted', () => {
|
|
1257
|
+
assert.deepEqual(
|
|
1258
|
+
extractSedReadTargets('grep -n "x" src/a.py | sed -n 1,5p src/b.py'),
|
|
1259
|
+
['src/b.py']);
|
|
1260
|
+
});
|
|
@@ -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 };
|
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const {
|
|
7
7
|
install, update, readManifest, getPluginVersion, checkScopeConflict,
|
|
8
8
|
cleanupDisabledStatusline, isPluginInactive, readJson, CACHE_DIR,
|
|
9
|
-
settingsPath,
|
|
9
|
+
settingsPath, isStaleRelicContext,
|
|
10
10
|
} = require('./lifecycle');
|
|
11
11
|
const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
|
|
12
12
|
const { maybeAutoAdopt, isAdopted } = require('./adopt');
|
|
@@ -55,6 +55,15 @@ function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function syncLifecycleConfig() {
|
|
58
|
+
// v0.49.1: stale-relic guard. A still-running Claude Code process fires
|
|
59
|
+
// SessionStart from the plugin-cache dir it loaded at startup; after
|
|
60
|
+
// auto-update installs a newer version, those old scripts would see
|
|
61
|
+
// `manifest.version !== currentVersion` below and — direction-blind —
|
|
62
|
+
// call update(), dragging manifest + every settings.json hook path back
|
|
63
|
+
// to the old dir (upgrade↔downgrade ping-pong, observed live 2026-06-12).
|
|
64
|
+
// installed_plugins.json is the authority on which install may self-heal.
|
|
65
|
+
if (isStaleRelicContext()) return 'deferred-to-active-install';
|
|
66
|
+
|
|
58
67
|
const manifest = readManifest();
|
|
59
68
|
const currentVersion = getPluginVersion();
|
|
60
69
|
|
|
@@ -81,6 +90,14 @@ function syncLifecycleConfig() {
|
|
|
81
90
|
install();
|
|
82
91
|
return 'self-healed-bad-path';
|
|
83
92
|
}
|
|
93
|
+
// v0.49.1: also self-heal when the composite path exists but is not the one
|
|
94
|
+
// we'd write now (old plugin-cache version dir that still exists on disk —
|
|
95
|
+
// invisible to the existence check above; same fault class as the binary pin).
|
|
96
|
+
const { compositeCommand } = require('./lifecycle');
|
|
97
|
+
if (settings.statusLine.command !== compositeCommand()) {
|
|
98
|
+
install();
|
|
99
|
+
return 'self-healed-stale-statusline';
|
|
100
|
+
}
|
|
84
101
|
// Self-heal if any hook command points to a non-existent script (path pollution)
|
|
85
102
|
if (settings.hooks) {
|
|
86
103
|
for (const entries of Object.values(settings.hooks)) {
|
|
@@ -101,18 +118,20 @@ function syncLifecycleConfig() {
|
|
|
101
118
|
// (e.g. user manually edited settings.json, or settings.json got rewritten
|
|
102
119
|
// by another tool that didn't preserve our entries). Without this, the
|
|
103
120
|
// user silently loses PreToolUse/PostToolUse hooks until next plugin update.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
// v0.49.1: upgraded from matcher-presence to surveyHookCoverage so a
|
|
122
|
+
// present-but-stale command path (old plugin-cache version dir that still
|
|
123
|
+
// exists) also heals. Previously only doctor checked staleness, so if the
|
|
124
|
+
// auto-update re-register step failed silently, users kept running old hook
|
|
125
|
+
// code indefinitely — the settings.json sibling of the binary-pin bug.
|
|
126
|
+
const { surveyHookCoverage } = require('./lifecycle');
|
|
127
|
+
const cov = surveyHookCoverage(settings);
|
|
128
|
+
if (cov.missing.length > 0) {
|
|
129
|
+
install();
|
|
130
|
+
return 'self-healed-missing-settings-hook';
|
|
131
|
+
}
|
|
132
|
+
if (cov.stale.length > 0) {
|
|
133
|
+
install();
|
|
134
|
+
return 'self-healed-stale-settings-hook';
|
|
116
135
|
}
|
|
117
136
|
return 'noop';
|
|
118
137
|
}
|
|
@@ -298,6 +317,11 @@ function runSessionInit() {
|
|
|
298
317
|
}
|
|
299
318
|
|
|
300
319
|
const lifecycle = syncLifecycleConfig();
|
|
320
|
+
// v0.49.1: a stale relic (see isStaleRelicContext) must not write ANY
|
|
321
|
+
// versioned state — that includes the adoption template: maybeAutoAdopt's
|
|
322
|
+
// drift-refresh would "refresh" MEMORY.md back to the relic's OLD shipped
|
|
323
|
+
// template, the adoption-surface twin of the settings.json downgrade war.
|
|
324
|
+
const isRelic = lifecycle === 'deferred-to-active-install';
|
|
301
325
|
|
|
302
326
|
// Verify binary availability — catch issues early with actionable diagnostics
|
|
303
327
|
const binaryCheck = verifyBinary();
|
|
@@ -308,7 +332,7 @@ function runSessionInit() {
|
|
|
308
332
|
// v0.9.0 C' 上下文感知默认:插件模式下首次 SessionStart 自动 adopt。
|
|
309
333
|
// v0.11.0: 已 adopt 的项目如果 shipped template 漂移也会触发一次刷新。
|
|
310
334
|
// 两种情况都发一次 stderr 提示,让用户知道发生了什么 + 如何回退。
|
|
311
|
-
const autoAdopt = maybeAutoAdopt({ scriptPath: __dirname });
|
|
335
|
+
const autoAdopt = isRelic ? { attempted: false, result: null } : maybeAutoAdopt({ scriptPath: __dirname });
|
|
312
336
|
if (autoAdopt.attempted && autoAdopt.result && autoAdopt.result.ok) {
|
|
313
337
|
if (autoAdopt.reason === 'refreshed') {
|
|
314
338
|
process.stderr.write(
|
|
@@ -38,6 +38,14 @@ 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
|
+
> `grep` 是 drop-in 替代:`-F` 字面 / `-i` / `-w` / `-l` / `-A/-B/-C N` 上下文 /
|
|
46
|
+
> 多路径 / `--max-count 0`,退出码兼容 grep(0/1/2),召回达 git-grep 级
|
|
47
|
+
> (tracked-but-gitignored 也能搜到),每条命中标注所属 fn/class。
|
|
48
|
+
>
|
|
41
49
|
> v0.10.0 起:tools/list 默认只暴露 7 个核心工具;下表"进阶 5"中的工具
|
|
42
50
|
> 已从 tools/list 隐藏以节省 session 启动 tokens。**Claude Code 里请走 CLI
|
|
43
51
|
> 子命令**(MCP schema 不在 list,Claude Code 的 ToolSearch 不会加载,直接
|