@sdsrs/code-graph 0.27.0 → 0.28.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/hooks/hooks.json +10 -0
- package/claude-plugin/scripts/pre-grep-guide.js +10 -1
- package/claude-plugin/scripts/pre-grep-guide.test.js +69 -0
- package/claude-plugin/scripts/pre-read-guide.js +173 -0
- package/claude-plugin/scripts/pre-read-guide.test.js +245 -0
- package/claude-plugin/scripts/user-prompt-context.js +65 -6
- package/claude-plugin/scripts/user-prompt-context.test.js +131 -1
- package/package.json +6 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.28.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.28.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.28.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.28.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.28.0"
|
|
43
43
|
}
|
|
44
44
|
}
|