From 3aacb7e9e0c4ee250ab9130639ff5b538c4fba5a Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Thu, 11 Jun 2026 02:24:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20answer=20in=20the=20deny=20?= =?UTF-8?q?=E2=80=94=20denied=20greps=20return=20actual=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When pre-grep-guide denies a symbol-shaped raw grep, run the AST-aware equivalent (code-graph-mcp grep, ~20ms warm, 2s timeout) synchronously inside the hook and embed the results in the deny reason. Measured recommend->use transfer of suggestion-style interventions is ~0%; results already in front of the model bypass that choice entirely. Three outcomes: hits -> deny with embedded results (4KB line-boundary truncation, recs answered:true); CLI unavailable/error/timeout -> v0.46 static deny (answered:false); 0 hits -> ALLOW with one-line FYI (fallthrough:"no-hits") since regex-dialect differences mean 0 hits is not proof of absence. Opt-out: CODE_GRAPH_NO_ANSWER_IN_DENY=1. Verified non-polluting: CLI grep does not write usage.jsonl, so hook-initiated runs cannot inflate the deny->use funnel. Rust untouched (answered/fallthrough are additive JSONL fields readers ignore). Also hardens hook stdin: read fd 0 instead of /dev/stdin (path form fails silently on socketpair stdin, e.g. spawnSync({input}) harnesses). additive: new-test-first, no prior failing path (35 new tests RED->GREEN; 121/121 pre-grep-guide + 14/14 cg-answer; live smoke against real repo index embedded real extract_relations hits, residue cleaned). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 25 +++ claude-plugin/scripts/cg-answer.js | 107 +++++++++ claude-plugin/scripts/cg-answer.test.js | 135 +++++++++++ claude-plugin/scripts/pre-grep-guide.js | 93 +++++++- claude-plugin/scripts/pre-grep-guide.test.js | 224 +++++++++++++++++++ 6 files changed, 584 insertions(+), 5 deletions(-) create mode 100644 claude-plugin/scripts/cg-answer.js create mode 100644 claude-plugin/scripts/cg-answer.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb67b4d..5fc383a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,10 @@ jobs: # local dev setup. hooks.test.js / pre-edit-guide.test.js were added # in v0.31.2 after the matcher-regression bug; future additions # should be vetted locally first, not blind-globbed. - run: node --test claude-plugin/scripts/lifecycle.test.js claude-plugin/scripts/lifecycle.e2e.test.js claude-plugin/scripts/auto-update.test.js claude-plugin/scripts/session-init.test.js claude-plugin/scripts/hooks.test.js claude-plugin/scripts/pre-edit-guide.test.js scripts/release-smoke.test.js + # pre-grep-guide.test.js + cg-answer.test.js added in v0.47.0 + # (deny-with-answer); vetted locally — node-only, stub binary via + # _CG_ANSWER_BINARY, tmpdir fixtures, no cargo build required. + run: node --test claude-plugin/scripts/lifecycle.test.js claude-plugin/scripts/lifecycle.e2e.test.js claude-plugin/scripts/auto-update.test.js claude-plugin/scripts/session-init.test.js claude-plugin/scripts/hooks.test.js claude-plugin/scripts/pre-edit-guide.test.js claude-plugin/scripts/pre-grep-guide.test.js claude-plugin/scripts/cg-answer.test.js scripts/release-smoke.test.js # Supply-chain CVE scan over Cargo.lock (376 transitive deps including the # Candle ML stack). cargo-audit reads RustSec advisories and exits non-zero diff --git a/CHANGELOG.md b/CHANGELOG.md index 705de70..cd009fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## v0.47.0 — feat: answer in the deny — denied greps now return the actual results + +**What changes for users**: when the PreToolUse hook denies a symbol-shaped raw grep, the +deny message now CONTAINS the results of the AST-aware equivalent (`code-graph-mcp grep +"" [path]`, run synchronously inside the hook, ~20ms warm / 2s timeout) instead of +only suggesting the command. Rationale: measured recommend→use transfer of suggestion-style +interventions is ~0% — the model rarely initiates a new tool call because a message told it +to, but it will use results already in front of it. +**Opt-out / revert**: `CODE_GRAPH_NO_ANSWER_IN_DENY=1` restores the v0.46 static deny; +`CODE_GRAPH_NO_BLOCK_GREP=1` still downgrades the whole block tier to hint. + +- **Three deny outcomes** (new `cg-answer.js`, all failure modes degrade, never break the + tool call): ≥1 hit → deny with embedded results (truncated at line boundary to ≤4KB); + CLI missing/error/timeout → v0.46 static deny; **0 hits → the raw grep is ALLOWED** with a + one-line FYI (regex-dialect differences — BRE `\|` vs ripgrep — mean 0 hits is not proof + of absence, so a hard deny could mislead). +- **Funnel semantics**: deny records gain `answered: true|false`; no-hit fallthroughs record + `{action:"hint", fallthrough:"no-hits"}`. Rust readers ignore the extra fields (verified: + CLI `grep` does not write `usage.jsonl`, so hook-initiated runs cannot inflate deny→use). + **Reading note**: an answered deny satisfies the need in-place, so `Deny→use` will read + LOW even when this feature works — segment by `answered` when reading Piece 3. +- **Hook stdin hardening**: hooks now read fd 0 directly instead of `/dev/stdin` (the path + form fails silently when stdin is a socketpair, e.g. under `spawnSync({input})` test + harnesses; real Claude Code pipes were unaffected). + ## v0.46.0 — feat: measure whether the DENY stick converts + honest conversion metric The recommend→use conversion metric (v0.39.0) was producing **zero usable data** in this repo diff --git a/claude-plugin/scripts/cg-answer.js b/claude-plugin/scripts/cg-answer.js new file mode 100644 index 0000000..bca8002 --- /dev/null +++ b/claude-plugin/scripts/cg-answer.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node +'use strict'; +// Synchronous "answer in the deny" runner (v0.47.0). +// +// When pre-grep-guide denies a symbol-shaped raw grep, the measured +// recommend→use transfer rate of a bare suggestion is ~0% — the model rarely +// initiates a NEW tool call just because a deny message told it to. This module +// closes that gap by running the AST-aware equivalent (`code-graph-mcp grep +// "" [path]`) inside the hook and handing the deny path the actual +// results, so the model never has to choose. +// +// Posture mirrors recommendation-log.js: bounded and best-effort. Any failure +// (no binary, nonzero exit, timeout, oversized pattern) degrades to +// `unavailable` and the caller falls back to the static deny — answering is an +// enhancement, never a new failure mode for the tool call. +// +// Verified non-polluting: the CLI `grep` subcommand does not write +// usage.jsonl (only the MCP server's SessionMetrics does), so hook-initiated +// runs cannot inflate the deny→use conversion funnel. + +const { spawnSync } = require('child_process'); + +const DEFAULT_TIMEOUT_MS = 2000; +// ~1000 tokens. A deny reason carrying more than this stops being an answer +// and starts being a context tax. +const DEFAULT_MAX_BYTES = 4000; +const MAX_PATTERN_LEN = 200; +// CLI empty-result contract (text mode): stable prefix owned by this repo. +const NO_MATCH_PREFIX = '[code-graph] No matches'; + +/** + * Truncate text to maxBytes, cutting at the last complete line that fits. + * Falls back to a hard byte cut when even the first line is oversized. + * @returns {{text: string, truncated: boolean}} + */ +function truncateAtLine(text, maxBytes) { + if (Buffer.byteLength(text, 'utf8') <= maxBytes) { + return { text, truncated: false }; + } + const buf = Buffer.from(text, 'utf8'); + const head = buf.subarray(0, maxBytes).toString('utf8'); + // Drop a possibly half-cut trailing line (and any UTF-8 replacement char + // from a mid-codepoint cut rides along with it). + const lastNl = head.lastIndexOf('\n'); + if (lastNl > 0) { + return { text: head.slice(0, lastNl), truncated: true }; + } + return { text: buf.subarray(0, maxBytes).toString('latin1'), truncated: true }; +} + +/** + * Run `code-graph-mcp grep [searchPath]` synchronously. + * + * @param {object} opts + * @param {string} opts.cwd project root (hook process.cwd()) + * @param {string} opts.pattern the symbol-shaped pattern that triggered the deny + * @param {string} [opts.searchPath] optional path scope extracted from the denied command + * @param {string|null} [opts.binary] binary path; tests inject a stub. Defaults to + * `_CG_ANSWER_BINARY` env override, then findBinary(). + * @param {number} [opts.timeoutMs] + * @param {number} [opts.maxBytes] + * @returns {{status: 'hits', text: string, truncated: boolean} + * | {status: 'no-hits'} + * | {status: 'unavailable'}} + */ +function runGrepAnswer(opts = {}) { + const { + cwd, + pattern, + searchPath, + timeoutMs = DEFAULT_TIMEOUT_MS, + maxBytes = DEFAULT_MAX_BYTES, + } = opts; + try { + if (!pattern || typeof pattern !== 'string' || pattern.length > MAX_PATTERN_LEN) { + return { status: 'unavailable' }; + } + let binary = opts.binary; + if (binary === undefined) { + binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary(); + } + if (!binary) return { status: 'unavailable' }; + + const args = ['grep', pattern]; + if (searchPath) args.push(searchPath); + const res = spawnSync(binary, args, { + cwd, + timeout: timeoutMs, + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (res.error || res.signal || res.status !== 0) { + return { status: 'unavailable' }; + } + const out = (res.stdout || '').trim(); + if (!out || out.startsWith(NO_MATCH_PREFIX)) { + return { status: 'no-hits' }; + } + const { text, truncated } = truncateAtLine(out, maxBytes); + return { status: 'hits', text, truncated }; + } catch { + return { status: 'unavailable' }; + } +} + +module.exports = { runGrepAnswer, truncateAtLine }; diff --git a/claude-plugin/scripts/cg-answer.test.js b/claude-plugin/scripts/cg-answer.test.js new file mode 100644 index 0000000..cbf51ce --- /dev/null +++ b/claude-plugin/scripts/cg-answer.test.js @@ -0,0 +1,135 @@ +'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 { runGrepAnswer, truncateAtLine } = require('./cg-answer'); + +// Stub "binary": a node script that reacts to its first real arg so one stub +// covers hits / no-hits / error / timeout cases. +let stubDir; +let stubPath; + +test.before(() => { + stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-answer-test-')); + stubPath = path.join(stubDir, 'cg-stub.js'); + fs.writeFileSync(stubPath, `#!/usr/bin/env node +'use strict'; +const pattern = process.argv[3] || ''; +if (pattern === 'HangForever') { setTimeout(() => {}, 60000); } +else if (pattern === 'ExplodePlease') { process.exit(3); } +else if (pattern === 'NothingHere') { + process.stdout.write('[code-graph] No matches for: NothingHere\\n'); +} else { + process.stdout.write( + 'src/storage/db.rs:42 fn ' + pattern + '() {\\n' + + ' -> fn ' + pattern + ' (lines 42-60)\\n' + + 'args=' + JSON.stringify(process.argv.slice(2)) + '\\n'); +} +`); +}); + +test.after(() => { + fs.rmSync(stubDir, { recursive: true, force: true }); +}); + +// Wrap the stub so spawnSync can exec it directly: binary = node, leading arg +// trick is not possible (runGrepAnswer controls args), so expose via a shim +// shell-free approach: point binary at node and prepend the script through +// _CG_ANSWER_BINARY handling is binary-only. Instead make the stub itself +// executable with a node shebang and rely on exec. +function stubBinary() { + fs.chmodSync(stubPath, 0o755); + return stubPath; +} + +test('runGrepAnswer: hits → status hits with stdout text', () => { + const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: stubBinary() }); + assert.equal(r.status, 'hits'); + assert.match(r.text, /fn fts5_search/); +}); + +test('runGrepAnswer: passes grep subcommand, pattern and path as argv', () => { + const r = runGrepAnswer({ + cwd: stubDir, pattern: 'fts5_search', searchPath: 'src/storage/', binary: stubBinary(), + }); + assert.equal(r.status, 'hits'); + assert.match(r.text, /args=\["grep","fts5_search","src\/storage\/"\]/); +}); + +test('runGrepAnswer: omits path argv when no searchPath', () => { + const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: stubBinary() }); + assert.match(r.text, /args=\["grep","fts5_search"\]/); +}); + +test('runGrepAnswer: CLI "[code-graph] No matches" → status no-hits', () => { + const r = runGrepAnswer({ cwd: stubDir, pattern: 'NothingHere', binary: stubBinary() }); + assert.equal(r.status, 'no-hits'); +}); + +test('runGrepAnswer: nonzero exit → unavailable', () => { + const r = runGrepAnswer({ cwd: stubDir, pattern: 'ExplodePlease', binary: stubBinary() }); + assert.equal(r.status, 'unavailable'); +}); + +test('runGrepAnswer: missing binary → unavailable', () => { + const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: null }); + assert.equal(r.status, 'unavailable'); +}); + +test('runGrepAnswer: nonexistent binary path → unavailable', () => { + const r = runGrepAnswer({ + cwd: stubDir, pattern: 'fts5_search', binary: path.join(stubDir, 'nope-bin'), + }); + assert.equal(r.status, 'unavailable'); +}); + +test('runGrepAnswer: timeout → unavailable', () => { + const r = runGrepAnswer({ + cwd: stubDir, pattern: 'HangForever', binary: stubBinary(), timeoutMs: 300, + }); + assert.equal(r.status, 'unavailable'); +}); + +test('runGrepAnswer: empty pattern → unavailable (never spawns)', () => { + const r = runGrepAnswer({ cwd: stubDir, pattern: '', binary: stubBinary() }); + assert.equal(r.status, 'unavailable'); +}); + +test('runGrepAnswer: oversized pattern (>200ch) → unavailable (never spawns)', () => { + const r = runGrepAnswer({ cwd: stubDir, pattern: 'A'.repeat(201), binary: stubBinary() }); + assert.equal(r.status, 'unavailable'); +}); + +test('runGrepAnswer: long output is truncated with marker', () => { + // Stub echoes args= line; force truncation via tiny maxBytes + const r = runGrepAnswer({ + cwd: stubDir, pattern: 'fts5_search', binary: stubBinary(), maxBytes: 30, + }); + assert.equal(r.status, 'hits'); + assert.equal(r.truncated, true); + assert.ok(Buffer.byteLength(r.text, 'utf8') <= 30); +}); + +// ── truncateAtLine (pure) ─────────────────────────────────────────── + +test('truncateAtLine: under limit → unchanged, not truncated', () => { + const { text, truncated } = truncateAtLine('a\nb\nc', 100); + assert.equal(text, 'a\nb\nc'); + assert.equal(truncated, false); +}); + +test('truncateAtLine: cuts at a line boundary', () => { + const input = 'line-one\nline-two\nline-three\n'; + const { text, truncated } = truncateAtLine(input, 20); + assert.equal(truncated, true); + // 20-byte budget fits 'line-one\nline-two' (17B); the half-cut 'li' is dropped + assert.equal(text, 'line-one\nline-two'); +}); + +test('truncateAtLine: single oversized line → hard cut', () => { + const { text, truncated } = truncateAtLine('x'.repeat(50), 10); + assert.equal(truncated, true); + assert.equal(Buffer.byteLength(text, 'utf8'), 10); +}); diff --git a/claude-plugin/scripts/pre-grep-guide.js b/claude-plugin/scripts/pre-grep-guide.js index 3818cb9..5ca2298 100644 --- a/claude-plugin/scripts/pre-grep-guide.js +++ b/claude-plugin/scripts/pre-grep-guide.js @@ -30,6 +30,7 @@ const path = require('path'); const crypto = require('crypto'); const { cgTmpDir } = require('./tmp-dir'); const { recordRecommendation } = require('./recommendation-log'); +const { runGrepAnswer } = require('./cg-answer'); // --- Pure logic (testable) --- @@ -43,7 +44,11 @@ const GREP_HEAD = /^\s*(?:env\s+\S+=\S+\s+)*(grep|rg|ag)\b/; // 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 SRC_PREFIXES = + '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 SRC_PATH = new RegExp(`(?:^|\\s|["'])(${SRC_PREFIXES})/`); +// Anchored variant for whole-token matching in extractSearchPath. +const SRC_PATH_TOKEN = new RegExp(`^(?:\\./)?(${SRC_PREFIXES})/`); const PIPE_INTO_GREP = /\|\s*(?:grep|rg|ag)\b/; const CG_INVOKED = /\bcode-graph-mcp\b/; // A file argument that ends in a config/lockfile extension AND no source-tree @@ -112,6 +117,24 @@ function shouldBlock(cmd) { return patterns.some(p => IDENTIFIER_LIKE.test(p)); } +// v0.47.0 — pull the first source-tree path token out of the denied command so +// the inline answer can scope its search the same way the raw grep would have. +function extractSearchPath(cmd) { + if (!cmd || typeof cmd !== 'string') return undefined; + for (const raw of cmd.split(/\s+/)) { + const token = raw.replace(/^["']|["']$/g, ''); + if (!token || token.startsWith('-')) continue; + if (token.includes('..')) return undefined; // traversal — don't scope, don't guess + if (SRC_PATH_TOKEN.test(token)) return token; + } + return undefined; +} + +// v0.47.0 — the pattern that justified the block: first identifier-like one. +function pickBlockPattern(cmd) { + return extractPatterns(cmd).find(p => IDENTIFIER_LIKE.test(p)); +} + function commandHash(cmd) { return crypto.createHash('sha1').update(cmd).digest('hex').slice(0, 12); } @@ -155,6 +178,34 @@ function buildBlockReason() { ].join('\n'); } +// v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare +// deny still asks the model to initiate a new tool call; embedding the actual +// results removes that choice entirely. Keep the escape hatch line — raw-text +// regex (BRE alternation, log scans) remains a legitimate need. +function buildBlockReasonWithAnswer(pattern, searchPath, answer) { + const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`; + const lines = [ + '[code-graph] Raw `grep` on indexed source — denied; the AST-aware equivalent already ran for you:', + `$ ${cmdShown}`, + answer.text, + ]; + if (answer.truncated) { + lines.push(`(truncated — run \`${cmdShown}\` yourself for the full list)`); + } + lines.push( + 'Each hit shows its containing fn/module — use these results directly instead of re-running the search.', + 'For raw-text regex (alternation, log/comment scans), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.', + ); + return lines.join('\n'); +} + +// v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs +// ripgrep) mean 0 hits is NOT proof of absence, so denying here could mislead. +// Let the raw grep through with an honest one-liner. +function buildNoHitsFyi(pattern) { + return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding.`; +} + // --- Main execution (only when run directly) --- // Kill switch: matches user-prompt-context.js convention. =1 forces silence @@ -172,6 +223,12 @@ function isBlockDisabled(env = process.env) { return env.CODE_GRAPH_NO_BLOCK_GREP === '1'; } +// v0.47.0 — opt-out for the inline-answer tier only: =1 restores the v0.46 +// static deny (no CLI run inside the hook). Independent of NO_BLOCK_GREP. +function isAnswerDisabled(env = process.env) { + return env.CODE_GRAPH_NO_ANSWER_IN_DENY === '1'; +} + function runMain() { if (isSilenced()) return; const cwd = process.cwd(); @@ -180,7 +237,10 @@ function runMain() { let input; try { - input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8')); + // fd 0, not '/dev/stdin': the path form open(2)s the symlink target, which + // fails with ENXIO when stdin is a socketpair (e.g. spawnSync {input}). + // Reading the fd directly works for pipes, sockets, and files alike. + input = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { return; } const cmd = (input.tool_input && input.tool_input.command) || ''; @@ -190,17 +250,37 @@ function runMain() { markCooldown(cmd); if (!isBlockDisabled() && shouldBlock(cmd)) { + // v0.47.0 — run the AST-aware equivalent inside the hook and embed the + // results in the deny reason ("answer in the deny"). Degrades to the + // v0.46 static deny on any failure; downgrades to allow+FYI on 0 hits + // (regex-dialect differences mean 0 hits ≠ proof of absence). + let answer = { status: 'unavailable' }; + const pattern = pickBlockPattern(cmd); + if (!isAnswerDisabled() && pattern) { + answer = runGrepAnswer({ cwd, pattern, searchPath: extractSearchPath(cmd) }); + } + + if (answer.status === 'no-hits') { + recordRecommendation(cwd, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' }); + process.stdout.write(buildNoHitsFyi(pattern) + '\n'); + return; + } + // PreToolUse block via current CC schema (`hookSpecificOutput.permissionDecision`). // Verified empirically 2026-05-24: legacy `{decision:"block",reason}` was // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form // is the documented modern path. Exit 0 — this is a routing decision, not // a hook failure (exit 2 would mark the tool call as "hook errored"). - recordRecommendation(cwd, { hook: 'grep', action: 'deny' }); + recordRecommendation(cwd, { + hook: 'grep', action: 'deny', answered: answer.status === 'hits', + }); process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - permissionDecisionReason: buildBlockReason(), + permissionDecisionReason: answer.status === 'hits' + ? buildBlockReasonWithAnswer(pattern, extractSearchPath(cmd), answer) + : buildBlockReason(), }, }) + '\n'); return; @@ -218,11 +298,16 @@ module.exports = { shouldHint, shouldBlock, extractPatterns, // v0.32.1 — exposed for tests + extractSearchPath, // v0.47.0 — deny-with-answer + pickBlockPattern, buildHint, buildBlockReason, + buildBlockReasonWithAnswer, + buildNoHitsFyi, commandHash, isOnCooldown, markCooldown, isSilenced, isBlockDisabled, + isAnswerDisabled, }; diff --git a/claude-plugin/scripts/pre-grep-guide.test.js b/claude-plugin/scripts/pre-grep-guide.test.js index b2afc15..fc76b3c 100644 --- a/claude-plugin/scripts/pre-grep-guide.test.js +++ b/claude-plugin/scripts/pre-grep-guide.test.js @@ -5,11 +5,16 @@ const { shouldHint, shouldBlock, extractPatterns, + extractSearchPath, + pickBlockPattern, buildHint, buildBlockReason, + buildBlockReasonWithAnswer, + buildNoHitsFyi, commandHash, isSilenced, isBlockDisabled, + isAnswerDisabled, } = require('./pre-grep-guide'); // ── Should fire: bare grep/rg/ag on indexed source tree ───────────── @@ -524,3 +529,222 @@ test('I4: grep -rn "def calc_total" src/ → BLOCK (def at start + snake_case)', test('I4: grep -rn "fn render" src/ → BLOCK (decl anchor at start)', () => { assert.equal(shouldBlock('grep -rn "fn render" src/'), true); }); + +// ── v0.47.0 deny-with-answer: extractSearchPath / pickBlockPattern ── + +test('extractSearchPath: dir path after pattern', () => { + assert.equal(extractSearchPath('grep -rn "fts5_search" src/storage/'), 'src/storage/'); +}); + +test('extractSearchPath: single file in src/', () => { + assert.equal( + extractSearchPath('grep -n "split_identifier" src/search/tokenizer.rs'), + 'src/search/tokenizer.rs'); +}); + +test('extractSearchPath: first of multiple paths wins', () => { + assert.equal(extractSearchPath('grep -rn "set_hook" src/main.rs src/lib.rs'), 'src/main.rs'); +}); + +test('extractSearchPath: quoted path is unwrapped', () => { + assert.equal(extractSearchPath('grep -rn "Foo" "claude-plugin/scripts/"'), 'claude-plugin/scripts/'); +}); + +test('extractSearchPath: flags and redirects are skipped', () => { + assert.equal(extractSearchPath('grep -rn "Foo" src/ 2>&1'), 'src/'); +}); + +test('extractSearchPath: ./-prefixed path is accepted', () => { + assert.equal(extractSearchPath('grep -rn "Foo" ./src/parser/'), './src/parser/'); +}); + +test('extractSearchPath: path traversal is rejected', () => { + assert.equal(extractSearchPath('grep -rn "Foo" src/../../etc/'), undefined); +}); + +test('extractSearchPath: no source path → undefined', () => { + assert.equal(extractSearchPath('grep -rn "Foo"'), undefined); +}); + +test('pickBlockPattern: returns the identifier-like pattern', () => { + assert.equal(pickBlockPattern('grep -rn "EmbeddingModel" src/'), 'EmbeddingModel'); +}); + +test('pickBlockPattern: skips non-identifier, picks identifier from -e args', () => { + assert.equal( + pickBlockPattern('grep -rn -e "some words" -e "fts5_search" src/'), + 'fts5_search'); +}); + +test('pickBlockPattern: no identifier-like pattern → undefined', () => { + assert.equal(pickBlockPattern('grep -rn "no ident here" src/'), undefined); +}); + +// ── v0.47.0 deny-with-answer: message builders + env gate ─────────── + +test('buildBlockReasonWithAnswer: embeds results, command, and escape hatch', () => { + const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', { + status: 'hits', text: 'src/storage/db.rs:42 fn fts5_search()', truncated: false, + }); + assert.match(reason, /already ran/); + assert.match(reason, /code-graph-mcp grep "fts5_search" src\/storage\//); + assert.match(reason, /src\/storage\/db\.rs:42/); + assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/); + assert.doesNotMatch(reason, /truncated/); +}); + +test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => { + const reason = buildBlockReasonWithAnswer('fts5_search', undefined, { + status: 'hits', text: 'hit', truncated: false, + }); + assert.match(reason, /code-graph-mcp grep "fts5_search"\n/); +}); + +test('buildBlockReasonWithAnswer: truncated flag adds marker', () => { + const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', { + status: 'hits', text: 'hit', truncated: true, + }); + assert.match(reason, /truncated/); +}); + +test('buildNoHitsFyi: names the pattern and says raw grep proceeds', () => { + const fyi = buildNoHitsFyi('GhostSymbol'); + assert.match(fyi, /GhostSymbol/); + assert.match(fyi, /[Nn]o matches/); +}); + +test('isAnswerDisabled: only env=1 disables', () => { + assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '1' }), true); + assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '0' }), false); + assert.equal(isAnswerDisabled({}), false); +}); + +// ── v0.47.0 deny-with-answer: stdin-spawn e2e with stub binary ────── + +const { spawnSync: spawnHook } = require('child_process'); +const fsE2e = require('fs'); +const osE2e = require('os'); +const pathE2e = require('path'); +const { cgTmpDir } = require('./tmp-dir'); + +function e2eFixture(stubBody) { + const dir = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'pre-grep-e2e-')); + fsE2e.mkdirSync(pathE2e.join(dir, '.code-graph'), { recursive: true }); + fsE2e.writeFileSync(pathE2e.join(dir, '.code-graph', 'index.db'), ''); + const stub = pathE2e.join(dir, 'cg-stub.js'); + fsE2e.writeFileSync(stub, '#!/usr/bin/env node\n' + stubBody); + fsE2e.chmodSync(stub, 0o755); + return { dir, stub }; +} + +function runHook(cmd, fixture) { + const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], { + cwd: fixture.dir, + input: JSON.stringify({ tool_input: { command: cmd } }), + encoding: 'utf8', + env: { + ...process.env, + _CG_ANSWER_BINARY: fixture.stub, + CODE_GRAPH_QUIET_HOOKS: '0', + CODE_GRAPH_NO_BLOCK_GREP: '0', + CODE_GRAPH_NO_ANSWER_IN_DENY: '0', + }, + }); + return res; +} + +function cleanupFixture(fixture, cmd) { + fsE2e.rmSync(fixture.dir, { recursive: true, force: true }); + // cooldown flag for this command lives in cgTmpDir — remove so reruns stay deterministic + try { + fsE2e.unlinkSync(pathE2e.join(cgTmpDir(), `.code-graph-bash-${commandHash(cmd)}`)); + } catch { /* ok */ } +} + +test('e2e: denied grep with stub hits → deny JSON embeds the answer + records answered:true', () => { + const uniq = `StubHit${Date.now()}`; + const fixture = e2eFixture( + `process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`); + const cmd = `grep -rn "${uniq}" src/`; + try { + const res = runHook(cmd, fixture); + assert.equal(res.status, 0); + const out = JSON.parse(res.stdout); + assert.equal(out.hookSpecificOutput.permissionDecision, 'deny'); + assert.match(out.hookSpecificOutput.permissionDecisionReason, /src\/foo\.rs:7/); + assert.match(out.hookSpecificOutput.permissionDecisionReason, new RegExp(uniq)); + const recs = fsE2e.readFileSync( + pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8'); + const rec = JSON.parse(recs.trim().split('\n').pop()); + assert.equal(rec.action, 'deny'); + assert.equal(rec.answered, true); + } finally { + cleanupFixture(fixture, cmd); + } +}); + +test('e2e: stub reports no matches → grep allowed with FYI + records fallthrough', () => { + const uniq = `StubMiss${Date.now()}`; + const fixture = e2eFixture( + `process.stdout.write('[code-graph] No matches for: ' + process.argv[3] + '\\n');`); + const cmd = `grep -rn "${uniq}" src/`; + try { + const res = runHook(cmd, fixture); + assert.equal(res.status, 0); + // No deny JSON — plain FYI text means the grep proceeds + assert.throws(() => JSON.parse(res.stdout)); + assert.match(res.stdout, /[Nn]o matches/); + const recs = fsE2e.readFileSync( + pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8'); + const rec = JSON.parse(recs.trim().split('\n').pop()); + assert.equal(rec.action, 'hint'); + assert.equal(rec.fallthrough, 'no-hits'); + } finally { + cleanupFixture(fixture, cmd); + } +}); + +test('e2e: stub fails → static deny (v0.46 fallback) + records answered:false', () => { + const uniq = `StubBoom${Date.now()}`; + const fixture = e2eFixture(`process.exit(3);`); + const cmd = `grep -rn "${uniq}" src/`; + try { + const res = runHook(cmd, fixture); + assert.equal(res.status, 0); + const out = JSON.parse(res.stdout); + assert.equal(out.hookSpecificOutput.permissionDecision, 'deny'); + // static reason, no embedded results + assert.match(out.hookSpecificOutput.permissionDecisionReason, /denied by code-graph hook/); + const rec = JSON.parse(fsE2e.readFileSync( + pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim()); + assert.equal(rec.action, 'deny'); + assert.equal(rec.answered, false); + } finally { + cleanupFixture(fixture, cmd); + } +}); + +test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would hit', () => { + const uniq = `StubOptout${Date.now()}`; + const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`); + const cmd = `grep -rn "${uniq}" src/`; + try { + const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], { + cwd: fixture.dir, + input: JSON.stringify({ tool_input: { command: cmd } }), + encoding: 'utf8', + env: { + ...process.env, + _CG_ANSWER_BINARY: fixture.stub, + CODE_GRAPH_QUIET_HOOKS: '0', + CODE_GRAPH_NO_BLOCK_GREP: '0', + CODE_GRAPH_NO_ANSWER_IN_DENY: '1', + }, + }); + const out = JSON.parse(res.stdout); + assert.equal(out.hookSpecificOutput.permissionDecision, 'deny'); + assert.doesNotMatch(out.hookSpecificOutput.permissionDecisionReason, /src\/foo\.rs:7/); + } finally { + cleanupFixture(fixture, cmd); + } +});