fix(ledger): proposer v3 — sym bases gated by language family, reference skips generic verbs, semantic held to the shape's own project (#2871, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s
The 2026-08 audit examined 407 proposals. Every cross-language hit was wrong: the Python MCP tool-module canon (symbol `register`) was offered for each auth view's handleSubmit (it calls authStore.register()) and for the TS auth store's own `register`; Minstrel/Forge TS canon matched Python bodies by resemblance. Every cross-project semantic proposal was noise. - Canon carries the snippet's language; match_canon skips a sym canon whose family (py / js / css / sh / sql, by language ↔ by path extension) differs from the shape's. Unknown on either side = no gate. - The reference basis ignores a stoplist of generic verbs (register, load, save, get, …): a bare mention is not a call site of THIS canon; the symbol basis still catches a second definition, and the call-site relation moves to `uses` edges with #2870. - The semantic arm only reaches canon in the shape's own project and family; symbol/text still reach family canon elsewhere (note 2786). - _PROPOSER_VERSION 2 → 3 so standing proposals re-examine on the next refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -718,7 +718,9 @@ _SEMANTIC_CAP = 150
|
|||||||
_SEMANTIC_FLOOR = 0.8
|
_SEMANTIC_FLOOR = 0.8
|
||||||
# Bump when a basis's rule changes: rows remember the (body, ruleset) they
|
# Bump when a basis's rule changes: rows remember the (body, ruleset) they
|
||||||
# were examined under, so a tightened rule re-examines everything once.
|
# were examined under, so a tightened rule re-examines everything once.
|
||||||
_PROPOSER_VERSION = 2
|
# v3: language-family gate on the sym bases, reference stoplist, semantic
|
||||||
|
# restricted to the shape's own project (#2871).
|
||||||
|
_PROPOSER_VERSION = 3
|
||||||
# Signature resemblance floor, name blanked (difflib ratio) — and a length
|
# Signature resemblance floor, name blanked (difflib ratio) — and a length
|
||||||
# floor, because `def NAME():` resembles `def NAME(x):` at 0.95 while saying
|
# floor, because `def NAME():` resembles `def NAME(x):` at 0.95 while saying
|
||||||
# nothing; a family shape has parameters to resemble.
|
# nothing; a family shape has parameters to resemble.
|
||||||
@@ -737,6 +739,64 @@ class Canon(NamedTuple):
|
|||||||
signature: str
|
signature: str
|
||||||
code_norm: str
|
code_norm: str
|
||||||
project_id: int = 0
|
project_id: int = 0
|
||||||
|
language: str = "" # the snippet's recorded language; "" = unknown, no gate
|
||||||
|
|
||||||
|
|
||||||
|
# Language families: the sym bases only propose within one. The 2026-08
|
||||||
|
# audit (#2871) found every cross-language hit wrong — a Python tool-module
|
||||||
|
# canon named `register` proposed for Vue `handleSubmit`s that call
|
||||||
|
# `authStore.register()`, and a TS store's `register` matched it by symbol;
|
||||||
|
# Minstrel/Forge TS canon proposed for Python bodies by resemblance. CSS is
|
||||||
|
# its own kind and is not gated here.
|
||||||
|
_FAMILY_BY_LANG = {
|
||||||
|
"python": "py", "py": "py",
|
||||||
|
"typescript": "js", "ts": "js", "tsx": "js", "javascript": "js", "js": "js",
|
||||||
|
"jsx": "js", "vue": "js", "mjs": "js", "cjs": "js",
|
||||||
|
"css": "css", "scss": "css", "sass": "css", "less": "css",
|
||||||
|
"bash": "sh", "sh": "sh", "shell": "sh", "zsh": "sh",
|
||||||
|
"sql": "sql",
|
||||||
|
}
|
||||||
|
_FAMILY_BY_EXT = {
|
||||||
|
".py": "py", ".pyi": "py",
|
||||||
|
".ts": "js", ".tsx": "js", ".js": "js", ".jsx": "js", ".vue": "js", ".mjs": "js", ".cjs": "js",
|
||||||
|
".css": "css", ".scss": "css", ".sass": "css", ".less": "css",
|
||||||
|
".sh": "sh", ".bash": "sh", ".zsh": "sh",
|
||||||
|
".sql": "sql",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def language_family(language: str) -> str:
|
||||||
|
"""The family a recorded snippet language belongs to ("" when unknown)."""
|
||||||
|
return _FAMILY_BY_LANG.get((language or "").strip().lower(), "")
|
||||||
|
|
||||||
|
|
||||||
|
def path_family(path: str) -> str:
|
||||||
|
"""The family a file path belongs to, by extension ("" when unknown)."""
|
||||||
|
p = (path or "").lower()
|
||||||
|
for ext, fam in _FAMILY_BY_EXT.items():
|
||||||
|
if p.endswith(ext):
|
||||||
|
return fam
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def same_family(path: str, canon_language: str) -> bool:
|
||||||
|
"""A sym basis may propose this canon for this path: both families known
|
||||||
|
and equal, or either unknown (no evidence either way → no gate)."""
|
||||||
|
a = path_family(path)
|
||||||
|
b = language_family(canon_language)
|
||||||
|
return not a or not b or a == b
|
||||||
|
|
||||||
|
|
||||||
|
# Reference basis: generic verbs name too many unrelated things to count a
|
||||||
|
# bare mention as a call site of THIS canon (`register`, `load`, `save` …).
|
||||||
|
# The symbol basis still catches a second definition of such a name; the
|
||||||
|
# call-site relation for these becomes a `uses` edge once #2870 lands.
|
||||||
|
_REFERENCE_STOPLIST = frozenset({
|
||||||
|
"get", "set", "put", "post", "load", "save", "run", "main", "init", "setup",
|
||||||
|
"register", "restore", "reset", "toggle", "close", "open", "submit", "handler",
|
||||||
|
"update", "create", "delete", "remove", "add", "start", "stop", "send",
|
||||||
|
"receive", "render", "mount", "dispatch", "call", "apply", "execute",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _norm_text(text: str) -> str:
|
def _norm_text(text: str) -> str:
|
||||||
@@ -789,11 +849,17 @@ def match_canon(
|
|||||||
for c in canons:
|
for c in canons:
|
||||||
if c.kind != kind:
|
if c.kind != kind:
|
||||||
continue
|
continue
|
||||||
|
if kind == "sym" and not same_family(path, c.language):
|
||||||
|
continue # a Python canon says nothing about a Vue body, and vice versa
|
||||||
if c.symbol and _norm_symbol(c.symbol) == norm_sym:
|
if c.symbol and _norm_symbol(c.symbol) == norm_sym:
|
||||||
if not any(location_covers(lp, ls, path, symbol) for lp, ls in c.locations):
|
if not any(location_covers(lp, ls, path, symbol) for lp, ls in c.locations):
|
||||||
offer("symbol", 1.0, c)
|
offer("symbol", 1.0, c)
|
||||||
continue # its own location is canonical territory, not a proposal
|
continue # its own location is canonical territory, not a proposal
|
||||||
if c.symbol and references_symbol(body, c.symbol, kind):
|
if (
|
||||||
|
c.symbol
|
||||||
|
and _norm_symbol(c.symbol).lower() not in _REFERENCE_STOPLIST
|
||||||
|
and references_symbol(body, c.symbol, kind)
|
||||||
|
):
|
||||||
offer("reference", 0.9, c)
|
offer("reference", 0.9, c)
|
||||||
if c.code_norm and text_contains(body, c.code_norm):
|
if c.code_norm and text_contains(body, c.code_norm):
|
||||||
offer("text", 0.95, c)
|
offer("text", 0.95, c)
|
||||||
@@ -849,6 +915,7 @@ async def canon_catalog(user_id: int) -> list[Canon]:
|
|||||||
for loc in fields.get("locations") or []
|
for loc in fields.get("locations") or []
|
||||||
),
|
),
|
||||||
signature, _norm_text(code), int(note.project_id or 0),
|
signature, _norm_text(code), int(note.project_id or 0),
|
||||||
|
(fields.get("language") or "").strip().lower(),
|
||||||
))
|
))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -907,7 +974,14 @@ async def propose_for_repo(
|
|||||||
if canons is None:
|
if canons is None:
|
||||||
canons = await canon_catalog(user_id)
|
canons = await canon_catalog(user_id)
|
||||||
by_key = {(d[0], d[1], d[2]): d for d in definitions}
|
by_key = {(d[0], d[1], d[2]): d for d in definitions}
|
||||||
sym_canon_ids = {c.snippet_id for c in canons if c.kind == "sym"}
|
# The semantic arm is the widest net and, across projects, was pure noise
|
||||||
|
# in the 2026-08 audit (#2871): it is held to the shape's own project and
|
||||||
|
# language family. The precise bases (symbol/text) still reach family
|
||||||
|
# canon in other projects (note 2786).
|
||||||
|
sym_canons = [c for c in canons if c.kind == "sym" and c.project_id == project_id]
|
||||||
|
|
||||||
|
def semantic_allowed(path: str) -> set[int]:
|
||||||
|
return {c.snippet_id for c in sym_canons if same_family(path, c.language)}
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
examined = proposed = checked = 0
|
examined = proposed = checked = 0
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -955,7 +1029,7 @@ async def propose_for_repo(
|
|||||||
continue
|
continue
|
||||||
checked += 1
|
checked += 1
|
||||||
try:
|
try:
|
||||||
found = await _semantic_canon(user_id, d[5], sym_canon_ids)
|
found = await _semantic_canon(user_id, d[5], semantic_allowed(row.path))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("semantic proposal failed", exc_info=True)
|
logger.warning("semantic proposal failed", exc_info=True)
|
||||||
found = None
|
found = None
|
||||||
|
|||||||
@@ -248,6 +248,49 @@ def test_match_canon_orders_bases_strongest_first_and_respects_kind():
|
|||||||
assert match_canon("sym", "x.py", "unrelated", "def unrelated(a, b, c, d, e):", "return 1", canons) is None
|
assert match_canon("sym", "x.py", "unrelated", "def unrelated(a, b, c, d, e):", "return 1", canons) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_canon_gates_sym_bases_by_language_family():
|
||||||
|
"""A Python canon says nothing about a Vue body (and vice versa): the
|
||||||
|
2026-08 audit's worst proposals were `register` (MCP tool module, python)
|
||||||
|
offered for every auth view's handleSubmit that calls authStore.register()
|
||||||
|
and for a TS store's own `register`. Unknown language on either side →
|
||||||
|
no gate (the canons recorded without a language keep proposing)."""
|
||||||
|
from scribe.services.shape_ledger import Canon, _norm_text, match_canon, same_family
|
||||||
|
py_register = Canon(46, "sym", "register", (("src/scribe/mcp/tools/notes.py", "register"),),
|
||||||
|
"def register(mcp) -> None:", _norm_text("def register(mcp) -> None: ..."), 2, "python")
|
||||||
|
ts_helper = Canon(53, "sym", "apiErrorMessage", (("frontend/src/api/client.ts", "apiErrorMessage"),),
|
||||||
|
"export function apiErrorMessage(e: unknown, fallback: string): string {",
|
||||||
|
_norm_text("export function apiErrorMessage(e, fallback) { return fallback }"), 2, "typescript")
|
||||||
|
canons = [py_register, ts_helper]
|
||||||
|
vue_body = "async function handleSubmit() {\n await authStore.register(username.value);\n error.value = apiErrorMessage(e, 'x');\n}"
|
||||||
|
# The Vue handler references the TS helper, never the Python canon.
|
||||||
|
assert match_canon("sym", "frontend/src/views/RegisterView.vue", "handleSubmit",
|
||||||
|
"async function handleSubmit() {", vue_body, canons) == (53, "reference", 0.9)
|
||||||
|
# A TS store's own `register` is not a second definition of the Python one.
|
||||||
|
assert match_canon("sym", "frontend/src/stores/auth.ts", "register",
|
||||||
|
"async function register(u: string) {", "return apiPost('/api/auth/register', {u})",
|
||||||
|
[py_register]) is None
|
||||||
|
# Same family still proposes by symbol; unknown language still proposes.
|
||||||
|
assert match_canon("sym", "src/scribe/mcp/tools/other.py", "register",
|
||||||
|
"def register(mcp) -> None:", "pass", [py_register]) == (46, "symbol", 1.0)
|
||||||
|
unknown = py_register._replace(language="")
|
||||||
|
assert match_canon("sym", "frontend/src/stores/auth.ts", "register",
|
||||||
|
"async function register(u: string) {", "", [unknown]) == (46, "symbol", 1.0)
|
||||||
|
assert same_family("a.py", "python") and same_family("a.vue", "typescript")
|
||||||
|
assert same_family("a.py", "") and same_family("", "python")
|
||||||
|
assert not same_family("a.py", "vue")
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_canon_reference_skips_generic_verbs():
|
||||||
|
"""A bare mention of `load`/`save`/`register` is not a call site of THIS
|
||||||
|
canon; the symbol basis still catches a second definition of the name."""
|
||||||
|
from scribe.services.shape_ledger import Canon, _norm_text, match_canon
|
||||||
|
loader = Canon(70, "sym", "load", (("frontend/src/components/A.vue", "load"),),
|
||||||
|
"async function load() {", _norm_text("async function load() { await fetch() }"), 2, "vue")
|
||||||
|
body = "async function refresh() {\n await load();\n}"
|
||||||
|
assert match_canon("sym", "frontend/src/components/B.vue", "refresh", "async function refresh() {", body, [loader]) is None
|
||||||
|
assert match_canon("sym", "frontend/src/components/B.vue", "load", "async function load() {", "", [loader]) == (70, "symbol", 1.0)
|
||||||
|
|
||||||
|
|
||||||
def test_match_canon_symbol_beats_everything_including_css_copies():
|
def test_match_canon_symbol_beats_everything_including_css_copies():
|
||||||
"""The previous test's css `btn-primary`-elsewhere case, stated plainly:
|
"""The previous test's css `btn-primary`-elsewhere case, stated plainly:
|
||||||
a second definition of the canon's own name is the symbol basis."""
|
a second definition of the canon's own name is the symbol basis."""
|
||||||
|
|||||||
Reference in New Issue
Block a user