fix(hooks): definition detector covers all code, not a language shortlist (#2682)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 27s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 27s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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.",
|
"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" },
|
"author": { "name": "Bryan Van Deusen" },
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"scribe": {
|
"scribe": {
|
||||||
|
|||||||
@@ -79,34 +79,57 @@ fi
|
|||||||
# Definition-shaped patterns only. Grepping for bare occurrences would match
|
# 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
|
# 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.
|
# 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=""
|
local_lines=""
|
||||||
if [ -n "$repo_root" ] && [ -n "$code" ]; then
|
if [ -n "$repo_root" ] && [ -n "$code" ]; then
|
||||||
# kind<TAB>name for each thing this payload DEFINES.
|
# kind<TAB>name for each thing this payload DEFINES.
|
||||||
names=$(printf '%s' "$code" | awk '
|
names=$(printf '%s' "$code" | awk '
|
||||||
match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/) {
|
{
|
||||||
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t);
|
# CSS class definition: .name { or .name,
|
||||||
if (t != "") print "css\t" t; next }
|
if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
|
||||||
match($0, /^[[:space:]]*(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
|
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t)
|
||||||
t = $0; sub(/^.*function[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
|
if (t != "") print "css\t" t; next
|
||||||
if (t != "") print "sym\t" t; next }
|
}
|
||||||
match($0, /^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
|
line = $0; sub(/^[[:space:]]+/, "", line)
|
||||||
t = $0; sub(/^.*class[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
|
# Strip leading declaration modifiers so the definition keyword is the
|
||||||
if (t != "") print "sym\t" t; next }
|
# first word regardless of language (export/pub/private/suspend/...).
|
||||||
match($0, /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) {
|
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
|
||||||
t = $0; sub(/^.*def[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_].*$/, "", t);
|
# Go method with receiver: func (r *T) Name(
|
||||||
if (t != "") print "sym\t" t; next }
|
if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
|
||||||
match($0, /^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/) {
|
t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
|
||||||
t = $0; sub(/^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+/, "", t);
|
sub(/[^A-Za-z0-9_].*$/, "", t)
|
||||||
sub(/[^A-Za-z0-9_$].*$/, "", t);
|
if (t != "") print "sym\t" t; next
|
||||||
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=""
|
' 2>/dev/null | sort -u | head -12) || names=""
|
||||||
|
|
||||||
while IFS=$'\t' read -r kind name; do
|
while IFS=$'\t' read -r kind name; do
|
||||||
[ -n "${name:-}" ] || continue
|
[ -n "${name:-}" ] || continue
|
||||||
case "$kind" in
|
case "$kind" in
|
||||||
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
|
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
|
esac
|
||||||
# -I skips binaries; :(exclude) drops the file being written, which would
|
# -I skips binaries; :(exclude) drops the file being written, which would
|
||||||
# otherwise always match itself on an Edit.
|
# otherwise always match itself on an Edit.
|
||||||
|
|||||||
@@ -861,3 +861,52 @@ def test_hook_stays_quiet_about_recording_when_nothing_is_duplicated(tmp_path):
|
|||||||
)
|
)
|
||||||
assert out.returncode == 0
|
assert out.returncode == 0
|
||||||
assert "create_snippet" not in out.stdout
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user