Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@sdsrs/code-graph

Package Overview
Dependencies
Maintainers
1
Versions
160
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sdsrs/code-graph - npm Package Compare versions

Comparing version
0.27.0
to
0.28.0
+173
claude-plugin/scripts/pre-read-guide.js
#!/usr/bin/env node
'use strict';
// PreToolUse(Read) hook: detect read-fanout into the same source directory
// and suggest module_overview / `code-graph-mcp overview` once. The 7d audit
// (2026-05-12 → 2026-05-14, 141 sessions) found 16 sessions with 5+ Reads
// into one source dir without a preceding module_overview call — Claude burns
// context fanning out file-by-file instead of grabbing a structured overview.
//
// Fires when ALL conditions met:
// 1. file_path is a source-code extension (.rs/.py/.ts/.js/.go/...)
// 2. file_path is under CWD (no escape to absolute paths outside the project)
// 3. file_path is not at CWD root (top-level files = config / one-off scripts)
// 4. .code-graph/index.db exists in CWD (project is indexed)
// 5. ≥5 prior Reads to the SAME parent dir tracked in /tmp state
// 6. Same-dir cooldown not active (5 min)
//
// State scoping: per-cwd (NOT per-session). Cost: two concurrent sessions in
// the same project might share counters and over-trigger by ~1 hint each.
// Cheaper than threading session_id through hook plumbing, and the hint is
// skippable. Stale entries (no read in 30 min) get pruned on load.
//
// Escape hatch: CODE_GRAPH_QUIET_HOOKS=1 — matches user-prompt-context.js /
// pre-grep-guide.js convention.
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
// --- Configuration ---
// Hint fires on the (FANOUT_THRESHOLD + 1)-th Read into the same dir.
// Set so that 4 reads stay quiet (legitimate "read a couple files to
// understand X" pattern); 5+ reads is the fanout we want to catch.
const FANOUT_THRESHOLD = 4;
// Per-dir cooldown after firing a hint. Prevents spam if Claude keeps
// reading the same dir after seeing the hint (e.g., still has 3 more
// files queued from a prior plan).
const COOLDOWN_MS = 5 * 60 * 1000;
// Entries older than this are pruned on load. Long enough to survive
// normal multi-step tasks (15-20 min typical), short enough that stale
// per-cwd state doesn't accumulate across days.
const STATE_TTL_MS = 30 * 60 * 1000;
// Source-code extensions. Whitelist (NOT blacklist) — config / docs /
// data files stay silent because Claude reading them is not a fanout
// signal worth converting to module_overview.
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;
// --- Pure logic (testable) ---
function isSourceFile(filePath) {
if (!filePath || typeof filePath !== 'string') return false;
return SRC_EXT.test(filePath);
}
function dirOf(filePath) {
if (!filePath || typeof filePath !== 'string') return '';
return path.dirname(filePath);
}
function cwdHash(cwd) {
return crypto.createHash('sha1').update(String(cwd)).digest('hex').slice(0, 12);
}
function statePath(cwd) {
return path.join(os.tmpdir(), `.code-graph-readfan-${cwdHash(cwd)}.json`);
}
function loadState(cwd, now = Date.now()) {
let state;
try {
const raw = fs.readFileSync(statePath(cwd), 'utf8');
state = JSON.parse(raw);
} catch { return { by_dir: {} }; }
if (!state || typeof state !== 'object' || !state.by_dir) return { by_dir: {} };
// Prune stale entries — anything not Read in STATE_TTL_MS gets dropped.
for (const dir of Object.keys(state.by_dir)) {
const e = state.by_dir[dir];
if (!e || (now - (e.last_read_at || 0) > STATE_TTL_MS)) {
delete state.by_dir[dir];
}
}
return state;
}
function saveState(cwd, state) {
try {
fs.writeFileSync(statePath(cwd), JSON.stringify(state));
} catch { /* ok */ }
}
function recordRead(state, dir, now = Date.now()) {
if (!state.by_dir[dir]) state.by_dir[dir] = { reads: 0, last_read_at: 0, last_hint_at: 0 };
const e = state.by_dir[dir];
e.reads += 1;
e.last_read_at = now;
}
function shouldHint(state, dir, now = Date.now()) {
if (!dir) return false;
const e = state.by_dir[dir];
if (!e) return false;
if (e.reads < FANOUT_THRESHOLD + 1) return false; // need >=5
if (e.last_hint_at && (now - e.last_hint_at < COOLDOWN_MS)) return false;
return true;
}
function markHint(state, dir, now = Date.now()) {
if (!state.by_dir[dir]) return;
state.by_dir[dir].last_hint_at = now;
}
function buildHint(dir) {
// Single-line, ~190-byte budget. Skip-clause matches pre-grep-guide voice.
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.`;
}
function isSilenced(env = process.env) {
return env.CODE_GRAPH_QUIET_HOOKS === '1';
}
// --- Main execution ---
function runMain() {
if (isSilenced()) return;
const cwd = process.cwd();
const dbPath = path.join(cwd, '.code-graph', 'index.db');
if (!fs.existsSync(dbPath)) return;
let input;
try {
input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
} catch { return; }
const filePath = (input.tool_input && input.tool_input.file_path) || '';
if (!isSourceFile(filePath)) return;
// Normalize to a cwd-relative path. If the file is outside cwd, skip —
// a hint pointing at an unrelated dir helps no one.
let rel;
try {
rel = path.relative(cwd, filePath);
} catch { return; }
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
const dir = path.dirname(rel);
if (!dir || dir === '.' || dir === '') return; // top-level file: not fanout
const now = Date.now();
const state = loadState(cwd, now);
recordRead(state, dir, now);
let fired = false;
if (shouldHint(state, dir, now)) {
markHint(state, dir, now);
fired = true;
}
saveState(cwd, state);
if (fired) process.stdout.write(buildHint(dir) + '\n');
}
if (require.main === module) {
runMain();
}
module.exports = {
isSourceFile, dirOf, cwdHash, statePath,
loadState, saveState, recordRead, shouldHint, markHint,
buildHint, isSilenced,
FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS, SRC_EXT,
};
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const {
isSourceFile, dirOf, recordRead, shouldHint, markHint,
buildHint, isSilenced,
FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS,
loadState, saveState, statePath,
} = require('./pre-read-guide');
// ── isSourceFile ────────────────────────────────────────────────────
test('isSourceFile: .rs is source', () => {
assert.equal(isSourceFile('src/main.rs'), true);
});
test('isSourceFile: .py is source', () => {
assert.equal(isSourceFile('backend/app/services/foo.py'), true);
});
test('isSourceFile: .ts and .tsx are source', () => {
assert.equal(isSourceFile('src/index.ts'), true);
assert.equal(isSourceFile('src/App.tsx'), true);
});
test('isSourceFile: .js .jsx .mjs .cjs are source', () => {
assert.equal(isSourceFile('lib/a.js'), true);
assert.equal(isSourceFile('lib/b.jsx'), true);
assert.equal(isSourceFile('lib/c.mjs'), true);
assert.equal(isSourceFile('lib/d.cjs'), true);
});
test('isSourceFile: .go .java .kt .rb .php .cs are source', () => {
for (const ext of ['go', 'java', 'kt', 'rb', 'php', 'cs']) {
assert.equal(isSourceFile('app/x.' + ext), true, ext + ' should be source');
}
});
test('isSourceFile: .md is NOT source', () => {
assert.equal(isSourceFile('CHANGELOG.md'), false);
});
test('isSourceFile: .json is NOT source', () => {
assert.equal(isSourceFile('package.json'), false);
});
test('isSourceFile: .toml .lock .yml are NOT source', () => {
assert.equal(isSourceFile('Cargo.toml'), false);
assert.equal(isSourceFile('package-lock.json'), false);
assert.equal(isSourceFile('.github/workflows/ci.yml'), false);
});
test('isSourceFile: .log is NOT source', () => {
assert.equal(isSourceFile('logs/app.log'), false);
});
test('isSourceFile: empty / non-string returns false', () => {
assert.equal(isSourceFile(''), false);
assert.equal(isSourceFile(null), false);
assert.equal(isSourceFile(undefined), false);
assert.equal(isSourceFile(42), false);
});
test('isSourceFile: extensionless file returns false', () => {
assert.equal(isSourceFile('Makefile'), false);
});
// ── dirOf ───────────────────────────────────────────────────────────
test('dirOf: relative path returns parent dir', () => {
assert.equal(dirOf('src/storage/queries.rs'), 'src/storage');
});
test('dirOf: top-level file returns "."', () => {
assert.equal(dirOf('main.rs'), '.');
});
test('dirOf: empty / non-string returns ""', () => {
assert.equal(dirOf(''), '');
assert.equal(dirOf(null), '');
});
// ── recordRead + shouldHint ─────────────────────────────────────────
test('shouldHint: first read does NOT hint', () => {
const s = { by_dir: {} };
recordRead(s, 'src/foo', 1000);
assert.equal(shouldHint(s, 'src/foo', 1000), false);
});
test('shouldHint: 4 reads do NOT hint (threshold = 5)', () => {
const s = { by_dir: {} };
for (let i = 0; i < 4; i++) recordRead(s, 'src/foo', 1000 + i);
assert.equal(shouldHint(s, 'src/foo', 1004), false);
});
test('shouldHint: 5th read DOES hint', () => {
const s = { by_dir: {} };
for (let i = 0; i < 5; i++) recordRead(s, 'src/foo', 1000 + i);
assert.equal(shouldHint(s, 'src/foo', 1004), true);
});
test('shouldHint: cooldown suppresses re-fire', () => {
const s = { by_dir: {} };
for (let i = 0; i < 6; i++) recordRead(s, 'src/foo', 1000 + i);
markHint(s, 'src/foo', 1005);
// 1 sec later — still in cooldown
recordRead(s, 'src/foo', 1005 + 1000);
assert.equal(shouldHint(s, 'src/foo', 1005 + 1000), false);
});
test('shouldHint: past cooldown re-fires', () => {
const s = { by_dir: {} };
for (let i = 0; i < 5; i++) recordRead(s, 'src/foo', 1000 + i);
markHint(s, 'src/foo', 1005);
// COOLDOWN_MS + 1 later, plus one more read
const after = 1005 + COOLDOWN_MS + 1;
recordRead(s, 'src/foo', after);
assert.equal(shouldHint(s, 'src/foo', after), true);
});
test('shouldHint: different dirs tracked independently', () => {
const s = { by_dir: {} };
for (let i = 0; i < 5; i++) recordRead(s, 'src/foo', 1000 + i);
for (let i = 0; i < 2; i++) recordRead(s, 'src/bar', 2000 + i);
assert.equal(shouldHint(s, 'src/foo', 1005), true);
assert.equal(shouldHint(s, 'src/bar', 2002), false);
});
test('shouldHint: unknown dir returns false', () => {
const s = { by_dir: {} };
assert.equal(shouldHint(s, 'src/unseen', 1000), false);
});
test('shouldHint: empty dir returns false', () => {
const s = { by_dir: {} };
assert.equal(shouldHint(s, '', 1000), false);
});
// ── buildHint ───────────────────────────────────────────────────────
test('buildHint: contains the directory + module_overview tool', () => {
const out = buildHint('src/storage');
assert.match(out, /src\/storage/);
assert.match(out, /module_overview|overview/);
});
test('buildHint: stays under 300 bytes (single-line budget)', () => {
assert.ok(buildHint('src/storage').length < 300,
`hint length ${buildHint('src/storage').length} exceeds budget`);
});
test('buildHint: starts with [code-graph]', () => {
assert.match(buildHint('any/dir'), /^\[code-graph\]/);
});
test('buildHint: single line (no embedded newlines)', () => {
const out = buildHint('src/foo');
// Trailing newline is added by the caller; the function itself should not embed any.
assert.equal(out.indexOf('\n'), -1, `hint contains newline: ${JSON.stringify(out)}`);
});
// ── isSilenced ──────────────────────────────────────────────────────
test('isSilenced: default (no env) → not silenced', () => {
assert.equal(isSilenced({}), false);
});
test('isSilenced: CODE_GRAPH_QUIET_HOOKS=1 → silenced', () => {
assert.equal(isSilenced({ CODE_GRAPH_QUIET_HOOKS: '1' }), true);
});
test('isSilenced: CODE_GRAPH_QUIET_HOOKS=0 → not silenced', () => {
assert.equal(isSilenced({ CODE_GRAPH_QUIET_HOOKS: '0' }), false);
});
// ── State load / save / TTL pruning ─────────────────────────────────
function tmpCwd() {
// Synthesize a unique cwd path so different test runs don't share state.
const id = crypto.randomBytes(8).toString('hex');
return `/nonexistent-test-cwd-${id}`;
}
test('loadState: missing file returns empty state', () => {
const cwd = tmpCwd();
const s = loadState(cwd);
assert.deepEqual(s, { by_dir: {} });
});
test('loadState + saveState: round-trip preserves by_dir', () => {
const cwd = tmpCwd();
const s1 = { by_dir: { 'src/foo': { reads: 3, last_read_at: 1000, last_hint_at: 0 } } };
saveState(cwd, s1);
const s2 = loadState(cwd, 1000);
assert.equal(s2.by_dir['src/foo'].reads, 3);
// Cleanup
try { fs.unlinkSync(statePath(cwd)); } catch { /* ok */ }
});
test('loadState: entries older than STATE_TTL_MS are pruned', () => {
const cwd = tmpCwd();
const old = { by_dir: {
'src/fresh': { reads: 2, last_read_at: 10_000, last_hint_at: 0 },
'src/stale': { reads: 9, last_read_at: 0, last_hint_at: 0 },
}};
saveState(cwd, old);
const now = STATE_TTL_MS + 100; // way past TTL for the stale entry
const loaded = loadState(cwd, now);
assert.ok(loaded.by_dir['src/fresh'], 'fresh entry kept');
assert.equal(loaded.by_dir['src/stale'], undefined, 'stale entry pruned');
try { fs.unlinkSync(statePath(cwd)); } catch { /* ok */ }
});
test('loadState: malformed JSON returns empty state', () => {
const cwd = tmpCwd();
const p = statePath(cwd);
fs.writeFileSync(p, 'not json {{{', 'utf8');
const s = loadState(cwd);
assert.deepEqual(s, { by_dir: {} });
try { fs.unlinkSync(p); } catch { /* ok */ }
});
// ── Integrated flow ─────────────────────────────────────────────────
test('flow: 5 reads to same dir → hint, 6th read same dir → no hint (cooldown)', () => {
const s = { by_dir: {} };
// Reads 1-4: no hint
for (let i = 0; i < 4; i++) {
recordRead(s, 'src/foo', 1000 + i);
assert.equal(shouldHint(s, 'src/foo', 1000 + i), false, `read ${i+1} should not hint`);
}
// Read 5: hint
recordRead(s, 'src/foo', 1004);
assert.equal(shouldHint(s, 'src/foo', 1004), true);
markHint(s, 'src/foo', 1004);
// Read 6 within cooldown: no hint
recordRead(s, 'src/foo', 1005);
assert.equal(shouldHint(s, 'src/foo', 1005), false);
});
+1
-1

@@ -7,3 +7,3 @@ {

},
"version": "0.27.0",
"version": "0.28.0",
"keywords": [

@@ -10,0 +10,0 @@ "code-graph",

@@ -37,2 +37,12 @@ {

]
},
{
"matcher": "tool == \"Read\"",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-read-guide.js\"",
"timeout": 3
}
]
}

@@ -39,0 +49,0 @@ ],

@@ -27,3 +27,12 @@ #!/usr/bin/env node

const GREP_HEAD = /^\s*(?:env\s+\S+=\S+\s+)*(grep|rg|ag)\b/;
const SRC_PATH = /(?:^|\s|["'])(src|tests|lib|scripts|claude-plugin|tools|pkg|cmd|internal|app|components?|server|client|crates|packages)\//;
// Source-tree prefix list. Expanded v0.27+ Phase C: original `src/tests/lib/...`
// missed real-world backend conventions where the prefix list term is preceded
// by something else (`backend/app/...` — `app/` doesn't match because `/` isn't
// in the lookbehind). 7d audit found 5 of the worst missed sessions used the
// daagu `backend/app/services/...` layout. Added: backend/frontend/services/
// models/domain/controllers/views/handlers/middleware/routes/repositories/
// entities/migrations/tasks/jobs/workers/features/modules/api/web. Generic
// terms like `core`/`utils`/`shared`/`common`/`types` deliberately omitted —
// they appear in too many non-code contexts to be precise enough.
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)\//;
const PIPE_INTO_GREP = /\|\s*(?:grep|rg|ag)\b/;

@@ -30,0 +39,0 @@ const CG_INVOKED = /\bcode-graph-mcp\b/;

@@ -173,2 +173,71 @@ 'use strict';

// ── Phase C: extended prefixes (real-world backend / DDD / web conventions) ──
// daagu pattern: `backend/app/services/...` — `app/` is preceded by `backend/`,
// which doesn't satisfy the `(?:^|\s|["'])` lookbehind in the old SRC_PATH.
// 7d audit found 5 of the worst missed sessions used exactly this layout.
test('shouldHint: grep -rn on backend/app/services/ (daagu)', () => {
assert.equal(
shouldHint('grep -rn "pct_chg|pct_change" backend/app/services/context_builder.py'),
true
);
});
test('shouldHint: grep -rn on backend/app/services/scheduler/', () => {
assert.equal(
shouldHint('grep -rn "TASK_ZOMBIE|zombie recovery|reason=age" backend/app/services/scheduler/'),
true
);
});
test('shouldHint: grep on services/ (no backend prefix)', () => {
assert.equal(shouldHint('grep -rn "fetchUser" services/auth/'), true);
});
test('shouldHint: grep on models/ (Rails / Django)', () => {
assert.equal(shouldHint('grep -rn "before_save" models/user.rb'), true);
});
test('shouldHint: grep on controllers/ (Rails / ASP.NET)', () => {
assert.equal(shouldHint('grep -rn "def index" controllers/UsersController.rb'), true);
});
test('shouldHint: grep on domain/ (DDD architecture)', () => {
assert.equal(shouldHint('grep -rn "Aggregate" domain/orders/'), true);
});
test('shouldHint: grep on handlers/ (web server)', () => {
assert.equal(shouldHint('grep -rn "func New" handlers/api/'), true);
});
test('shouldHint: grep on migrations/ (db schema)', () => {
assert.equal(shouldHint('grep -rn "add_column" migrations/'), true);
});
test('shouldHint: grep on features/ (modular monolith)', () => {
assert.equal(shouldHint('grep -rn "useFeature" features/billing/'), true);
});
test('shouldHint: grep on api/ + frontend/', () => {
assert.equal(shouldHint('grep -rn "POST" api/v1/'), true);
assert.equal(shouldHint('grep -rn "import React" frontend/'), true);
});
// Precision guards — these MUST still NOT fire after the expansion.
test('shouldHint: grep on web.config (config file ext keeps suppression)', () => {
assert.equal(shouldHint('grep "<connectionStrings" web.config'), false);
});
test('shouldHint: grep on node_modules/ (NOT in src list)', () => {
assert.equal(shouldHint('grep -rn "deprecated" node_modules/some-pkg/'), false);
});
test('shouldHint: grep on docs/ (docs trees stay out)', () => {
// We deliberately did NOT add `docs` to the prefix list — docs are typically
// markdown and the existing CONFIG_TARGET_ONLY already filters `.md`-only
// greps. A bare `grep "X" docs/foo.md` would be CONFIG_TARGET_ONLY-suppressed.
assert.equal(shouldHint('grep "v0.24" docs/CHANGELOG.md'), false);
});
// ── Regression cases from real session telemetry (2026-05-11) ───────

@@ -175,0 +244,0 @@

@@ -20,2 +20,3 @@ #!/usr/bin/env node

search: 60 * 1000, // 1min
symptom: 10 * 60 * 1000, // 10min — Phase E meta-advisory hint, low value to repeat
};

@@ -311,3 +312,46 @@

function determineQueryType(intents, symbols, filePaths, isCoolingDownFn) {
// Phase E: symptom-driven fallback. The 7d audit + TriggerRate hard-oracle
// baseline (60%) show the failure mode "user phrases a problem without giving
// a symbol/path/file — Claude defaults to bash grep". The 4 existing channels
// (intent / qualified symbol / file path / any symbol) all miss on these
// prompts. SYMPTOM_PATTERNS catches the bug-flavored prompts so we can emit
// ONE-LINE prose hint (no CLI execution — different from the other 4 paths
// that inject results). Keeps noise low while planting the routing seed.
const SYMPTOM_PATTERNS = [
// English symptom / failure-mode markers
/\bbug\b/i,
/\bcrash(?:ed|ing|es)?\b/i,
/\bbroken\b/i,
/\bnot work(?:ing)?\b/i,
/\bdoesn'?t work\b/i,
/\bfail(?:ed|ing|s|ure)?\b/i,
/\bwhy (?:does|is|are|isn'?t|doesn'?t|won'?t)/i,
/\bmissing\b/i,
// Chinese symptom / failure markers
/有\s?bug/i,
/又挂/,
/又失败/,
/挂了/,
/失败了/,
/卡死/,
/卡住/,
/不准/,
/不对/,
/缺失/,
/丢失/,
/没响应/,
/出错/,
/报错/,
/为什么/,
/怎么(?:修|解决)/,
/哪里[\s\S]{0,5}(?:错|有问题|不对)/, // 哪里写错 / 哪里出错 / 哪里有问题 / 哪里报错了
/出了什么问题/,
];
function hasSymptom(msg) {
if (!msg || typeof msg !== 'string') return false;
return SYMPTOM_PATTERNS.some(p => p.test(msg));
}
function determineQueryType(intents, symbols, filePaths, isCoolingDownFn, message = '') {
const hasStrict = symbols.symbols.length > 0 && !symbols.lowConfidence;

@@ -317,5 +361,2 @@ const hasQualified = symbols.symbols.some(s => s.includes('::'));

// Gate: need intent, qualified symbol, file path, or any symbol
if (!hasAny && !hasQualified && filePaths.length === 0 && symbols.symbols.length === 0) return null;
const cd = isCoolingDownFn || (() => false);

@@ -329,2 +370,9 @@

// Phase E fallback: nothing actionable above, but message has symptom phrasing.
// Emit ONE-LINE prose hint (no CLI execution). Default-empty `message` keeps
// backward-compat for callers that don't pass it (legacy `analyze` helpers).
if (message && hasSymptom(message) && !cd('symptom')) {
return { type: 'symptom-hint' };
}
return null;

@@ -380,6 +428,17 @@ }

const intents = detectIntents(message);
const query = determineQueryType(intents, symbols, filePaths, isCoolingDown);
const query = determineQueryType(intents, symbols, filePaths, isCoolingDown, message);
if (!query) return;
// Phase E: symptom-hint is prose-only (no CLI execution). Emit + cooldown
// before the result-fetching paths so it can short-circuit cleanly.
if (query.type === 'symptom-hint') {
markCooldown('symptom');
process.stdout.write(
'[code-graph:hint] indexed repo — for vague-symptom prompts, try `semantic_code_search "<symptom>"` ' +
'or `module_overview <suspected-dir>` to surface candidate code structurally. Skip if not searching code.\n'
);
return;
}
const PREFIXES = {

@@ -421,2 +480,2 @@ impact: '[code-graph:impact] Blast radius — review before editing:',

module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE };
module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE, hasSymptom, SYMPTOM_PATTERNS };

@@ -402,3 +402,4 @@ 'use strict';

const intents = detectIntents(msg);
const query = determineQueryType(intents, sym, fp);
// Phase E: pass message into determineQueryType so symptom-hint fallback fires.
const query = determineQueryType(intents, sym, fp, undefined, msg);
return { query, intents, symbols: sym, filePaths: fp };

@@ -560,1 +561,130 @@ }

});
// ── Phase E: hasSymptom + symptom-hint fallback ──────────────
const { hasSymptom, SYMPTOM_PATTERNS } = require('./user-prompt-context');
test('hasSymptom: 报告数据不准', () => {
assert.equal(hasSymptom('今天的报告数据不准'), true);
});
test('hasSymptom: test 又挂了', () => {
assert.equal(hasSymptom('test 又挂了'), true);
});
test('hasSymptom: Why does this not work?', () => {
assert.equal(hasSymptom('Why does this not work?'), true);
});
test('hasSymptom: 有 bug', () => {
assert.equal(hasSymptom('有 bug,帮我看看'), true);
});
test('hasSymptom: 为什么 (vague-question marker)', () => {
assert.equal(hasSymptom('为什么会这样'), true);
});
test('hasSymptom: 哪里写错了', () => {
assert.equal(hasSymptom('find 一下哪里写错了'), true);
});
test('hasSymptom: doesn\'t work / not working', () => {
assert.equal(hasSymptom("this doesn't work as expected"), true);
assert.equal(hasSymptom('the service is not working'), true);
});
test('hasSymptom: 挂了 / 失败 / 卡死', () => {
assert.equal(hasSymptom('test 挂了'), true);
assert.equal(hasSymptom('又失败了'), true);
assert.equal(hasSymptom('整个服务卡死了'), true);
});
// Precision: must NOT flag normal task statements as symptoms.
test('hasSymptom: 修改 parse_code → false', () => {
assert.equal(hasSymptom('修改 parse_code 函数增加错误处理'), false);
});
test('hasSymptom: 看看 src/mcp/ → false', () => {
assert.equal(hasSymptom('看看 src/mcp/ 模块的代码结构'), false);
});
test('hasSymptom: write tests → false', () => {
assert.equal(hasSymptom('write tests for the embedding module'), false);
});
test('hasSymptom: empty / non-string → false', () => {
assert.equal(hasSymptom(''), false);
assert.equal(hasSymptom(null), false);
assert.equal(hasSymptom(undefined), false);
});
test('SYMPTOM_PATTERNS: exported + non-empty array', () => {
assert.ok(Array.isArray(SYMPTOM_PATTERNS));
assert.ok(SYMPTOM_PATTERNS.length >= 8,
`SYMPTOM_PATTERNS has ${SYMPTOM_PATTERNS.length} entries; want ≥8 for coverage`);
});
// ── determineQueryType: symptom-hint fallback ────────────────
test('symptom-fallback: pure symptom message, no anchor → symptom-hint', () => {
const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: false };
const symbols = { symbols: [], lowConfidence: false };
const result = determineQueryType(intents, symbols, [], undefined, '今天的报告数据不准');
assert.equal(result && result.type, 'symptom-hint');
});
test('symptom-fallback: intent + no symbol/path + symptom → symptom-hint', () => {
// "find 一下哪里写错了" — search intent fires but no symbol or path is extractable.
const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: true };
const symbols = { symbols: [], lowConfidence: false };
const result = determineQueryType(intents, symbols, [], undefined, 'find 一下哪里写错了');
assert.equal(result && result.type, 'symptom-hint');
});
test('symptom-fallback: actionable path beats symptom-hint (precedence)', () => {
// Impact path with strict symbol must take precedence even when symptom phrasing is present.
const intents = { impact: true, modify: false, implement: false, understand: false, callgraph: false, search: false };
const symbols = { symbols: ['parse_code'], lowConfidence: false };
const result = determineQueryType(intents, symbols, [], undefined, '修改前看看 parse_code 的 bug 影响');
assert.equal(result.type, 'impact');
});
test('symptom-fallback: no symptom + no anchor → null (unchanged)', () => {
const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: false };
const symbols = { symbols: [], lowConfidence: false };
const result = determineQueryType(intents, symbols, [], undefined, 'hello there');
assert.equal(result, null);
});
test('symptom-fallback: cooldown blocks symptom-hint', () => {
const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: false };
const symbols = { symbols: [], lowConfidence: false };
const result = determineQueryType(intents, symbols, [], (t) => t === 'symptom', '今天的报告数据不准');
assert.equal(result, null);
});
test('symptom-fallback: omitted message arg → backward-compat null', () => {
// Existing callers (and the legacy bench harness) call determineQueryType
// without the 5th arg. The fallback must NOT fire — preserve prior behavior.
const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: false };
const symbols = { symbols: [], lowConfidence: false };
const result = determineQueryType(intents, symbols, []);
assert.equal(result, null);
});
// ── Integration: analyze() with symptom-only messages ──
test('integration: 今天的报告数据不准 → symptom-hint', () => {
const r = analyze('今天的报告数据不准');
assert.equal(r.query && r.query.type, 'symptom-hint');
});
test('integration: test 又挂了 → symptom-hint', () => {
const r = analyze('test 又挂了');
assert.equal(r.query && r.query.type, 'symptom-hint');
});
test('integration: Why does this not work? → symptom-hint', () => {
const r = analyze('Why does this not work?');
assert.equal(r.query && r.query.type, 'symptom-hint');
});
{
"name": "@sdsrs/code-graph",
"version": "0.27.0",
"version": "0.28.0",
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",

@@ -38,8 +38,8 @@ "license": "MIT",

"optionalDependencies": {
"@sdsrs/code-graph-linux-x64": "0.27.0",
"@sdsrs/code-graph-linux-arm64": "0.27.0",
"@sdsrs/code-graph-darwin-x64": "0.27.0",
"@sdsrs/code-graph-darwin-arm64": "0.27.0",
"@sdsrs/code-graph-win32-x64": "0.27.0"
"@sdsrs/code-graph-linux-x64": "0.28.0",
"@sdsrs/code-graph-linux-arm64": "0.28.0",
"@sdsrs/code-graph-darwin-x64": "0.28.0",
"@sdsrs/code-graph-darwin-arm64": "0.28.0",
"@sdsrs/code-graph-win32-x64": "0.28.0"
}
}