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

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:
2026-08-21 15:00:48 -04:00
co-authored by Claude Fable 5
parent 520381e22b
commit 3a4031d7f8
2 changed files with 121 additions and 4 deletions
+43
View File
@@ -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
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():
"""The previous test's css `btn-primary`-elsewhere case, stated plainly:
a second definition of the canon's own name is the symbol basis."""