From 8407368c0ce63e15d35317d285f7e5987b7f0d3f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 11:33:24 -0400 Subject: [PATCH] fix(hooks): definition detector covers all code, not a language shortlist (#2682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARM 1 extracted definitions with patterns for CSS/JS/TS/Python only — the languages of the repo it was born in — so the local duplication proof, and the #2664 record nudge gated on it, were structurally unreachable in Go/Kotlin/Rust projects (Minstrel, FabledExchange): precisely where recording was observed never to happen. One modifier-strip plus a definition-keyword family (func/fun/fn/function/ def/sub, struct/trait/interface/enum/object/protocol/type, plus Go method receivers) now covers them all; impl is excluded because several impl blocks per type is normal Rust, and keyword-less declarations (C/Java/Dart) are documented out of scope. Grep patterns mirror the same forms so hits are definitions, never call sites. Parameterized tests pin the coverage per language family. Plugin 0.1.30. Co-Authored-By: Claude Fable 5 --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_prior_art.sh | 57 ++++++++++++++++++++++--------- tests/test_write_path_trigger.py | 49 ++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 18 deletions(-) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 64bfa12..e6efa1c 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.29", + "version": "0.1.30", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh index 69cf351..b14813b 100755 --- a/plugin/hooks/scribe_prior_art.sh +++ b/plugin/hooks/scribe_prior_art.sh @@ -79,34 +79,57 @@ fi # Definition-shaped patterns only. Grepping for bare occurrences would match # every CALL site and drown the real finding — and a hint that is mostly noise # is one people learn to skip, which is worse than none. +# +# ALL code, not a language shortlist (#2682): the detector was born covering +# only the languages of the repo it was written in, which silently amputated +# this whole arm — and the record nudge gated on it — for every Go/Kotlin/Rust +# project. Definitions are announced by a small keyword family across +# languages (func/fun/fn/function/def/sub · class/struct/trait/interface/ +# enum/object/protocol/type), so one modifier-strip + keyword match covers +# them all. Known out of scope: keyword-less declaration syntax (C/Java/Dart +# `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded +# because several per type is normal Rust, not duplication. # --------------------------------------------------------------------------- local_lines="" if [ -n "$repo_root" ] && [ -n "$code" ]; then # kindname for each thing this payload DEFINES. names=$(printf '%s' "$code" | awk ' - match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/) { - t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t); - if (t != "") print "css\t" t; next } - match($0, /^[[:space:]]*(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) { - t = $0; sub(/^.*function[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t); - if (t != "") print "sym\t" t; next } - match($0, /^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) { - t = $0; sub(/^.*class[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t); - if (t != "") print "sym\t" t; next } - match($0, /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) { - t = $0; sub(/^.*def[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_].*$/, "", t); - if (t != "") print "sym\t" t; next } - match($0, /^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/) { - t = $0; sub(/^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+/, "", t); - sub(/[^A-Za-z0-9_$].*$/, "", t); - if (t != "") print "sym\t" t; next } + { + # CSS class definition: .name { or .name, + if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) { + t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t) + if (t != "") print "css\t" t; next + } + line = $0; sub(/^[[:space:]]+/, "", line) + # Strip leading declaration modifiers so the definition keyword is the + # first word regardless of language (export/pub/private/suspend/...). + sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line) + # Go method with receiver: func (r *T) Name( + if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) { + t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t) + sub(/[^A-Za-z0-9_].*$/, "", t) + if (t != "") print "sym\t" t; next + } + # Keyword-announced definitions, functions and named types alike. + if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) { + t = line; sub(/^[a-z]+[[:space:]]+/, "", t) + sub(/[^A-Za-z0-9_$].*$/, "", t) + if (t != "") print "sym\t" t; next + } + # Arrow/expression assignment: const name = (…) / let name = async ( + if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) { + t = line; sub(/^(const|let)[[:space:]]+/, "", t) + sub(/[^A-Za-z0-9_$].*$/, "", t) + if (t != "") print "sym\t" t; next + } + } ' 2>/dev/null | sort -u | head -12) || names="" while IFS=$'\t' read -r kind name; do [ -n "${name:-}" ] || continue case "$kind" in css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;; - *) pat="(function|class|def)[[:space:]]+${name}[^A-Za-z0-9_]|(const|let)[[:space:]]+${name}[[:space:]]*=" ;; + *) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;; esac # -I skips binaries; :(exclude) drops the file being written, which would # otherwise always match itself on an Edit. diff --git a/tests/test_write_path_trigger.py b/tests/test_write_path_trigger.py index 7cf92e2..8e84fae 100644 --- a/tests/test_write_path_trigger.py +++ b/tests/test_write_path_trigger.py @@ -861,3 +861,52 @@ def test_hook_stays_quiet_about_recording_when_nothing_is_duplicated(tmp_path): ) assert out.returncode == 0 assert "create_snippet" not in out.stdout + + +@pytest.mark.parametrize( + ("fname", "definition"), + [ + ("scanner.go", "func Resolve(x int) error {\n\treturn nil\n}\n"), + ("scanner_m.go", + "func (s *Scanner) Resolve(x int) error {\n\treturn nil\n}\n"), + ("queue.kt", "suspend fun refreshQueue(id: Long) {\n}\n"), + ("fetch.rs", "pub async fn fetch_all() -> u32 {\n 0\n}\n"), + ("adapter.go", "type ForgeAdapter struct {\n\tname string\n}\n"), + ], + ids=["go-func", "go-method", "kotlin-fun", "rust-fn", "go-type"], +) +def test_local_arm_finds_duplicates_in_every_language_family( + tmp_path, fname, definition +): + """#2682: the definition detector must cover ALL code, not the languages of + the repo it was born in. Its original CSS/JS/Python-only patterns silently + amputated the local arm — and the #2664 recording nudge gated on it — for + every Go/Kotlin/Rust project, which is exactly where the operator observed + recording never happening. Each case stages an existing copy and writes the + same definition to a second file; the hook must prove the duplication and + ask for the record.""" + env = _hook_runtime_env() + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env) + (repo / fname).write_text(definition) + subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env) + ext = fname.rsplit(".", 1)[1] + out = subprocess.run( + ["bash", str(HOOK)], + input=json.dumps({ + "session_id": f"s-lang-{ext}", "cwd": str(repo), + "tool_name": "Write", + "tool_input": {"file_path": str(repo / f"copy.{ext}"), + "content": definition}, + }), + capture_output=True, text=True, env=env, + ) + assert out.returncode == 0 + assert out.stdout.strip(), ( + f"hook produced no output for {fname} — the local arm should have " + f"found the staged duplicate definition" + ) + ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"] + assert "already defined" in ctx + assert "create_snippet" in ctx