@sdsrs/code-graph
Advanced tools
| #!/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); | ||
| }); |
@@ -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'); | ||
| }); |
+6
-6
| { | ||
| "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" | ||
| } | ||
| } |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
334295
8.71%41
5.13%6753
9.43%80
3.9%