@sdsrs/code-graph 0.27.0 → 0.29.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,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.27.0",
7
+ "version": "0.29.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -34,6 +34,16 @@
34
34
  "timeout": 3
35
35
  }
36
36
  ]
37
+ },
38
+ {
39
+ "matcher": "tool == \"Read\"",
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-read-guide.js\"",
44
+ "timeout": 3
45
+ }
46
+ ]
37
47
  }
38
48
  ],
39
49
  "PostToolUse": [
@@ -24,7 +24,16 @@ const crypto = require('crypto');
24
24
  // --- Pure logic (testable) ---
25
25
 
26
26
  const GREP_HEAD = /^\s*(?:env\s+\S+=\S+\s+)*(grep|rg|ag)\b/;
27
- const SRC_PATH = /(?:^|\s|["'])(src|tests|lib|scripts|claude-plugin|tools|pkg|cmd|internal|app|components?|server|client|crates|packages)\//;
27
+ // Source-tree prefix list. Expanded v0.27+ Phase C: original `src/tests/lib/...`
28
+ // missed real-world backend conventions where the prefix list term is preceded
29
+ // by something else (`backend/app/...` — `app/` doesn't match because `/` isn't
30
+ // in the lookbehind). 7d audit found 5 of the worst missed sessions used the
31
+ // daagu `backend/app/services/...` layout. Added: backend/frontend/services/
32
+ // models/domain/controllers/views/handlers/middleware/routes/repositories/
33
+ // entities/migrations/tasks/jobs/workers/features/modules/api/web. Generic
34
+ // terms like `core`/`utils`/`shared`/`common`/`types` deliberately omitted —
35
+ // they appear in too many non-code contexts to be precise enough.
36
+ const SRC_PATH = /(?:^|\s|["'])(src|tests|lib|libs|scripts|claude-plugin|tools|pkg|cmd|internal|app|apps|components?|server|client|crates|packages|backend|frontend|services|models|domain|controllers|views|handlers|middleware|routes|repositories|entities|migrations|tasks|jobs|workers|features|modules|api|web)\//;
28
37
  const PIPE_INTO_GREP = /\|\s*(?:grep|rg|ag)\b/;
29
38
  const CG_INVOKED = /\bcode-graph-mcp\b/;
30
39
  // A file argument that ends in a config/lockfile extension AND no source-tree
@@ -170,6 +170,75 @@ test('isSilenced: VERBOSE_HOOKS=1 alone → not silenced (noisy by default alrea
170
170
  assert.equal(isSilenced({ CODE_GRAPH_VERBOSE_HOOKS: '1' }), false);
171
171
  });
172
172
 
173
+ // ── Phase C: extended prefixes (real-world backend / DDD / web conventions) ──
174
+
175
+ // daagu pattern: `backend/app/services/...` — `app/` is preceded by `backend/`,
176
+ // which doesn't satisfy the `(?:^|\s|["'])` lookbehind in the old SRC_PATH.
177
+ // 7d audit found 5 of the worst missed sessions used exactly this layout.
178
+ test('shouldHint: grep -rn on backend/app/services/ (daagu)', () => {
179
+ assert.equal(
180
+ shouldHint('grep -rn "pct_chg|pct_change" backend/app/services/context_builder.py'),
181
+ true
182
+ );
183
+ });
184
+
185
+ test('shouldHint: grep -rn on backend/app/services/scheduler/', () => {
186
+ assert.equal(
187
+ shouldHint('grep -rn "TASK_ZOMBIE|zombie recovery|reason=age" backend/app/services/scheduler/'),
188
+ true
189
+ );
190
+ });
191
+
192
+ test('shouldHint: grep on services/ (no backend prefix)', () => {
193
+ assert.equal(shouldHint('grep -rn "fetchUser" services/auth/'), true);
194
+ });
195
+
196
+ test('shouldHint: grep on models/ (Rails / Django)', () => {
197
+ assert.equal(shouldHint('grep -rn "before_save" models/user.rb'), true);
198
+ });
199
+
200
+ test('shouldHint: grep on controllers/ (Rails / ASP.NET)', () => {
201
+ assert.equal(shouldHint('grep -rn "def index" controllers/UsersController.rb'), true);
202
+ });
203
+
204
+ test('shouldHint: grep on domain/ (DDD architecture)', () => {
205
+ assert.equal(shouldHint('grep -rn "Aggregate" domain/orders/'), true);
206
+ });
207
+
208
+ test('shouldHint: grep on handlers/ (web server)', () => {
209
+ assert.equal(shouldHint('grep -rn "func New" handlers/api/'), true);
210
+ });
211
+
212
+ test('shouldHint: grep on migrations/ (db schema)', () => {
213
+ assert.equal(shouldHint('grep -rn "add_column" migrations/'), true);
214
+ });
215
+
216
+ test('shouldHint: grep on features/ (modular monolith)', () => {
217
+ assert.equal(shouldHint('grep -rn "useFeature" features/billing/'), true);
218
+ });
219
+
220
+ test('shouldHint: grep on api/ + frontend/', () => {
221
+ assert.equal(shouldHint('grep -rn "POST" api/v1/'), true);
222
+ assert.equal(shouldHint('grep -rn "import React" frontend/'), true);
223
+ });
224
+
225
+ // Precision guards — these MUST still NOT fire after the expansion.
226
+
227
+ test('shouldHint: grep on web.config (config file ext keeps suppression)', () => {
228
+ assert.equal(shouldHint('grep "<connectionStrings" web.config'), false);
229
+ });
230
+
231
+ test('shouldHint: grep on node_modules/ (NOT in src list)', () => {
232
+ assert.equal(shouldHint('grep -rn "deprecated" node_modules/some-pkg/'), false);
233
+ });
234
+
235
+ test('shouldHint: grep on docs/ (docs trees stay out)', () => {
236
+ // We deliberately did NOT add `docs` to the prefix list — docs are typically
237
+ // markdown and the existing CONFIG_TARGET_ONLY already filters `.md`-only
238
+ // greps. A bare `grep "X" docs/foo.md` would be CONFIG_TARGET_ONLY-suppressed.
239
+ assert.equal(shouldHint('grep "v0.24" docs/CHANGELOG.md'), false);
240
+ });
241
+
173
242
  // ── Regression cases from real session telemetry (2026-05-11) ───────
174
243
 
175
244
  test('regression: grep -n "Error\\|anyhow" src/main.rs (sess 5052e2a1)', () => {
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // PreToolUse(Read) hook: detect read-fanout into the same source directory
4
+ // and suggest module_overview / `code-graph-mcp overview` once. The 7d audit
5
+ // (2026-05-12 → 2026-05-14, 141 sessions) found 16 sessions with 5+ Reads
6
+ // into one source dir without a preceding module_overview call — Claude burns
7
+ // context fanning out file-by-file instead of grabbing a structured overview.
8
+ //
9
+ // Fires when ALL conditions met:
10
+ // 1. file_path is a source-code extension (.rs/.py/.ts/.js/.go/...)
11
+ // 2. file_path is under CWD (no escape to absolute paths outside the project)
12
+ // 3. file_path is not at CWD root (top-level files = config / one-off scripts)
13
+ // 4. .code-graph/index.db exists in CWD (project is indexed)
14
+ // 5. ≥5 prior Reads to the SAME parent dir tracked in /tmp state
15
+ // 6. Same-dir cooldown not active (5 min)
16
+ //
17
+ // State scoping: per-cwd (NOT per-session). Cost: two concurrent sessions in
18
+ // the same project might share counters and over-trigger by ~1 hint each.
19
+ // Cheaper than threading session_id through hook plumbing, and the hint is
20
+ // skippable. Stale entries (no read in 30 min) get pruned on load.
21
+ //
22
+ // Escape hatch: CODE_GRAPH_QUIET_HOOKS=1 — matches user-prompt-context.js /
23
+ // pre-grep-guide.js convention.
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const os = require('os');
28
+ const crypto = require('crypto');
29
+
30
+ // --- Configuration ---
31
+
32
+ // Hint fires on the (FANOUT_THRESHOLD + 1)-th Read into the same dir.
33
+ // Set so that 4 reads stay quiet (legitimate "read a couple files to
34
+ // understand X" pattern); 5+ reads is the fanout we want to catch.
35
+ const FANOUT_THRESHOLD = 4;
36
+
37
+ // Per-dir cooldown after firing a hint. Prevents spam if Claude keeps
38
+ // reading the same dir after seeing the hint (e.g., still has 3 more
39
+ // files queued from a prior plan).
40
+ const COOLDOWN_MS = 5 * 60 * 1000;
41
+
42
+ // Entries older than this are pruned on load. Long enough to survive
43
+ // normal multi-step tasks (15-20 min typical), short enough that stale
44
+ // per-cwd state doesn't accumulate across days.
45
+ const STATE_TTL_MS = 30 * 60 * 1000;
46
+
47
+ // Source-code extensions. Whitelist (NOT blacklist) — config / docs /
48
+ // data files stay silent because Claude reading them is not a fanout
49
+ // signal worth converting to module_overview.
50
+ const SRC_EXT = /\.(rs|py|ts|tsx|js|jsx|mjs|cjs|go|java|kt|swift|rb|php|cs|cpp|cc|c|h|hpp|hxx|m|scala|clj|cljs|ex|exs|hs|ml|fs|r|lua|sh|bash|zsh|fish|sql|vue|svelte|astro|dart|elm|nim|zig)$/i;
51
+
52
+ // --- Pure logic (testable) ---
53
+
54
+ function isSourceFile(filePath) {
55
+ if (!filePath || typeof filePath !== 'string') return false;
56
+ return SRC_EXT.test(filePath);
57
+ }
58
+
59
+ function dirOf(filePath) {
60
+ if (!filePath || typeof filePath !== 'string') return '';
61
+ return path.dirname(filePath);
62
+ }
63
+
64
+ function cwdHash(cwd) {
65
+ return crypto.createHash('sha1').update(String(cwd)).digest('hex').slice(0, 12);
66
+ }
67
+
68
+ function statePath(cwd) {
69
+ return path.join(os.tmpdir(), `.code-graph-readfan-${cwdHash(cwd)}.json`);
70
+ }
71
+
72
+ function loadState(cwd, now = Date.now()) {
73
+ let state;
74
+ try {
75
+ const raw = fs.readFileSync(statePath(cwd), 'utf8');
76
+ state = JSON.parse(raw);
77
+ } catch { return { by_dir: {} }; }
78
+ if (!state || typeof state !== 'object' || !state.by_dir) return { by_dir: {} };
79
+ // Prune stale entries — anything not Read in STATE_TTL_MS gets dropped.
80
+ for (const dir of Object.keys(state.by_dir)) {
81
+ const e = state.by_dir[dir];
82
+ if (!e || (now - (e.last_read_at || 0) > STATE_TTL_MS)) {
83
+ delete state.by_dir[dir];
84
+ }
85
+ }
86
+ return state;
87
+ }
88
+
89
+ function saveState(cwd, state) {
90
+ try {
91
+ fs.writeFileSync(statePath(cwd), JSON.stringify(state));
92
+ } catch { /* ok */ }
93
+ }
94
+
95
+ function recordRead(state, dir, now = Date.now()) {
96
+ if (!state.by_dir[dir]) state.by_dir[dir] = { reads: 0, last_read_at: 0, last_hint_at: 0 };
97
+ const e = state.by_dir[dir];
98
+ e.reads += 1;
99
+ e.last_read_at = now;
100
+ }
101
+
102
+ function shouldHint(state, dir, now = Date.now()) {
103
+ if (!dir) return false;
104
+ const e = state.by_dir[dir];
105
+ if (!e) return false;
106
+ if (e.reads < FANOUT_THRESHOLD + 1) return false; // need >=5
107
+ if (e.last_hint_at && (now - e.last_hint_at < COOLDOWN_MS)) return false;
108
+ return true;
109
+ }
110
+
111
+ function markHint(state, dir, now = Date.now()) {
112
+ if (!state.by_dir[dir]) return;
113
+ state.by_dir[dir].last_hint_at = now;
114
+ }
115
+
116
+ function buildHint(dir) {
117
+ // Single-line, ~190-byte budget. Skip-clause matches pre-grep-guide voice.
118
+ 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.`;
119
+ }
120
+
121
+ function isSilenced(env = process.env) {
122
+ return env.CODE_GRAPH_QUIET_HOOKS === '1';
123
+ }
124
+
125
+ // --- Main execution ---
126
+
127
+ function runMain() {
128
+ if (isSilenced()) return;
129
+ const cwd = process.cwd();
130
+ const dbPath = path.join(cwd, '.code-graph', 'index.db');
131
+ if (!fs.existsSync(dbPath)) return;
132
+
133
+ let input;
134
+ try {
135
+ input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
136
+ } catch { return; }
137
+
138
+ const filePath = (input.tool_input && input.tool_input.file_path) || '';
139
+ if (!isSourceFile(filePath)) return;
140
+
141
+ // Normalize to a cwd-relative path. If the file is outside cwd, skip —
142
+ // a hint pointing at an unrelated dir helps no one.
143
+ let rel;
144
+ try {
145
+ rel = path.relative(cwd, filePath);
146
+ } catch { return; }
147
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
148
+
149
+ const dir = path.dirname(rel);
150
+ if (!dir || dir === '.' || dir === '') return; // top-level file: not fanout
151
+
152
+ const now = Date.now();
153
+ const state = loadState(cwd, now);
154
+ recordRead(state, dir, now);
155
+ let fired = false;
156
+ if (shouldHint(state, dir, now)) {
157
+ markHint(state, dir, now);
158
+ fired = true;
159
+ }
160
+ saveState(cwd, state);
161
+ if (fired) process.stdout.write(buildHint(dir) + '\n');
162
+ }
163
+
164
+ if (require.main === module) {
165
+ runMain();
166
+ }
167
+
168
+ module.exports = {
169
+ isSourceFile, dirOf, cwdHash, statePath,
170
+ loadState, saveState, recordRead, shouldHint, markHint,
171
+ buildHint, isSilenced,
172
+ FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS, SRC_EXT,
173
+ };
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+
9
+ const {
10
+ isSourceFile, dirOf, recordRead, shouldHint, markHint,
11
+ buildHint, isSilenced,
12
+ FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS,
13
+ loadState, saveState, statePath,
14
+ } = require('./pre-read-guide');
15
+
16
+ // ── isSourceFile ────────────────────────────────────────────────────
17
+
18
+ test('isSourceFile: .rs is source', () => {
19
+ assert.equal(isSourceFile('src/main.rs'), true);
20
+ });
21
+
22
+ test('isSourceFile: .py is source', () => {
23
+ assert.equal(isSourceFile('backend/app/services/foo.py'), true);
24
+ });
25
+
26
+ test('isSourceFile: .ts and .tsx are source', () => {
27
+ assert.equal(isSourceFile('src/index.ts'), true);
28
+ assert.equal(isSourceFile('src/App.tsx'), true);
29
+ });
30
+
31
+ test('isSourceFile: .js .jsx .mjs .cjs are source', () => {
32
+ assert.equal(isSourceFile('lib/a.js'), true);
33
+ assert.equal(isSourceFile('lib/b.jsx'), true);
34
+ assert.equal(isSourceFile('lib/c.mjs'), true);
35
+ assert.equal(isSourceFile('lib/d.cjs'), true);
36
+ });
37
+
38
+ test('isSourceFile: .go .java .kt .rb .php .cs are source', () => {
39
+ for (const ext of ['go', 'java', 'kt', 'rb', 'php', 'cs']) {
40
+ assert.equal(isSourceFile('app/x.' + ext), true, ext + ' should be source');
41
+ }
42
+ });
43
+
44
+ test('isSourceFile: .md is NOT source', () => {
45
+ assert.equal(isSourceFile('CHANGELOG.md'), false);
46
+ });
47
+
48
+ test('isSourceFile: .json is NOT source', () => {
49
+ assert.equal(isSourceFile('package.json'), false);
50
+ });
51
+
52
+ test('isSourceFile: .toml .lock .yml are NOT source', () => {
53
+ assert.equal(isSourceFile('Cargo.toml'), false);
54
+ assert.equal(isSourceFile('package-lock.json'), false);
55
+ assert.equal(isSourceFile('.github/workflows/ci.yml'), false);
56
+ });
57
+
58
+ test('isSourceFile: .log is NOT source', () => {
59
+ assert.equal(isSourceFile('logs/app.log'), false);
60
+ });
61
+
62
+ test('isSourceFile: empty / non-string returns false', () => {
63
+ assert.equal(isSourceFile(''), false);
64
+ assert.equal(isSourceFile(null), false);
65
+ assert.equal(isSourceFile(undefined), false);
66
+ assert.equal(isSourceFile(42), false);
67
+ });
68
+
69
+ test('isSourceFile: extensionless file returns false', () => {
70
+ assert.equal(isSourceFile('Makefile'), false);
71
+ });
72
+
73
+ // ── dirOf ───────────────────────────────────────────────────────────
74
+
75
+ test('dirOf: relative path returns parent dir', () => {
76
+ assert.equal(dirOf('src/storage/queries.rs'), 'src/storage');
77
+ });
78
+
79
+ test('dirOf: top-level file returns "."', () => {
80
+ assert.equal(dirOf('main.rs'), '.');
81
+ });
82
+
83
+ test('dirOf: empty / non-string returns ""', () => {
84
+ assert.equal(dirOf(''), '');
85
+ assert.equal(dirOf(null), '');
86
+ });
87
+
88
+ // ── recordRead + shouldHint ─────────────────────────────────────────
89
+
90
+ test('shouldHint: first read does NOT hint', () => {
91
+ const s = { by_dir: {} };
92
+ recordRead(s, 'src/foo', 1000);
93
+ assert.equal(shouldHint(s, 'src/foo', 1000), false);
94
+ });
95
+
96
+ test('shouldHint: 4 reads do NOT hint (threshold = 5)', () => {
97
+ const s = { by_dir: {} };
98
+ for (let i = 0; i < 4; i++) recordRead(s, 'src/foo', 1000 + i);
99
+ assert.equal(shouldHint(s, 'src/foo', 1004), false);
100
+ });
101
+
102
+ test('shouldHint: 5th read DOES hint', () => {
103
+ const s = { by_dir: {} };
104
+ for (let i = 0; i < 5; i++) recordRead(s, 'src/foo', 1000 + i);
105
+ assert.equal(shouldHint(s, 'src/foo', 1004), true);
106
+ });
107
+
108
+ test('shouldHint: cooldown suppresses re-fire', () => {
109
+ const s = { by_dir: {} };
110
+ for (let i = 0; i < 6; i++) recordRead(s, 'src/foo', 1000 + i);
111
+ markHint(s, 'src/foo', 1005);
112
+ // 1 sec later — still in cooldown
113
+ recordRead(s, 'src/foo', 1005 + 1000);
114
+ assert.equal(shouldHint(s, 'src/foo', 1005 + 1000), false);
115
+ });
116
+
117
+ test('shouldHint: past cooldown re-fires', () => {
118
+ const s = { by_dir: {} };
119
+ for (let i = 0; i < 5; i++) recordRead(s, 'src/foo', 1000 + i);
120
+ markHint(s, 'src/foo', 1005);
121
+ // COOLDOWN_MS + 1 later, plus one more read
122
+ const after = 1005 + COOLDOWN_MS + 1;
123
+ recordRead(s, 'src/foo', after);
124
+ assert.equal(shouldHint(s, 'src/foo', after), true);
125
+ });
126
+
127
+ test('shouldHint: different dirs tracked independently', () => {
128
+ const s = { by_dir: {} };
129
+ for (let i = 0; i < 5; i++) recordRead(s, 'src/foo', 1000 + i);
130
+ for (let i = 0; i < 2; i++) recordRead(s, 'src/bar', 2000 + i);
131
+ assert.equal(shouldHint(s, 'src/foo', 1005), true);
132
+ assert.equal(shouldHint(s, 'src/bar', 2002), false);
133
+ });
134
+
135
+ test('shouldHint: unknown dir returns false', () => {
136
+ const s = { by_dir: {} };
137
+ assert.equal(shouldHint(s, 'src/unseen', 1000), false);
138
+ });
139
+
140
+ test('shouldHint: empty dir returns false', () => {
141
+ const s = { by_dir: {} };
142
+ assert.equal(shouldHint(s, '', 1000), false);
143
+ });
144
+
145
+ // ── buildHint ───────────────────────────────────────────────────────
146
+
147
+ test('buildHint: contains the directory + module_overview tool', () => {
148
+ const out = buildHint('src/storage');
149
+ assert.match(out, /src\/storage/);
150
+ assert.match(out, /module_overview|overview/);
151
+ });
152
+
153
+ test('buildHint: stays under 300 bytes (single-line budget)', () => {
154
+ assert.ok(buildHint('src/storage').length < 300,
155
+ `hint length ${buildHint('src/storage').length} exceeds budget`);
156
+ });
157
+
158
+ test('buildHint: starts with [code-graph]', () => {
159
+ assert.match(buildHint('any/dir'), /^\[code-graph\]/);
160
+ });
161
+
162
+ test('buildHint: single line (no embedded newlines)', () => {
163
+ const out = buildHint('src/foo');
164
+ // Trailing newline is added by the caller; the function itself should not embed any.
165
+ assert.equal(out.indexOf('\n'), -1, `hint contains newline: ${JSON.stringify(out)}`);
166
+ });
167
+
168
+ // ── isSilenced ──────────────────────────────────────────────────────
169
+
170
+ test('isSilenced: default (no env) → not silenced', () => {
171
+ assert.equal(isSilenced({}), false);
172
+ });
173
+
174
+ test('isSilenced: CODE_GRAPH_QUIET_HOOKS=1 → silenced', () => {
175
+ assert.equal(isSilenced({ CODE_GRAPH_QUIET_HOOKS: '1' }), true);
176
+ });
177
+
178
+ test('isSilenced: CODE_GRAPH_QUIET_HOOKS=0 → not silenced', () => {
179
+ assert.equal(isSilenced({ CODE_GRAPH_QUIET_HOOKS: '0' }), false);
180
+ });
181
+
182
+ // ── State load / save / TTL pruning ─────────────────────────────────
183
+
184
+ function tmpCwd() {
185
+ // Synthesize a unique cwd path so different test runs don't share state.
186
+ const id = crypto.randomBytes(8).toString('hex');
187
+ return `/nonexistent-test-cwd-${id}`;
188
+ }
189
+
190
+ test('loadState: missing file returns empty state', () => {
191
+ const cwd = tmpCwd();
192
+ const s = loadState(cwd);
193
+ assert.deepEqual(s, { by_dir: {} });
194
+ });
195
+
196
+ test('loadState + saveState: round-trip preserves by_dir', () => {
197
+ const cwd = tmpCwd();
198
+ const s1 = { by_dir: { 'src/foo': { reads: 3, last_read_at: 1000, last_hint_at: 0 } } };
199
+ saveState(cwd, s1);
200
+ const s2 = loadState(cwd, 1000);
201
+ assert.equal(s2.by_dir['src/foo'].reads, 3);
202
+ // Cleanup
203
+ try { fs.unlinkSync(statePath(cwd)); } catch { /* ok */ }
204
+ });
205
+
206
+ test('loadState: entries older than STATE_TTL_MS are pruned', () => {
207
+ const cwd = tmpCwd();
208
+ const old = { by_dir: {
209
+ 'src/fresh': { reads: 2, last_read_at: 10_000, last_hint_at: 0 },
210
+ 'src/stale': { reads: 9, last_read_at: 0, last_hint_at: 0 },
211
+ }};
212
+ saveState(cwd, old);
213
+ const now = STATE_TTL_MS + 100; // way past TTL for the stale entry
214
+ const loaded = loadState(cwd, now);
215
+ assert.ok(loaded.by_dir['src/fresh'], 'fresh entry kept');
216
+ assert.equal(loaded.by_dir['src/stale'], undefined, 'stale entry pruned');
217
+ try { fs.unlinkSync(statePath(cwd)); } catch { /* ok */ }
218
+ });
219
+
220
+ test('loadState: malformed JSON returns empty state', () => {
221
+ const cwd = tmpCwd();
222
+ const p = statePath(cwd);
223
+ fs.writeFileSync(p, 'not json {{{', 'utf8');
224
+ const s = loadState(cwd);
225
+ assert.deepEqual(s, { by_dir: {} });
226
+ try { fs.unlinkSync(p); } catch { /* ok */ }
227
+ });
228
+
229
+ // ── Integrated flow ─────────────────────────────────────────────────
230
+
231
+ test('flow: 5 reads to same dir → hint, 6th read same dir → no hint (cooldown)', () => {
232
+ const s = { by_dir: {} };
233
+ // Reads 1-4: no hint
234
+ for (let i = 0; i < 4; i++) {
235
+ recordRead(s, 'src/foo', 1000 + i);
236
+ assert.equal(shouldHint(s, 'src/foo', 1000 + i), false, `read ${i+1} should not hint`);
237
+ }
238
+ // Read 5: hint
239
+ recordRead(s, 'src/foo', 1004);
240
+ assert.equal(shouldHint(s, 'src/foo', 1004), true);
241
+ markHint(s, 'src/foo', 1004);
242
+ // Read 6 within cooldown: no hint
243
+ recordRead(s, 'src/foo', 1005);
244
+ assert.equal(shouldHint(s, 'src/foo', 1005), false);
245
+ });
@@ -17,6 +17,7 @@ const COOLDOWNS = {
17
17
  overview: 5 * 60 * 1000, // 5min — module structure rarely changes mid-session
18
18
  callgraph: 60 * 1000, // 1min
19
19
  search: 60 * 1000, // 1min
20
+ symptom: 10 * 60 * 1000, // 10min — Phase E meta-advisory hint, low value to repeat
20
21
  };
21
22
 
22
23
  function isCoolingDown(type) {
@@ -307,14 +308,54 @@ function detectIntents(msg) {
307
308
  };
308
309
  }
309
310
 
310
- function determineQueryType(intents, symbols, filePaths, isCoolingDownFn) {
311
+ // Phase E: symptom-driven fallback. The 7d audit + TriggerRate hard-oracle
312
+ // baseline (60%) show the failure mode "user phrases a problem without giving
313
+ // a symbol/path/file — Claude defaults to bash grep". The 4 existing channels
314
+ // (intent / qualified symbol / file path / any symbol) all miss on these
315
+ // prompts. SYMPTOM_PATTERNS catches the bug-flavored prompts so we can emit
316
+ // ONE-LINE prose hint (no CLI execution — different from the other 4 paths
317
+ // that inject results). Keeps noise low while planting the routing seed.
318
+ const SYMPTOM_PATTERNS = [
319
+ // English symptom / failure-mode markers
320
+ /\bbug\b/i,
321
+ /\bcrash(?:ed|ing|es)?\b/i,
322
+ /\bbroken\b/i,
323
+ /\bnot work(?:ing)?\b/i,
324
+ /\bdoesn'?t work\b/i,
325
+ /\bfail(?:ed|ing|s|ure)?\b/i,
326
+ /\bwhy (?:does|is|are|isn'?t|doesn'?t|won'?t)/i,
327
+ /\bmissing\b/i,
328
+ // Chinese symptom / failure markers
329
+ /有\s?bug/i,
330
+ /又挂/,
331
+ /又失败/,
332
+ /挂了/,
333
+ /失败了/,
334
+ /卡死/,
335
+ /卡住/,
336
+ /不准/,
337
+ /不对/,
338
+ /缺失/,
339
+ /丢失/,
340
+ /没响应/,
341
+ /出错/,
342
+ /报错/,
343
+ /为什么/,
344
+ /怎么(?:修|解决)/,
345
+ /哪里[\s\S]{0,5}(?:错|有问题|不对)/, // 哪里写错 / 哪里出错 / 哪里有问题 / 哪里报错了
346
+ /出了什么问题/,
347
+ ];
348
+
349
+ function hasSymptom(msg) {
350
+ if (!msg || typeof msg !== 'string') return false;
351
+ return SYMPTOM_PATTERNS.some(p => p.test(msg));
352
+ }
353
+
354
+ function determineQueryType(intents, symbols, filePaths, isCoolingDownFn, message = '') {
311
355
  const hasStrict = symbols.symbols.length > 0 && !symbols.lowConfidence;
312
356
  const hasQualified = symbols.symbols.some(s => s.includes('::'));
313
357
  const hasAny = intents.impact || intents.modify || intents.implement || intents.understand || intents.callgraph || intents.search;
314
358
 
315
- // Gate: need intent, qualified symbol, file path, or any symbol
316
- if (!hasAny && !hasQualified && filePaths.length === 0 && symbols.symbols.length === 0) return null;
317
-
318
359
  const cd = isCoolingDownFn || (() => false);
319
360
 
320
361
  if ((intents.impact || intents.modify) && hasStrict && !cd('impact')) return { type: 'impact', symbol: symbols.symbols[0] };
@@ -323,6 +364,13 @@ function determineQueryType(intents, symbols, filePaths, isCoolingDownFn) {
323
364
  if ((intents.search || intents.implement || hasQualified) && symbols.symbols.length > 0 && !cd('search')) return { type: 'search', symbol: symbols.symbols[0] };
324
365
  if ((intents.understand || !hasAny) && symbols.symbols.length > 0 && !cd('search')) return { type: 'search', symbol: symbols.symbols[0] };
325
366
 
367
+ // Phase E fallback: nothing actionable above, but message has symptom phrasing.
368
+ // Emit ONE-LINE prose hint (no CLI execution). Default-empty `message` keeps
369
+ // backward-compat for callers that don't pass it (legacy `analyze` helpers).
370
+ if (message && hasSymptom(message) && !cd('symptom')) {
371
+ return { type: 'symptom-hint' };
372
+ }
373
+
326
374
  return null;
327
375
  }
328
376
 
@@ -373,10 +421,21 @@ function runMain() {
373
421
  const filePaths = extractFilePaths(message);
374
422
  const symbols = extractSymbols(message);
375
423
  const intents = detectIntents(message);
376
- const query = determineQueryType(intents, symbols, filePaths, isCoolingDown);
424
+ const query = determineQueryType(intents, symbols, filePaths, isCoolingDown, message);
377
425
 
378
426
  if (!query) return;
379
427
 
428
+ // Phase E: symptom-hint is prose-only (no CLI execution). Emit + cooldown
429
+ // before the result-fetching paths so it can short-circuit cleanly.
430
+ if (query.type === 'symptom-hint') {
431
+ markCooldown('symptom');
432
+ process.stdout.write(
433
+ '[code-graph:hint] indexed repo — for vague-symptom prompts, try `semantic_code_search "<symptom>"` ' +
434
+ 'or `module_overview <suspected-dir>` to surface candidate code structurally. Skip if not searching code.\n'
435
+ );
436
+ return;
437
+ }
438
+
380
439
  const PREFIXES = {
381
440
  impact: '[code-graph:impact] Blast radius — review before editing:',
382
441
  overview: '[code-graph:structure] Module structure:',
@@ -413,4 +472,4 @@ if (require.main === module) {
413
472
  runMain();
414
473
  }
415
474
 
416
- module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE };
475
+ module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE, hasSymptom, SYMPTOM_PATTERNS };
@@ -399,7 +399,8 @@ function analyze(msg) {
399
399
  const fp = extractFilePaths(msg);
400
400
  const sym = extractSymbols(msg);
401
401
  const intents = detectIntents(msg);
402
- const query = determineQueryType(intents, sym, fp);
402
+ // Phase E: pass message into determineQueryType so symptom-hint fallback fires.
403
+ const query = determineQueryType(intents, sym, fp, undefined, msg);
403
404
  return { query, intents, symbols: sym, filePaths: fp };
404
405
  }
405
406
 
@@ -556,3 +557,132 @@ test('CODE_GRAPH_QUIET_HOOKS=1 short-circuits silently on stdout, stderr, exit 0
556
557
  assert.equal(proc.stderr, '', 'quiet must be silent on stderr');
557
558
  assert.equal(proc.status, 0, 'quiet must exit 0');
558
559
  });
560
+
561
+ // ── Phase E: hasSymptom + symptom-hint fallback ──────────────
562
+
563
+ const { hasSymptom, SYMPTOM_PATTERNS } = require('./user-prompt-context');
564
+
565
+ test('hasSymptom: 报告数据不准', () => {
566
+ assert.equal(hasSymptom('今天的报告数据不准'), true);
567
+ });
568
+
569
+ test('hasSymptom: test 又挂了', () => {
570
+ assert.equal(hasSymptom('test 又挂了'), true);
571
+ });
572
+
573
+ test('hasSymptom: Why does this not work?', () => {
574
+ assert.equal(hasSymptom('Why does this not work?'), true);
575
+ });
576
+
577
+ test('hasSymptom: 有 bug', () => {
578
+ assert.equal(hasSymptom('有 bug,帮我看看'), true);
579
+ });
580
+
581
+ test('hasSymptom: 为什么 (vague-question marker)', () => {
582
+ assert.equal(hasSymptom('为什么会这样'), true);
583
+ });
584
+
585
+ test('hasSymptom: 哪里写错了', () => {
586
+ assert.equal(hasSymptom('find 一下哪里写错了'), true);
587
+ });
588
+
589
+ test('hasSymptom: doesn\'t work / not working', () => {
590
+ assert.equal(hasSymptom("this doesn't work as expected"), true);
591
+ assert.equal(hasSymptom('the service is not working'), true);
592
+ });
593
+
594
+ test('hasSymptom: 挂了 / 失败 / 卡死', () => {
595
+ assert.equal(hasSymptom('test 挂了'), true);
596
+ assert.equal(hasSymptom('又失败了'), true);
597
+ assert.equal(hasSymptom('整个服务卡死了'), true);
598
+ });
599
+
600
+ // Precision: must NOT flag normal task statements as symptoms.
601
+ test('hasSymptom: 修改 parse_code → false', () => {
602
+ assert.equal(hasSymptom('修改 parse_code 函数增加错误处理'), false);
603
+ });
604
+
605
+ test('hasSymptom: 看看 src/mcp/ → false', () => {
606
+ assert.equal(hasSymptom('看看 src/mcp/ 模块的代码结构'), false);
607
+ });
608
+
609
+ test('hasSymptom: write tests → false', () => {
610
+ assert.equal(hasSymptom('write tests for the embedding module'), false);
611
+ });
612
+
613
+ test('hasSymptom: empty / non-string → false', () => {
614
+ assert.equal(hasSymptom(''), false);
615
+ assert.equal(hasSymptom(null), false);
616
+ assert.equal(hasSymptom(undefined), false);
617
+ });
618
+
619
+ test('SYMPTOM_PATTERNS: exported + non-empty array', () => {
620
+ assert.ok(Array.isArray(SYMPTOM_PATTERNS));
621
+ assert.ok(SYMPTOM_PATTERNS.length >= 8,
622
+ `SYMPTOM_PATTERNS has ${SYMPTOM_PATTERNS.length} entries; want ≥8 for coverage`);
623
+ });
624
+
625
+ // ── determineQueryType: symptom-hint fallback ────────────────
626
+
627
+ test('symptom-fallback: pure symptom message, no anchor → symptom-hint', () => {
628
+ const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: false };
629
+ const symbols = { symbols: [], lowConfidence: false };
630
+ const result = determineQueryType(intents, symbols, [], undefined, '今天的报告数据不准');
631
+ assert.equal(result && result.type, 'symptom-hint');
632
+ });
633
+
634
+ test('symptom-fallback: intent + no symbol/path + symptom → symptom-hint', () => {
635
+ // "find 一下哪里写错了" — search intent fires but no symbol or path is extractable.
636
+ const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: true };
637
+ const symbols = { symbols: [], lowConfidence: false };
638
+ const result = determineQueryType(intents, symbols, [], undefined, 'find 一下哪里写错了');
639
+ assert.equal(result && result.type, 'symptom-hint');
640
+ });
641
+
642
+ test('symptom-fallback: actionable path beats symptom-hint (precedence)', () => {
643
+ // Impact path with strict symbol must take precedence even when symptom phrasing is present.
644
+ const intents = { impact: true, modify: false, implement: false, understand: false, callgraph: false, search: false };
645
+ const symbols = { symbols: ['parse_code'], lowConfidence: false };
646
+ const result = determineQueryType(intents, symbols, [], undefined, '修改前看看 parse_code 的 bug 影响');
647
+ assert.equal(result.type, 'impact');
648
+ });
649
+
650
+ test('symptom-fallback: no symptom + no anchor → null (unchanged)', () => {
651
+ const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: false };
652
+ const symbols = { symbols: [], lowConfidence: false };
653
+ const result = determineQueryType(intents, symbols, [], undefined, 'hello there');
654
+ assert.equal(result, null);
655
+ });
656
+
657
+ test('symptom-fallback: cooldown blocks symptom-hint', () => {
658
+ const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: false };
659
+ const symbols = { symbols: [], lowConfidence: false };
660
+ const result = determineQueryType(intents, symbols, [], (t) => t === 'symptom', '今天的报告数据不准');
661
+ assert.equal(result, null);
662
+ });
663
+
664
+ test('symptom-fallback: omitted message arg → backward-compat null', () => {
665
+ // Existing callers (and the legacy bench harness) call determineQueryType
666
+ // without the 5th arg. The fallback must NOT fire — preserve prior behavior.
667
+ const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: false };
668
+ const symbols = { symbols: [], lowConfidence: false };
669
+ const result = determineQueryType(intents, symbols, []);
670
+ assert.equal(result, null);
671
+ });
672
+
673
+ // ── Integration: analyze() with symptom-only messages ──
674
+
675
+ test('integration: 今天的报告数据不准 → symptom-hint', () => {
676
+ const r = analyze('今天的报告数据不准');
677
+ assert.equal(r.query && r.query.type, 'symptom-hint');
678
+ });
679
+
680
+ test('integration: test 又挂了 → symptom-hint', () => {
681
+ const r = analyze('test 又挂了');
682
+ assert.equal(r.query && r.query.type, 'symptom-hint');
683
+ });
684
+
685
+ test('integration: Why does this not work? → symptom-hint', () => {
686
+ const r = analyze('Why does this not work?');
687
+ assert.equal(r.query && r.query.type, 'symptom-hint');
688
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.27.0",
3
+ "version": "0.29.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.27.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.27.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.27.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.27.0",
42
- "@sdsrs/code-graph-win32-x64": "0.27.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.29.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.29.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.29.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.29.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.29.0"
43
43
  }
44
44
  }