From c12164d797a8085da4fda27ab77890352968bd21 Mon Sep 17 00:00:00 2001 From: Antawari Date: Tue, 28 Jul 2026 22:19:43 -0600 Subject: [PATCH] Refuse an exemption whose anchor no longer names live code, and say which way it rotted cf-exemptions holds two halves of one contract: every suppression traces to a reasoned entry, AND every entry still points at the code it blessed. Only the first half existed. The matcher flagged an entry covering more than one suppression and tolerated an entry covering NOTHING in silence, and frozen_count ratchets entries rather than live coverage, so the ratchet stayed satisfied. Rename an enclosing def and two wrongs happen at once. The blessed site is accused -- "a self-issued suppression is not an exemption" -- which is convincing and wrong, because the site WAS blessed and the registry pointer is what rotted; that class of message has cost real review time. And the orphaned entry is reported not at all. Worse, rename some OTHER def into the orphaned name with the same rule and its suppression is silently blessed by an entry whose reason and approver describe different code: an approval transferred to code nobody approved. A dead entry is now a violation, and the message says which of the three world-states it is, because each has a different remedy: the anchored file is gone; the anchored symbol no longer exists in the file, renamed or removed; or the symbol is alive but carries no such suppression. A line anchor past end of file, or on a line that no longer carries the suppression, is named as its own case. The contested group -- an entry claiming a site while a sibling site in the same file and rule is unregistered -- is refused, because from one snapshot the gate cannot tell which site the entry was written for. The unregistered accusation stops lying. When a suppression has no entry AND the registry holds a stale anchor for the same file and rule, the message says it is very probably a rename, names the stale anchor, and gives the qualified symbol to re-anchor to. Where no rot exists the original strict wording stands byte for byte, at the same code, path, line and exit level, so nothing is softened. Line anchors are reported loudly and are NOT a violation. The registry cannot express "this pin is deliberate, here is why", so the gate has no oracle for "documented" and promoting them would fail a consumer for reasoned decisions it wrote down and defended. The audit publishes its own denominator on every run -- entries graded against live gated suppressions, anchors live, dead, unmeasured -- so a run that graded nothing cannot read as clean. Two consumer-visible shapes were restored after they broke a consumer's resolver cross-check: _scan_src keeps returning exactly two values, and _matches keeps its original argument order as a pure adapter over the single resolution rule. A shared library does not change a published shape for internal convenience, and patching the consumer instead would have deadlocked both repos, since a consumer edited for the new API fails against the currently pinned kit. Three rods pin that contract against recurrence. The 500-line file law forced the anchor half and the measured-surface half into their own modules; the import contract declares both as one-way helper strata below the gates, and a reverse import is refused. Co-Authored-By: Claude Opus 5 (1M context) --- DESIGN.md | 87 ++++- README.md | 2 +- pyproject.toml | 13 +- src/cf_quality/exemption_anchors.py | 484 ++++++++++++++++++++++++++++ src/cf_quality/exemption_surface.py | 78 +++++ src/cf_quality/exemptions.py | 218 ++++++------- tests/test_exemption_anchors.py | 390 ++++++++++++++++++++++ 7 files changed, 1143 insertions(+), 129 deletions(-) create mode 100644 src/cf_quality/exemption_anchors.py create mode 100644 src/cf_quality/exemption_surface.py create mode 100644 tests/test_exemption_anchors.py diff --git a/DESIGN.md b/DESIGN.md index d4d15ea..5dc8e0d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -50,7 +50,7 @@ Law — no swallowed exceptions, no `None`/`-1` returns): | `cf-sticky-check` | `sticky_check.py` | sticky intro mounted byte-faithful; chewed gum fails with a diff | | `cf-mirror-check` | `mirror_check.py` | cross-repo copies declared in `MIRRORS.md`; hash + pin-age checks | | `cf-recursion-check` | `recursion_check.py` | self-recursion declared with a stated bound, or it fails | -| `cf-exemptions` | `exemptions.py` | every gated suppression traces to a reasoned, approved registry entry | +| `cf-exemptions` | `exemptions.py` + `exemption_anchors.py` + `exemption_surface.py` | every gated suppression traces to a reasoned, approved registry entry — and every entry still anchors at live code | Shared configs: `configs/ruff-base.toml` (C901 ≤ 10, PLR0915 ≤ 50, S battery, BLE ban — the ratified budgets), `configs/mypy-base.toml` (strict-leaning @@ -242,12 +242,62 @@ covering more than one live suppression fails `EXEMPTION_ENTRY_OVERLOADED` (d) the measured surface is discovered (src/ when present, else top-level packages and modules), so a flat/app layout is measured, never a no-op. +*Hardened 2026-07-28 (anchor rot — the mirror question).* The matcher asked +"does every suppression have a blessing?" and never "does every blessing still +point at the code it blessed?": `matched_counts` failed only on `count > 1`, so +a `count == 0` entry was tolerated in silence, and `frozen_count` ratchets +ENTRIES rather than live coverage, so the ratchet stayed satisfied. Rename an +enclosing `def` holding a registered suppression and two harms landed together +— the site was accused of self-issuing a suppression that HAD been blessed +(a convincing, wrong message; this class of message has cost real review time +and nearly provoked rewrites of load-bearing error barriers), and the orphaned +entry was reported not at all. Rename an unrelated `def` INTO the orphaned name +and its suppression was silently blessed by an entry whose reason and approver +describe different code — an approval transferred to code nobody approved. +`cf_quality.exemption_anchors` (a gate helper, not a gate; the 500-line file +law forced it out of a 494-line module, and the measured-surface half went with +it into `cf_quality.exemption_surface`) now grades every anchor and names the +world-state, because each has a different remedy: `EXEMPTION_FILE_MISSING` (drop or re-point), +`EXEMPTION_SYMBOL_MISSING` (re-anchor to the current qualified name), +`EXEMPTION_SUPPRESSION_GONE` (drop the entry and lower `frozen_count`; the +line-anchored analogue rides the same code), `EXEMPTION_ANCHOR_CONTESTED` (an +entry claims a site while a sibling site in the same file+rule group is +unregistered — the pairing cannot be settled by name alone). +`UNREGISTERED_SUPPRESSION` keeps its code, path and exit level but changes its +PROSE when, and only when, the registry holds a zero-coverage entry for the +same file and rule: that world is very probably a rename, so the message names +the stale anchor instead of the developer. With no such orphan the strict +accusation stands verbatim. Line-anchored entries and entries the gate cannot +grade (outside the scanned surface, an ungated rule, an unparsable file) are +LOUD NOTICES, never violations — reding a consumer for a decision it +documented teaches consumers to pin an older SHA. Every denominator the audit +reasoned from is printed (`=== ANCHOR AUDIT: ...`) so a run that graded nothing +says so. + *Residue:* the `approver` field is a string; its authenticity is carried by PR review of the `exemptions.json` diff, not by signature. Style/import noqa codes (E/F/I/UP/B) remain ungated by the registry — ruff + review carry them. +A rename-into whose ORIGINAL site was deleted in the same commit leaves one +live suppression and one live anchor: from a single snapshot it is +indistinguishable from an honest entry, and no message can honestly claim +otherwise (Open issues). + +*Consumer-visible surface (published, not incidental).* A consumer repo mirrors +this resolver in its own drift-proof pin and cross-checks the two entry by +entry, reaching in by name: `exemptions._scan_src(root)` unpacked as exactly +`(suppressions, violations)`, and `exemptions._matches(suppression, entry)`. +The anchor split therefore does NOT change either shape — `_matches` stays put +as a pure adapter over the single resolution rule (`exemption_anchors.covers`), +and the audit takes its surface from `exemption_surface.discover_scan_paths` +rather than widening `_scan_src`'s return arity. Both are pinned by +`TestConsumerResolverContract` in `tests/test_exemption_anchors.py`: a kit that +breaks its consumers' mirror breaks the one test whose job is detecting drift, +and that break cannot be repaired in a single PR (a consumer patched for a new +API fails against the currently pinned kit), so this is a contract, not +incidental private naming. **Verdict:** CLOSED for the form, security, and Elegance batteries; the -approver-authenticity residue is review-carried. +approver-authenticity and snapshot-blind-rename residues are review-carried. ### 4.4 jscpd untouched-file blind spot @@ -589,7 +639,18 @@ Everything below is a known gap, on the record. Ordered by blood. 14. **Exemption entry swap (§4.11)** — `frozen_count` is a count; a 1-for-1 registry swap is review-visible, not machine-refused. (The collision UNDERCOUNT is closed: qualified symbols + `EXEMPTION_ENTRY_OVERLOADED` - force a 1:1 entry-to-suppression map.) + force a 1:1 entry-to-suppression map. Dead anchors are closed too as of + 2026-07-28 — `EXEMPTION_FILE_MISSING` / `EXEMPTION_SYMBOL_MISSING` / + `EXEMPTION_SUPPRESSION_GONE` / `EXEMPTION_ANCHOR_CONTESTED`.) Two residues + remain: (a) a **snapshot-blind rename** — rename a blessed `def` away and + an unrelated `def` INTO its name in the same commit, DELETING the original + suppression, and the registry shows one live anchor covering one live + suppression; nothing in the tree distinguishes that from an honest entry, + so the gate says nothing rather than guess. Candidate closure: bind the + entry to the approved site's content (a normalized fingerprint of the + suppressed statement), or require `reason` to open with the qualified + symbol so a drift is at least review-visible. (b) **line-anchored entries + are a NOTICE, not a violation** — see item 18. 15. **Mutual recursion (`a -> b -> a`)** — `cf-recursion-check` detects genuine SELF-recursion only; call-graph cycles are a v2 feature, pinned by tests as a declared limitation. Same family, same verdict for the @@ -608,6 +669,26 @@ Everything below is a known gap, on the record. Ordered by blood. heuristic, not prose understanding: a paraphrased neutralizer passes the gate. Full salience is a reading task — review and the scanning models carry it; disclosed in the module docstring. +18. **Line anchors in `exemptions.json` — policy, not yet law (§4.3)** — the + form law wants every entry symbol-anchored (a line anchor rots on any + insertion above it), and the gate now names every line-anchored entry + loudly with its migration path. Promoting that to a violation is a POLICY + call the operator owns, not the gate's to take, and the blocker is a + MISSING SCHEMA FIELD, not nerve. Measured on the flagship consumer + (52 entries, 49 symbol-anchored): all **3** line pins are deliberate and + each carries a written reason — one at module level (no enclosing def to + anchor to) and two adjacent same-rule suppressions sharing one enclosing + method, where a single symbol anchor would cover both, kill one entry, and + blanket-bless any future suppression of that rule dropped into that method. + That consumer documents the three in a repo-local pin test whose set + EQUALITY makes an undocumented line anchor fail and a stale documented row + fail too. The kit cannot express either direction today: `exemptions.json` + has no field saying "this pin is deliberate, and here is why", so the gate + has no oracle for "documented" and would red three reasoned decisions. + Candidate closure, in order: add a review-gated + `"line_anchored_by_design": ""` field (empty/absent = undocumented), + THEN make an undocumented line anchor a violation and a documented-but-gone + row a notice. Until the field exists, notice is the honest verdict. ## 6. Declared source roots (monorepo layouts) diff --git a/README.md b/README.md index 6aad0be..753a2e4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ whatever package the first agent downloaded. | `cf-sticky-check` | The repo carries the canonical sticky intro, byte-faithful to this kit's mirror. | | `cf-mirror-check` | Cross-repo copies are declared mirrors (`MIRRORS.md`); undeclared drift fails. | | `cf-recursion-check` | Recursion is declared with a stated bound, or it fails. | -| `cf-exemptions` | Gated suppressions — `# noqa: C901`/`PLR0915` (form), `# noqa: S###`/`# nosec B###` (security), `# noqa: BLE###` (Elegance) — trace to a reasoned entry in `exemptions.json`; bare/blanket and self-issued suppressions fail. Style codes (E/F/I/UP/B) ride on ruff + review. | +| `cf-exemptions` | Gated suppressions — `# noqa: C901`/`PLR0915` (form), `# noqa: S###`/`# nosec B###` (security), `# noqa: BLE###` (Elegance) — trace to a reasoned entry in `exemptions.json`; bare/blanket and self-issued suppressions fail, and so does a registry entry whose anchor no longer resolves (deleted file, renamed symbol, removed suppression). Style codes (E/F/I/UP/B) ride on ruff + review. | Budgets are anchored to measurement, never invented: CC ≤ 10 per function, ≤ 50 statements per function, new files ≤ 500 lines, 0 new type errors. Existing diff --git a/pyproject.toml b/pyproject.toml index e297895..7d0b958 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,14 +68,21 @@ include_external_packages = false exclude_type_checking_imports = false [[tool.importlinter.contracts]] -# The kit's measured layering: the seven gate modules sit above repo_config -# (declared-layout resolution) above errors (the typed failure vocabulary). -# Sibling gates are independent — no gate imports another gate. +# The kit's measured layering: the gate modules sit above their own helper +# stratum, above repo_config (declared-layout resolution), above errors (the +# typed failure vocabulary). Sibling gates stay independent — no gate imports +# another gate. exemption_anchors and exemption_surface are gate HELPERS, not +# gates: they own cf-exemptions' anchor-liveness and measured-surface halves +# (the 500-line file law forced both out of a 494-line module), they ship no +# console script, and the direction is one-way — exemptions imports them, they +# import no gate, and anchors imports surface, never the reverse. name = "the gates sit above repo_config above errors" type = "layers" containers = ["cf_quality"] layers = [ "burn_land | exemptions | file_budget | gate_runner | import_contract | mirror_check | recursion_check | sticky_check", + "exemption_anchors", + "exemption_surface", "repo_config", "errors", ] diff --git a/src/cf_quality/exemption_anchors.py b/src/cf_quality/exemption_anchors.py new file mode 100644 index 0000000..ec0dace --- /dev/null +++ b/src/cf_quality/exemption_anchors.py @@ -0,0 +1,484 @@ +"""The anchor half of cf-exemptions — a blessing that points at nothing is rot. + +Split from :mod:`cf_quality.exemptions` because the file law MEASURES (that +module sits against the 500-line cap) and because the halves answer different +refuters: the matcher there asks "does every suppression have a blessing?"; +this asks the mirror question the gate never asked — "does every blessing +still point at the code it blessed?" + +The defect, measured against a live consumer registry: ``_match_suppressions`` +counted how many suppressions each entry matched and failed only on +``count > 1``. A ``count == 0`` entry — one whose anchored symbol was RENAMED +away — was tolerated in silence, and ``frozen_count`` ratchets ENTRIES, never +live coverage, so the ratchet stayed satisfied. Two harms rode on the silence: + +1. the renamed site matched nothing and was reported + ``UNREGISTERED_SUPPRESSION`` — "a self-issued suppression is not an + exemption". Convincing, and after a rename WRONG: the site WAS blessed; the + pointer rotted. A consumer's notes record that this class of misleading + message has cost real review time and nearly provoked rewrites of + load-bearing error barriers. Naming innocent code as the culprit is the scar + this module refuses, so :func:`audit` publishes the stale anchors + (:attr:`AnchorAudit.rotted`) and the matcher picks its message from them; +2. rename some OTHER symbol INTO the orphaned name and its suppression is + silently blessed by an entry whose ``reason`` and ``approver`` describe + entirely different code — an approval transferred to code nobody approved + (``EXEMPTION_ANCHOR_CONTESTED``). + +Three world-states, three codes, three remedies — never one vague "dead entry", +because the reader ACTS on the message: ``EXEMPTION_FILE_MISSING``, +``EXEMPTION_SYMBOL_MISSING``, ``EXEMPTION_SUPPRESSION_GONE`` (each message +carries its own remedy; DESIGN.md §4.3 carries the table). + +Deliberately NOT violations, because reding a consumer for a decision it +DOCUMENTED teaches consumers to pin an older SHA: (a) LINE-ANCHORED entries, +named loudly with their migration path and nothing more — the flagship consumer +pins 3 of 52 at sites no symbol identifies uniquely and documents each one, so +promotion is the operator's policy call (DESIGN.md open issue 18); (b) +UNMEASURABLE entries — outside the scanned surface, an ungated rule, an +unreadable file — because an entry the scanner never read cannot be graded, and +grading it anyway is the innocent-culprit failure running the other way. + +Every denominator is printed (``=== ANCHOR AUDIT: ...``): a population that +selects itself by the value it guards goes vacuous in silence, so a run that +checked nothing must SAY it checked nothing. +""" + +from __future__ import annotations + +import ast +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from cf_quality.errors import GateViolation +from cf_quality.exemption_surface import in_surface + +GATED_NOQA_RULES = frozenset({"C901", "PLR0915"}) +#: Whole rule families that are registry-gated code-by-code: the ruff-ported +#: bandit battery (S###) and the Elegance Law's blind-except ban (BLE###). +_GATED_FAMILY_RE = re.compile(r"^(?:S|BLE)\d+$") +#: The bandit ids a ``# nosec B###`` comment yields — the same suppression +#: reached through bandit's own vocabulary rather than ruff's. +_BANDIT_RULE_RE = re.compile(r"^B\d{3}$") +#: mypy error codes whose ``# type: ignore[...]`` suppression is registry-gated: +#: ``override`` is the one token that silences the substitutability gauge +#: (mypy's [override] / Liskov error) entirely. Other codes ride the mypy +#: gauge (``warn_unused_ignores``) + review. +GATED_TYPE_IGNORE_CODES = frozenset({"override"}) + +_REANCHOR = "re-anchor the entry to the enclosing symbol's current qualified name" +_DROP = "drop this entry and lower frozen_count" + + +@dataclass(frozen=True) +class Suppression: + """One gated suppression comment found in the code under measurement.""" + + path: str + line: int + rule: str + symbol: str | None + + +@dataclass(frozen=True) +class AnchorAudit: + """The anchor half's verdict. + + ``rotted`` maps a ``(file, rule)`` pair to descriptions of every entry in + that group whose anchor covers nothing live — the ONLY input the matcher + needs to stop accusing a renamed site of self-issuing its suppression. The + discrimination rule lives at the matcher; the evidence is measured here. + """ + + violations: list[GateViolation] + notices: list[str] + rotted: dict[tuple[str, str], list[str]] + + +@dataclass(frozen=True) +class _FileFacts: + """What one registry-named file IS, measured once per file. + + ``symbols`` is None when the file could not be parsed: absent an AST the + module makes NO claim about whether a symbol exists, because "I could not + read it" and "it is gone" are different world-states. + """ + + exists: bool + scanned: bool + lines: int + symbols: frozenset[str] | None + + +def is_gated_code(code: str) -> bool: + """Form budgets plus the whole S and BLE families are registry-gated.""" + return code in GATED_NOQA_RULES or _GATED_FAMILY_RE.match(code) is not None + + +def is_registrable_rule(rule: str) -> bool: + """True when the SCANNER can ever emit this rule, so an entry for it is gradeable. + + The exact union of what :mod:`cf_quality.exemptions` extracts — gated ruff + codes, bandit ``B###`` from ``# nosec``, gated type-ignore codes. An entry + naming anything else (a consumer documenting an ``E402`` it keeps for its + own reasons) can never match by construction, so grading it as rot would + invent a defect out of a documentation habit. + """ + return ( + is_gated_code(rule) + or _BANDIT_RULE_RE.match(rule) is not None + or rule in GATED_TYPE_IGNORE_CODES + ) + + +def covers(entry: dict[str, Any], suppression: Suppression) -> bool: + """An entry covers a suppression by file + rule + (line OR enclosing symbol). + + THE one resolution rule, shared by the matcher (``exemptions._matches`` + adapts it at its published argument order) and the anchor audit. Two copies + would let coverage and liveness disagree — a gauge that does not measure + what its name says. + """ + if entry["file"] != suppression.path or entry["rule"] != suppression.rule: + return False + anchor = str(entry["symbol_or_line"]).strip() + return anchor == str(suppression.line) or anchor == suppression.symbol + + +def _read_source(file_path: Path) -> str | None: + """The file's text, or None when it cannot be read — never an accusation. + + ``ValueError`` covers ``UnicodeDecodeError``; naming both would be a + redundant handler pair. + """ + try: + return file_path.read_text(encoding="utf-8") + except (OSError, ValueError): + return None + + +def _parse_source(text: str) -> ast.Module | None: + """The parsed module, or None when the source will not parse.""" + try: + return ast.parse(text) + except (SyntaxError, ValueError): + return None + + +def _spans_of(tree: ast.Module) -> list[tuple[int, int, str]]: + """(start, end, QUALIFIED name) spans of every def/class in one parsed tree.""" + spans: list[tuple[int, int, str]] = [] + pending: list[tuple[ast.AST, str]] = [(tree, "")] + while pending: + node, prefix = pending.pop() + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): + qualname = f"{prefix}{child.name}" + if child.end_lineno is not None: + spans.append((child.lineno, child.end_lineno, qualname)) + pending.append((child, f"{qualname}.")) + else: + pending.append((child, prefix)) + return spans + + +def symbol_spans(file_path: Path) -> list[tuple[int, int, str]]: + """(start, end, QUALIFIED name) spans of every def/class. + + Names are dotted paths (``Outer.run``), never bare names — the refuter + showed that bare names let N same-named suppressions collapse onto one + registry entry. Empty when unparsable (line match only). + """ + text = _read_source(file_path) + tree = None if text is None else _parse_source(text) + return [] if tree is None else _spans_of(tree) + + +def enclosing_symbol(spans: list[tuple[int, int, str]], line: int) -> str | None: + """Innermost def/class name enclosing a line, or None at module level.""" + best: tuple[int, str] | None = None + for start, end, name in spans: + if start <= line <= end and (best is None or end - start < best[0]): + best = (end - start, name) + return best[1] if best else None + + +def _file_facts(root: Path, rel: str, surface: tuple[Path, ...]) -> _FileFacts: + """Measure one registry-named file: existence, scanned-ness, size, symbols.""" + path = root / rel + if not path.is_file(): + return _FileFacts(exists=False, scanned=False, lines=0, symbols=None) + if not in_surface(path, surface): + return _FileFacts(exists=True, scanned=False, lines=0, symbols=None) + text = _read_source(path) + if text is None: + return _FileFacts(exists=True, scanned=True, lines=0, symbols=None) + tree = _parse_source(text) + symbols = None if tree is None else frozenset(name for _, _, name in _spans_of(tree)) + return _FileFacts(exists=True, scanned=True, lines=len(text.splitlines()), symbols=symbols) + + +def _unmeasured(index: int, entry: dict[str, Any], why: str) -> str: + """A notice for an entry the gate cannot grade — never a verdict.""" + return ( + f"UNMEASURED ENTRY: entry {index} ({entry['file']} '{entry['rule']}' at " + f"'{entry['symbol_or_line']}') {why} — the gate makes no claim about this " + "anchor; it documents, it does not gate" + ) + + +def _dead(index: int, entry: dict[str, Any], code: str, message: str) -> GateViolation: + """One zero-coverage verdict, always located at the registry that holds it.""" + return GateViolation( + code=code, + message=f"entry {index}: {message}", + path="exemptions.json", + context={ + "entry_index": index, + "file": entry["file"], + "rule": entry["rule"], + "anchor": entry["symbol_or_line"], + }, + ) + + +def _line_anchor_verdict( + index: int, entry: dict[str, Any], line: int, facts: _FileFacts +) -> GateViolation: + """The line-anchored analogue of a dead symbol anchor: out of range, or clean.""" + rel, rule = entry["file"], entry["rule"] + where = f"line {line} of {rel} carries no '{rule}' suppression" + if line > facts.lines: + where = ( + f"line {line} does not exist in {rel} (the file has {facts.lines} lines), " + f"so it carries no '{rule}' suppression" + ) + return _dead( + index, + entry, + "EXEMPTION_SUPPRESSION_GONE", + f"{where} — a line anchor rots on any insertion above it; the suppression moved or " + f"was removed, so {_REANCHOR}, or {_DROP}", + ) + + +def _zero_coverage_verdict( + index: int, entry: dict[str, Any], facts: _FileFacts +) -> GateViolation | str: + """Name the world-state behind ONE entry that covers nothing live. + + A violation when the registry is provably wrong; a notice string when the + entry is outside what this gate measures. The order is deliberate: file + existence is unambiguous and is settled first, so a deleted file is never + softened into "unmeasured" by a rule or surface technicality. + """ + rel, rule = entry["file"], entry["rule"] + anchor = str(entry["symbol_or_line"]).strip() + if not facts.exists: + return _dead( + index, + entry, + "EXEMPTION_FILE_MISSING", + f"the anchored file {rel} no longer exists — drop the entry (and lower " + f"frozen_count) or re-point it at the file that carries the '{rule}' suppression", + ) + if not facts.scanned: + return _unmeasured(index, entry, "sits outside the measured source surface") + if not is_registrable_rule(rule): + return _unmeasured(index, entry, f"names '{rule}', a rule this gate never gates") + if facts.symbols is None: + return _unmeasured(index, entry, f"lives in {rel}, which could not be read or parsed") + if anchor.isdigit(): + return _line_anchor_verdict(index, entry, int(anchor), facts) + if anchor not in facts.symbols: + return _dead( + index, + entry, + "EXEMPTION_SYMBOL_MISSING", + f"the anchored symbol '{anchor}' no longer exists in {rel} (renamed or " + f"removed) — {_REANCHOR}", + ) + return _dead( + index, + entry, + "EXEMPTION_SUPPRESSION_GONE", + f"the anchored symbol '{anchor}' exists in {rel} but carries no '{rule}' " + f"suppression — the suppression was removed; {_DROP}", + ) + + +def _grade_entries( + root: Path, + entries: list[dict[str, Any]], + suppressions: list[Suppression], + surface: tuple[Path, ...], +) -> list[GateViolation | str | None]: + """One positional verdict per entry: None = live coverage, violation = rot, str = notice.""" + facts: dict[str, _FileFacts] = {} + verdicts: list[GateViolation | str | None] = [] + for index, entry in enumerate(entries): + rel = str(entry["file"]) + if rel not in facts: + facts[rel] = _file_facts(root, rel, surface) + if any(covers(entry, suppression) for suppression in suppressions): + verdicts.append(None) + else: + verdicts.append(_zero_coverage_verdict(index, entry, facts[rel])) + return verdicts + + +def _rotted_index( + entries: list[dict[str, Any]], verdicts: list[GateViolation | str | None] +) -> dict[tuple[str, str], list[str]]: + """(file, rule) -> descriptions of the entries in that group covering nothing live.""" + rotted: dict[tuple[str, str], list[str]] = {} + for index, verdict in enumerate(verdicts): + if not isinstance(verdict, GateViolation): + continue + entry = entries[index] + key = (str(entry["file"]), str(entry["rule"])) + rotted.setdefault(key, []).append(f"entry {index} anchored '{entry['symbol_or_line']}'") + return rotted + + +def _line_anchor_notices(entries: list[dict[str, Any]]) -> list[str]: + """Part C: line anchors are named LOUDLY and are NOT a violation. + + Turning them red would fail a consumer for a documented decision (a live + consumer pins sites no symbol identifies uniquely), so the gate reports the + population plus the migration path and leaves the policy to the operator. + Silence is not an option: this is the anchor shape that rots on insertion. + """ + pinned = [ + (index, entry) + for index, entry in enumerate(entries) + if str(entry["symbol_or_line"]).strip().isdigit() + ] + if not pinned: + return [] + notices = [ + f"=== LINE-ANCHORED ENTRIES: {len(pinned)} of {len(entries)} (NOTICE, not a violation) ===" + ] + notices.extend( + f"line-anchored: entry {index} {entry['file']}:{entry['symbol_or_line']} " + f"'{entry['rule']}' — rots on any insertion above the line" + for index, entry in pinned + ) + notices.append( + "migration path: replace symbol_or_line with the enclosing symbol's QUALIFIED " + "name (Class.method); keep a line only where no symbol identifies the site uniquely" + ) + return notices + + +def _group_sites( + owners: list[tuple[int, dict[str, Any]]], group: list[Suppression] +) -> tuple[dict[int, str], list[str]]: + """Split one (file, rule) group into {entry index -> claimed site} and unregistered sites.""" + claimed: dict[int, str] = {} + unregistered: list[str] = [] + for suppression in group: + hits = [index for index, entry in owners if covers(entry, suppression)] + site = f"{suppression.path}:{suppression.line}" + if hits: + claimed[hits[0]] = site + else: + unregistered.append(site) + return claimed, unregistered + + +def _contested( + entries: list[dict[str, Any]], suppressions: list[Suppression] +) -> list[GateViolation]: + """The bystander refuter: a rename can move an approval onto code nobody approved. + + Rename a blessed ``def`` away and an unrelated ``def`` INTO its name: the + orphaned entry matches the newcomer, its ``reason`` and ``approver`` bless + code they never described, and the anchor is technically alive, so + :func:`_zero_coverage_verdict` cannot see it. The GROUP is visible though — + an entry claiming a site while a sibling site in the same (file, rule) pair + is unregistered means the pairing cannot be settled by name alone. The gate + names the ambiguity and both sides, never which site is the impostor: from + one snapshot it cannot know, and naming an unprovable culprit is the + misleading-message scar again. + """ + violations: list[GateViolation] = [] + groups: dict[tuple[str, str], list[Suppression]] = {} + for suppression in suppressions: + groups.setdefault((suppression.path, suppression.rule), []).append(suppression) + for (rel, rule), group in sorted(groups.items()): + owners = [ + (index, entry) + for index, entry in enumerate(entries) + if (str(entry["file"]), str(entry["rule"])) == (rel, rule) + ] + claimed, unregistered = _group_sites(owners, group) + if not claimed or not unregistered: + continue + violations.append(_contested_violation(rel, rule, entries, group, (claimed, unregistered))) + return violations + + +def _contested_violation( + rel: str, + rule: str, + entries: list[dict[str, Any]], + group: list[Suppression], + split: tuple[dict[int, str], list[str]], +) -> GateViolation: + """The contested-group report: both sides named, no culprit invented.""" + claimed, unregistered = split + claims = ", ".join( + f"entry {index} (anchored '{entries[index]['symbol_or_line']}') claims {site}" + for index, site in sorted(claimed.items()) + ) + return GateViolation( + code="EXEMPTION_ANCHOR_CONTESTED", + message=( + f"{rel} '{rule}': {len(group)} live suppressions, {len(claimed)} claimed by " + f"entries; unregistered: {', '.join(unregistered)}; {claims} — after a rename an " + "entry's reason and approver can land on code nobody approved, and from one " + "snapshot the gate cannot tell which site an entry was written for: anchor every " + "suppression in this group to its own qualified symbol" + ), + path="exemptions.json", + context={ + "file": rel, + "rule": rule, + "live_suppressions": len(group), + "claimed_sites": sorted(claimed.values()), + "unregistered_sites": unregistered, + }, + ) + + +def audit( + root: Path, + entries: list[dict[str, Any]], + suppressions: list[Suppression], + surface: tuple[Path, ...], +) -> AnchorAudit: + """Grade every registry anchor against the live suppression population. + + ``surface`` is the discovered scan bases (``_discover_scan_paths``): an + entry outside them cannot be graded, only noticed. The header line + publishes the denominators the audit reasoned from because a population + that selects itself by the value it guards goes vacuous in silence: a run + that checked nothing must SAY it checked nothing. + """ + verdicts = _grade_entries(root, entries, suppressions, surface) + dead = [verdict for verdict in verdicts if isinstance(verdict, GateViolation)] + unmeasured = [verdict for verdict in verdicts if isinstance(verdict, str)] + live = sum(1 for verdict in verdicts if verdict is None) + header = ( + f"=== ANCHOR AUDIT: {len(entries) - len(unmeasured)} entries graded vs " + f"{len(suppressions)} live gated suppression(s) — {live} anchors live, " + f"{len(dead)} dead, {len(unmeasured)} unmeasured ===" + ) + return AnchorAudit( + violations=[*dead, *_contested(entries, suppressions)], + notices=[header, *unmeasured, *_line_anchor_notices(entries)], + rotted=_rotted_index(entries, verdicts), + ) diff --git a/src/cf_quality/exemption_surface.py b/src/cf_quality/exemption_surface.py new file mode 100644 index 0000000..1611be3 --- /dev/null +++ b/src/cf_quality/exemption_surface.py @@ -0,0 +1,78 @@ +"""Which tree cf-exemptions measures — discovery, and membership in it. + +The third module of the exemptions gate, and the file law is why there are +three: ``exemptions.py`` sat at 494 of its 500 measured lines, so the anchor +half (:mod:`cf_quality.exemption_anchors`) and this surface half had to leave. +They are not arbitrary slices. Each answers a question with its own defect +history and its own test file: *does every blessing still point at live code?* +(`test_exemption_anchors.py`) and *which tree did we even read?* +(`test_exemptions_source_root.py`). + +The scar behind this one is the mexxa main-green pass: the gate discovered a +repo-root ``src/`` holding JavaScript, never visited the committed +``server/src``, and every registered exemption was documentation-grade because +the scanner never opened the files the entries covered. So the surface resolves +through cf-repo-config exactly like the gauges do — typed failure on an invalid +declaration, never a silent fallback — and a flat or app layout is DISCOVERED +rather than skipped (the refuter's src-only escape). + +:func:`in_surface` exists because the anchor audit must grade an entry only +against files the scanner actually read: an entry pointing outside the measured +tree is undecidable, and calling it dead would accuse code the gate never +opened. Membership is tested against the very bases the scan walks, so the two +can never drift into disagreeing about what was measured. +""" + +from __future__ import annotations + +from pathlib import Path + +from cf_quality import repo_config + +#: Directories never measured for suppressions (tests carry their own +#: per-file-ignores discipline; the rest is non-shipping surface). The set +#: itself lives in repo_config — one gauge-block, shared with the first-party +#: derivation, never two lists drifting apart. +EXCLUDED_SCAN_DIRS = repo_config.NON_SHIPPING_DIRS + + +def discover_scan_paths(root: Path) -> list[Path]: + """The declared ``source_root`` when committed; else ``src/`` when + present; otherwise every top-level Python location. + + A declared layout is honored exactly like the gauges honor it (typed + failure on an invalid declaration, never a silent fallback). A flat or + app layout is measured, never a silent no-op: top-level ``*.py`` files + and every non-excluded directory holding Python count. + """ + if repo_config.load(root).source_root is not None: + return [repo_config.resolve_source_root(root)] + src = root / "src" + if src.is_dir(): + return [src] + paths: list[Path] = [] + for child in sorted(root.iterdir()): + if child.name.startswith(".") or child.name in EXCLUDED_SCAN_DIRS: + continue + if child.is_file() and child.suffix == ".py": + paths.append(child) + elif child.is_dir() and any(child.rglob("*.py")): + paths.append(child) + return paths + + +def in_surface(path: Path, surface: tuple[Path, ...]) -> bool: + """Is this registry-named file inside the tree the scanner actually walked? + + Tested against the DISCOVERED bases the scan iterates, never a second + discovery rule: the scan walks every ``*.py`` under each base, so "under a + base" and "was read" are one set for the files a registry can name. Bases + rather than a visited-path set, so ``exemptions._scan_src`` keeps the + two-value arity a consumer's drift-proof pin unpacks. + """ + resolved = path.resolve() + for base in surface: + anchor = base.resolve() + if resolved == anchor or resolved.is_relative_to(anchor): + return True + return False diff --git a/src/cf_quality/exemptions.py b/src/cf_quality/exemptions.py index fe0c700..950e9bb 100644 --- a/src/cf_quality/exemptions.py +++ b/src/cf_quality/exemptions.py @@ -34,7 +34,11 @@ collapsed N same-named suppressions onto one frozen entry; (d) fold-in wrappers: when the target repo ships ``scripts/check_english.py`` or ``scripts/check_host_free.py`` the gate runs them and propagates - their exit codes; absent scripts are skipped silently. + their exit codes; absent scripts are skipped silently; +(e) every entry must still ANCHOR at live code — the mirror of (b), delegated + whole to :mod:`cf_quality.exemption_anchors` (that module's docstring and + DESIGN.md §4.3 carry the taxonomy). The one thing decided HERE is which + message an unmatched suppression earns (:func:`_unregistered_violation`). The measured surface is the committed ``[tool.cf-quality] source_root`` when declared — resolved through cf-repo-config exactly like the gauges (the @@ -52,35 +56,28 @@ from __future__ import annotations import argparse -import ast import json import re import subprocess # fold-in scripts run via sys.executable, fixed argv, no shell (S603 gated below) import sys import tokenize -from dataclasses import dataclass from pathlib import Path from typing import Any -from cf_quality import repo_config +from cf_quality import exemption_anchors from cf_quality.errors import GateError, GateViolation +from cf_quality.exemption_anchors import ( + GATED_TYPE_IGNORE_CODES, + Suppression, + covers, + enclosing_symbol, + is_gated_code, + symbol_spans, +) +from cf_quality.exemption_surface import discover_scan_paths from cf_quality.reporting import print_verdict -GATED_NOQA_RULES = frozenset({"C901", "PLR0915"}) -#: Whole rule families that are registry-gated code-by-code: the ruff-ported -#: bandit battery (S###) and the Elegance Law's blind-except ban (BLE###). -_GATED_FAMILY_RE = re.compile(r"^(?:S|BLE)\d+$") -#: mypy error codes whose ``# type: ignore[...]`` suppression is registry-gated: -#: ``override`` is the one token that silences the substitutability gauge -#: (mypy's [override] / Liskov error) entirely. Other codes ride the mypy -#: gauge (``warn_unused_ignores`` fails the stale ones) + review. -GATED_TYPE_IGNORE_CODES = frozenset({"override"}) FOLD_IN_SCRIPTS = ("check_english.py", "check_host_free.py") -#: Directories never measured for suppressions (tests carry their own -#: per-file-ignores discipline; the rest is non-shipping surface). The set -#: itself lives in repo_config — one gauge-block, shared with the -#: first-party derivation, never two lists drifting apart. -EXCLUDED_SCAN_DIRS = repo_config.NON_SHIPPING_DIRS REQUIRED_ENTRY_KEYS = ("file", "symbol_or_line", "rule", "reason", "approver") _NOSEC_RE = re.compile(r"#\s*nosec\b(.*)") @@ -90,16 +87,6 @@ _CODE_SPLIT_RE = re.compile(r"[\s,]+") -@dataclass(frozen=True) -class Suppression: - """One gated suppression comment found in the code under measurement.""" - - path: str - line: int - rule: str - symbol: str | None - - def _comment_tokens(file_path: Path) -> list[tuple[int, str]]: """All COMMENT tokens of a file as (line, text); typed error if unreadable.""" try: @@ -114,41 +101,6 @@ def _comment_tokens(file_path: Path) -> list[tuple[int, str]]: return [(tok.start[0], tok.string) for tok in tokens if tok.type == tokenize.COMMENT] -def _symbol_spans(file_path: Path) -> list[tuple[int, int, str]]: - """(start, end, QUALIFIED name) spans of every def/class. - - Names are dotted paths (``Outer.run``), never bare names — the refuter - showed that bare names let N same-named suppressions collapse onto one - registry entry. Empty when unparsable (line match only). - """ - try: - tree = ast.parse(file_path.read_text(encoding="utf-8")) - except (SyntaxError, UnicodeDecodeError): - return [] - spans: list[tuple[int, int, str]] = [] - pending: list[tuple[ast.AST, str]] = [(tree, "")] - while pending: - node, prefix = pending.pop() - for child in ast.iter_child_nodes(node): - if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): - qualname = f"{prefix}{child.name}" - if child.end_lineno is not None: - spans.append((child.lineno, child.end_lineno, qualname)) - pending.append((child, f"{qualname}.")) - else: - pending.append((child, prefix)) - return spans - - -def _enclosing_symbol(spans: list[tuple[int, int, str]], line: int) -> str | None: - """Innermost def/class name enclosing a line, or None at module level.""" - best: tuple[int, str] | None = None - for start, end, name in spans: - if start <= line <= end and (best is None or end - start < best[0]): - best = (end - start, name) - return best[1] if best else None - - def _classify_comment( rel_path: str, line: int, text: str, symbol: str | None ) -> tuple[list[Suppression], list[GateViolation]]: @@ -175,7 +127,7 @@ def _classify_comment( if noqa and noqa.group(1): codes = _CODE_SPLIT_RE.split(noqa.group(1).strip()) suppressions.extend( - Suppression(rel_path, line, code, symbol) for code in codes if _is_gated_code(code) + Suppression(rel_path, line, code, symbol) for code in codes if is_gated_code(code) ) elif noqa: violations.append( @@ -215,54 +167,45 @@ def _type_ignore_suppressions( ] -def _is_gated_code(code: str) -> bool: - """Form budgets plus the whole S and BLE families are registry-gated.""" - return code in GATED_NOQA_RULES or _GATED_FAMILY_RE.match(code) is not None - - -def _discover_scan_paths(root: Path) -> list[Path]: - """The declared ``source_root`` when committed; else ``src/`` when - present; otherwise every top-level Python location. +def _scan_src(root: Path) -> tuple[list[Suppression], list[GateViolation]]: + """Scan the discovered Python surface for suppression comments. - A declared layout is honored exactly like the gauges honor it (typed - failure on an invalid declaration, never a silent fallback). A flat or - app layout is measured, never a silent no-op: top-level ``*.py`` files - and every non-excluded directory holding Python count. + The ``(suppressions, violations)`` ARITY is a published contract, not an + implementation detail: a consumer's drift-proof pin unpacks exactly two + values from this to cross-check its mirrored resolver (see + :func:`_matches`). The anchor audit therefore takes its surface from + :mod:`cf_quality.exemption_surface` rather than riding a third element. """ - if repo_config.load(root).source_root is not None: - return [repo_config.resolve_source_root(root)] - src = root / "src" - if src.is_dir(): - return [src] - paths: list[Path] = [] - for child in sorted(root.iterdir()): - if child.name.startswith(".") or child.name in EXCLUDED_SCAN_DIRS: - continue - if child.is_file() and child.suffix == ".py": - paths.append(child) - elif child.is_dir() and any(child.rglob("*.py")): - paths.append(child) - return paths - - -def _scan_src(root: Path) -> tuple[list[Suppression], list[GateViolation]]: - """Scan the discovered Python surface for suppression comments.""" suppressions: list[Suppression] = [] violations: list[GateViolation] = [] - for base in _discover_scan_paths(root): + for base in discover_scan_paths(root): files = [base] if base.is_file() else sorted(base.rglob("*.py")) for file_path in files: rel_path = file_path.relative_to(root).as_posix() - spans = _symbol_spans(file_path) + spans = symbol_spans(file_path) for line, text in _comment_tokens(file_path): found, broken = _classify_comment( - rel_path, line, text, _enclosing_symbol(spans, line) + rel_path, line, text, enclosing_symbol(spans, line) ) suppressions.extend(found) violations.extend(broken) return suppressions, violations +def _matches(suppression: Suppression, entry: dict[str, Any]) -> bool: + """The resolution rule at its PUBLISHED name and argument order. + + A pure adapter over :func:`cf_quality.exemption_anchors.covers` — ONE + implementation of the rule, never two, because two would let coverage and + liveness disagree. The name survives the anchor-half split deliberately: a + consumer's ``test_exemption_anchors_are_drift_proof`` pin calls + ``exemptions._matches(suppression, entry)`` to prove its mirrored resolver + has not drifted from this gate, and a cross-repo signature break cannot + land as one PR. A contract, not dead code — do not "clean it up". + """ + return covers(entry, suppression) + + def _validate_entry(index: int, entry: Any) -> None: """Every entry carries the full five-field registration, none of it empty.""" if not isinstance(entry, dict): @@ -316,14 +259,6 @@ def _load_config(root: Path) -> tuple[list[dict[str, Any]], int] | None: return entries, frozen_count -def _matches(suppression: Suppression, entry: dict[str, Any]) -> bool: - """An entry covers a suppression by file + rule + (line OR enclosing symbol).""" - if entry["file"] != suppression.path or entry["rule"] != suppression.rule: - return False - symbol_or_line = str(entry["symbol_or_line"]).strip() - return symbol_or_line == str(suppression.line) or symbol_or_line == suppression.symbol - - def _ratchet_report(entry_count: int, frozen_count: int) -> tuple[list[GateViolation], list[str]]: """The count ratchet — always loud, never silent.""" lines = [f"=== EXEMPTION RATCHET: {entry_count} entries / frozen_count {frozen_count} ==="] @@ -351,7 +286,7 @@ def _ratchet_report(entry_count: int, frozen_count: int) -> tuple[list[GateViola def check(root: Path) -> tuple[list[GateViolation], list[str]]: - """Run checks (a)-(c) against a repo root; returns (violations, report lines).""" + """Run checks (a)-(e) against a repo root; returns (violations, report lines).""" suppressions, violations = _scan_src(root) config = _load_config(root) if config is None: @@ -363,15 +298,65 @@ def check(root: Path) -> tuple[list[GateViolation], list[str]]: ) return violations, ["no exemptions.json and no gated suppressions — nothing to register"] entries, frozen_count = config - match_violations, registered_lines = _match_suppressions(suppressions, entries) + surface = tuple(discover_scan_paths(root)) + anchors = exemption_anchors.audit(root, entries, suppressions, surface) + match_violations, registered_lines = _match_suppressions(suppressions, entries, anchors.rotted) + violations.extend(anchors.violations) violations.extend(match_violations) ratchet_violations, lines = _ratchet_report(len(entries), frozen_count) violations.extend(ratchet_violations) - return violations, [*lines, *registered_lines] + return violations, [*lines, *anchors.notices, *registered_lines] + + +def _unregistered_violation( + suppression: Suppression, rotted: dict[tuple[str, str], list[str]] +) -> GateViolation: + """Check (b)'s verdict — and the DISCRIMINATION that stops it from lying. + + A suppression with no entry is only *probably* self-issued. When the + registry holds a stale anchor for the SAME file and the SAME rule — an + entry the audit graded as covering nothing live — the likelier world is a + RENAME: the site was blessed and the pointer rotted. The accusation ("a + self-issued suppression is not an exemption") has cost real review time and + nearly provoked rewrites of load-bearing error barriers, so it is reserved + for the case where NOTHING rotted: every entry for the pair is live, or no + entry names it. Then the strict message stands verbatim. This cannot soften + the gate — code, path, line and exit level are IDENTICAL in both branches, + only the prose differs, and the rot branch is reachable only once the + orphan has emitted its OWN violation, so it never subtracts a finding. + """ + key = (suppression.path, suppression.rule) + stale = rotted.get(key) + if not stale: + message = ( + f"'{suppression.rule}' suppression has no matching entry in " + "exemptions.json — a self-issued suppression is not an exemption" + ) + else: + hint = suppression.symbol or f"line {suppression.line}" + message = ( + f"'{suppression.rule}' suppression has no matching entry, but the registry holds a " + f"STALE anchor for this file+rule ({', '.join(stale)}) covering nothing live — very " + "probably a RENAME: this site WAS blessed and the registry pointer rotted. " + f"Re-anchor that entry to '{hint}' rather than reading this site as unblessed" + ) + return GateViolation( + code="UNREGISTERED_SUPPRESSION", + message=message, + path=suppression.path, + line=suppression.line, + context={ + "rule": suppression.rule, + "symbol": suppression.symbol, + "stale_anchors": list(stale or ()), + }, + ) def _match_suppressions( - suppressions: list[Suppression], entries: list[dict[str, Any]] + suppressions: list[Suppression], + entries: list[dict[str, Any]], + rotted: dict[tuple[str, str], list[str]], ) -> tuple[list[GateViolation], list[str]]: """Every suppression needs an entry; every entry covers at most ONE. @@ -385,20 +370,9 @@ def _match_suppressions( registered: list[str] = [] matched_counts = [0] * len(entries) for suppression in suppressions: - hits = [i for i, entry in enumerate(entries) if _matches(suppression, entry)] + hits = [i for i, entry in enumerate(entries) if covers(entry, suppression)] if not hits: - violations.append( - GateViolation( - code="UNREGISTERED_SUPPRESSION", - message=( - f"'{suppression.rule}' suppression has no matching entry in " - "exemptions.json — a self-issued suppression is not an exemption" - ), - path=suppression.path, - line=suppression.line, - context={"rule": suppression.rule, "symbol": suppression.symbol}, - ) - ) + violations.append(_unregistered_violation(suppression, rotted)) else: registered.append(_registered_line(suppression, hits[0], entries[hits[0]])) for index in hits: diff --git a/tests/test_exemption_anchors.py b/tests/test_exemption_anchors.py new file mode 100644 index 0000000..e5c5966 --- /dev/null +++ b/tests/test_exemption_anchors.py @@ -0,0 +1,390 @@ +"""Control rods for the anchor half of cf-exemptions — the rot the gate used to tolerate. + +Split from ``test_exemptions.py`` by design (the file budget measures, review +judges): this file carries one coherent unit — an exemption entry that covers +NOTHING and the honesty of what the gate says about it. + +The defect, measured with the kit's own resolver before the fix: +``_match_suppressions`` built ``matched_counts`` and failed only on +``count > 1``. A ``count == 0`` entry was silently tolerated, and +``frozen_count`` ratchets ENTRIES rather than live coverage, so the ratchet +stayed satisfied. Rename an enclosing ``def`` that holds a registered +suppression and two things happened at once: the site was reported +``UNREGISTERED_SUPPRESSION`` — "a self-issued suppression is not an +exemption", a convincing message that is WRONG after a rename — while the +orphaned entry was reported not at all. Rename an unrelated ``def`` INTO the +orphaned name and its suppression was silently blessed by an entry whose +``reason`` and ``approver`` describe entirely different code. + +The rods assert MESSAGE TEXT, not only exit codes: the honesty of the message +is the contract here, and a red for the wrong reason is the thing being fixed. +The reused fixture helpers come from ``test_exemptions`` — one mechanism for +this gate's tests, never a parallel one. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from test_exemptions import entry, run_gate, write_exemptions, write_src + +from cf_quality.exemption_anchors import covers +from cf_quality.exemptions import _matches, _scan_src + +#: The accusation that must NOT reach a developer whose registry merely rotted. +SELF_ISSUED = "a self-issued suppression is not an exemption" + +_AUDIT_RE = re.compile( + r"=== ANCHOR AUDIT: (\d+) entries graded vs (\d+) live gated suppression\(s\)" +) + + +def audit_counts(out: str) -> tuple[int, int]: + """(entries graded, live suppressions) as the gate itself reported them. + + The non-vacuity instrument: a gate whose population selects itself by the + value it guards goes vacuous in silence, so every rod that claims a clean + verdict reads the denominators back out of the output and asserts them. + """ + match = _AUDIT_RE.search(out) + assert match is not None, f"the gate must publish its anchor-audit denominators:\n{out}" + return int(match.group(1)), int(match.group(2)) + + +# --- rod 1: the rename rod ---------------------------------------------------- + +RENAMED = "def new_name(): # noqa: C901\n return 1\n" + + +def test_rename_rod_names_the_missing_anchor( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # The world: `def old_name` carried a REGISTERED `# noqa: C901` and was + # renamed to `new_name`. The entry's anchor is now stale. + write_src(tmp_path, "mod.py", RENAMED) + write_exemptions(tmp_path, [entry("src/mod.py", "old_name", "C901")], frozen_count=1) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 1 + assert "EXEMPTION_SYMBOL_MISSING" in out + missing = "the anchored symbol 'old_name' no longer exists in src/mod.py (renamed or removed)" + assert missing in out + assert "re-anchor the entry to the enclosing symbol's current qualified name" in out + + +def test_rename_rod_does_not_accuse_the_blessed_site( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # The suppression WAS blessed; only the pointer rotted. The old accusation + # has cost real review time and nearly provoked rewrites of load-bearing + # error barriers, so it must not be the message this developer sees. + write_src(tmp_path, "mod.py", RENAMED) + write_exemptions(tmp_path, [entry("src/mod.py", "old_name", "C901")], frozen_count=1) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 1 + assert SELF_ISSUED not in out + assert "UNREGISTERED_SUPPRESSION" in out, "the site is still unresolved — still red" + assert "very probably a RENAME" in out + assert "entry 0 anchored 'old_name'" in out, "the stale anchor must be NAMED" + assert "Re-anchor that entry to 'new_name'" in out + + +def test_genuine_unregistered_suppression_keeps_the_strict_accusation( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # The converse rod (anti-softening): every registry entry is LIVE, so + # nothing rotted and the original strict message stands verbatim. + # Discrimination that swallowed this case would be softening in disguise. + write_src(tmp_path, "mod.py", "def gnarly(): # noqa: C901\n return 1\n") + write_src(tmp_path, "other.py", "def elsewhere(): # noqa: C901\n return 1\n") + write_exemptions(tmp_path, [entry("src/other.py", "elsewhere", "C901")], frozen_count=1) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 1 + assert "UNREGISTERED_SUPPRESSION" in out + assert SELF_ISSUED in out + assert "very probably a RENAME" not in out + assert "1 anchors live, 0 dead" in out, "the strict branch must be the no-rot world" + + +# --- rod 2: the bystander rod ------------------------------------------------- + +BYSTANDER_BEFORE = ( + "def old_name(): # noqa: C901\n return 1\n\n\ndef other(): # noqa: C901\n return 2\n" +) +#: `old_name` -> `new_name` AND `other` -> `old_name`: the orphaned entry now +#: matches a stranger, and its reason/approver bless code nobody approved. +BYSTANDER_AFTER = ( + "def new_name(): # noqa: C901\n return 1\n\n\ndef old_name(): # noqa: C901\n return 2\n" +) +BYSTANDER_ENTRIES = [entry("src/mod.py", "old_name", "C901"), entry("src/mod.py", "other", "C901")] + + +def test_bystander_fixture_is_green_before_the_renames( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # The rod's own non-vacuity: the world it breaks was genuinely clean, and + # the gate graded a non-empty population to say so. + write_src(tmp_path, "mod.py", BYSTANDER_BEFORE) + write_exemptions(tmp_path, BYSTANDER_ENTRIES, frozen_count=2) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 0 + assert audit_counts(out) == (2, 2) + + +def test_bystander_rename_is_not_silently_blessed( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + write_src(tmp_path, "mod.py", BYSTANDER_AFTER) + write_exemptions(tmp_path, BYSTANDER_ENTRIES, frozen_count=2) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 1 + # The specific wrong world-state: entry 0's approval has landed on the + # site at line 5, which is not the code its reason was written for. + assert "EXEMPTION_ANCHOR_CONTESTED" in out + assert "entry 0 (anchored 'old_name') claims src/mod.py:5" in out + assert "unregistered: src/mod.py:1" in out + assert "anchor every suppression in this group to its own qualified symbol" in out + # And the entry whose symbol really did vanish is reported by name. + assert "EXEMPTION_SYMBOL_MISSING" in out + assert "the anchored symbol 'other' no longer exists in src/mod.py" in out + assert SELF_ISSUED not in out + + +# --- rods 3-5: the remaining dead-anchor world-states ------------------------- + + +def test_entry_whose_file_is_gone_is_red_with_the_file_missing_message( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + write_src(tmp_path, "mod.py", "def keeper(): # noqa: C901\n return 1\n") + entries = [entry("src/mod.py", "keeper", "C901"), entry("src/gone.py", "vanished", "C901")] + write_exemptions(tmp_path, entries, frozen_count=2) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 1 + assert "EXEMPTION_FILE_MISSING" in out + assert "the anchored file src/gone.py no longer exists" in out + assert "drop the entry (and lower frozen_count) or re-point it" in out + + +def test_symbol_alive_but_suppression_removed_is_red( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # The remedy differs from a rename: nothing to re-anchor to, so the entry + # goes and frozen_count comes down. The message must say THAT. + write_src(tmp_path, "mod.py", "def keeper():\n return 1\n") + write_exemptions(tmp_path, [entry("src/mod.py", "keeper", "C901")], frozen_count=1) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 1 + assert "EXEMPTION_SUPPRESSION_GONE" in out + alive = "the anchored symbol 'keeper' exists in src/mod.py but carries no 'C901' suppression" + assert alive in out + assert "the suppression was removed; drop this entry and lower frozen_count" in out + + +LINE_DRIFT = "def keeper(): # noqa: C901\n x = 1\n y = 2\n return x + y\n" + + +def test_line_anchor_pointing_at_a_clean_line_is_red( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + entries = [entry("src/mod.py", "keeper", "C901"), entry("src/mod.py", 3, "PLR0915")] + write_src(tmp_path, "mod.py", LINE_DRIFT) + write_exemptions(tmp_path, entries, frozen_count=2) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 1 + assert "EXEMPTION_SUPPRESSION_GONE" in out + assert "line 3 of src/mod.py carries no 'PLR0915' suppression" in out + assert "a line anchor rots on any insertion above it" in out + + +def test_line_anchor_past_the_end_of_the_file_is_red( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + write_src(tmp_path, "mod.py", LINE_DRIFT) + write_exemptions(tmp_path, [entry("src/mod.py", 99, "C901")], frozen_count=1) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 1 + assert "line 99 does not exist in src/mod.py (the file has 4 lines)" in out + # The live C901 at line 1 reads as rot, not as a self-issued suppression. + assert SELF_ISSUED not in out + assert "entry 0 anchored '99'" in out + + +# --- rods 6-8: green, loud, and non-vacuous ----------------------------------- + +LIVE_1TO1 = ( + "class A:\n" + " def run(self): # noqa: C901\n" + " return 1\n" + "\n" + "\n" + "def solo(): # noqa: PLR0915\n" + " return 2\n" +) + + +def test_all_anchors_live_one_to_one_is_clean_and_still_prints_loudly( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + entries = [entry("src/mod.py", "A.run", "C901"), entry("src/mod.py", "solo", "PLR0915")] + write_src(tmp_path, "mod.py", LIVE_1TO1) + write_exemptions(tmp_path, entries, frozen_count=2) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 0 + assert "registered: src/mod.py:2 'C901' (A.run) — covered by entry 0" in out + assert "registered: src/mod.py:6 'PLR0915' (solo) — covered by entry 1" in out + assert "2 anchors live, 0 dead, 0 unmeasured" in out + assert "LINE-ANCHORED" not in out + + +def test_live_line_anchors_are_clean_but_reported_loudly( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # A live consumer deliberately line-pins entries whose sites no symbol + # identifies uniquely. Reding that would fail a repo for a documented + # decision, so the gate NOTICES and hands the policy to the operator. + write_src(tmp_path, "mod.py", "def keeper(): # noqa: C901\n return 1\n") + write_exemptions(tmp_path, [entry("src/mod.py", 1, "C901")], frozen_count=1) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 0 + assert "=== LINE-ANCHORED ENTRIES: 1 of 1 (NOTICE, not a violation) ===" in out + assert "line-anchored: entry 0 src/mod.py:1 'C901'" in out + assert "migration path: replace symbol_or_line with the enclosing symbol's QUALIFIED" in out + assert "EXEMPTION_SUPPRESSION_GONE" not in out + assert "EXEMPTION_SYMBOL_MISSING" not in out + assert audit_counts(out) == (1, 1) + + +def test_the_audit_publishes_a_non_empty_denominator( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # Non-vacuity: a clean verdict is only worth something if the run graded a + # non-empty set of entries AND saw a non-empty set of suppressions. + entries = [entry("src/mod.py", "A.run", "C901"), entry("src/mod.py", "solo", "PLR0915")] + write_src(tmp_path, "mod.py", LIVE_1TO1) + write_exemptions(tmp_path, entries, frozen_count=2) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 0 + graded, suppressions = audit_counts(out) + assert graded == 2 + assert suppressions == 2 + + +def test_a_run_that_grades_nothing_says_so( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # The control rod on the rod: an empty population reports ZERO out loud, + # so a vacuous clean verdict is legible instead of comfortable. + write_exemptions(tmp_path, [], frozen_count=0) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 0 + assert audit_counts(out) == (0, 0) + + +# --- the ungradeable: notices, never accusations ------------------------------ + + +def test_entry_for_a_rule_this_gate_never_gates_is_a_notice( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # A consumer documenting an E402 it keeps for its own reasons has not + # rotted anything: the scanner can never emit that rule, so grading the + # entry as dead would invent a defect out of a documentation habit. + write_src(tmp_path, "mod.py", "x = 1\n") + write_exemptions(tmp_path, [entry("src/mod.py", 1, "E402")], frozen_count=1) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 0 + assert "UNMEASURED ENTRY: entry 0" in out + assert "names 'E402', a rule this gate never gates" in out + assert audit_counts(out) == (0, 0) + + +def test_entry_outside_the_measured_surface_is_a_notice( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + write_src(tmp_path, "mod.py", "x = 1\n") + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + (tests_dir / "test_x.py").write_text( + "def helper(): # noqa: C901\n return 1\n", encoding="utf-8" + ) + write_exemptions(tmp_path, [entry("tests/test_x.py", "helper", "C901")], frozen_count=1) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 0 + assert "sits outside the measured source surface" in out + assert "EXEMPTION_SYMBOL_MISSING" not in out + + +def test_an_overloaded_entry_is_never_also_reported_as_dead( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # Anti-softening in the other direction: the anchor audit must not pile a + # second, contradictory verdict onto the collision refuter's finding. + body = "def big(): # noqa: PLR0915\n x = 1 # noqa: PLR0915\n return x\n" + write_src(tmp_path, "mod.py", body) + write_exemptions(tmp_path, [entry("src/mod.py", "big", "PLR0915")], frozen_count=1) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 1 + assert "EXEMPTION_ENTRY_OVERLOADED" in out + assert "EXEMPTION_SYMBOL_MISSING" not in out + assert "EXEMPTION_SUPPRESSION_GONE" not in out + + +class TestConsumerResolverContract: + """The published surface a consumer's drift-proof pin actually calls. + + A consumer repo mirrors this gate's resolver in its own pin test and + cross-checks the two entry by entry, *so that the mirror cannot silently + drift*. It reaches in by name: `suppressions, _ = exemptions._scan_src(root)` + and `exemptions._matches(suppression, entry)`. Changing that arity or + flipping those arguments breaks the one test whose job is detecting drift, + and the break cannot be repaired in one PR — a consumer patched for a new + kit API fails against the currently pinned kit, so neither side can merge + alone. These rods make the shape a contract rather than an accident: if you + are here because a refactor turned them red, fix the refactor. + """ + + def test_scan_src_returns_exactly_two_values(self, tmp_path: Path) -> None: + write_src(tmp_path, "mod.py", "def gnarly(): # noqa: C901\n return 1\n") + result = _scan_src(tmp_path) + assert len(result) == 2, "the consumer pin unpacks exactly two values" + suppressions, violations = result + assert [(s.path, s.line, s.rule, s.symbol) for s in suppressions] == [ + ("src/mod.py", 1, "C901", "gnarly") + ] + assert violations == [] + + def test_matches_keeps_its_published_argument_order(self, tmp_path: Path) -> None: + write_src(tmp_path, "mod.py", "def gnarly(): # noqa: C901\n return 1\n") + suppressions, _ = _scan_src(tmp_path) + suppression = suppressions[0] + assert _matches(suppression, entry("src/mod.py", "gnarly", "C901")) is True + assert _matches(suppression, entry("src/mod.py", 1, "C901")) is True + assert _matches(suppression, entry("src/mod.py", "other", "C901")) is False + assert _matches(suppression, entry("src/other.py", "gnarly", "C901")) is False + + def test_matches_is_a_pure_adapter_over_the_single_rule(self, tmp_path: Path) -> None: + # ONE implementation of the rule: two would let coverage and liveness + # disagree, which is the gauge that does not measure what its name says. + body = "class A:\n def run(self): # noqa: C901\n return 1\n" + write_src(tmp_path, "mod.py", body) + suppressions, _ = _scan_src(tmp_path) + for anchor in ("A.run", "run", 2, 1): + candidate = entry("src/mod.py", anchor, "C901") + assert _matches(suppressions[0], candidate) == covers(candidate, suppressions[0]) + + +def test_the_kit_own_registry_carries_no_dead_anchors( + capsys: pytest.CaptureFixture[str], +) -> None: + # The kit submits to the gate it ships: its own four entries are live + # symbol anchors, 1:1, with nothing line-pinned and nothing unmeasured. + kit_root = Path(__file__).resolve().parents[1] + code, out, _ = run_gate(kit_root, capsys) + assert code == 0, out + graded, suppressions = audit_counts(out) + assert graded >= 4, "the self-check must grade a non-empty registry" + assert suppressions >= 4 + assert "0 dead, 0 unmeasured" in out + assert "LINE-ANCHORED" not in out