diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index ef7ed67..701cf7b 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -987,6 +987,59 @@ def forms_conflict(a: str, b: str) -> bool: return bool(a) and bool(b) and a != b +def shape_family(form: str) -> str: + """The coarse category a form belongs to, or "" when the form is unknown. + + WHY THIS IS SEPARATE FROM `shape_form`, and it is the correction to the + first version of this change. The two checks in this module ask opposite + questions, so they cannot share a burden: + + STAMPING asserts "this IS that canon". Agreement in form is evidence + FOR the claim, so `forms_agree` / `forms_conflict` — the precise level, + where `fn` and `async-fn` are different — is right. + + DIVERGENCE asserts "this is NOT the canon that dominates here — did you + mean to?" A form MISMATCH is the premise of that prompt, not an + objection to it. Gating it on `forms_agree` inverted the check: it went + silent on exactly the mismatches it exists to catch, and the acceptance + case of #2793 — a hand-rolled sync `confirmDanger` in a directory where + an async confirm helper is canon — stopped being flagged. + + So divergence gates at FAMILY level and only on contradiction. A sync + function beside an async one is still a fair question. A frozen dataclass + told to build from an async service function is not a question at all, + and that is the #4204 prompt this removes. + + This does NOT remove every false prompt #4204 recorded. Of the five, it + silences `Point` (a type, against a callable canon); `_p`, `get_point`, + `is_registered` and `sources_expected_to_emit` are callables like the + canon and still ask. At the signature level they are indistinguishable + from the #2793 case above, so nothing readable here can separate them — + only widening `kind` past `css | sym` (#4204 option 2) or comparing + meaning rather than form can. Stated here so the next reader does not + assume the gap is an oversight. + """ + if form in ("fn", "async-fn"): + return "callable" + if form == "type": + return "type" + if form == "binding": + return "value" + if form == "css": + return "css" + return "" + + +def families_conflict(a: str, b: str) -> bool: + """Do two forms belong to categorically different KNOWN families? + + Like `forms_conflict`, both sides must be readable: an unknown form makes + this quieter, never more confident. + """ + fa, fb = shape_family(a), shape_family(b) + return bool(fa) and bool(fb) and fa != fb + + def canon_form(rows: Iterable, snippet_id: int) -> str: """The form a canon's own judged rows agree on, or FORM_UNKNOWN.""" forms: dict[str, int] = {} @@ -1996,18 +2049,22 @@ async def write_time_divergence( row.status != "unclassified" or row.proposed_snippet_id == sid ): continue - # DOES THIS SHAPE EVEN RESEMBLE THE CANON? (#4204) Without this the - # line was a pure base rate: "most things here are X, so be X", with - # the candidate never examined. It told a frozen dataclass and three - # pure predicates to build from the `async_session` service canon. + # IS THIS EVEN THE SAME CATEGORY OF THING AS THE CANON? (#4204) + # Without this the line was a pure base rate: "most things here are X, + # so be X", with the candidate never examined at all. It told a frozen + # dataclass to build from the `async_session` service canon. + # + # FAMILY, not form, and only on contradiction — see `shape_family`. A + # divergence prompt is ABOUT a mismatch, so requiring the candidate to + # match would silence the check precisely where it belongs. # # Signature from the payload first — a shape being written now may - # have no row yet, and `forms_agree` needs both sides known, so an - # unreadable one produces silence rather than a guess. + # have no row yet — and an unreadable one produces a fair question + # rather than a guess, because `families_conflict` needs both sides. mine = shape_form( signature_in(code, name, kind) or getattr(row, "signature", "") or "", kind ) - if not forms_agree(mine, cform): + if families_conflict(mine, cform): continue out.append({"symbol": name, "kind": kind, "canon_snippet_id": sid, "instances": n, "judged": judged}) @@ -2129,7 +2186,7 @@ async def flag_divergence(project_id: int, *, since: datetime | None) -> int: # (#4204). The sweep and the hook must agree about what counts # as divergence, or an audit contradicts the line the writer # was shown at the keyboard. - if not forms_agree(shape_form(r.signature or "", r.kind), cform): + if families_conflict(shape_form(r.signature or "", r.kind), cform): continue r.diverges_from = dom[0] flagged += 1 diff --git a/tests/test_shape_form_gate.py b/tests/test_shape_form_gate.py index 40931d5..e295841 100644 --- a/tests/test_shape_form_gate.py +++ b/tests/test_shape_form_gate.py @@ -26,7 +26,8 @@ from __future__ import annotations import pytest from scribe.services.shape_ledger import ( - FORM_UNKNOWN, canon_form, forms_agree, shape_form, signature_in, + FORM_UNKNOWN, canon_form, families_conflict, forms_agree, shape_family, + shape_form, signature_in, ) @@ -89,6 +90,65 @@ def test_the_five_shapes_that_started_this_are_not_service_functions() -> None: assert not forms_agree(shape_form(sig, "sym"), canon), sig +def test_how_many_of_the_five_the_divergence_gate_actually_silences() -> None: + """HONEST ACCOUNTING, and it is deliberately an assertion rather than a + comment so it fails the day the answer changes. + + Divergence gates at FAMILY level and only on contradiction, because a form + mismatch is the PREMISE of a divergence prompt, not an objection to it — + see `shape_family`. So of #4204's five false prompts this silences one: + `Point` is a type and the canon is a callable. The four `def` helpers are + callables like the canon and still ask. + + That is not a shortcoming of the gate, it is the limit of the signature: + at this level those four are indistinguishable from #2793's acceptance + case, where a sync `confirmDanger` beside an async confirm helper SHOULD + be flagged. Separating them needs #4204 option 2 (widen `kind`) or a + comparison of meaning.""" + canon = shape_form("async def create_note(user_id: int, ...):", "sym") + silenced = { + sig: families_conflict(shape_form(sig, "sym"), canon) + for sig in ( + "class Point:", + "def _p(source, kind, what, **kw) -> tuple[str, Point]:", + "def get_point(source: str) -> Point | None:", + "def is_registered(source: str) -> bool:", + "def sources_expected_to_emit() -> list[str]:", + ) + } + assert silenced["class Point:"] is True + assert sum(silenced.values()) == 1 + + +def test_a_sync_function_beside_an_async_canon_is_still_a_fair_question() -> None: + """#2793's acceptance case, which the first version of this change broke: + gating divergence on `forms_agree` silenced a hand-rolled sync confirm in + a directory where an async confirm helper is canon — the exact prompt the + milestone exists to produce.""" + canon = shape_form("async function onTrash() {", "sym") + mine = shape_form("function confirmDanger() {", "sym") + assert canon == "async-fn" and mine == "fn" + assert not forms_agree(mine, canon) # the STAMP gate would refuse + assert not families_conflict(mine, canon) # the DIVERGENCE gate asks anyway + + +@pytest.mark.parametrize(("form", "want"), [ + ("fn", "callable"), ("async-fn", "callable"), ("type", "type"), + ("binding", "value"), ("css", "css"), (FORM_UNKNOWN, ""), +]) +def test_every_form_has_a_family_and_unknown_has_none(form, want) -> None: + assert shape_family(form) == want + + +@pytest.mark.parametrize(("a", "b"), [ + (FORM_UNKNOWN, "fn"), ("fn", FORM_UNKNOWN), (FORM_UNKNOWN, FORM_UNKNOWN), +]) +def test_an_unreadable_form_never_silences_a_divergence_prompt(a, b) -> None: + """Same direction as everywhere else in this module: not knowing makes the + check quieter about ASSERTING and never quieter about ASKING.""" + assert not families_conflict(a, b) + + # ── forms_agree: unknown never matches ──────────────────────────────────── def test_two_known_equal_forms_agree() -> None: