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