From aba16583ab2949e9b6b42c4233b7db555d4c94a1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 17:34:12 -0400 Subject: [PATCH] fix(ledger): one-line CSS rules fingerprint their own declarations; the proposer writes uses edges for judged rows too (#2872, #2870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First deploy of v26.08.21.1 showed two gaps: - Every one-line CSS rule followed by a blank line hashed to sha1("") — the declarations live on the selector line, which the #2872 "declarations only" fingerprint dropped — so 68 unrelated one-liners across 17 files read as one body-identical copy at the top of the derive readout. The selector line's tail after "{" is now part of the hash; an all-blank remainder falls back to the whole block. - The proposer only examined unjudged rows, so consumers that were already classified (auth.create_invitation → hash_token) never got a uses edge: 3 edges for hash_token after the first refresh. Judged rows are now scanned for references (once per body), no proposal is made on them. - 0084 migration docstring reworded: "function that …" at a line start parsed as a definition (extractor false positive). Co-Authored-By: Claude Fable 5 --- alembic/versions/0084_code_shape_uses.py | 6 +++--- src/scribe/services/coverage.py | 14 +++++++++++++- src/scribe/services/shape_ledger.py | 13 ++++++++++++- tests/test_integration_shape_classify.py | 13 +++++++++++++ tests/test_pattern_coverage.py | 7 +++++++ 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/alembic/versions/0084_code_shape_uses.py b/alembic/versions/0084_code_shape_uses.py index 1da91e6..652a7f1 100644 --- a/alembic/versions/0084_code_shape_uses.py +++ b/alembic/versions/0084_code_shape_uses.py @@ -5,9 +5,9 @@ Revises: 0083 Create Date: 2026-08-21 A ledger row carries ONE snippet_id: what shape this is (instance/variant of -a canon). But a shape can also CALL several canonical helpers — a service -function that is an instance of the service-function convention and a -consumer of hash_token. The 2026-08 audit had to pick one; hook evidence +a canon). But a shape can also CALL several canonical helpers — e.g. a +service function both conforming to the service-function convention and +consuming hash_token. The 2026-08 audit had to pick one; hook evidence ("pulled #N then wrote code referencing it") was stamped as instance when it is a uses fact. This table holds the many-valued relation: shape → snippet, with the basis and the evidence. Cascades with the shape and the snippet. diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index 89e12b0..3c3565f 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -191,7 +191,19 @@ def extract_definitions(text: str) -> list[Definition]: # same rule under another name?" — .closed-msg / .error-block / # .success-msg with identical bodies are one dup group, not three # lonely rows. Sym blocks keep their signature line in the hash. - hashed = block[1:] if kind == "css" and len(block) > 1 else block + if kind == "css": + # One-line rules (`.x { color: red; }`) carry their declarations on + # the selector line itself; a block that is only the selector plus + # trailing blanks must not hash to the empty string (which grouped + # 68 unrelated one-liners as one "copy" on first deploy, #2872). + first = lines[i] + brace = first.find("{") + head = [first[brace + 1:]] if brace >= 0 and first[brace + 1:].strip() else [] + hashed = head + block[1:] + if not any(x.strip() for x in hashed): + hashed = block + else: + hashed = block out.append(Definition( kind, name, lines[i].strip()[:_SIGNATURE_CAP], _block_sha(hashed), "\n".join(block), i, diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index 3bd1956..454501a 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -1225,7 +1225,6 @@ async def propose_for_repo( select(CodeShape).where( CodeShape.project_id == project_id, CodeShape.repo_key == repo_key, - CodeShape.status.in_(_MECHANICAL_TODO), CodeShape.vanished_at.is_(None), ) ) @@ -1239,6 +1238,18 @@ async def propose_for_repo( examined_as = f"{body_sha}@{_PROPOSER_VERSION}" if row.proposed_at is not None and row.proposed_sha == examined_as: continue + if row.status not in _MECHANICAL_TODO: + # A judged row gets no proposal — but its uses edges (#2870) + # are a fact about the body, judged or not: the consumer map + # of a canon must include the call sites someone already + # classified. Mark it examined so the scan runs once per body. + used = reference_canons(row.kind, row.path, row.symbol, body, canons) + if used: + await record_uses(session, row, used, basis="reference", + evidence="proposer: body names the canon's symbol") + row.proposed_at = now + row.proposed_sha = examined_as + continue examined += 1 group = row.proposal_group # derive grouping is reassigned below hit = match_canon( diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index 82edece..83e436e 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -230,6 +230,19 @@ async def test_uses_edges_are_the_consumer_map(seeded): ) assert out["classified"] == 1 assert (await list_project_shapes(owner, pid, uses=hid))[1] == 2 + # The proposer writes uses edges for JUDGED rows too: Config (exempt) + # names hash_token in its body → an edge, no proposal. + from scribe.services.shape_ledger import propose_for_repo + await classify_shapes(owner, pid, [ + {"path": "src/app.py", "symbol": "Config", "status": "exempt", "reason": "settings"}, + ]) + defs = _defs(("src/app.py", "sym", "Config", "class Config:", "class Config:\n token = hash_token(raw)\n")) + with _quiet_semantic(): + await propose_for_repo(owner, pid, REPO, defs) + rows, total = await list_project_shapes(owner, pid, uses=hid) + assert total == 3 and {r.symbol for r in rows} >= {"Config"} + cfg = next(r for r in rows if r.symbol == "Config") + assert cfg.status == "exempt" and cfg.proposal is None with pytest.raises(ValueError): await classify_shapes(owner, pid, [ {"path": "src/app.py", "symbol": "Config", "status": "exempt", "reason": "x", "uses": [999999]}, diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index a49f24d..c82b7eb 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -481,6 +481,13 @@ def test_extract_definitions_fingerprints_each_block(): css = ".closed-msg {\n text-align: center;\n padding: 0.5rem 0;\n}\n.error-block {\n text-align: center;\n padding: 0.5rem 0;\n}\n.other {\n text-align: left;\n}\n" d = {x.name: x for x in extract_definitions(css)} assert d["closed-msg"].body_sha == d["error-block"].body_sha != d["other"].body_sha + # One-line rules hash their own declarations — never the empty string + # (first deploy grouped 68 unrelated one-liners as one copy). + one = ".a { color: red; }\n\n.b { color: red; }\n\n.c { color: blue; }\n\n.d {\n color: red;\n}\n" + e = {x.name: x for x in extract_definitions(one)} + import hashlib + assert e["a"].body_sha == e["b"].body_sha != e["c"].body_sha + assert e["a"].body_sha != hashlib.sha1(b"").hexdigest()[:16] def test_coverage_line_names_the_proposers_standing(): -- 2.54.0