From 292f2dda50f7c1431444d97ee861553e0dbfa6c1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:35:22 -0400 Subject: [PATCH 01/18] debt_ratchet.py is only the CI entry; the pinned audit owns every measure tools/debt_ratchet.py was 2,919 lines and 95 classes, a second audit engine beside the refactor-audit this repository pins. The engine, its measures and its report moved to refactor-audit (OpenHCSDev/refactor-audit#7). agent-comms' own facts are that package's audit/agent_comms_project.py, derived from their owners here, and the pool and runner stay ForkedProcessPool and SpawnedProcess. The entry is now argparse for --root/--base/--head/--workers and one call to audit.ratchet.ratchet(): 32 lines, 0 classes. The report, the stderr lines and the exit status are unchanged, and both workflows run as before. - The submodule pin moves to refactor-audit 45b7fc27 (the PR head; the pin moves to the reviewed commit before merge). - tools/typing_names.py moved to audit.typing_names; module_layers imports it from there. - repairs.py, test_boundary_decoding.py and test_carry.py import their measures from the audit owner directly. - pinned_audit takes ForeignAbsenceProbe from audit.source_measures, and phase_evidence calls its occurrences(node). - PlainStrIdRatchetPhase and LockCensusPhase kept grepping tools/debt_ratchet.py, which no longer holds those classes. The new PinnedPatternPresent reads the submodule tree main pins, at the commit its gitlink names (MainTree.pinned). - tests/guards/test_debt_ratchet.py: the generic suite moved to refactor-audit. What only agent-comms declares stays here as test_agent_comms_ratchet.py: lock owners, the boundary decoder, codec modules, done phases, the stored-record probe, the pool's process tree and the CLI's merged log. The lock-owner test had asserted two owners since 1ab5bdee2 added process_claim as a third; it now asserts the three the declaration derives. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/phase_evidence.py | 25 +- src/agent_comms/pinned_audit.py | 2 +- src/agent_comms/plan_phases.py | 7 +- src/agent_comms/skills/refactor-audit | 2 +- tests/guards/test_agent_comms_ratchet.py | 341 +++ tests/guards/test_debt_ratchet.py | 1512 ----------- tests/test_boundary_decoding.py | 5 +- tests/test_carry.py | 11 +- tools/debt_ratchet.py | 2905 +--------------------- tools/module_layers.py | 3 +- tools/repairs.py | 11 +- tools/typing_names.py | 151 -- 12 files changed, 402 insertions(+), 4573 deletions(-) create mode 100644 tests/guards/test_agent_comms_ratchet.py delete mode 100644 tests/guards/test_debt_ratchet.py delete mode 100644 tools/typing_names.py diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index 4f4e1fb89a..f0f8ecbd5b 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -99,6 +99,15 @@ def matching_lines(self, pattern: str, paths: tuple[str, ...]) -> frozenset[tupl ) ) + def pinned(self, path: str) -> MainTree: + """The tree of the submodule this commit pins at ``path``: another repository's tree, + read at exactly the commit its gitlink names, from that repository's own objects.""" + entry, _, _ = Repo.git(self.repo.checkout, "ls-tree", self.commit, "--", path).partition("\t") + match entry.split(): + case [_, "commit", commit]: + return MainTree(Repo.of(self.repo.checkout / path), commit) + raise ValueError(f"{path} is no submodule at {self.commit}") + def module(self, path: str) -> ParsedModule: source = Repo.git(self.repo.checkout, "show", f"{self.commit}:{path}") return ParsedModule(path, source, ast.parse(source, filename=path), path) @@ -388,6 +397,20 @@ def describe(self) -> str: return f"{self.pattern!r} in {' '.join(self.paths)} on main" +@dataclass(frozen=True) +class PinnedPatternPresent(PatternPresent): + """A pattern in the tree of a submodule main pins, at the commit it pins: code this + repository runs from another repository.""" + + submodule: str = field(kw_only=True) + + def read(self, main: MainTree) -> bool: + return self.satisfied(main.pinned(self.submodule).count(self.pattern, self.paths)) + + def describe(self) -> str: + return f"{self.pattern!r} in {' '.join(self.paths)} of the {self.submodule} main pins" + + @dataclass(frozen=True) class DocumentPatternPresent(PatternPresent): """A recorded document fact, rather than executable Python source.""" @@ -668,7 +691,7 @@ class NoAbsenceProbes(MainEvidence): def read(self, main: MainTree) -> bool: """Every node is offered to the probe, whose own pattern decides what it counts.""" return not any( - ForeignAbsenceProbe.count(node) + ForeignAbsenceProbe.occurrences(node) for module in main.present_modules(self.paths) for node in ast.walk(module.tree) ) diff --git a/src/agent_comms/pinned_audit.py b/src/agent_comms/pinned_audit.py index 22bd0898d8..9b63ebad2b 100644 --- a/src/agent_comms/pinned_audit.py +++ b/src/agent_comms/pinned_audit.py @@ -18,4 +18,4 @@ import audit as audit # noqa: E402 from audit.findings import GodFunction as GodFunction # noqa: E402 from audit.findings import ParsedModule as ParsedModule # noqa: E402 -from audit.measures import ForeignAbsenceProbe as ForeignAbsenceProbe # noqa: E402 +from audit.source_measures import ForeignAbsenceProbe as ForeignAbsenceProbe # noqa: E402 diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index 338e1d3b8a..48ffd00991 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -41,6 +41,7 @@ PatternAbsent, PatternPresent, PhaseEvidence, + PinnedPatternPresent, PlanReading, RestatedMembersAbsent, RulesetGates, @@ -551,7 +552,8 @@ class PlainStrIdRatchetPhase(Id128NominalPhase): """2: no file gains a plain ``str`` annotation on an id-named field or parameter.""" after = (NominalIdTypePhase,) - evidence = PatternPresent(r"^class PlainStrIdAnnotation\(", ("tools/debt_ratchet.py",)) + evidence = PinnedPatternPresent(r"^class PlainStrIdAnnotation\(", ("scripts/audit",), + submodule="src/agent_comms/skills/refactor-audit") class ClaimIdsPhase(Id128NominalPhase): @@ -1019,7 +1021,8 @@ class MaintenanceInWritePhase(StoreGuardsPhase): class LockCensusPhase(StoreGuardsPhase): """5: the debt ratchet counts lock calls outside the store owners and refuses growth.""" - evidence = PatternPresent(r"^class StoreLockCalls\(", ("tools/debt_ratchet.py",)) + evidence = PinnedPatternPresent(r"^class StoreLockCalls\(", ("scripts/audit",), + submodule="src/agent_comms/skills/refactor-audit") class ScopedReadsPhase(StoreGuardsPhase): diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index 5a969d3a7e..45b7fc272d 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit 5a969d3a7e6c414702e461e89c66b4e37a423646 +Subproject commit 45b7fc272d7f41a5a0d2a6a6c12d9455abcf2f90 diff --git a/tests/guards/test_agent_comms_ratchet.py b/tests/guards/test_agent_comms_ratchet.py new file mode 100644 index 0000000000..1baedbe612 --- /dev/null +++ b/tests/guards/test_agent_comms_ratchet.py @@ -0,0 +1,341 @@ +"""agent-comms' ratchet: its declared facts and its CI entry, against real Git commits. + +The measures and the comparison are the pinned refactor-audit's and are tested there; these +tests prove what only agent-comms declares (its lock owners, its absence decoder, its codec +modules, its done plan phases and its stored-record probe), that the entry's pool and probes +die with it, and that its command line keeps the report and exit contract.""" + +import ast +import asyncio +import json +import os +import subprocess +import sys +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +import pytest + +from agent_comms.child_process import ObservedProcess, SpawnedProcess +from agent_comms.field_codec import FieldCodec +from agent_comms.pinned_audit import audit +from audit.agent_comms_project import AgentCommsProject +from audit.project_measures import DonePhaseBroken, UncarriedRecordFormat +from audit.ratchet import AUDIT_SCRIPTS, Compared, ContentCache, compare +from audit.repository import Repository +from audit.source_measures import BroadSwallow, FamilyFlattened, NoneAbsence, StoreLockCalls +from fixture_git import IDENTITY +from process_tree_fixture import TREE, identities, ready + +pytestmark = pytest.mark.refactor_guard + +SOURCE = Path(__file__).resolve().parents[2] + + +def search_path(*first: Path) -> str: + """first, then the inherited PYTHONPATH when the caller's environment has one.""" + inherited = os.environ.get("PYTHONPATH") + return os.pathsep.join([*map(str, first), *([inherited] if inherited else [])]) + + +def ratchet_environment() -> dict[str, str]: + """The command's environment: this checkout's src before the inherited path.""" + return {**os.environ, "PYTHONPATH": search_path(SOURCE / "src")} + + +class Checkout: + """A fresh Git repository whose files live under one source root, fetching this one's objects.""" + + def __init__(self, path: Path, root: str): + self.path = path + self.root = root + self.git("init", "-q") + self.git("remote", "add", "origin", str(SOURCE)) + + def git(self, *args: str) -> str: + return subprocess.check_output( + ["git", *IDENTITY, "-C", str(self.path), *args], text=True, timeout=10 + ).strip() + + def commit(self, files: dict[str, str | None]) -> str: + for name, source in files.items(): + path = self.path / self.root / name + if source is None: + path.unlink() + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source) + self.git("add", "--all") + self.git("commit", "-qm", "test", "--allow-empty") + return self.git("rev-parse", "HEAD") + + def arguments(self, base: str, head: str) -> tuple[str, ...]: + # pytest-xdist runs these comparisons side by side; each keeps a narrow pool. + return ("--root", self.root, "--base", base, "--head", head, "--workers", "2") + + @contextmanager + def comparing(self, base: str, head: str) -> Iterator[SpawnedProcess]: + """The command launched as its own process, for the tests of its process tree.""" + command = (sys.executable, str(SOURCE / "tools/debt_ratchet.py"), + *self.arguments(base, head)) + with SpawnedProcess.launch(command, cwd=self.path, env=ratchet_environment(), + output=subprocess.PIPE) as child: + yield child + + def compare(self, base: str, head: str, *measured: type[Compared]) -> tuple[int, dict]: + """The comparison's exit status and report, taking only the named measures' + measurements. The command line itself is proven by the tests that launch it.""" + result = compare(Repository(self.path), base, head, self.root, 2, measured) + return int(result.failed), json.loads(json.dumps(result.report())) + + +@pytest.fixture(params=["src/agent_comms", "src/toad"]) +def repo(tmp_path: Path, request) -> Checkout: + checkout = tmp_path / "repository" + checkout.mkdir() + return Checkout(checkout, request.param) + + +def test_agent_comms_broad_swallows_stay_within_their_budget(): + root = SOURCE / "src/agent_comms" + assert sum(BroadSwallow.count(path.read_bytes(), str(path)) for path in root.rglob("*.py")) <= 29 + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + ("with store.guarded_read():\n pass", 0), + ("async with store.guarded_write_async():\n pass", 0), + ("with _store_lock(path, mode, contention), StoreLock.ordered(guards):\n pass", 1), + ("fcntl.flock(descriptor, fcntl.LOCK_EX)", 1), + ("def try_store_lock(descriptor):\n is_store_lock(path)", 0), + ('"""with store.guarded_write():"""\nnote = "fcntl.flock("', 0), + ("ordered(owners)\nlocked = True", 0), + ], +) +def test_store_lock_calls_count_calls_not_spellings(source, expected): + assert StoreLockCalls.count(source.encode(), "src/agent_comms/example.py") == expected + + +def test_store_lock_calls_skip_the_store_owners(): + """Locking is store_files' and locked_store's own mechanism, never a hand-placed copy.""" + source = b"with _store_lock(path):\n pass" + owners = AgentCommsProject.lock_owners() + assert owners == ("agent_comms/store_files.py", "agent_comms/locked_store.py", + "agent_comms/process_claim.py") + assert [StoreLockCalls.count(source, f"src/{owner}") for owner in owners] == [0, 0, 0] + + +def test_a_new_store_lock_call_fails_on_its_own_file_with_its_line(repo): + base = repo.commit({"turns.py": "def save(store):\n with _store_lock(store.path, mode, contention):\n pass\n", + "other.py": "def read(store):\n with _store_lock(store.path, mode, contention):\n pass\n"}) + head = repo.commit({"turns.py": "def save(store):\n pass\n", + "other.py": "def read(store):\n with _store_lock(store.path, mode, contention):\n pass\n" + " with _store_lock(store.path, mode, contention):\n pass\n"}) + status, report = repo.compare(base, head, StoreLockCalls) + assert status == 1 + grown = StoreLockCalls.at(f"{repo.root}/other.py").text + assert report["delta"] == {StoreLockCalls.at(f"{repo.root}/turns.py").text: -1, grown: 1} + assert report["found"] == {grown: [{"path": f"{repo.root}/other.py", "line": line} + for line in (2, 4)]} + + +def test_compare_timeout_retires_pool_probe_and_defiant_grandchild(tmp_path: Path) -> None: + repo = Checkout(tmp_path, "src/sample") + announcement = tmp_path / "probe-tree.json" + declaration = ( + "from dataclasses import dataclass\nfrom typing import ClassVar\n" + "@dataclass(frozen=True)\nclass Record:\n" + " version: ClassVar[int] = 1\n value: str = ''\n" + ) + # A real stored-format probe imports this committed module. Its child tree + # announces exact identities before blocking; no production method is patched. + source = declaration + f"\nimport sys\nsys.argv = ['tree', {str(announcement)!r}]\n" + TREE + base = repo.commit({"__init__.py": "", "record.py": source}) + head = repo.commit({"record.py": source.replace("version: ClassVar[int] = 1", + "version: ClassVar[int] = 2")}) + observed = () + try: + with repo.comparing(base, head) as child: + asyncio.run(ready(announcement, child.alive)) + observed = identities(announcement) + assert all(member.alive() for member in observed) + pool = child.platform.group_members(child.identity) + assert len(pool) > 1, "the real ratchet must have started its pool" + with pytest.raises(subprocess.TimeoutExpired): + child.collect(timeout=.05) + after = child.platform.group_members(child.identity) + surviving_probe = tuple(member for member in observed if member.alive()) + (tmp_path / "comparison-tree.json").write_text(json.dumps({ + "tool_group_before": [FieldCodec.encode(member) for member in pool], + "probe_before": [FieldCodec.encode(member) for member in observed], + "tool_group_after": [FieldCodec.encode(member) for member in after], + "probe_after": [FieldCodec.encode(member) for member in surviving_probe], + }, indent=2)) + assert not after + assert not surviving_probe + finally: + # Failure cleanup has exact identities and runs after the assertions. It + # cannot make the test pass by sweeping unrelated processes by name. + for member in observed: + if member.alive(): + ObservedProcess(member).stop_sync() + + +def test_format_probe_keeps_parent_dependencies_and_reads_archived_source(tmp_path, monkeypatch): + repo = Checkout(tmp_path, "src/agent_comms") + source = subprocess.check_output( + ["git", "archive", "HEAD", "--", "src/agent_comms"], timeout=20 + ) + subprocess.run(["tar", "-x", "-C", str(tmp_path)], input=source, check=True, timeout=60) + # A Git archive carries no submodule: the complete producer stamp's child reaches the + # pinned audit only through the measurement's own source paths. + assert not (tmp_path / AUDIT_SCRIPTS.resolve().relative_to(SOURCE) / "audit").exists() + dependency = tmp_path / "dependencies" + dependency.mkdir() + (dependency / "parent_only_dependency.py").write_text("VERSION = 7\n") + inherited = search_path(dependency) + monkeypatch.setenv("PYTHONPATH", inherited) + revision = repo.commit( + { + "probe_record.py": ( + "from dataclasses import dataclass\nfrom typing import ClassVar, Literal\n" + "from parent_only_dependency import VERSION\n" + "from agent_comms.owner_protocol import CORE_COMMIT\n" + "@dataclass(frozen=True)\nclass Record:\n" + " version: ClassVar[int] = VERSION\n value: str\n" + " commit: Literal[CORE_COMMIT]\n" + ) + } + ) + (tmp_path / "src/agent_comms/probe_record.py").write_text( + "raise RuntimeError('not archived')\n" + ) + forms = UncarriedRecordFormat.formats( + Repository(tmp_path), revision, repo.root, {"src/agent_comms/probe_record.py"} + ) + assert forms["agent_comms.probe_record.Record"].version == 7 + # The archive holds no Git checkout: the protocol's commit is the revision the stamp + # factory was given, reached through the complete writer family without asking Git. + assert forms["agent_comms.probe_record.Record"].fields["commit"]["enum"] == [revision] + assert os.environ["PYTHONPATH"] == inherited + + +def test_a_done_phase_fails_the_change_that_breaks_it(tmp_path: Path) -> None: + """OwnTypeChecksPhase holds at the base, so re-adding an isinstance on our own class + fails at its line; the phases not done at the base (every merged-commit one, here) + block nothing.""" + repo = Checkout(tmp_path, "src") + # The merged-commit phases name this repository's commits; borrowing its objects lets + # them read "not an ancestor" of the fixture instead of fetching. + objects = subprocess.check_output(["git", "-C", str(SOURCE), "rev-parse", "--path-format=absolute", + "--git-common-dir"], text=True).strip() + (tmp_path / ".git/objects/info/alternates").write_text(f"{objects}/objects\n") + owned = "class Owned:\n pass\n" + base = repo.commit({"agent_comms/owned.py": owned}) + head = repo.commit({"agent_comms/owned.py": owned + "\n\ndef owns(value):\n" + " return isinstance(value, Owned)\n"}) + status, report = repo.compare(base, head, DonePhaseBroken) + assert status == 1 + assert report["delta"] == { + "DonePhaseBroken:OwnTypeChecksPhase::src/agent_comms/owned.py:6": 1, + } + clean = repo.commit({"agent_comms/owned.py": owned + "\n\ndef owns(value):\n" + " return value.owned()\n"}) + assert repo.compare(base, clean, DonePhaseBroken)[0] == 0 + + +@pytest.mark.parametrize("root", ["src/agent_comms", "src/toad", "src/textual"]) +def test_family_flattening_cannot_offset_another_file_or_admit_a_named_facade( + tmp_path: Path, root: str +): + repo = Checkout(tmp_path, root) + original = "def carry(value):\n return (value.declared_name, value.family_name)\n" + base = repo.commit({"first.py": original, "second.py": "value = 1\n"}) + head = repo.commit( + { + "first.py": "def carry(value):\n return value\n", + "second.py": "def carry(value):\n return value.declared_name\n", + } + ) + status, report = repo.compare(base, head, FamilyFlattened) + assert status == 1 + assert report["delta"][FamilyFlattened.at(f"{repo.root}/first.py").text] == -2 + assert report["delta"][FamilyFlattened.at(f"{repo.root}/second.py").text] == 1 + assert FamilyFlattened.count(original.encode(), "consumer.py") == ( + FamilyFlattened.occurrences(ast.parse(original)) + ) + original_modules = tuple( + module for module in AgentCommsProject.mechanism_modules() if module.parent == Path(repo.root) + ) + facades = { + f"{directory}/{module.parent.name}/{module.name}" + for module in AgentCommsProject.mechanism_modules() + for directory in ("nested", "vendor") + } + facades.update(f"nested/{module.name}" for module in AgentCommsProject.mechanism_modules()) + moved = repo.commit( + { + "second.py": "def carry(value):\n return value\n", + "another_codec.py": original, + **{module.name: original for module in original_modules}, + **{path: original for path in facades}, + } + ) + status, report = repo.compare(head, moved, FamilyFlattened) + assert status == 1 + assert report["delta"][FamilyFlattened.at(f"{repo.root}/another_codec.py").text] == 2 + for module in original_modules: + assert report["head"][FamilyFlattened.at(module.as_posix()).text] == 0 + for path in facades: + assert report["head"][FamilyFlattened.at(f"{repo.root}/{path}").text] == 2 + + +def test_none_as_absence_fails_with_its_line_except_in_a_declared_boundary_decoder(repo): + marked = ( + "from agent_comms.field_codec import boundary_decoder\n\n\n" + "@boundary_decoder\ndef decoded(value: str):\n return value\n" + ) + files = { + "plan.py": marked, + "foreign.py": marked.replace("agent_comms.field_codec", "elsewhere"), + "owner.py": "def owner(value: str):\n return value\n", + } + base = repo.commit(files) + head = repo.commit( + {name: source.replace("value: str", "value: str | None") for name, source in files.items()} + ) + status, report = repo.compare(base, head, NoneAbsence) + assert status == 1 + assert {identity: lines for identity, lines in report["introduced"].items() if lines} == { + NoneAbsence.at(f"{repo.root}/owner.py").text: [1], + NoneAbsence.at(f"{repo.root}/foreign.py").text: [5], + } + plan = NoneAbsence.at(f"{repo.root}/plan.py").text + assert report["introduced"][plan] == [] and report["delta"][plan] == 0 + + +def test_growth_lines_end_the_merged_log(repo: Checkout) -> None: + """run-step.sh annotates the last lines of stdout and stderr merged into one pipe; the + report is flushed first, so what grew is what a reader without the job log sees.""" + base = repo.commit({"check.py": 'value = row["a"]\n'}) + head = repo.commit({"check.py": "value = type(x) is int\n"}) + merged = subprocess.run( + (sys.executable, str(SOURCE / "tools/debt_ratchet.py"), + "--root", repo.root, "--base", base, "--head", head), + cwd=repo.path, env=ratchet_environment(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, timeout=120, + ) + assert merged.returncode == 1 + assert merged.stdout.rstrip().splitlines()[-1].startswith(("grew ", "found ", "introduced ")) + + +def test_content_cache_digest_covers_a_module_only_loaded_indirectly(): + """The ratchet never imports coordination_tables.attempts by name; agent_comms loads it + through its own imports. Its source is still one of the ratchet's measuring code, so a + change to it is a different cache key.""" + indirect = Path(sys.modules["agent_comms.coordination_tables.attempts"].__file__).resolve() + assert indirect in ContentCache.sources() + assert (AUDIT_SCRIPTS / "audit" / "findings.py").resolve() in ContentCache.sources() + assert indirect.is_relative_to(Path(__file__).resolve().parents[2] / "src") diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py deleted file mode 100644 index 738aecee97..0000000000 --- a/tests/guards/test_debt_ratchet.py +++ /dev/null @@ -1,1512 +0,0 @@ -"""Exercise the command against real Git commits, without mocking Git or ASTs.""" - -import ast -import asyncio -import json -import os -import subprocess -import sys -import time -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path - -import pytest - -from agent_comms.child_process import ObservedProcess, SpawnedProcess -from agent_comms.field_codec import FieldCodec -from fixture_git import IDENTITY -from process_tree_fixture import TREE, identities, ready - -from debt_ratchet import ( - AUDIT_SCRIPTS, - REPOSITORY, - BannedVocabulary, - BroadExceptDefault, - BroadSwallow, - ContentCache, - DonePhaseBroken, - ForeignFieldSpellings, - FrozenFieldWrite, - EnumDeclaration, - FunctionLocalImport, - GitMeasure, - NoneAbsence, - UncarriedRecordFormat, - ComparedKind, - Comparison, - DeclaredSpellings, - NamedAccess, - NamedDeclaration, - PlainStrIdAnnotation, - StringFieldMetadata, - StringIdentity, - python_paths, - RawRecordShapes, - AnyName, - BooleanChainTerms, - BuiltinHandlerTypeSwitch, - CodecSubclass, - FamilyFlattened, - ForeignAbsenceProbe, - GodClassExcess, - LintFindings, - LongBooleanChain, - Measure, - OwnTypeTest, - StringDispatch, - StringDispatchArms, - StoreLockCalls, - StringSubscript, - TypeErrors, - TypeIdentity, - TypeSwitch, - TypeSwitchArms, - UnannotatedParameter, - Unformatted, - compare, -) - -pytestmark = pytest.mark.refactor_guard - - -def test_measure_owner_rejects_a_new_absence() -> None: - name = NoneAbsence.at("tools/example.py") - result = Comparison("tools", "base", "head", (), {name: 0}, {name: 1}, {}, {}) - assert result.delta[name] == 1 - assert result.failed - - -@pytest.mark.parametrize( - ("source", "expected"), - [ - ("from dataclasses import field as f\nx=f(metadata={'sql': 1, 'wire_name': 'x'})", 2), - ("import dataclasses as dc\nx=dc.field(metadata={'sql': 1})", 1), - ("from dataclasses import field\nx=field(metadata=options)", 1), - ("from dataclasses import field\nx=field(metadata={**options})", 1), - ("from dataclasses import field\nx=field(default=None)", 0), - ("from another_library import field\nx=field(metadata={'ui_hidden': True})", 0), - ], -) -def test_string_field_metadata_resolves_imports_and_refuses_opaque_keys(source, expected): - assert StringFieldMetadata.count(source.encode(), "example.py") == expected - - -@pytest.mark.parametrize( - ("source", "expected"), - [ - ("turn_id: str", 1), - ("turn_id: TurnId", 0), - ("session_id: str", 1), - ("goal_id: str | None", 1), - ("goal_id: None | str", 1), - ("goal_id: Annotated[str, Id128Text]", 1), - ("goal_id: typing.Annotated[str, Id128Text] | None", 1), - ("goal_id: Annotated[GoalId, Metadata]", 0), - ("def f(turn_id: str, session_id: str | None): pass", 2), - ("def f(*, lookup: str, nonce: str, generation: str): pass", 3), - ("self.turn_id: str", 1), - ("description: str", 0), - ("turn_id: bytes", 0), - ], -) -def test_plain_str_id_annotations_block_growth_per_file(source, expected): - path = "src/example.py" - before = PlainStrIdAnnotation.snapshot([(path, b"description: str")]) - after = PlainStrIdAnnotation.snapshot([(path, source.encode())]) - identity = PlainStrIdAnnotation.at(path) - comparison = Comparison("src", "base", "head", (path,), before, after, {}, {}) - assert comparison.delta[identity] == expected - assert comparison.failed == bool(expected) - - -@pytest.mark.parametrize( - ("source", "expected"), - [ - ("with store.guarded_read():\n pass", 0), - ("async with store.guarded_write_async():\n pass", 0), - ("with _store_lock(path, mode, contention), StoreLock.ordered(guards):\n pass", 1), - ("fcntl.flock(descriptor, fcntl.LOCK_EX)", 1), - ("def try_store_lock(descriptor):\n is_store_lock(path)", 0), - ('"""with store.guarded_write():"""\nnote = "fcntl.flock("', 0), - ("ordered(owners)\nlocked = True", 0), - ], -) -def test_store_lock_calls_count_calls_not_spellings(source, expected): - assert StoreLockCalls.count(source.encode(), "src/agent_comms/example.py") == expected - - -def test_store_lock_calls_skip_the_store_owners(): - """Locking is store_files' and locked_store's own mechanism, never a hand-placed copy.""" - source = b"with _store_lock(path):\n pass" - assert StoreLockCalls.owners == ("agent_comms/store_files.py", "agent_comms/locked_store.py") - assert [StoreLockCalls.count(source, f"src/{owner}") for owner in StoreLockCalls.owners] == [0, 0] - - -def test_a_new_store_lock_call_fails_on_its_own_file_with_its_line(repo): - base = repo.commit({"turns.py": "def save(store):\n with _store_lock(store.path, mode, contention):\n pass\n", - "other.py": "def read(store):\n with _store_lock(store.path, mode, contention):\n pass\n"}) - head = repo.commit({"turns.py": "def save(store):\n pass\n", - "other.py": "def read(store):\n with _store_lock(store.path, mode, contention):\n pass\n" - " with _store_lock(store.path, mode, contention):\n pass\n"}) - status, report = repo.compare(base, head, StoreLockCalls) - assert status == 1 - grown = StoreLockCalls.at(f"{repo.root}/other.py").text - assert report["delta"] == {StoreLockCalls.at(f"{repo.root}/turns.py").text: -1, grown: 1} - assert report["found"] == {grown: [{"path": f"{repo.root}/other.py", "line": line} - for line in (2, 4)]} - - -SOURCE = Path(__file__).resolve().parents[2] - - -def search_path(*first: Path) -> str: - """first, then the inherited PYTHONPATH when the caller's environment has one.""" - inherited = os.environ.get("PYTHONPATH") - return os.pathsep.join([*map(str, first), *([inherited] if inherited else [])]) - - -def ratchet_environment() -> dict[str, str]: - """The command's environment: this checkout's src before the inherited path.""" - return {**os.environ, "PYTHONPATH": search_path(SOURCE / "src")} - - -class Repository: - def __init__(self, path: Path, root: str): - self.path = path - self.root = root - self.git("init", "-q") - self.git("remote", "add", "origin", str(SOURCE)) - - def git(self, *args: str) -> str: - return subprocess.check_output( - ["git", *IDENTITY, "-C", str(self.path), *args], text=True, timeout=10 - ).strip() - - def commit(self, files: dict[str, str | None]) -> str: - for name, source in files.items(): - path = self.path / self.root / name - if source is None: - path.unlink() - else: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(source) - self.git("add", "--all") - self.git("commit", "-qm", "test", "--allow-empty") - return self.git("rev-parse", "HEAD") - - def arguments(self, base: str, head: str) -> tuple[str, ...]: - # pytest-xdist runs these comparisons side by side; each keeps a narrow pool. - return ("--root", self.root, "--base", base, "--head", head, "--workers", "2") - - @contextmanager - def comparing(self, base: str, head: str) -> Iterator[SpawnedProcess]: - """The command launched as its own process, for the tests of its process tree.""" - command = (sys.executable, str(SOURCE / "tools/debt_ratchet.py"), - *self.arguments(base, head)) - with SpawnedProcess.launch(command, cwd=self.path, env=ratchet_environment(), - output=subprocess.PIPE) as child: - yield child - - def compare(self, base: str, head: str, *measured: type[Measure]) -> tuple[int, dict]: - """The command's exit status and report, taking only the named measures' measurements. - - The command line itself is proven by the tests that launch it. - """ - result = compare(self.path, base, head, self.root, 2, measured) - return int(result.failed), json.loads(json.dumps(FieldCodec.project(result, "report"))) - - -def test_compare_timeout_retires_pool_probe_and_defiant_grandchild(tmp_path: Path) -> None: - repo = Repository(tmp_path, "src/sample") - announcement = tmp_path / "probe-tree.json" - declaration = ( - "from dataclasses import dataclass\nfrom typing import ClassVar\n" - "@dataclass(frozen=True)\nclass Record:\n" - " version: ClassVar[int] = 1\n value: str = ''\n" - ) - # A real stored-format probe imports this committed module. Its child tree - # announces exact identities before blocking; no production method is patched. - source = declaration + f"\nimport sys\nsys.argv = ['tree', {str(announcement)!r}]\n" + TREE - base = repo.commit({"__init__.py": "", "record.py": source}) - head = repo.commit({"record.py": source.replace("version: ClassVar[int] = 1", - "version: ClassVar[int] = 2")}) - observed = () - try: - with repo.comparing(base, head) as child: - asyncio.run(ready(announcement, child.alive)) - observed = identities(announcement) - assert all(member.alive() for member in observed) - pool = child.platform.group_members(child.identity) - assert len(pool) > 1, "the real ratchet must have started its pool" - with pytest.raises(subprocess.TimeoutExpired): - child.collect(timeout=.05) - after = child.platform.group_members(child.identity) - surviving_probe = tuple(member for member in observed if member.alive()) - (tmp_path / "comparison-tree.json").write_text(json.dumps({ - "tool_group_before": [FieldCodec.encode(member) for member in pool], - "probe_before": [FieldCodec.encode(member) for member in observed], - "tool_group_after": [FieldCodec.encode(member) for member in after], - "probe_after": [FieldCodec.encode(member) for member in surviving_probe], - }, indent=2)) - assert not after - assert not surviving_probe - finally: - # Failure cleanup has exact identities and runs after the assertions. It - # cannot make the test pass by sweeping unrelated processes by name. - for member in observed: - if member.alive(): - ObservedProcess(member).stop_sync() - - -@pytest.fixture(params=["src/agent_comms", "src/toad"]) -def repo(tmp_path: Path, request, monkeypatch) -> Repository: - # Checker processes and their dependencies belong to this test's environment. - # A prior run with an unavailable checker must not supply this run's findings. - monkeypatch.setattr(ContentCache, "directory", tmp_path / "measure-cache") - checkout = tmp_path / "repository" - checkout.mkdir() - return Repository(checkout, request.param) - - -def test_batch_sources_keep_exact_git_bytes_and_path_names(repo: Repository, monkeypatch) -> None: - files = { - "ignored.py": "value = 1\n", - "substituted.py": "stamp = '$Format:%H$'\n", - "a b.py": "value = 2\n", - "[literal].py": "value = 3\n", - "line\nbreak.py": "value = 4\n", - "unicode-π.py": "value = 5\n", - "duplicate.py": "value = 1\n", - ".gitattributes": "ignored.py export-ignore\nsubstituted.py export-subst\n", - } - revision = repo.commit(files) - selected = {f"{repo.root}/{name}" for name in files if name.endswith(".py")} - assert python_paths(repo.path, revision, repo.root) == selected - expected = [ - (path, subprocess.check_output(["git", "-C", str(repo.path), "show", f"{revision}:{path}"])) - for path in sorted(selected) - ] - trace = repo.path / "git-trace.json" - monkeypatch.setenv("GIT_TRACE2_EVENT", str(trace)) - assert GitMeasure.sources(repo.path, revision, selected) == expected - starts = [ - json.loads(line) - for line in trace.read_text().splitlines() - if json.loads(line)["event"] == "start" - ] - assert len(starts) == 2 - assert GitMeasure.sources(repo.path, revision, set()) == [] - with pytest.raises(ValueError): - GitMeasure.sources(repo.path, revision, selected | {f'{repo.root}/missing.py'}) - - -def test_format_probe_keeps_parent_dependencies_and_reads_archived_source(tmp_path, monkeypatch): - repo = Repository(tmp_path, "src/agent_comms") - source = subprocess.check_output( - ["git", "archive", "HEAD", "--", "src/agent_comms"], timeout=20 - ) - subprocess.run(["tar", "-x", "-C", str(tmp_path)], input=source, check=True, timeout=60) - # A Git archive carries no submodule: the complete producer stamp's child reaches the - # pinned audit only through the measurement's own source paths. - assert not (tmp_path / AUDIT_SCRIPTS.resolve().relative_to(REPOSITORY) / "audit").exists() - dependency = tmp_path / "dependencies" - dependency.mkdir() - (dependency / "parent_only_dependency.py").write_text("VERSION = 7\n") - inherited = search_path(dependency) - monkeypatch.setenv("PYTHONPATH", inherited) - revision = repo.commit( - { - "probe_record.py": ( - "from dataclasses import dataclass\nfrom typing import ClassVar, Literal\n" - "from parent_only_dependency import VERSION\n" - "from agent_comms.owner_protocol import CORE_COMMIT\n" - "@dataclass(frozen=True)\nclass Record:\n" - " version: ClassVar[int] = VERSION\n value: str\n" - " commit: Literal[CORE_COMMIT]\n" - ) - } - ) - (tmp_path / "src/agent_comms/probe_record.py").write_text( - "raise RuntimeError('not archived')\n" - ) - forms = UncarriedRecordFormat.formats( - tmp_path, revision, repo.root, {"src/agent_comms/probe_record.py"} - ) - assert forms["agent_comms.probe_record.Record"].version == 7 - # The archive holds no Git checkout: the protocol's commit is the revision the stamp - # factory was given, reached through the complete writer family without asking Git. - assert forms["agent_comms.probe_record.Record"].fields["commit"]["enum"] == [revision] - assert os.environ["PYTHONPATH"] == inherited - - -def test_clean_growth_passes_but_type_and_string_dispatch_fail(repo: Repository) -> None: - base = repo.commit({"new.py": "value = 1\n"}) - source = "value = 1\nother = 2\n" - grown = repo.commit({"new.py": source}) - assert repo.compare(base, grown, StringDispatch, TypeIdentity)[0] == 0 - filtered = repo.commit({"new.py": source + "filtered = type(value) is int\n"}) - status, report = repo.compare(grown, filtered, StringDispatch, TypeIdentity) - assert status == 1 - assert report["delta"]["TypeIdentity"] == 1 - dispatch = repo.commit( - { - "new.py": source - + ( - "\n\ndef select(value):\n" - ' if value == "one":\n return 1\n' - ' if value == "two":\n return 2\n' - ' if value == "three":\n return 3\n' - " return 0\n" - ) - } - ) - status, report = repo.compare(grown, dispatch, StringDispatch, TypeIdentity) - assert status == 1 - assert report["delta"][StringDispatch.at(f"{repo.root}/new.py").text] == 1 - - -def test_added_type_check_fails(repo: Repository) -> None: - base = repo.commit({"check.py": 'value = row["a"]\n'}) - head = repo.commit({"check.py": "value = type(x) is int\n"}) - status, report = repo.compare(base, head, LongBooleanChain, StringSubscript, TypeIdentity) - assert status == 1 # A different measure's reduction cannot cancel this increase. - assert { - key: report["delta"][key] for key in ("TypeIdentity", "LongBooleanChain", "StringSubscript") - } == { - "TypeIdentity": 1, - "LongBooleanChain": 0, - "StringSubscript": -1, - } - - -def test_a_done_phase_fails_the_change_that_breaks_it(tmp_path: Path) -> None: - """OwnTypeChecksPhase holds at the base, so re-adding an isinstance on our own class - fails at its line; the phases not done at the base (every merged-commit one, here) - block nothing.""" - repo = Repository(tmp_path, "src") - # The merged-commit phases name this repository's commits; borrowing its objects lets - # them read "not an ancestor" of the fixture instead of fetching. - objects = subprocess.check_output(["git", "-C", str(SOURCE), "rev-parse", "--path-format=absolute", - "--git-common-dir"], text=True).strip() - (tmp_path / ".git/objects/info/alternates").write_text(f"{objects}/objects\n") - owned = "class Owned:\n pass\n" - base = repo.commit({"agent_comms/owned.py": owned}) - head = repo.commit({"agent_comms/owned.py": owned + "\n\ndef owns(value):\n" - " return isinstance(value, Owned)\n"}) - status, report = repo.compare(base, head, DonePhaseBroken) - assert status == 1 - assert report["delta"] == { - "DonePhaseBroken:OwnTypeChecksPhase::src/agent_comms/owned.py:6": 1, - } - clean = repo.commit({"agent_comms/owned.py": owned + "\n\ndef owns(value):\n" - " return value.owned()\n"}) - assert repo.compare(base, clean, DonePhaseBroken)[0] == 0 - - -def test_moves_between_files_pass(repo: Repository) -> None: - source = 'x = row = {"key": 1}\nvalue = type(x) is not int\nread = row["key"]\n' - base = repo.commit({"from.py": source, "other.py": "a = 1\n"}) - moved = repo.commit({"from.py": None, "nested/to.py": source}) - combined = repo.commit({"nested/to.py": None, "other.py": "a = 1\n" + source}) - for head in (moved, combined): - status, report = repo.compare(base, head, StringSubscript, TypeIdentity) - assert status == 0 - assert not any(report["delta"].values()) - assert report["head"]["TypeIdentity"] == 1 - assert report["head"]["StringSubscript"] == 1 - - -def test_reduction_passes(repo: Repository) -> None: - base = repo.commit({"removed.py": 'value = type(x) is int and a and b and row["key"]\n'}) - head = repo.commit({"removed.py": None}) - status, report = repo.compare(base, head, LongBooleanChain, StringSubscript, TypeIdentity) - assert status == 0 - assert { - key: report["delta"][key] for key in ("TypeIdentity", "LongBooleanChain", "StringSubscript") - } == { - "TypeIdentity": -1, - "LongBooleanChain": -1, - "StringSubscript": -1, - } - - -def test_new_file_smells_count(repo: Repository) -> None: - base = repo.commit({}) - head = repo.commit( - { - "new.py": """ -value = type(x) is int is not type(y) -other = type(z) == int -chain = a or b or c or d -short = a and b and c -read = row["key"] -row['other'] = read -numeric = row[0] -text = 'type(x) is int and a and b and row["not code"]' -# type(x) is int and a and b and row["not code"] -""" - } - ) - status, report = repo.compare(base, head, LongBooleanChain, StringSubscript, TypeIdentity) - assert status == 1 - assert report["base"] == {name: 0 for name in report["head"]} - assert { - key: report["head"][key] for key in ("TypeIdentity", "LongBooleanChain", "StringSubscript") - } == { - "TypeIdentity": 2, - "LongBooleanChain": 1, - "StringSubscript": 2, - } - - -def test_other_source_root_does_not_enter_report(repo: Repository) -> None: - base = repo.commit({"kept.py": "value = 1\n"}) - outside = repo.path / "unrelated.py" - outside.write_text('value = type(x) is int and a and b and row["key"]\n') - head = repo.commit({"kept.py": "value = 2\n"}) - status, report = repo.compare(base, head, TypeIdentity, LongBooleanChain, StringSubscript) - assert status == 0 - assert report["paths"] == [f"{repo.root}/kept.py"] - - -def test_builtin_handler_measure_uses_shared_collector_and_rejects_growth(repo: Repository) -> None: - from audit.handler_declarations import BuiltinHandlerDeclarations - - assert issubclass(BuiltinHandlerTypeSwitch, BuiltinHandlerDeclarations) - source = """from .mro_dispatch import MroDispatch, handles as cases -class Consumer(MroDispatch): - @cases(dict) - def one(self, value): pass - @cases(DomainRecord) - def domain(self, value): pass -""" - base = repo.commit({"policy.py": source}) - growing = source.replace("@cases(DomainRecord)", "@cases(list, str)") - assert BuiltinHandlerTypeSwitch.count(source.encode(), "policy.py") == 1 - assert BuiltinHandlerTypeSwitch.count(growing.encode(), "policy.py") == 3 - assert BuiltinHandlerTypeSwitch.count(b"not valid Python", "field_codec.py") == 0 - head = repo.commit({"policy.py": growing}) - status, report = repo.compare(base, head, BuiltinHandlerTypeSwitch) - identity = BuiltinHandlerTypeSwitch.at(f"{repo.root}/policy.py").text - assert status == 1 - assert report["base"][identity] == 1 and report["head"][identity] == 3 - assert report["delta"][identity] == 2 - moved = repo.commit( - {"policy.py": source, "another_codec.py": growing, "field_codec.py": growing} - ) - status, report = repo.compare(head, moved, BuiltinHandlerTypeSwitch) - assert status == 1 # A filename suffix cannot admit another primitive dispatcher. - assert report["head"][BuiltinHandlerTypeSwitch.at(f"{repo.root}/field_codec.py").text] == 0 - assert report["delta"][BuiltinHandlerTypeSwitch.at(f"{repo.root}/another_codec.py").text] == 3 - - -def owner(name: str, lines: int, indent: str = "") -> str: - return ( - indent - + f"class {name}:\n" - + "".join(indent + f" field_{index} = {index}\n" for index in range(lines - 1)) - ) - - -def module(*owners: str) -> str: - """The owners as one module in Black's form, so the formatting ratchet counts nothing and - each comparison measures only the classes.""" - import black - - return black.format_str("".join(owners), mode=black.Mode()) - - -@pytest.mark.parametrize("root", ["src/agent_comms", "src/toad", "src/textual"]) -def test_family_flattening_cannot_offset_another_file_or_admit_a_named_facade( - tmp_path: Path, root: str -): - repo = Repository(tmp_path, root) - from audit.measures import FamilyFlattened as AuditFamilyFlattened - - original = "def carry(value):\n return (value.declared_name, value.family_name)\n" - base = repo.commit({"first.py": original, "second.py": "value = 1\n"}) - head = repo.commit( - { - "first.py": "def carry(value):\n return value\n", - "second.py": "def carry(value):\n return value.declared_name\n", - } - ) - status, report = repo.compare(base, head, FamilyFlattened) - assert status == 1 - assert report["delta"][FamilyFlattened.at(f"{repo.root}/first.py").text] == -2 - assert report["delta"][FamilyFlattened.at(f"{repo.root}/second.py").text] == 1 - assert FamilyFlattened.count(original.encode(), "consumer.py") == ( - AuditFamilyFlattened.count(ast.parse(original)) - ) - original_modules = tuple( - module for module in FamilyFlattened.mechanism_modules if module.parent == Path(repo.root) - ) - facades = { - f"{directory}/{module.parent.name}/{module.name}" - for module in FamilyFlattened.mechanism_modules - for directory in ("nested", "vendor") - } - facades.update(f"nested/{module.name}" for module in FamilyFlattened.mechanism_modules) - moved = repo.commit( - { - "second.py": "def carry(value):\n return value\n", - "another_codec.py": original, - **{module.name: original for module in original_modules}, - **{path: original for path in facades}, - } - ) - status, report = repo.compare(head, moved, FamilyFlattened) - assert status == 1 - assert report["delta"][FamilyFlattened.at(f"{repo.root}/another_codec.py").text] == 2 - for module in original_modules: - assert report["head"][FamilyFlattened.at(module.as_posix()).text] == 0 - for path in facades: - assert report["head"][FamilyFlattened.at(f"{repo.root}/{path}").text] == 2 - - -@pytest.mark.parametrize( - ("source", "expected"), - [ - ("class Owner:\n def name(self):\n return self.declared_name\n", 0), - ("class Owner:\n @classmethod\n def name(cls):\n return cls.declared_name\n", 0), - ("class Owner:\n @classmethod\n def name(cls):\n return Owner.declared_name\n", 0), - ("class Owner:\n def name(self):\n return Other.declared_name\n", 1), - ("def name(value):\n return value.declared_name\n", 1), - ("def name(value):\n return FieldCodec.decode(str, FieldCodec.encode(value))\n", 1), - ( - "class Owner:\n @classmethod\n def name(cls):\n return FieldCodec.decode(str, FieldCodec.encode(cls))\n", - 1, - ), - ( - "def name(value):\n return FieldCodec.decode(str, FieldCodec.encode(value.declared_name))\n", - 1, - ), - ("def name(value):\n return FieldCodec.decode(str, value)\n", 0), - ( - "class Owner:\n def name(self, other):\n return (self.declared_name, other.declared_name)\n", - 1, - ), - ("if __name__ == '__main__':\n main()\n", 0), - ("def name(value):\n return value.name == 'released'\n", 1), - ], -) -def test_family_name_owner_reads_and_codec_detours(source: str, expected: int): - - assert FamilyFlattened.count(source.encode(), "consumer.py") == expected - - -def test_small_owners_stay_within_the_class_threshold(repo: Repository) -> None: - base = repo.commit({"owners.py": owner("Small", 2)}) - head = repo.commit({"owners.py": module(owner("Small", 500), owner("New", 500))}) - status, report = repo.compare(base, head, GodClassExcess) - assert status == 0 - assert report["delta"][GodClassExcess.at(f"{repo.root}/owners.py").member("Small").text] == 0 - assert report["delta"][GodClassExcess.at(f"{repo.root}/owners.py").member("New").text] == 0 - - -def test_class_growth_cannot_be_cancelled_by_another_class(repo: Repository) -> None: - base = repo.commit({"classes.py": module(owner("Growing", 501), owner("Shrinking", 600))}) - head = repo.commit({"classes.py": module(owner("Growing", 502), owner("Shrinking", 500))}) - status, report = repo.compare(base, head, GodClassExcess) - assert status == 0 - assert report["delta"][GodClassExcess.at(f"{repo.root}/classes.py").member("Growing").text] == 1 - assert report["delta"][GodClassExcess.at(f"{repo.root}/classes.py").member("Shrinking").text] == -100 - - -@pytest.mark.parametrize("before", [0, 500]) -def test_new_or_crossing_god_class_is_reported_without_refusal(repo: Repository, before: int) -> None: - base = repo.commit({"owners.py": owner("New", before)} if before else {}) - head = repo.commit({"owners.py": owner("New", 501)}) - status, report = repo.compare(base, head, GodClassExcess) - assert status == 0 - assert report["base"][GodClassExcess.at(f"{repo.root}/owners.py").member("New").text] == 0 - assert report["delta"][GodClassExcess.at(f"{repo.root}/owners.py").member("New").text] == 1 - - -def test_class_move_keeps_baseline_and_new_small_owner_has_no_class_debt(repo: Repository) -> None: - base = repo.commit({"old.py": owner("Existing", 501)}) - moved = repo.commit({"old.py": None, "new.py": module(owner("Existing", 501), owner("NewOwner", 25))}) - status, report = repo.compare(base, moved, GodClassExcess) - assert status == 0 - assert report["base"][GodClassExcess.at(f"{repo.root}/new.py").member("Existing").text] == 1 - assert report["delta"][GodClassExcess.at(f"{repo.root}/new.py").member("Existing").text] == 0 - assert report["delta"][GodClassExcess.at(f"{repo.root}/new.py").member("NewOwner").text] == 0 - grown = repo.commit({"new.py": module(owner("Existing", 502), owner("NewOwner", 26))}) - assert repo.compare(moved, grown, GodClassExcess)[0] == 0 - - -def test_moved_class_growth_is_reported_without_refusal(repo: Repository) -> None: - base = repo.commit({"old.py": owner("Existing", 500)}) - head = repo.commit({"old.py": None, "new.py": owner("Existing", 501)}) - status, report = repo.compare(base, head, GodClassExcess) - assert status == 0 - assert report["delta"][GodClassExcess.at(f"{repo.root}/new.py").member("Existing").text] == 1 - - -def test_formatting_a_god_class_is_not_growth(repo: Repository) -> None: - import black - - wide = " wide = combine(" + ", ".join(f"argument_{index}" for index in range(30)) + ")\n" - unformatted = owner("Wide", 501) + wide - formatted = black.format_str(unformatted, mode=black.Mode()) - assert formatted.count("\n") > unformatted.count("\n") - base = repo.commit({"wide.py": unformatted}) - head = repo.commit({"wide.py": formatted}) - status, report = repo.compare(base, head, GodClassExcess) - assert report["delta"][GodClassExcess.at(f"{repo.root}/wide.py").member("Wide").text] == 0 - assert status == 0 - - -def test_nested_and_duplicate_named_classes_keep_separate_baselines(repo: Repository) -> None: - nested = "class Outer:\n" + owner("Inner", 501, " ") - base = repo.commit({"one.py": nested, "two.py": owner("Inner", 500)}) - head = repo.commit({"one.py": nested, "two.py": owner("Inner", 501)}) - status, report = repo.compare(base, head, GodClassExcess) - assert status == 0 - assert report["delta"][GodClassExcess.at(f"{repo.root}/one.py::Outer.Inner").text] == 0 - assert report["delta"][GodClassExcess.at(f"{repo.root}/two.py").member("Inner").text] == 1 - - -def test_chain_terms_catch_growth_hidden_by_unchanged_chain_count(repo: Repository) -> None: - base = repo.commit({"rules.py": "valid = a and b and c and d\n"}) - head = repo.commit({"rules.py": "valid = a and b and c and d and e\n"}) - status, report = repo.compare(base, head, BooleanChainTerms, LongBooleanChain) - assert status == 1 - assert report["delta"]["LongBooleanChain"] == 0 - assert report["delta"][BooleanChainTerms.at(f"{repo.root}/rules.py").text] == 1 - - -@pytest.mark.parametrize( - ("measure", "arms", "decision"), - [ - (StringDispatch, StringDispatchArms, "if value == {case!r}: return 1"), - (StringDispatch, StringDispatchArms, "if value in ({case!r},): return 1"), - (StringDispatch, StringDispatchArms, "match value:\n case {case!r}: return 1"), - (StringDispatch, StringDispatchArms, "match value:\n case {case!r} | {case!r}: return 1"), - (StringDispatch, StringDispatchArms, "match value:\n case ({case!r} | {case!r}) as named: return 1"), - (TypeSwitch, TypeSwitchArms, "if isinstance(value, Case{case}): return 1"), - (TypeSwitch, TypeSwitchArms, "if type(value) is Case{case}: return 1"), - (TypeSwitch, TypeSwitchArms, "match value:\n case Case{case}(): return 1"), - (TypeSwitch, TypeSwitchArms, "match value:\n case Case{case}() as named: return 1"), - (TypeSwitch, TypeSwitchArms, "if issubclass(value, Case{case}): return 1"), - (TypeSwitch, TypeSwitchArms, "if value is Case{case}: return 1"), - ], -) -def test_dispatch_growth_and_reduction_use_the_installed_command( - repo: Repository, measure: type[Measure], arms: type[Measure], decision: str -) -> None: - def source(cases): - return ( - "def decide(value):\n" - + "\n".join(" " + decision.format(case=case) for case in cases) - + "\n" - ) - - before = repo.commit({"policy.py": source(range(3))}) - grown = repo.commit({"policy.py": source(range(4))}) - status, report = repo.compare(before, grown, measure, arms) - assert status == 1 - assert report["delta"][measure.at(f"{repo.root}/policy.py").text] == 0 - assert report["delta"][arms.at(f"{repo.root}/policy.py").text] == 1 - reduced = repo.commit({"policy.py": source(range(2))}) - status, report = repo.compare(grown, reduced, measure, arms) - assert status == 0 - assert report["delta"][measure.at(f"{repo.root}/policy.py").text] == -1 - assert report["delta"][arms.at(f"{repo.root}/policy.py").text] == -4 - - -def test_own_class_type_test_counts_one_arm_and_never_a_foreign_class(repo: Repository) -> None: - owned = "class Case:\n pass\n\n\nclass Import:\n pass\n" - base = repo.commit({"owned.py": owned, "check.py": "import ast\n\n\ndef read(node):\n" - " return isinstance(node, ast.Import)\n"}) - head = repo.commit({"check.py": "import ast\n\nfrom .owned import Case\n\n\ndef read(node):\n" - " return isinstance(node, ast.Import) or issubclass(node, Case)\n"}) - status, report = repo.compare(base, head, OwnTypeTest) - assert status == 1 - assert report["base"][OwnTypeTest.at(f"{repo.root}/check.py").text] == 0 - assert report["delta"][OwnTypeTest.at(f"{repo.root}/check.py").text] == 1 - - -def test_dispatch_scopes_subjects_and_repeated_cases_do_not_combine(repo: Repository) -> None: - before = repo.commit({}) - head = repo.commit( - { - "scope.py": """ -def first(value): - if value == "one": return 1 - if value == "two": return 2 - if other == "three": return 3 - if value == "two": return 2 - def inner(value): - if value == "three": return 3 - return inner -async def second(value): - if value == "three": return 3 -""" - } - ) - status, report = repo.compare(before, head, FamilyFlattened, StringDispatch, StringDispatchArms) - # This checks the three-arm dispatch measure's scope. The separate - # family measure also reports these literal comparisons. - assert status == 1 - assert report["head"][FamilyFlattened.at(f"{repo.root}/scope.py").text] > 0 - assert report["head"][StringDispatch.at(f"{repo.root}/scope.py").text] == 0 - assert report["head"][StringDispatchArms.at(f"{repo.root}/scope.py").text] == 0 - - -@pytest.mark.parametrize( - ("measure", "source"), - [ - (BooleanChainTerms, "valid = a and b and c and d\n"), - (ForeignAbsenceProbe, "absent = other.value is None\n"), - (CodecSubclass, "class Local(FieldCodec):\n pass\n"), - (StringDispatch, 'def decide(x):\n return x in ("a", "b", "c")\n'), - ( - TypeSwitch, - "def decide(x):\n return isinstance(x, A) or isinstance(x, B) or isinstance(x, C)\n", - ), - (OwnTypeTest, "class Case:\n pass\ndef read(x):\n return isinstance(x, Case)\n"), - (AnyName, "from typing import Any\ndef read(value: Any) -> None: ...\n"), - (AnyName, "from typing import Any\nvalue = Any\n"), - (UnannotatedParameter, "def read(value): ...\n"), - (TypeErrors, 'value: int = "text"\n'), - ], -) -def test_per_file_increase_cannot_be_offset_or_moved( - repo: Repository, measure: type[Measure], source: str -) -> None: - base = repo.commit({"old.py": source, "new.py": ""}) - head = repo.commit({"old.py": None, "new.py": source}) - status, report = repo.compare(base, head, measure) - assert status == 1 - assert report["delta"][measure.at(f"{repo.root}/new.py").text] > 0 - assert ( - sum(report["delta"].get(measure.at(f"{repo.root}/{name}").text, 0) - for name in ("old.py", "new.py")) == 0 - ) - - -@pytest.mark.parametrize( - ("measure", "source"), [(Unformatted, "value=1\n"), (LintFindings, "import os\n")] -) -def test_advisory_growth_is_measured_and_warned_never_failed( - repo: Repository, measure: type[Measure], source: str -) -> None: - base = repo.commit({"new.py": ""}) - head = repo.commit({"new.py": source}) - status, report = repo.compare(base, head, measure) - count = measure.at(f"{repo.root}/new.py") - assert status == 0 - assert report["delta"][count.text] > 0 - assert measure.grew(count, 1).startswith(f"::warning file={repo.root}/new.py::") - - -def test_a_renamed_file_compares_with_its_old_path(repo: Repository) -> None: - kept = "".join(f"def keep{i}(value: int) -> int:\n return value\n" for i in range(8)) - source = kept + "def read(value): ...\n" - base = repo.commit({"old.py": source}) - moved = repo.commit({"old.py": None, "nested/new.py": source}) - status, report = repo.compare(base, moved, UnannotatedParameter) - assert status == 0 - assert report["delta"][UnannotatedParameter.at(f"{repo.root}/nested/new.py").text] == 0 - assert UnannotatedParameter.at(f"{repo.root}/old.py").text not in report["delta"] - grown = repo.commit({"nested/new.py": source + "def write(value): ...\n"}) - status, report = repo.compare(base, grown, UnannotatedParameter) - assert status == 1 - assert report["delta"][UnannotatedParameter.at(f"{repo.root}/nested/new.py").text] == 1 - - -def test_both_revisions_are_checked_under_the_head_configuration(repo: Repository) -> None: - source = "import sys\nimport os\n\nvalue: int = 'text'\nused = (os, sys)\n" - (repo.path / "pyproject.toml").write_text( - '[tool.ruff.lint]\nselect = ["E", "F"]\n[tool.mypy]\npython_version = "3.11"\n' - ) - base = repo.commit({"kept.py": source + "type Pair = tuple[int, int]\n"}) - (repo.path / "pyproject.toml").write_text('[tool.ruff.lint]\nselect = ["E", "F", "I"]\n') - head = repo.commit({"kept.py": source + "type Pair = tuple[int, int]\nother = 1\n"}) - status, report = repo.compare(base, head, LintFindings, TypeErrors, Unformatted) - # The base alone cannot parse PEP 695 under its own 3.11 target, and the head adds the - # import-order rule; under one configuration neither changes the file's counts. - assert report["base"][TypeErrors.at(f"{repo.root}/kept.py").text] == 1 - assert report["base"][LintFindings.at(f"{repo.root}/kept.py").text] == 1 - for measure in (TypeErrors, LintFindings, Unformatted): - assert report["delta"][measure.at(f"{repo.root}/kept.py").text] == 0 - - -def test_foreign_probes_and_codec_bases_use_ast_not_text(repo: Repository) -> None: - base = repo.commit({}) - head = repo.commit( - { - "boundary.py": """ -class Owned: - def inspect(self, other): - local = self.value is None - local_truth = not self.value - foreign = other.value is not None - foreign_truth = not other.value - nested = self.other.value is None - text = 'not other.value' -class LocalCodec(package.FieldCodec): - pass -class OwnedCodec: - pass -""" - } - ) - status, report = repo.compare(base, head, CodecSubclass, ForeignAbsenceProbe) - assert status == 1 - assert report["head"][ForeignAbsenceProbe.at(f"{repo.root}/boundary.py").text] == 2 - assert report["head"][CodecSubclass.at(f"{repo.root}/boundary.py").text] == 1 - - -def test_connection_boolean_is_not_an_absence_probe(repo: Repository) -> None: - base = repo.commit({}) - boolean = repo.commit( - { - "connection.py": """ -from multiprocessing.connection import Connection - -class Reader: - connection: Connection - - def readable(self): - return not self.connection.closed -""" - } - ) - status, report = repo.compare(base, boolean, ForeignAbsenceProbe) - assert status == 0 - assert report["head"][ForeignAbsenceProbe.at(f"{repo.root}/connection.py").text] == 0 - - missing = repo.commit( - { - "missing.py": """ -class State: - value: str | None - -def missing(state: State): - return state.value is None - -def present(state: State): - return state.value is not None -""" - } - ) - status, report = repo.compare(boolean, missing, ForeignAbsenceProbe) - assert status == 1 - assert report["delta"][ForeignAbsenceProbe.at(f"{repo.root}/missing.py").text] == 2 - - -def test_type_expression_boundary_keeps_runtime_dictionary_access(repo: Repository) -> None: - source = ( - "\n".join( - [ - "import typing as t", - "from typing import Literal as Choice", - 'transport: Choice["stdio"]', - "class Declaration:", - ' transport: t.Literal["stdio"]', - 'def render(value: Choice["stdio"]) -> t.Literal["stdio"]:', - ' local: Choice["stdio"]', - ' return "stdio"', - 'async def asynchronous(value: Choice["stdio"]) -> Choice["stdio"]:', - ' return "stdio"', - ] - ) - + "\n" - ) - if hasattr(ast, "TypeAlias"): - source += 'type Transport = t.Literal["stdio"]\n' - source += 'def generic[T: t.Literal["stdio"]](value: T) -> T:\n return value\n' - base = repo.commit({"boundary.py": ""}) - declarations = repo.commit({"boundary.py": source}) - status, report = repo.compare(base, declarations, StringSubscript) - assert status == 0 - assert report["head"]["StringSubscript"] == 0 - # Annotation spelling cannot conceal actual initializer/default/decorator/body reads. - runtime = ( - source - + "\n".join( - [ - 'transport: Choice["stdio"] = payload["transport"]', - 'dynamic_type: schema["type"]', - '@decorators["render"]', - 'def read(value: Choice["stdio"] = defaults["transport"]) -> Choice["stdio"]:', - ' return payload["transport"]', - ] - ) - + "\n" - ) - changed = repo.commit({"boundary.py": runtime}) - status, report = repo.compare(declarations, changed, StringSubscript) - assert status == 1 - assert report["delta"]["StringSubscript"] == 5 - - -def test_typing_spelling_does_not_hide_shadowed_or_unresolved_read(repo: Repository) -> None: - base = repo.commit({"shadow.py": ""}) - head = repo.commit( - { - "shadow.py": "\n".join( - [ - "from typing import Literal", - "def read(Literal):", - ' return Literal["value"]', - 'unknown: schema["type"]', - ] - ) - + "\n" - } - ) - status, report = repo.compare(base, head, StringSubscript) - assert status == 1 - assert report["delta"]["StringSubscript"] == 2 - - -ANY_DECLARATIONS = ( - "\n".join( - [ - "import typing as t", - "from dataclasses import dataclass", - "from typing import TYPE_CHECKING, Annotated, Callable, Literal, Optional, TypeAlias", - "if TYPE_CHECKING:", - " from typing import Any", - "Payload = dict[str, Any]", - "Row: TypeAlias = list[Any]", - "@dataclass(frozen=True)", - "class Record:", - " fill: Any = 0", - ' quoted: "Optional[Any]" = None', - "class Plain:", - " cache: t.Any", - " def read(self, value: Any | None, *rest: Any, **named: Any) -> Callable[..., Any]:", - ' local: Annotated[Any, "Any"] = value', - " return local", - ] - ) - + "\n" -) - -# Loaded Any references count even outside a declaration; literal strings do not. -ANY_NON_DECLARATIONS = ( - "\n".join( - [ - "from typing import Annotated, Any, Literal", - 'choice: Literal["Any"]', - "described: Annotated[int, Any]", - "skipped = (Any, object)", - 'chosen = handlers["Any"]', - "def unstated(annotation: object) -> bool:", - " return annotation is Any or isinstance(annotation, str)", - "class Local:", - " Any = int", - ] - ) - + "\n" -) - - -def test_any_is_counted_once_in_declarations_and_values(repo: Repository) -> None: - - uses = AnyName.uses(ANY_DECLARATIONS.encode(), "declarations.py") - assert sorted((use.site, use.owner) for use in uses) == [ - ("alias", ""), - ("alias", ""), - ("class attribute", "Plain"), - ("dataclass field", "Record"), - ("dataclass field", "Record"), - ("parameter", "Plain.read"), - ("parameter", "Plain.read"), - ("parameter", "Plain.read"), - ("return", "Plain.read"), - ("variable", "Plain.read"), - ] - assert AnyName.count(ANY_NON_DECLARATIONS.encode(), "values.py") == 3 - - base = repo.commit({"declarations.py": "", "values.py": ""}) - head = repo.commit({"declarations.py": ANY_DECLARATIONS, "values.py": ANY_NON_DECLARATIONS}) - status, report = repo.compare(base, head, AnyName) - assert status == 1 - assert report["delta"][AnyName.at(f"{repo.root}/declarations.py").text] == len(uses) - assert report["delta"][AnyName.at(f"{repo.root}/values.py").text] == 3 - - -def test_stating_a_type_lowers_the_any_count_and_passes(repo: Repository) -> None: - base = repo.commit({"declarations.py": ANY_DECLARATIONS}) - head = repo.commit( - {"declarations.py": ANY_DECLARATIONS.replace("fill: Any = 0", "fill: int = 0")} - ) - status, report = repo.compare(base, head, AnyName) - assert status == 0 - assert report["delta"][AnyName.at(f"{repo.root}/declarations.py").text] == -1 - - -@pytest.mark.parametrize( - ("measure", "deleted", "source"), - [ - (FrozenFieldWrite, 'object.__setattr__(self, "old", 1)\n', - 'object.__setattr__(self, "value", 1)\n'), - (BannedVocabulary, "old_receipt = 1\n", "pending_receipt = 1\n"), - ], -) -def test_clean_changed_lines_cannot_be_offset_with_a_deletion(repo, measure, deleted, source): - """Deleting one occurrence never pays for writing a different one; the same occurrence - leaving one module for another is a move - (test_an_absence_moved_with_its_class_to_another_module_is_not_written).""" - base = repo.commit({"old.py": deleted, "new.py": ""}) - head = repo.commit({"old.py": None, "new.py": source}) - status, report = repo.compare(base, head, measure) - identity = measure.at(f"{repo.root}/new.py").text - assert status == 1 - assert report["delta"][identity] == 1 - assert len(report["introduced"][identity]) == 1 - - -@pytest.mark.parametrize( - ("measure", "source"), - [ - (FrozenFieldWrite, 'object.__setattr__(self, "value", 1)\n'), - (BannedVocabulary, "pending_receipt = 1\n"), - ], -) -def test_unchanged_old_sites_are_not_forced_to_zero(repo, measure, source): - base = repo.commit({"old.py": source + "clean = 1\n"}) - head = repo.commit({"old.py": source + "clean = 2\n"}) - status, report = repo.compare(base, head, measure) - identity = measure.at(f"{repo.root}/old.py").text - assert status == 0 - assert report["head"][identity] == 1 - assert report["introduced"][identity] == [] - - -def test_none_as_absence_fails_with_its_line_except_in_a_declared_boundary_decoder(repo): - marked = ( - "from agent_comms.field_codec import boundary_decoder\n\n\n" - "@boundary_decoder\ndef decoded(value: str):\n return value\n" - ) - files = { - "plan.py": marked, - "foreign.py": marked.replace("agent_comms.field_codec", "elsewhere"), - "owner.py": "def owner(value: str):\n return value\n", - } - base = repo.commit(files) - head = repo.commit( - {name: source.replace("value: str", "value: str | None") for name, source in files.items()} - ) - status, report = repo.compare(base, head, NoneAbsence) - assert status == 1 - assert {identity: lines for identity, lines in report["introduced"].items() if lines} == { - NoneAbsence.at(f"{repo.root}/owner.py").text: [1], - NoneAbsence.at(f"{repo.root}/foreign.py").text: [5], - } - plan = NoneAbsence.at(f"{repo.root}/plan.py").text - assert report["introduced"][plan] == [] and report["delta"][plan] == 0 - - -def test_an_unchanged_absence_on_a_rewritten_line_is_not_written_but_a_new_one_is(repo): - base = repo.commit({"owner.py": "def ready(stored):\n return stored is not None and stored.done\n"}) - kept = repo.commit({"owner.py": "def ready(stored):\n return stored is not None and stored.settled()\n"}) - status, report = repo.compare(base, kept, NoneAbsence) - identity = NoneAbsence.at(f"{repo.root}/owner.py").text - assert report["introduced"][identity] == [] - added = repo.commit( - {"owner.py": "def ready(stored):\n return stored is not None and stored.owner is not None\n"} - ) - status, report = repo.compare(kept, added, NoneAbsence) - assert status == 1 - assert report["introduced"][identity] == [2] - - -def test_an_absence_moved_with_its_class_to_another_module_is_not_written(repo): - holder = "class Holder:\n def check(self, v):\n return v is None\n" - other = "class Other:\n def check(self, v):\n return v is None\n" - base = repo.commit({"attempts.py": holder, "executions.py": "VALUE = 1\n"}) - moved = repo.commit({"attempts.py": "VALUE = 2\n", "executions.py": holder}) - status, report = repo.compare(base, moved, NoneAbsence) - assert report["introduced"][NoneAbsence.at(f"{repo.root}/executions.py").text] == [] - deleted = repo.commit({"attempts.py": holder, "executions.py": None}) - moved_out = repo.commit({"attempts.py": None, "owners.py": holder + "\n\n" + other}) - status, report = repo.compare(deleted, moved_out, NoneAbsence) - assert status == 1 - assert report["introduced"][NoneAbsence.at(f"{repo.root}/owners.py").text] == [8] - - -def test_git_context_configuration_cannot_mark_unchanged_lines_as_new(repo): - repo.git("config", "diff.interHunkContext", "3") - base = repo.commit({"names.py": "first = 1\nold_receipt = 1\nlast = 1\n"}) - head = repo.commit({"names.py": "first = 2\nold_receipt = 1\nlast = 2\n"}) - status, report = repo.compare(base, head, BannedVocabulary) - identity = BannedVocabulary.at(f"{repo.root}/names.py").text - assert status == 0 - assert report["head"][identity] == 1 - assert report["introduced"][identity] == [] - - -@pytest.mark.parametrize("word", BannedVocabulary.words) -def test_net_reduction_does_not_pay_for_a_new_identifier(repo, word): - base = repo.commit({"names.py": "old_admit = 1\nold_receipt = 2\n"}) - head = repo.commit({"names.py": f"new_{word} = 3\n"}) - status, report = repo.compare(base, head, BannedVocabulary) - identity = BannedVocabulary.at(f"{repo.root}/names.py").text - assert status == 1 - assert report["base"][identity] == 2 - assert report["head"][identity] == 1 - assert len(report["introduced"][identity]) == 1 - - -@pytest.mark.parametrize("word", BannedVocabulary.words) -def test_old_function_name_and_multiline_attribute_have_their_own_lines(repo, word): - base = repo.commit( - {"names.py": f"def old_admit():\n return (\n peer\n .{word}\n )\n"} - ) - head = repo.commit( - {"names.py": f"def old_admit():\n return (\n other\n .{word}\n )\n"} - ) - status, report = repo.compare(base, head, BannedVocabulary) - identity = BannedVocabulary.at(f"{repo.root}/names.py").text - assert status == 0 - assert report["head"][identity] == 2 - assert report["introduced"][identity] == [] - - -def test_changed_argument_still_changes_a_multiline_frozen_write(repo): - base = repo.commit({"owner.py": 'object.__setattr__(\n self, "value",\n 1,\n)\n'}) - head = repo.commit({"owner.py": 'object.__setattr__(\n self, "value",\n 2,\n)\n'}) - status, report = repo.compare(base, head, FrozenFieldWrite) - identity = FrozenFieldWrite.at(f"{repo.root}/owner.py").text - assert status == 1 - assert report["delta"][identity] == 0 - assert len(report["introduced"][identity]) == 1 - - -@pytest.mark.parametrize("word", BannedVocabulary.words) -def test_vocabulary_includes_identifiers_comments_and_docstring_lines_only(word): - source = f'''"""Receipt and {word} share one prose line.\nAdmitted work.\n"""\n# admission\nkey = "admission_generation"\ndef admit_receipt({word.title()}, **clean):\n return peer.receipt({word.title()}, admission=1)\n'''.encode() - # Three prose lines; function, parameter, attribute, loaded parameter and keyword. - assert BannedVocabulary.count(source, "values.py") == 8 - assert FrozenFieldWrite.count(b'text = "object.__setattr__(x, y, z)"\n', "values.py") == 0 - - -def test_a_frozen_write_counts_except_in_its_own_classes_post_init(): - source = b"""class Snapshot: - def __post_init__(self): - object.__setattr__(self, "maps", dict(self.maps)) - object.__setattr__(self.other, "maps", {}) - def later(): - object.__setattr__(self, "maps", {}) - def refresh(self): - object.__setattr__(self, "maps", {}) -def __post_init__(self): - object.__setattr__(self, "maps", {}) -""" - # Only the first write builds the instance; another object, a nested scope, another - # method and a module-level function of the same name all still count. - assert FrozenFieldWrite.count(source, "values.py") == 4 - - -def test_changed_prose_fails_but_an_unchanged_disk_key_does_not(repo): - original = 'key = "admission_generation"\n# clean wording\n' - base = repo.commit({"words.py": original}) - head = repo.commit({"words.py": original.replace("clean wording", "admission wording")}) - status, report = repo.compare(base, head, BannedVocabulary) - identity = BannedVocabulary.at(f"{repo.root}/words.py").text - assert status == 1 - assert report["head"][identity] == 1 - assert len(report["introduced"][identity]) == 1 - - -def test_local_import_scope_counts_nested_functions_and_classes_once(): - source = b"""import top\nclass ModuleClass:\n import top_class\ndef outer():\n import one\n class Local:\n import two\n def method(self):\n from package import three\n async def inner():\n import four\n""" - assert FunctionLocalImport.count(source, "imports.py") == 4 - - -def test_a_new_enum_fails_the_ratchet_and_folding_one_passes(repo): - plain = "class Family:\n pass\n" - base = repo.commit({"kinds.py": plain}) - grown = repo.commit({"kinds.py": plain + "import enum\nclass Kind(enum.StrEnum):\n A = 'a'\n"}) - status, report = repo.compare(base, grown, EnumDeclaration) - identity = EnumDeclaration.at(f"{repo.root}/kinds.py").text - assert status == 1 - assert report["head"][identity] == 1 - folded = repo.commit({"kinds.py": plain}) - assert repo.compare(grown, folded, EnumDeclaration)[0] == 0 - - -def test_enum_declarations_count_every_stdlib_enum_base_and_nothing_else(): - source = b"""import enum\nfrom enum import Enum, StrEnum\nclass A(Enum): pass\nclass B(enum.IntFlag): pass\nclass C(str, StrEnum): pass\nclass Family: pass\nclass Member(Family): pass\ndef local():\n class D(enum.Flag): pass\n""" - assert EnumDeclaration.lines(source, "kinds.py") == [3, 4, 5, 9] - - -def string_identity_readings(source: bytes) -> list[tuple[str, type]]: - declared = b"class CarryStep: pass\nclass Claimed: pass\ndef carry_hunts(): pass\n" - spellings = DeclaredSpellings.read( - { - path: DeclaredSpellings.declarations(path, module) - for path, module in [ - ("tools/carry_hunts.py", declared), - ("src/pkg/__init__.py", b""), - ("src/pkg/route_table.py", b""), - ("tools/use.py", source), - ] - } - ) - return [(site.spelling, site.reading) for site in spellings.sites(source, "tools/use.py")] - - -def test_string_identity_reads_declared_names_compared_kinds_and_named_access(): - source = b"""after = "carry_hunts" -step = "CarryStep" -table = "pkg.route_table" -loaded = sys.modules["pkg.route_table"] -if kind == "release": pass -if mode in {"fast", "slow"}: pass -match shape: - case "square": pass -getattr(peer, "model") -getattr(peer, name) -importlib.import_module("pkg.plugin") -Family.member_named("text") -text = "getattr(peer, 'model')" -word = "plain words here" -""" - assert string_identity_readings(source) == [ - ("carry_hunts", NamedDeclaration), - ("CarryStep", NamedDeclaration), - ("pkg.route_table", NamedDeclaration), - ("release", ComparedKind), - ("fast", ComparedKind), - ("slow", ComparedKind), - ("square", ComparedKind), - ("model", NamedAccess), - ("pkg.plugin", NamedAccess), - ("text", NamedAccess), - ] - - -def test_a_one_word_class_name_counts_only_where_a_type_is_expected(): - source = b"""notice = Message(owner, owner, "Claimed", kind) -label = "Claimed" -field: Annotated[int, tool_field("Claimed")] = 0 -step = "CarryStep" -def read() -> "Claimed": pass -held: "Claimed" -class Index(list["Claimed"]): pass -Bound = TypeVar("Bound", bound="Claimed") -""" - assert string_identity_readings(source) == [ - ("CarryStep", NamedDeclaration), - ("Claimed", NamedDeclaration), - ("Claimed", NamedDeclaration), - ("Claimed", NamedDeclaration), - ("Claimed", NamedDeclaration), - ] - - -def test_a_module_name_counts_only_where_a_string_selects_a_module(): - source = b"""migrate = numbers and "route_table" in stored -path = "pkg.route_table" -spec = importlib.util.find_spec("pkg.route_table") -cached = sys.modules.get("pkg.route_table") -present = "pkg.route_table" in sys.modules -skills = files("pkg.route_table") / "skills" -command = (interpreter, "-m", "pkg.route_table", root) -""" - assert string_identity_readings(source) == [ - ("pkg.route_table", NamedDeclaration), - ("pkg.route_table", NamedDeclaration), - ("pkg.route_table", NamedDeclaration), - ("pkg.route_table", NamedDeclaration), - ("pkg.route_table", NamedDeclaration), - ] - - -def test_string_identity_exempts_prose_interpolation_enum_bodies_class_options_and_entry_guard(): - source = b'''"""carry_hunts is described here.""" -import enum -class Wire(enum.StrEnum): - STEP = "carry_hunts" -class Hook(Wire): - OTHER = "CarryStep" -class Member(Base, declared_name="carry_hunts"): - """CarryStep docs.""" -label = f"{name} carry_hunts" -if __name__ == "__main__": pass -''' - assert string_identity_readings(source) == [] - - -def test_string_identity_measures_spellings_outside_root_and_skips_declared_tests( - repo: Repository, -) -> None: - (repo.path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests"]\n') - outside = { - "tools/carry_other.py": "def run(): pass\n", - "tools/step.py": "after = None\n", - "tests/test_step.py": "after = None\n", - } - for name, source in outside.items(): - (repo.path / name).parent.mkdir(parents=True, exist_ok=True) - (repo.path / name).write_text(source) - base = repo.commit({"kept.py": "value = 1\n"}) - for name in ("tools/step.py", "tests/test_step.py"): - (repo.path / name).write_text('after = sys.modules["carry_other"]\n') - head = repo.commit({}) - status, report = repo.compare(base, head, StringIdentity) - assert status == 1 - assert {key: value for key, value in report["delta"].items() if value} == { - StringIdentity.at("tools/step.py").text: 1 - } - assert StringIdentity.at("tests/test_step.py").text not in report["head"] - - -def test_broad_suppress_is_counted_and_cannot_be_added(repo): - source = b"""with suppress(OSError): work() -with contextlib.suppress(ConnectionError, TimeoutError): work() -with suppress(FileNotFoundError): absent() -with suppress(Exception): raise RuntimeError() -""" - assert BroadSwallow.count(source, "read.py") == 3 - before = repo.commit({"reader.py": "work()\n"}) - after = repo.commit({"reader.py": "with suppress(OSError): work()\n"}) - status, report = repo.compare(before, after, BroadSwallow) - identity = BroadSwallow.at(f"{repo.root}/reader.py").text - assert status == 1 and len(report["introduced"][identity]) == 1 - assert report["delta"][identity] == 1 - - -def test_broad_handler_carrying_its_error_into_a_typed_outcome_is_not_a_swallow(): - source = b"""for pid in pids: - try: read(pid) - except (OSError, IdentityMismatchError) as error: - outcomes.append(UnreadableRuntimeProcess(pid, str(error))) - continue - try: read(pid) - except OSError as error: - log.warning(str(error)) - continue - try: read(pid) - except OSError as error: - return Missing() -""" - assert BroadSwallow.count(source, "read.py") == 2 - - -@pytest.mark.parametrize(('handler', 'expected'), ( - ('self.show_error(error, "Change directory")\n return', 0), - ('self.show_error(error, "Change directory")', 0), - ('self.show_error(error=error)\n return None', 0), - ('self.show_error(str(error))\n return', 1), - ('self.show_error(error)\n return False', 1), - ('if visible: self.show_error(error)\n return', 1), - ('self.show_error(other)\n return', 1), - ('def later(): self.show_error(error)\n return', 1), -)) -def test_broad_handler_transfers_original_error_without_a_success_default(handler, expected): - source = ('def change(self):\n' - ' try: qualify()\n' - ' except (OSError, ValueError) as error:\n' - f' {handler}\n').encode() - assert BroadSwallow.count(source, 'project.py') == expected - - -def test_field_spelling_rosters_count_loops_not_declarations(): - source = b"""fields = ("path", "file_path", "filePath")\nfor key in ("path", "file_path", "filePath"):\n value = data[key]\nvalue = next(data[key] for key in ("path", "file_path", "filePath") if key in data)\n""" - assert ForeignFieldSpellings.count(source, "read.py") == 2 - assert ForeignFieldSpellings.lines(source, "read.py") == [2, 4] - - -def test_broad_swallow_is_counted_and_cannot_be_added(repo): - source = b"""try: work() -except (OSError, ValueError): pass -try: work() -except Exception: return_default() -try: work() -except: continue_work() -try: work() -except RuntimeError: raise -try: work() -except FileNotFoundError: absent() -""" - assert BroadSwallow.count(source, "read.py") == 3 - root = Path(__file__).resolve().parents[2] / "src/agent_comms" - assert ( - sum(BroadSwallow.count(path.read_bytes(), str(path)) for path in root.rglob("*.py")) <= 29 - ) - before = repo.commit({"reader.py": "work()\n"}) - after = repo.commit({"reader.py": "try: work()\nexcept OSError: pass\n"}) - status, report = repo.compare(before, after, BroadSwallow) - identity = BroadSwallow.at(f"{repo.root}/reader.py").text - assert status == 1 and len(report["introduced"][identity]) == 1 - assert report["delta"][identity] == 1 - - -def test_broad_handlers_only_count_defaults_returned_by_the_handler(): - source = b"""def read():\n try: work()\n except Exception:\n return None\n try: work()\n except:\n if condition: return {}\n try: work()\n except OSError:\n return None\n try: work()\n except BaseException:\n raise\n try: work()\n except Exception:\n def helper(): return None\n raise\n""" - assert BroadExceptDefault.count(source, "read.py") == 2 - - -def test_growth_lines_end_the_merged_log(repo: Repository) -> None: - """run-step.sh annotates the last lines of stdout and stderr merged into one pipe; the - report is flushed first, so what grew is what a reader without the job log sees.""" - base = repo.commit({"check.py": 'value = row["a"]\n'}) - head = repo.commit({"check.py": "value = type(x) is int\n"}) - merged = subprocess.run( - (sys.executable, str(SOURCE / "tools/debt_ratchet.py"), - "--root", repo.root, "--base", base, "--head", head), - cwd=repo.path, env=ratchet_environment(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, timeout=120, - ) - assert merged.returncode == 1 - assert merged.stdout.rstrip().splitlines()[-1].startswith(("grew ", "found ", "introduced ")) - - -def test_content_cache_digest_covers_a_module_only_loaded_indirectly(): - """The ratchet never imports coordination_tables.attempts by name; agent_comms loads it - through its own imports. Its source is still one of the ratchet's measuring code, so a - change to it is a different cache key.""" - indirect = Path(sys.modules["agent_comms.coordination_tables.attempts"].__file__).resolve() - assert indirect in ContentCache.sources() - assert (AUDIT_SCRIPTS / "audit" / "findings.py").resolve() in ContentCache.sources() - assert indirect.is_relative_to(Path(__file__).resolve().parents[2] / "src") - - -def test_content_cache_prunes_entries_unused_for_the_retention_period(tmp_path, monkeypatch): - """An entry read after it aged survives the prune; one neither written nor read does not.""" - monkeypatch.setattr(ContentCache, "directory", tmp_path / "debt-ratchet") - ContentCache.store(1, "read") - ContentCache.store(2, "unused") - old = time.time() - (ContentCache.retention_days + 1) * 86400 - for name in ("read", "unused"): - os.utime(ContentCache.path(name), (old, old)) - assert ContentCache.stored("read") == (1,) - ContentCache.prune() - assert ContentCache.stored("read") == (1,) - assert ContentCache.stored("unused") == () - - -def test_a_grown_measure_points_at_every_head_site_it_counts(repo): - """A summed measure's sites span its files; a per-file measure's stay in its own file.""" - base = repo.commit({"check.py": "value = 1\n", "other.py": "VALUE = 1\n"}) - head = repo.commit({"check.py": "value = 1\nkind = type(x) is int\n", - "other.py": "from typing import Any\nVALUE: Any = 1\n"}) - status, report = repo.compare(base, head, TypeIdentity, AnyName) - assert status == 1 - assert report["found"] == { - "TypeIdentity": [{"path": f"{repo.root}/check.py", "line": 2}], - AnyName.at(f"{repo.root}/other.py").text: [{"path": f"{repo.root}/other.py", "line": 2}], - } - - -def test_only_the_asked_measures_are_compared_and_located(repo): - """The audit's raw-shape count has no lines, so it grows without sites, and passes the - shared runner also took never reach the report.""" - base = repo.commit({"rows.py": "VALUE = 1\n"}) - head = repo.commit({"rows.py": 'def read(row: dict):\n return row["a"], row["b"], row["c"], row["d"]\n'}) - status, report = repo.compare(base, head, RawRecordShapes) - assert status == 1 - assert report["delta"] == {RawRecordShapes.at(f"{repo.root}/rows.py").text: 1} - assert report["found"] == {} - status, report = repo.compare(base, head, StringSubscript) - assert report["delta"] == {"StringSubscript": 4} - assert report["found"] == {"StringSubscript": [{"path": f"{repo.root}/rows.py", "line": 2}] * 4} diff --git a/tests/test_boundary_decoding.py b/tests/test_boundary_decoding.py index d98abf86b6..d6a7ce3c01 100644 --- a/tests/test_boundary_decoding.py +++ b/tests/test_boundary_decoding.py @@ -4,6 +4,9 @@ import pytest +from agent_comms.pinned_audit import audit +from audit.source_measures import RawRecordShapes + from agent_comms.channel_coding_tools import CodingToolRequest from agent_comms.input_key import BusInputKey from agent_comms.cli_bridges.claude_protocol import ClaudeHeader @@ -78,8 +81,6 @@ def test_native_source_receipt_checks_bytes_without_installing(tmp_path): def test_raw_shape_guard_reuses_the_existing_overlay_detector(): - from debt_ratchet import RawRecordShapes - raw = b'def read(row):\n return row["a"], row["b"], row.get("c")\n' typed = b'def read(row):\n return row.a, row.b, row.c\n' assert RawRecordShapes.count(raw, 'src/example.py') == 1 diff --git a/tests/test_carry.py b/tests/test_carry.py index d226e2defd..00b32c096c 100644 --- a/tests/test_carry.py +++ b/tests/test_carry.py @@ -11,7 +11,11 @@ from pathlib import Path import pytest -from debt_ratchet import UncarriedRecordFormat, git + +from agent_comms.pinned_audit import audit +from audit import agent_comms_project +from audit import repository as audit_git +from audit.project_measures import UncarriedRecordFormat from agent_comms.locked_store import LiveFilesRequest from agent_comms.cli_verbs.stop import StopCliCommand @@ -118,7 +122,7 @@ def record_source(version: int, fields: str) -> str: def test_a_stored_record_format_change_bumps_its_version_and_ships_a_carry( tmp_path, before, version, fields, step, uncarried): repo = Repository(tmp_path) - package = git(REPO, "archive", "HEAD", "--", "src/agent_comms") + package = audit_git.Repository(REPO).read("archive", "HEAD", "--", "src/agent_comms") subprocess.run(["tar", "-x", "-C", str(tmp_path)], input=package, check=True, timeout=60) record = tmp_path / "src" / "agent_comms" / "record.py" record.write_text(record_source(1, before)) @@ -126,7 +130,8 @@ def test_a_stored_record_format_change_bumps_its_version_and_ships_a_carry( record.write_text(record_source(version, fields)) head = repo.commit(step) changed = {"src/agent_comms/record.py"} - assert UncarriedRecordFormat.compare(tmp_path, base, head, changed, "src/agent_comms") == ( + assert UncarriedRecordFormat.compare(audit_git.Repository(tmp_path), base, head, changed, + "src/agent_comms") == ( {UncarriedRecordFormat.total(): 0}, {UncarriedRecordFormat.total(): uncarried}) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 301c03804a..8848b98999 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -1,2896 +1,23 @@ #!/usr/bin/env python3 -"""Compare AST debt in every changed production Python path at two Git revisions.""" +"""The debt ratchet's CI entry: agent-comms compared by the refactor-audit this checkout pins. + +Every measure, the comparison, its JSON report and its exit status belong to the audit package; +agent-comms' own facts are its declaration there, audit/agent_comms_project.py, which importing +declares. This entry only reads the command line.""" from __future__ import annotations import argparse -import ast -import atexit -import dataclasses -import enum -import fcntl -import hashlib -import importlib -import importlib.metadata -import inspect -import importlib.resources -import importlib.util -import io -import itertools -import json import os -import pickle -import re -import shutil -import subprocess -import sys -import tempfile -import time -import tokenize -import tomllib -import types -import typing -from typing import Annotated -from abc import abstractmethod -from collections import Counter -from collections.abc import Callable, Iterator -from concurrent.futures import as_completed -from dataclasses import dataclass -from functools import cache, cached_property, lru_cache, partial from pathlib import Path from agent_comms.pinned_audit import audit -from audit.findings import Package as AuditPackage, ParsedModule as AuditModule, RawShape -from audit.repository import Repository as AuditRepository -from audit.handler_declarations import BuiltinHandlerDeclarations -from audit.measures import FamilyFlattened as AuditFamilyFlattened -from audit.measures import ForeignAbsenceProbe as AuditForeignAbsenceProbe - -import agent_comms -from agent_comms import coordination_schema, field_codec, locked_store, process_claim, store_files, typed_table -from agent_comms.child_process import ForkedProcessPool, SpawnedProcess -from agent_comms.core_stamp import WheelStamp -from agent_comms.declared_family import DeclaredFamily -from agent_comms.installer.carry import STEPS -from metaclass_registry.mro_dispatch import ( - MroDispatch, - handles, -) -from module_layers import PACKAGE, StackClient -from stored_formats import StoredForm -from typing_names import TypingImports, TypingReference -from agent_comms.field_codec import FieldCodec, ViewExclusion, boundary_decoder, projected -from agent_comms.phase_evidence import MainTree, PlanReading -from agent_comms.plan_phases import PlanPhase -from agent_comms.repo import Repo - -AUDIT_SCRIPTS = Path(audit.__path__[0]).parent -REPOSITORY = Path(__file__).resolve().parents[1] - - -class ContentCache: - """Results that are pure functions of content, stored under a digest of that content, of - this ratchet's own code and of the environment its checkers run in. The key is the content, - so an entry never goes stale: any change to what produced it is a different key. Batch 3579 - went red on 170 Toad errors nobody wrote, because the key held mypy's version but not - Textual's commit, and a git dependency moves without changing its version.""" - - directory: typing.ClassVar[Path] = Path.home() / ".cache/agent-comms/debt-ratchet" - retention_days: typing.ClassVar[int] = 14 - - @staticmethod - def sources() -> tuple[Path, ...]: - """Every module loaded in this process under this repository's tools/ or src/, plus the - audit package: measuring subprocesses import it from AUDIT_SCRIPTS, so sys.modules - alone would miss a change to it. Sorted.""" - roots = (REPOSITORY / "tools", REPOSITORY / "src") - origins = (Path(origin).resolve() for module in list(sys.modules.values()) - if isinstance(module, types.ModuleType) - and (origin := module.__dict__.get("__file__"))) - loaded = {path for path in origins if any(path.is_relative_to(root) for root in roots)} - audited = {path.resolve() for path in (AUDIT_SCRIPTS / "audit").rglob("*.py")} - return tuple(sorted(loaded | audited)) - - @staticmethod - @cache - def code() -> bytes: - """This ratchet's measuring code (see sources), and every installed distribution: the - checkers and everything they read, such as the stack client's Textual. A git install - keeps its version across commits, so its recorded origin (direct_url.json) is digested - too. Computed once, at the first key, after the measures' imports, so the set is the one - that ran.""" - digest = hashlib.sha256(f"{sys.version}\0{__name__}".encode()) - for installed in sorted(f"{dist.name}={dist.version}\0{dist.read_text('direct_url.json')}" - for dist in importlib.metadata.distributions()): - digest.update(b"\0" + installed.encode()) - for path in ContentCache.sources(): - digest.update(b"\0" + str(path).encode() + b"\0" + path.read_bytes()) - return digest.digest() - - @classmethod - def path(cls, *parts: str) -> Path: - key = hashlib.sha256(b"\0".join((cls.code(), *(part.encode() for part in parts)))) - identity = key.hexdigest() - return cls.directory / identity[:2] / identity - - @classmethod - def stored(cls, *parts: str) -> tuple[object, ...]: - """The stored result for these parts, as a one-item tuple, or an empty one.""" - path = cls.path(*parts) - if not path.is_file(): - return () - os.utime(path) # the mtime records the last use, which is what pruning reads - return (pickle.loads(path.read_bytes()),) - - @classmethod - def prune(cls) -> None: - """Delete the entries neither written nor read for retention_days.""" - horizon = time.time() - cls.retention_days * 86400 - for path in cls.directory.glob("*/*"): - if path.stat().st_mtime < horizon: - path.unlink(missing_ok=True) - - @classmethod - def store[Value](cls, value: Value, *parts: str) -> Value: - path = cls.path(*parts) - path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as staged: - staged.write(pickle.dumps(value)) - os.replace(staged.name, path) - return value - - @classmethod - def memo[Value](cls, compute: Callable[[], Value], *parts: str) -> Value: - for value in cls.stored(*parts): - return typing.cast(Value, value) - return cls.store(compute(), *parts) - - - -class Side(DeclaredFamily, affix="Side"): - """One of the two revisions a comparison measures; its declared name names a checker's cache.""" - - @staticmethod - @abstractmethod - def ref(base: str, head: str) -> str: - """This side's revision of the compared pair.""" - - @classmethod - def sides(cls) -> tuple[type[Side], ...]: - return cls.members_with(cls) - - -class BaseSide(Side): - @staticmethod - def ref(base: str, head: str) -> str: - return base - - -class HeadSide(Side): - @staticmethod - def ref(base: str, head: str) -> str: - return head - - -class Measure(DeclaredFamily, affix="Measure"): - @classmethod - def owns(cls, key: MeasureCount) -> bool: - """Whether a count is one of this measure's.""" - return key.measure is cls - - @classmethod - def total(cls) -> MeasureCount: - return MeasureCount(cls) - - @classmethod - def at(cls, path: str) -> FileCount: - return FileCount(cls, path) - - @classmethod - def blocks(cls, delta: Counts) -> bool: - return any(value > 0 for key, value in delta.items() if cls.owns(key)) - - @classmethod - def grew(cls, key: MeasureCount, grown: int) -> str: - """The line reporting one count's growth; the count words it.""" - return key.grew(grown) - - @classmethod - def run_by(cls) -> type[Measure]: - """The measure a comparison runs to take this one's measurements.""" - return cls - - @staticmethod - def environment(root: str, tree: Path) -> dict[str, str]: - """The environment of a tool run in a revision extracted to the tree.""" - return dict(os.environ) - - @classmethod - def checker_output(cls, result: subprocess.CompletedProcess[bytes], subject: str) -> str: - """A checker's report, once it exits as a finding run: zero or one.""" - output = (result.stdout + result.stderr).decode() - if result.returncode not in (0, 1): - raise ValueError(f"{cls.__name__} could not check {subject}: {output}") - return output - - @staticmethod - def source_paths(root: str, tree: Path) -> tuple[str, ...]: - """Where a revision extracted to the tree imports from: the measured root's directory, - then its ``src`` (``agent-comms install`` runs tools with it), never the installed one, - then the pinned audit's scripts, a submodule no ``git archive`` of the tree carries.""" - return (*dict.fromkeys(str(tree / directory) for directory in (Path(root).parts[0], "src")), - str(AUDIT_SCRIPTS)) - - @classmethod - @abstractmethod - def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: - """Independent pieces of the comparison; compare() runs each in a separate process.""" - - @classmethod - @abstractmethod - def combine(cls, results: list, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Counts, Counts]: - """The before/after measurements from the results of parts(), in order.""" - - @classmethod - def align(cls, base: Counts, head: Counts) -> tuple[Counts, Counts]: - return base, head - - @classmethod - def compare(cls, repo: Path, base: str, head: str, changed: set[str], root: str): - """Return independent before/after measurements for this declaration.""" - parts = cls.parts(repo, base, head, changed, root) - return cls.combine([part() for part in parts], repo, base, head, changed, root) - - -@dataclass(frozen=True) -class MeasureCount: - """What one reported count counts: here, a measure's total over the change. Its text is - only the report's key; the comparison matches the record.""" - - measure: type[Measure] - - @property - def text(self) -> str: - return self.measure.__name__ - - def moved(self, moved: dict[str, str]) -> MeasureCount: - """The count at its head path; a total belongs to no path.""" - return self - - def warning(self, text: str) -> str: - """A check annotation for this count; a total annotates no file.""" - return f"::warning::{text}" - - def grew(self, grown: int) -> str: - """The line saying this count grew.""" - return f"grew {self.text} +{grown}" - - -@dataclass(frozen=True) -class FileCount(MeasureCount): - """A measure's count in one file, so an improvement in one file cannot hide a regression - in another.""" - - path: str - - @property - def text(self) -> str: - return f"{super().text}:{self.path}" - - def moved(self, moved: dict[str, str]) -> FileCount: - return dataclasses.replace(self, path=moved.get(self.path, self.path)) - - def warning(self, text: str) -> str: - return f"::warning file={self.path}::{text}" - - def member(self, name: str) -> MemberCount: - return MemberCount(self.measure, self.path, name) - - -@dataclass(frozen=True) -class MemberCount(FileCount): - """A measure's count for one declaration in a file, named by its dotted scope.""" - - name: str - - @property - def text(self) -> str: - return f"{super().text}::{self.name}" - - -@dataclass(frozen=True) -class PhaseSiteCount(MeasureCount): - """One site where a done plan phase's evidence fails.""" - - phase: type[PlanPhase] - site: str - - @property - def text(self) -> str: - return f"{super().text}:{self.phase.__name__}::{self.site}" - - def grew(self, grown: int) -> str: - return f"done plan phase {self.phase.__name__} no longer holds: {self.site}" - - -@dataclass(frozen=True) -class ClientFileCount(FileCount): - """Type errors in one stack client file, which the client's own code cannot add: both sides - check the same client commit.""" - - def grew(self, grown: int) -> str: - return (f"grew {self.text} +{grown}: this change removes or changes an agent_comms name " - "that client file uses; run mypy on it to see which") - - -# One revision's counts, each under the record of what it counts. -type Counts = dict[MeasureCount, int] - - -class ChangeMeasure(Measure): - """A count of the change as a whole, taken in one piece.""" - - @classmethod - @abstractmethod - def counted(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Counts, Counts]: - """The before/after counts.""" - - @classmethod - def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: - return (partial(cls.counted, repo, base, head, changed, root),) - - @classmethod - def combine(cls, results: list, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Counts, Counts]: - before, after = results[0] - return before, after - - -class InventoryMeasure[Reading](Measure): - """A measure of every file a revision holds, not only the changed ones. A file the change - did not touch reads the same at both revisions, so it is read once; the reading is split - across processes.""" - - @classmethod - @abstractmethod - def inventory(cls, repo: Path, ref: str, root: str) -> frozenset[str]: - """The paths measured at this revision.""" - - @classmethod - @abstractmethod - def reading(cls, path: str, source: bytes) -> Reading: - """What the measure needs from one file, independent of the revision holding it.""" - - @classmethod - @abstractmethod - def measured(cls, repo: Path, ref: str, base: str, head: str, readings: dict[str, Reading], - root: str) -> Counts: - """This revision's measurements from its files' readings, in path order.""" - - @classmethod - def context(cls, repo: Path, ref: str) -> str: - """What a file's reading depends on besides the file itself.""" - return "" - - @classmethod - def read(cls, repo: Path, ref: str, paths: list[str]) -> dict[str, Reading]: - """Each file's reading, computed once per blob and context.""" - identities = GitMeasure.blobs(repo, ref, set(paths)) - context = cls.context(repo, ref) - parts = {path: (cls.__name__, context, path, identities[path]) for path in paths} - readings = {path: typing.cast(Reading, stored) - for path in paths for stored in ContentCache.stored(*parts[path])} - fresh = cls.read_fresh(repo, ref, sorted(set(paths) - readings.keys())) - return {**readings, **{path: ContentCache.store(reading, *parts[path]) - for path, reading in fresh.items()}} - - @classmethod - def read_fresh(cls, repo: Path, ref: str, paths: list[str]) -> dict[str, Reading]: - return {path: cls.reading(path, source) - for path, source in GitMeasure.sources(repo, ref, set(paths))} - - @classmethod - def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: - before, after = cls.inventory(repo, base, root), cls.inventory(repo, head, root) - shared = sorted((before & after) - ChangedPaths.of(repo, base, head).touched) - shards = os.process_cpu_count() or 1 - return (partial(cls.read, repo, base, sorted(before.difference(shared))), - partial(cls.read, repo, head, sorted(after.difference(shared))), - *(partial(cls.read, repo, head, shared[shard::shards]) for shard in range(shards))) - - @classmethod - def combine(cls, results: list, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Counts, Counts]: - before, after, *shards = results - shared = {path: reading for shard in shards for path, reading in shard.items()} - return cls.align(*(cls.measured(repo, ref, base, head, - dict(sorted({**shared, **own}.items())), root) - for ref, own in ((base, before), (head, after)))) - - -class GitMeasure(Measure): - """Shared Git collection for declaration-owned snapshots.""" - - @staticmethod - def blobs(repo: Path, ref: str, selected: set[str]) -> dict[str, str]: - """Each selected path's blob identity at the revision.""" - if not selected: - return {} - tree = git(repo, "--literal-pathspecs", "ls-tree", "-rz", ref, "--", *sorted(selected)) - identities = {} - for entry in tree.split(b"\0"): - if entry: - metadata, path = entry.split(b"\t", 1) - _, kind, identity = metadata.split() - if kind != b"blob": - raise ValueError(f"Expected a source blob at {path!r}") - identities[path.decode()] = identity.decode() - if identities.keys() != selected: - raise ValueError("Git omitted a selected path") - return identities - - @staticmethod - def sources(repo: Path, ref: str, selected: set[str]) -> list[tuple[str, bytes]]: - """Read the selected Git blobs with one batch command.""" - if not selected: - return [] - identities = {path: identity.encode() - for path, identity in GitMeasure.blobs(repo, ref, selected).items()} - objects = dict.fromkeys(identities.values()) - stream = io.BytesIO(git(repo, "cat-file", "--batch", input=b"\n".join(objects) + b"\n")) - blobs = {} - for identity in objects: - recorded, kind, size = stream.readline().split() - if recorded != identity or kind != b"blob": - raise ValueError("Git returned an unrelated object") - length = int(size) - content = stream.read(length) - if len(content) != length or stream.read(1) != b"\n": - raise ValueError("Git returned an incomplete object") - blobs[identity] = content - if stream.read(1): - raise ValueError("Git returned unrequested objects") - return [(path, blobs[identities[path]]) for path in sorted(selected)] - - -class CountedSource(GitMeasure): - """A measure that is one number per source file: a pass over the tree SyntaxPasses parses - once for every pass. Passes read the shared tree and never write it.""" - - @classmethod - @abstractmethod - def count(cls, source: bytes, filename: str) -> int: - """Return this file's count.""" - - @classmethod - @abstractmethod - def tally(cls, counts: list[tuple[str, int]]) -> Counts: - """A revision's measurements from each selected file's count, in path order.""" - - @classmethod - def snapshot(cls, sources: list[tuple[str, bytes]]) -> Counts: - return cls.tally([(path, cls.count(source, path)) for path, source in sources]) - - @staticmethod - @lru_cache(maxsize=4) - def tree(source: bytes, filename: str) -> ast.Module: - """The file's syntax, parsed once while every pass counts it.""" - return ast.parse(source, filename) - - @classmethod - def run_by(cls) -> type[Measure]: - return SyntaxPasses - - @classmethod - def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: - return SyntaxPasses.parts(repo, base, head, changed, root) - - @classmethod - def combine(cls, results: list, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Counts, Counts]: - before, after = SyntaxPasses.combine(results, repo, base, head, changed, root) - return ({key: value for key, value in before.items() if cls.owns(key)}, - {key: value for key, value in after.items() if cls.owns(key)}) - - -@dataclass(frozen=True) -class Site: - """One counted occurrence: the file and line a grown measure points at.""" - - path: str - line: int - - -class LocatedSource(CountedSource): - """A pass that knows the line of every occurrence it counts; its count is how many.""" - - @classmethod - @abstractmethod - def lines(cls, source: bytes, filename: str) -> list[int]: - """The line of each occurrence, once per unit counted.""" - - @classmethod - def count(cls, source: bytes, filename: str) -> int: - return len(cls.lines(source, filename)) - - @classmethod - def located(cls, sources: list[tuple[str, bytes]]) -> dict[MeasureCount, list[Site]]: - """Each count's sites. A site belongs to the count one occurrence in its file - tallies to, so the count layout stays tally's alone.""" - found: dict[MeasureCount, list[Site]] = {} - for path, source in sources: - (key,) = cls.tally([(path, 1)]) - found.setdefault(key, []).extend(Site(path, line) for line in cls.lines(source, path)) - return found - - -class SourcePosition(MroDispatch): - """The lines a counted node spans, chosen by the node's own syntax class. The parser - positions every node a pass counts except a comprehension clause.""" - - @handles(ast.stmt, ast.expr, ast.arg, ast.keyword, ast.excepthandler, ast.alias, ast.pattern) - def positioned(self, node: ast.stmt | ast.expr | ast.arg | ast.keyword | ast.excepthandler - | ast.alias | ast.pattern) -> range: - return range(node.lineno, typing.cast(int, node.end_lineno) + 1) - - def lines(self, node: ast.AST) -> range: - return type(self).handler_for(type(node))(self, node) - - -class RosterPosition(SourcePosition): - """A loop's roster is the literal it runs over, which also places a comprehension.""" - - @handles(ast.For, ast.AsyncFor, ast.comprehension) - def loop(self, node: ast.For | ast.AsyncFor | ast.comprehension) -> range: - return self.lines(node.iter) - - -class NamePosition(SourcePosition): - """A name occupies one line: an attribute's is where the attribute ends.""" - - @handles(ast.Attribute) - def attribute(self, node: ast.Attribute) -> range: - line = typing.cast(int, node.end_lineno) - return range(line, line + 1) - - def lines(self, node: ast.AST) -> range: - line = super().lines(node).start - return range(line, line + 1) - - -class OccurrenceMeasure(LocatedSource): - """Shared additive counting for syntactic occurrence measures.""" - - position: typing.ClassVar[SourcePosition] = SourcePosition() - - @staticmethod - @abstractmethod - def occurrences(node: ast.AST) -> int: - """Return this node's contribution, excluding its descendants.""" - - @staticmethod - def sites(tree: ast.AST, filename: str) -> Iterator[ast.AST]: - """The nodes this measure reads; every node unless a measure excludes some.""" - return ast.walk(tree) - - @classmethod - def lines(cls, source: bytes, filename: str) -> list[int]: - tree = cls.tree(source, filename) - return [cls.position.lines(node).start for node in cls.sites(tree, filename) - for _ in range(cls.occurrences(node))] - - @classmethod - def tally(cls, counts: list[tuple[str, int]]) -> Counts: - return {cls.total(): sum(count for _, count in counts)} - - -class PerFileMeasure(CountedSource): - """An improvement in one file cannot hide a regression in another.""" - - @classmethod - def tally(cls, counts: list[tuple[str, int]]) -> Counts: - return {cls.at(path): count for path, count in counts} - - @classmethod - def align(cls, base: Counts, head: Counts) -> tuple[Counts, Counts]: - identities = sorted(base.keys() | head.keys(), key=lambda identity: identity.text) - return ({key: base.get(key, 0) for key in identities}, - {key: head.get(key, 0) for key in identities}) - - -class PerFileOccurrenceMeasure(PerFileMeasure, OccurrenceMeasure): - """A per-node count, kept separately for each file.""" - - -class SyntaxPasses(InventoryMeasure[dict[type[CountedSource], int]]): - """The front end of every CountedSource pass, as a compiler runs its passes over one tree. - - Each changed file is read and parsed once per revision and every pass counts that tree; - the counts are stored under the file's blob, so a file an earlier run saw is not parsed - again. The changed files of each revision are split across the pool.""" - - @staticmethod - def passes() -> tuple[type[CountedSource], ...]: - return CountedSource.members_with(CountedSource) - - @classmethod - def inventory(cls, repo: Path, ref: str, root: str) -> frozenset[str]: - return python_paths(repo, ref, root) - - @classmethod - def reading(cls, path: str, source: bytes) -> dict[type[CountedSource], int]: - return {measure: measure.count(source, path) for measure in cls.passes()} - - @classmethod - def measured(cls, repo: Path, ref: str, base: str, head: str, - readings: dict[str, dict[type[CountedSource], int]], root: str) -> Counts: - return {key: value for measure in cls.passes() for key, value in measure.tally( - [(path, reading[measure]) for path, reading in readings.items()]).items()} - - @classmethod - def side(cls, side: type[Side], repo: Path, ref: str, - paths: list[str]) -> tuple[type[Side], dict[str, dict[type[CountedSource], int]]]: - return side, cls.read(repo, ref, paths) - - @classmethod - def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: - shards = os.process_cpu_count() or 1 - return tuple(partial(cls.side, side, repo, ref, paths[shard::shards]) - for side in Side.sides() for ref in (side.ref(base, head),) - for paths in (sorted(changed & cls.inventory(repo, ref, root)),) - for shard in range(min(shards, len(paths)))) - - @classmethod - def combine(cls, results: list, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Counts, Counts]: - readings: dict[type[Side], dict[str, dict[type[CountedSource], int]]] = { - side: {} for side in Side.sides()} - for side, read in results: - readings[side].update(read) - return PerFileMeasure.align(*(cls.measured(repo, side.ref(base, head), base, head, - dict(sorted(readings[side].items())), root) - for side in Side.sides())) - - -class MetadataKeys(ast.NodeVisitor): - """The Python AST owns dispatch for literal and opaque metadata keys.""" - - def generic_visit(self, node: ast.AST) -> int: - return 1 - - def visit_Dict(self, node: ast.Dict) -> int: - return sum(typing.cast(int, self.visit(key)) if key else 1 for key in node.keys) - - def visit_Constant(self, node: ast.Constant) -> int: - return int(isinstance(node.value, str)) - - -class StringFieldMetadata(PerFileOccurrenceMeasure): - """Dataclass instructions belong to typed annotations, not metadata dictionaries. - - Imported aliases are resolved at this Python boundary. Dynamic rebinding is - outside this syntactic guard's proof scope, as with the existing import guards. - """ - - @staticmethod - def sites(tree: ast.AST, filename: str) -> Iterator[ast.AST]: - binding = DecoratorBinding.of(dataclasses.field, tree, filename) - for node in ast.walk(tree): - if isinstance(node, ast.Call) and binding.marks(node.func): - yield node - - @staticmethod - def occurrences(node: ast.AST) -> int: - descriptor: types.MemberDescriptorType = inspect.getattr_static( - dataclasses.Field, "metadata" - ) - metadata = descriptor.__name__ - return sum( - typing.cast(int, MetadataKeys().visit(keyword.value)) - for keyword in typing.cast(ast.Call, node).keywords - if keyword.arg == metadata - ) - - -class PlainStrIdAnnotation(PerFileOccurrenceMeasure): - """ID-shaped fields and parameters retain their nominal kind instead of plain str.""" - - @staticmethod - def sites(tree: ast.AST, filename: str) -> Iterator[ast.AST]: - for node in ast.walk(tree): - match node: - case ast.AnnAssign( - target=ast.Name(id=name) | ast.Attribute(attr=name), annotation=annotation - ) | ast.arg(arg=name, annotation=annotation): - if not name.endswith(("_id", "nonce", "lookup", "generation")): - continue - case _: - continue - while True: - match annotation: - case ast.Name(id=str.__name__): - yield node - break - case ast.BinOp(left=inner, op=ast.BitOr(), right=ast.Constant(value=None)) \ - | ast.BinOp(left=ast.Constant(value=None), op=ast.BitOr(), right=inner): - annotation = inner - case ast.Subscript( - value=ast.Name(id=TypePositions.annotated) - | ast.Attribute(attr=TypePositions.annotated), - slice=ast.Tuple(elts=[inner, *_]), - ): - annotation = inner - case _: - break - - @staticmethod - def occurrences(node: ast.AST) -> int: - return 1 - - -class SideCounts(Measure): - """Findings counted per subject on each side, summed over the measure's parts.""" - - @classmethod - def measured(cls, found: dict[type[Side], Counter[str]], changed: set[str]) -> list[str]: - """The subjects compared: every changed file, counting zero where a side has none.""" - return sorted(changed) - - @classmethod - def combine(cls, results: list, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Counts, Counts]: - found: dict[type[Side], Counter[str]] = {side: Counter() for side in Side.sides()} - for side, counted in results: - found[side] += counted - subjects = cls.measured(found, changed) - def counts(side: type[Side]) -> Counts: - return {cls.at(subject): found[side][subject] for subject in subjects} - return counts(BaseSide), counts(HeadSide) - - -class CheckedRevision(SideCounts): - """A checker's findings per changed file. Both revisions are checked under the head's - configuration, so the difference is the code's alone. A path missing at one revision counts - zero there, as in every per-file measure.""" - - finding: typing.ClassVar[re.Pattern[str]] # One output line per finding; group 1 is its path. - - @staticmethod - @abstractmethod - def command(tree: Path, selected: list[str], side: type[Side]) -> list[str]: - """The checker's arguments after `python -m`, run in the revision extracted to the tree.""" - - @staticmethod - def batches(selected: list[str]) -> list[list[str]]: - """The files checked by one run of the checker.""" - return [selected] - - @staticmethod - def extract(repo: Path, ref: str, scratch: Path, selected: list[str]) -> None: - """Write the revision's files the checker reads.""" - subprocess.run(["tar", "-x", "-C", scratch], input=git(repo, "archive", ref), check=True, - timeout=60) - - @staticmethod - def configuration(repo: Path, ref: str) -> str: - """The revision's checker configuration blob; a revision without one has none.""" - listed = git(repo, "ls-tree", "--name-only", ref, "--", "pyproject.toml") - return git(repo, "rev-parse", f"{ref}:pyproject.toml").decode().strip() if listed else "" - - @classmethod - def inputs(cls, repo: Path, ref: str, head: str, root: str, selected: list[str]) -> str: - """What the findings on the selected files depend on: the revision's whole tree, which - a checker following imports reads, and the head's configuration.""" - tree = git(repo, "rev-parse", f"{ref}^{{tree}}").decode().strip() - return f"{tree} {cls.configuration(repo, head)}" - - @classmethod - def findings(cls, repo: Path, ref: str, head: str, root: str, selected: list[str], - side: type[Side]) -> tuple[type[Side], Counter[str]]: - return side, ContentCache.memo( - partial(cls.checked, repo, ref, head, root, selected, side), - cls.__name__, root, cls.inputs(repo, ref, head, root, selected), *selected) - - @classmethod - def checked(cls, repo: Path, ref: str, head: str, root: str, selected: list[str], - side: type[Side]) -> Counter[str]: - # A missing checker exits 1 like a finding run, so importing it first fails loud instead. - importlib.import_module(cls.command(Path(), [], side)[0]) - with tempfile.TemporaryDirectory(prefix="debt-ratchet-check-") as scratch: - tree = Path(scratch) - cls.extract(repo, ref, tree, selected) - configuration = tree / "pyproject.toml" - configuration.unlink(missing_ok=True) - if git(repo, "ls-tree", "--name-only", head, "--", "pyproject.toml"): - configuration.write_bytes(git(repo, "show", f"{head}:pyproject.toml")) - result = SpawnedProcess.run( - (sys.executable, "-m", *cls.command(tree, selected, side)), - cwd=tree, env=cls.environment(root, tree), timeout=600, - ) - # A checker reports each file under the path it was given, inside the tree or relative to - # it; mypy also reports the modules it follows outside the tree, which are not measured. - output = cls.checker_output(result, ref) - reported = (Path(tree, match[1]) for match in cls.finding.finditer(output)) - return Counter(path.relative_to(tree).as_posix() - for path in reported if path.is_relative_to(tree)) - - @classmethod - def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: - return tuple(partial(cls.findings, repo, ref, head, root, batch, side) - for side in Side.sides() for ref in (side.ref(base, head),) - for batch in cls.batches(sorted(changed & python_paths(repo, ref, root))) - if batch) - - -class Advisory(Measure): - """Growth that restates no fact, such as a lint convention or layout: the ratchet warns - on the file and never fails on it. Only measures of restated facts block a merge.""" - - @classmethod - def blocks(cls, delta: Counts) -> bool: - return False - - @classmethod - def grew(cls, key: MeasureCount, grown: int) -> str: - return key.warning(f"{cls.__name__} +{grown} (warns, never blocks)") - - -class Unformatted(Advisory, CheckedRevision): - """1 when Black would rewrite the file: a formatted file stays formatted.""" - - finding = re.compile(r"^would reformat (.+)$", re.MULTILINE) - - @staticmethod - def black(*arguments: str) -> list[str]: - """Black's arguments after `python -m`. Every Black run here is already one of the - ratchet's pool workers, so Black starts no process pool of its own.""" - return ["black", "--workers", "1", *arguments] - - @staticmethod - def batches(selected: list[str]) -> list[list[str]]: - # One run per pool worker: a Black start costs more than checking a file. - shards = os.process_cpu_count() or 1 - return [selected[shard::shards] for shard in range(shards)] - - @staticmethod - def extract(repo: Path, ref: str, scratch: Path, selected: list[str]) -> None: - # Black reads only the file and the configuration. - for path, source in GitMeasure.sources(repo, ref, set(selected)): - (scratch / path).parent.mkdir(parents=True, exist_ok=True) - (scratch / path).write_bytes(source) - - @staticmethod - def command(tree: Path, selected: list[str], side: type[Side]) -> list[str]: - return Unformatted.black("--check", *selected) - - @classmethod - def findings(cls, repo: Path, ref: str, head: str, root: str, selected: list[str], - side: type[Side]) -> tuple[type[Side], Counter[str]]: - """Black reads only the file and the configuration, so each file's finding is stored - under its own blob and one Black run checks the batch's unstored files.""" - configuration = cls.configuration(repo, head) - blobs = GitMeasure.blobs(repo, ref, set(selected)) - parts = {path: (cls.__name__, configuration, path, blobs[path]) for path in selected} - found = {path: typing.cast(int, stored) - for path in selected for stored in ContentCache.stored(*parts[path])} - fresh = [path for path in selected if path not in found] - checked = cls.checked(repo, ref, head, root, fresh, side) if fresh else Counter() - found |= {path: ContentCache.store(checked[path], *parts[path]) for path in fresh} - return side, Counter(found) - - -class LintFindings(Advisory, CheckedRevision): - """Ruff diagnostics under the selected rules.""" - - finding = re.compile(r"^(.+?\.py):\d+:\d+: ", re.MULTILINE) - - @staticmethod - def command(tree: Path, selected: list[str], side: type[Side]) -> list[str]: - return ["ruff", "check", "--no-cache", "--output-format", "concise", *selected] - - -class TypeErrors(CheckedRevision): - """Mypy errors per changed file. Mypy checks the changed files and every module they import, - so a file's count can change when a module it imports does.""" - - finding = re.compile(r"^(.+?\.py):\d+: error: ", re.MULTILINE) - - @staticmethod - def mypy(cache: Path, *arguments: str) -> list[str]: - """Mypy's arguments after `python -m`, keeping its analysis in the cache directory.""" - return ["mypy", "--explicit-package-bases", "--cache-dir", str(cache), "--no-error-summary", - *arguments] - - @staticmethod - def command(tree: Path, selected: list[str], side: type[Side]) -> list[str]: - # Mypy reuses a cached module whose path, size and whole-second mtime match its file, and - # hashes the file otherwise. Every extracted tree has a new path, so each module is checked - # against its content while unchanged analysis carries from one run to the next. Each side - # keeps its own cache: the two revisions run at once, and a base usually matches the - # previous run's base. - cache = ContentCache.directory.with_name("debt-ratchet-mypy") / side.declared_name - return TypeErrors.mypy(cache, *(str(tree / path) for path in selected)) - - @staticmethod - def environment(root: str, tree: Path) -> dict[str, str]: - """Module names start at the source directory, as they do when the package is installed.""" - return {**os.environ, "MYPYPATH": os.pathsep.join(Measure.source_paths(root, tree))} - - -class ClientTypeErrors(SideCounts): - """Mypy errors in each stack client file that imports agent_comms, checked against each - revision's package. Both sides read the same client commit, so the client's own errors cancel - and an error the head adds is a name or signature the client uses that the change took away: - #3110 deleted TurnState.settles as unused while Toad's conversation_turn.py:262 called it, and - only the running app found it.""" - - finding = TypeErrors.finding - - @staticmethod - @cache - def clients(repo: Path, head: str) -> tuple[StackClient, ...]: - """The head's stack clients, cloned once per run and read by both sides; a revision - declaring no stack has none.""" - if not git(repo, "ls-tree", "--name-only", head, "--", "stack/pyproject.toml"): - return () - into = Path(tempfile.mkdtemp(prefix="debt-ratchet-clients-")) - atexit.register(shutil.rmtree, into, True) - (into / "stack").mkdir() - (into / "stack/pyproject.toml").write_bytes(git(repo, "show", f"{head}:stack/pyproject.toml")) - return StackClient.fetched(into / "stack", into) - - @staticmethod - def selected(client: StackClient) -> list[str]: - return sorted({entry.path for entry in client.imports - if entry.module.partition(".")[0] == PACKAGE}) - - @classmethod - def findings(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> tuple[type[Side], Counter[str]]: - commit = git(client.checkout, "rev-parse", "HEAD").decode().strip() - tree = git(repo, "rev-parse", f"{ref}:src").decode().strip() - return side, ContentCache.memo(partial(cls.checked, repo, ref, client, side), - cls.__name__, client.name, commit, tree) - - @classmethod - def checked(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> Counter[str]: - selected = cls.selected(client) - cache = ContentCache.directory.with_name("debt-ratchet-mypy-clients") / client.name / side.declared_name - with tempfile.TemporaryDirectory(prefix="debt-ratchet-client-") as scratch: - subprocess.run(["tar", "-x", "-C", scratch], input=git(repo, "archive", ref, "src"), - check=True, timeout=60) - # The client checks under its own configuration, from its checkout. - result = SpawnedProcess.run( - (sys.executable, "-m", *TypeErrors.mypy( - cache, "--follow-imports=silent", "--ignore-missing-imports", *selected)), - cwd=client.checkout, timeout=600, - env={**os.environ, "MYPYPATH": os.pathsep.join( - (str(Path(scratch, "src")), str(client.checkout / "src")))}, - ) - output = cls.checker_output(result, f"{client.name} at {ref}") - return Counter(f"{client.name}/{match[1]}" for match in cls.finding.finditer(output) - if match[1] in selected) - - @classmethod - def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: - # Only a change to the package the clients import can change what they see. - if not any(path.startswith(f"src/{PACKAGE}/") for path in changed): - return () - return tuple(partial(cls.findings, repo, side.ref(base, head), client, side) - for client in cls.clients(repo, head) - if cls.selected(client) - for side in Side.sides()) - - @classmethod - def measured(cls, found: dict[type[Side], Counter[str]], changed: set[str]) -> list[str]: - """The client files with an error on either side; changed paths are the package's.""" - return sorted(found[BaseSide].keys() | found[HeadSide].keys()) - - @classmethod - def at(cls, path: str) -> ClientFileCount: - return ClientFileCount(cls, path) - - -@dataclass(frozen=True) -class ChangedSource: - """One head module, its base revision, and the new-side lines changed by its Git patch. - A path missing at the base is empty there, as in every per-file measure.""" - - path: str - source: bytes - lines: frozenset[int] - base_source: bytes - - @cached_property - def tree(self) -> ast.Module: - return CountedSource.tree(self.source, self.path) - - @cached_property - def base_tree(self) -> ast.Module: - return CountedSource.tree(self.base_source, self.path) - - @cached_property - def tokens(self) -> tuple[tokenize.TokenInfo, ...]: - return tuple(tokenize.tokenize(io.BytesIO(self.source).readline)) - - @classmethod - def read(cls, repo: Path, base: str, head: str, path: str) -> ChangedSource: - patch = git(repo, "diff", "--no-renames", "--no-ext-diff", "--no-textconv", - "--no-color", "--unified=0", "--inter-hunk-context=0", base, head, "--", path) - lines: set[int] = set() - for header in patch.decode().splitlines(): - if header.startswith("@@ "): - # Git owns this hunk header, not the inspected Python source. - span = header.split()[2].removeprefix("+").split(",") - start = int(span[0]) - count = int(span[1]) if len(span) == 2 else 1 - lines.update(range(start, start + count)) - listed = git(repo, "ls-tree", "--name-only", base, "--", path).strip() - base_source = git(repo, "show", f"{base}:{path}") if listed else b"" - return cls(path, git(repo, "show", f"{head}:{path}"), frozenset(lines), base_source) - - @classmethod - def departed(cls, repo: Path, base: str, head: str, path: str) -> ChangedSource: - """A changed module present at the base, read for what left it; a module the head - deleted is empty there.""" - listed = git(repo, "ls-tree", "--name-only", head, "--", path).strip() - source = git(repo, "show", f"{head}:{path}") if listed else b"" - return cls(path, source, frozenset(), git(repo, "show", f"{base}:{path}")) - - -class ScopeNames(ast.NodeVisitor): - """Each node's enclosing class and function names, dotted, through the AST's own dispatch.""" - - def __init__(self) -> None: - self.found: dict[ast.AST, str] = {} - self.scope = "" - - def generic_visit(self, node: ast.AST) -> None: - self.found[node] = self.scope - super().generic_visit(node) - - def visit_named(self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> None: - self.found[node] = outer = self.scope - self.scope = f"{outer}.{node.name}" - super().generic_visit(node) - self.scope = outer - - def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - self.visit_named(node) - - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - self.visit_named(node) - - def visit_ClassDef(self, node: ast.ClassDef) -> None: - self.visit_named(node) - - -class CleanChangedLines(OccurrenceMeasure): - """No occurrence may be written, even when another occurrence is deleted. - - An occurrence is identified by its enclosing scope and its source text, so rewriting - the rest of a line around an unchanged occurrence writes nothing new, while a different - occurrence elsewhere never pays for a new one. Scopes are class and function names, not - modules, so an occurrence that leaves one changed module and arrives in another, as a - class moved between files, is the same occurrence moved, not a new one written. - """ - - @staticmethod - def introduced( - repo: Path, base: str, head: str, paths: tuple[str, ...], departed_from: tuple[str, ...], - ) -> dict[FileCount, list[int]]: - """Each strict measure's sites on each path's changed lines. Occurrences that left the - changed modules in ``departed_from`` (those present at the base) pay for the same - occurrences arriving elsewhere.""" - sources = [ChangedSource.read(repo, base, head, path) for path in paths] - left = [ChangedSource.departed(repo, base, head, path) for path in departed_from] - found: dict[FileCount, list[int]] = {} - for measure in Measure.members_with(CleanChangedLines): - departed: Counter[tuple[str, str]] = Counter() - for source in left: - gone = measure.identities(source.base_tree, source.path) - gone.subtract(measure.identities(source.tree, source.path)) - departed.update(+gone) - for source in sources: - found[measure.at(source.path)] = measure.changed_sites(source, departed) - return found - - @staticmethod - def scopes(tree: ast.AST) -> dict[ast.AST, str]: - """Each node's enclosing class and function names, dotted; the module is empty.""" - names = ScopeNames() - names.visit(tree) - return names.found - - @classmethod - def identities(cls, tree: ast.AST, filename: str) -> Counter[tuple[str, str]]: - """Every occurrence in the module, keyed by its scope and source text.""" - scopes = cls.scopes(tree) - found: Counter[tuple[str, str]] = Counter() - for node in cls.sites(tree, filename): - if count := cls.occurrences(node): - found[scopes[node], ast.unparse(node)] += count - return found - - @classmethod - def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]]) -> list[int]: - """The first changed line of each occurrence written there that the base lacks and no - changed module gave up; a moved occurrence spends the departure it came from.""" - scopes = cls.scopes(source.tree) - unwritten = cls.identities(source.tree, source.path) - unwritten.subtract(cls.identities(source.base_tree, source.path)) - sites = [] - for node in cls.sites(source.tree, source.path): - if ((count := cls.occurrences(node)) - and (touched := source.lines.intersection(cls.position.lines(node)))): - identity = scopes[node], ast.unparse(node) - arrived = max(0, min(count, unwritten[identity])) - unwritten[identity] -= arrived - moved = min(arrived, departed[identity]) - departed[identity] -= moved - sites.extend([min(touched)] * (arrived - moved)) - return sites - - -class OwnInstanceWrites(ast.NodeVisitor): - """A method body's object.__setattr__ calls on its own instance; nested scopes own theirs.""" - - def __init__(self, instance: str) -> None: - self.instance = instance - self.found: list[ast.Call] = [] - - def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - """A nested scope's writes are its own.""" - - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - """A nested scope's writes are its own.""" - - def visit_Lambda(self, node: ast.Lambda) -> None: - """A nested scope's writes are its own.""" - - def visit_ClassDef(self, node: ast.ClassDef) -> None: - """A nested scope's writes are its own.""" - - def visit_Call(self, node: ast.Call) -> None: - written = [*map(ast.unparse, node.args[:1])] - if FrozenFieldWrite.occurrences(node) and written == [self.instance]: - self.found.append(node) - self.generic_visit(node) - - -class FrozenFieldWrite(CleanChangedLines, PerFileOccurrenceMeasure): - """object.__setattr__ writes past a frozen class; only the class's own __post_init__ may - write its own instance, because there the instance is still being constructed.""" - - construction: typing.ClassVar[str] = "__post_init__" - - @classmethod - def sites(cls, tree: ast.AST, filename: str) -> Iterator[ast.AST]: - constructing = {id(write) for node in ast.walk(tree) for write in cls.constructing(node)} - return (node for node in ast.walk(tree) if id(node) not in constructing) - - @classmethod - def constructing(cls, node: ast.AST) -> Iterator[ast.Call]: - match node: - case ast.ClassDef(body=members): - for member in members: - match member: - case ast.FunctionDef(name=FrozenFieldWrite.construction, - args=ast.arguments(args=[instance, *_]), body=body): - writes = OwnInstanceWrites(instance.arg) - for statement in body: - writes.visit(statement) - yield from writes.found - - @staticmethod - def occurrences(node: ast.AST) -> int: - match node: - case ast.Call(func=ast.Attribute(value=ast.Name(id="object"), attr="__setattr__")): - return 1 - return 0 - - -class BannedVocabulary(CleanChangedLines, PerFileOccurrenceMeasure): - words: typing.ClassVar[tuple[str, ...]] = ("admission", "admit", "custody", "receipt") - - @classmethod - def occurrences(cls, node: ast.AST) -> int: - match node: - case (ast.Name(id=name) | ast.Attribute(attr=name) - | ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name) - | ast.ClassDef(name=name) | ast.arg(arg=name) - | ast.keyword(arg=str(name))): - return int(any(word in name.casefold() for word in cls.words)) - return 0 - - position = NamePosition() - - @classmethod - def prose_lines(cls, source: ChangedSource) -> frozenset[int]: - docstrings: set[int] = set() - for node in ast.walk(source.tree): - if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - match node.body: - case [ast.Expr(value=ast.Constant(value=str()) as value), *_]: - docstrings.update(range(value.lineno, value.end_lineno + 1)) - found: set[int] = set() - for token in source.tokens: - if token.type == tokenize.COMMENT or ( - token.type == tokenize.STRING and token.start[0] in docstrings - ): - for line, text in enumerate(token.string.splitlines(), token.start[0]): - if any(word in text.casefold() for word in cls.words): - found.add(line) - return frozenset(found) - - @classmethod - def lines(cls, source: bytes, filename: str) -> list[int]: - module = ChangedSource(filename, source, frozenset(), b"") - return sorted([*super().lines(source, filename), *cls.prose_lines(module)]) - - @classmethod - def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]]) -> list[int]: - prose = cls.prose_lines(source) & source.lines - return [*super().changed_sites(source, departed), *prose] - - -@dataclass(frozen=True) -class DecoratorBinding: - """The spellings one module binds to a declared decorator, read from its imports alone. - - A decorator of the same name declared or imported from anywhere else marks nothing. - """ - - names: frozenset[str] - - @classmethod - def of(cls, decorator: typing.Callable[..., object], tree: ast.AST, - filename: str) -> DecoratorBinding: - owner, name = decorator.__module__, decorator.__name__ - parts = Path(filename).with_suffix("").parts - module = parts[1:] if parts[:1] == ("src",) else parts - package = module[:-1] - names = {name} if ".".join(module) == owner else set() - for node in ast.walk(tree): - match node: - case ast.Import(names=aliases): - names.update(f"{alias.asname or alias.name}.{name}" - for alias in aliases if alias.name == owner) - case ast.ImportFrom(module=source, names=aliases, level=level): - base = package[:len(package) - level + 1] if level else () - resolved = ".".join((*base, source) if source else base) - for alias in aliases: - if f"{resolved}.{alias.name}" == owner: - names.add(f"{alias.asname or alias.name}.{name}") - if resolved == owner and alias.name == name: - names.add(alias.asname or name) - return cls(frozenset(names)) - - def marks(self, decorator: ast.expr) -> bool: - return ast.unparse(decorator) in self.names - - -class NoneSpelling(MroDispatch): - """One node's spellings of absence as None, never evaluated code.""" - - def __init__(self) -> None: - self.found = 0 - - @staticmethod - def absent(node: ast.AST) -> bool: - match node: - case ast.Constant(value=None): - return True - return False - - @handles(ast.Compare) - def identity(self, node: ast.Compare) -> None: - left = node.left - for operator, right in zip(node.ops, node.comparators, strict=True): - match operator: - case ast.Is() | ast.IsNot(): - self.found += self.absent(left) or self.absent(right) - left = right - - @handles(ast.BinOp) - def union(self, node: ast.BinOp) -> None: - match node: - case ast.BinOp(op=ast.BitOr(), left=left, right=right): - self.found += self.absent(left) or self.absent(right) - - @handles(ast.Subscript) - def typing_spelling(self, node: ast.Subscript) -> None: - match node.value, node.slice: - case ast.Name(id="Optional") | ast.Attribute(attr="Optional"), _: - self.found += 1 - case ast.Name(id="Union") | ast.Attribute(attr="Union"), ast.Tuple(elts=members): - self.found += any(self.absent(member) for member in members) - - @handles(ast.keyword) - def default(self, node: ast.keyword) -> None: - match node: - case ast.keyword(arg="default", value=value): - self.found += self.absent(value) - - -class NoneAbsence(CleanChangedLines, PerFileOccurrenceMeasure): - """Absence spelled as None: ``is None`` tests, annotations that allow None, ``default=None``. - - Absence belongs to a declared null member that answers for itself. A decoder that - meets None from outside declares itself with ``boundary_decoder``; its body is not counted. - """ - - @classmethod - def sites(cls, tree: ast.AST, filename: str) -> Iterator[ast.AST]: - marker = DecoratorBinding.of(boundary_decoder, tree, filename) - pending = [tree] - while pending: - node = pending.pop() - match node: - case ast.FunctionDef(decorator_list=decorators) | ast.AsyncFunctionDef( - decorator_list=decorators) if any(map(marker.marks, decorators)): - continue - yield node - pending.extend(ast.iter_child_nodes(node)) - - @staticmethod - def occurrences(node: ast.AST) -> int: - spelling = NoneSpelling() - spelling.dispatch_sync(node) - return spelling.found - -class RawRecordShapes(PerFileMeasure): - """Reuse the overlay's raw-shape detector for every production file.""" - - @classmethod - def count(cls, source: bytes, filename: str) -> int: - text = source.decode() - module = AuditModule(filename, text, cls.tree(source, filename), filename) - package = AuditPackage(AuditRepository(Path.cwd()), "", filename, (module,), ()) - return sum(1 for facts in package.functions[filename] - for _finding in RawShape.from_facts(facts, package)) - - -class FunctionLocalImport(PerFileMeasure, LocatedSource): - """Imports executed inside a function, including nested local classes.""" - - class Collector(ast.NodeVisitor): - def __init__(self) -> None: - self.depth = 0 - self.found: list[int] = [] - - def generic_visit(self, node: ast.AST) -> None: - nested = isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - self.depth += nested - if self.depth and isinstance(node, (ast.Import, ast.ImportFrom)): - self.found.append(node.lineno) - super().generic_visit(node) - self.depth -= nested - - @classmethod - def lines(cls, source: bytes, filename: str) -> list[int]: - collector = cls.Collector() - collector.visit(cls.tree(source, filename)) - return collector.found - - -class EnumDeclaration(PerFileMeasure, LocatedSource): - """Classes built on the standard enum types: a family whose members cannot own their - behaviour, so every case is decided by a switch outside it.""" - - bases: typing.ClassVar[frozenset[str]] = frozenset( - name for name, value in vars(enum).items() - if isinstance(value, type) and issubclass(value, enum.Enum)) - - @classmethod - def lines(cls, source: bytes, filename: str) -> list[int]: - return [node.lineno for node in ast.walk(cls.tree(source, filename)) - if isinstance(node, ast.ClassDef) - and any(ast.unparse(base).rpartition(".")[2] in cls.bases for base in node.bases)] - - -class IdentityReading: - """How one literal string stands for an identity instead of a typed reference.""" - - -class NamedDeclaration(IdentityReading): - """The literal spells a class, module or function this repository declares.""" - - -class ComparedKind(IdentityReading): - """The literal is one arm of a kind decided by comparing spellings.""" - - -class NamedAccess(IdentityReading): - """The literal names the attribute, module or family member a call reaches by name.""" - - -@dataclass(frozen=True) -class IdentitySite: - line: int - spelling: str - reading: type[IdentityReading] - target: str - - -@dataclass(frozen=True) -class ModuleDeclarations: - """One module's top-level multi-word function names and its classes with their base names.""" - - functions: tuple[str, ...] - classes: tuple[tuple[str, tuple[str, ...]], ...] - - -class DeclaredPositions(MroDispatch): - """Where a literal is used as a declaration's name, each position declared by its node.""" - - def __init__(self) -> None: - self.found: list[ast.expr] = [] - self.excluded: list[ast.expr] = [] - - @staticmethod - def called(node: ast.expr) -> str: - return ast.unparse(node).rpartition(".")[2] - - @classmethod - def literals(cls, tree: ast.AST) -> Iterator[ast.AST]: - """The nodes inside a position, so a name spelled there names its declaration.""" - positions = cls() - for node in ast.walk(tree): - positions.dispatch_sync(node) - excluded = {id(part) for expression in positions.excluded for part in ast.walk(expression)} - return (part for expression in positions.found for part in ast.walk(expression) - if id(part) not in excluded) - - -class TypePositions(DeclaredPositions): - """The expressions where a type is expected, so a one-word class name there names it.""" - - type_variable: typing.ClassVar[str] = typing.cast(type, typing.TypeVar).__name__ - annotated: typing.ClassVar[str] = typing.cast(type, Annotated).__name__ - - @handles(ast.FunctionDef, ast.AsyncFunctionDef) - def signature(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: - arguments = node.args - self.found.extend(filter(None, (node.returns, *(argument.annotation for argument in ( - *arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs, - *filter(None, (arguments.vararg, arguments.kwarg))))))) - - @handles(ast.AnnAssign) - def annotation(self, node: ast.AnnAssign) -> None: - self.found.append(node.annotation) - - @handles(ast.ClassDef) - def bases(self, node: ast.ClassDef) -> None: - self.found.extend(node.bases) - - @handles(ast.Call) - def type_variable_bound(self, node: ast.Call) -> None: - if self.called(node.func) == self.type_variable: - self.found.extend((*node.args[1:], *(keyword.value for keyword in node.keywords))) - - @handles(ast.Subscript) - def annotated_metadata(self, node: ast.Subscript) -> None: - """Annotated[T, ...] declares T; what follows it is metadata, not a type.""" - if self.called(node.value) == self.annotated: - self.excluded.extend(typing.cast(ast.Tuple, node.slice).elts[1:]) - - -class ModulePositions(DeclaredPositions): - """Where a string selects a module: an importer's argument, a key of the module table or - the module an interpreter runs. Anywhere else a module's name is a word, a table or a path - that happens to share it.""" - - importers: typing.ClassVar[frozenset[str]] = frozenset( - call.__name__ for call in (importlib.import_module, __import__, importlib.util.find_spec, - importlib.resources.files)) - # CPython's command-line option for running a module by name: another program's contract. - run_module: typing.ClassVar[str] = ast.dump(ast.Constant("-m")) - module_table: typing.ClassVar[str] = ".".join((sys.__name__, next( - name for name, value in vars(sys).items() if value is sys.modules))) - - @handles(ast.Call) - def imported(self, node: ast.Call) -> None: - if self.called(node.func) in self.importers: - self.found.extend(node.args[:1]) - - @handles(ast.Call) - def table_method(self, node: ast.Call) -> None: - if ast.unparse(node.func).rpartition(".")[0] == self.module_table: - self.found.extend(node.args[:1]) - - @handles(ast.Subscript) - def table_entry(self, node: ast.Subscript) -> None: - if ast.unparse(node.value) == self.module_table: - self.found.append(node.slice) - - @handles(ast.Compare) - def table_membership(self, node: ast.Compare) -> None: - if self.module_table in map(ast.unparse, node.comparators): - self.found.append(node.left) - - @handles(ast.Tuple, ast.List) - def interpreter_module(self, node: ast.Tuple | ast.List) -> None: - self.found.extend(argument for option, argument in itertools.pairwise(node.elts) - if ast.dump(option) == self.run_module) - - -@dataclass(frozen=True) -class DeclaredSpellings: - """Every spelling a revision's production Python declares as a name. - - Classes count under their own name, src modules under their dotted path, and modules and - top-level functions under a multi-word name; a one-word module or function name is also an - ordinary word, so a literal equal to it is not taken as naming it. A one-word class name - (Read, Open, Claimed) is an ordinary word too, so a literal equal to it names the class - only where a type is expected: an annotation, a return, a class base or a type variable's - bound. A module's name names it only where a string selects a module: an importer's - argument or a key of ``sys.modules``; elsewhere it is a word, a table or a path that shares - the spelling. Enum members are the declared boundary spellings of another program's - contract, so literals in an enum's body are the one place such a spelling may appear. - """ - - names: dict[str, str] - enums: frozenset[str] - unmistakable: dict[str, str] - """The names a literal names wherever it appears: all but one-word classes and modules.""" - modules: dict[str, str] - - boundary_enums: typing.ClassVar[frozenset[str]] = frozenset( - kind.__name__ for kind in (enum.Enum, enum.StrEnum, enum.IntEnum, enum.Flag, enum.IntFlag)) - named_calls: typing.ClassVar[frozenset[str]] = frozenset( - call.__name__ for call in (getattr, setattr, hasattr, delattr, __import__, - importlib.import_module)) - selecting_calls: typing.ClassVar[frozenset[str]] = frozenset( - call.__name__ for call in (DeclaredFamily.member_named,)) - kind_spelling: typing.ClassVar[re.Pattern[str]] = re.compile(r"[A-Za-z_][\w.-]*") - class_word: typing.ClassVar[re.Pattern[str]] = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z0-9]+") - package_marker: typing.ClassVar[str] = Path(importlib.__file__).name - entry_guard: typing.ClassVar[str] = ast.dump(ast.parse('__name__ == "__main__"', mode="eval").body) - - @staticmethod - def multiword(name: str) -> bool: - return name.strip("_").count("_") > 0 - - @classmethod - def one_word_class(cls, name: str) -> bool: - return len(cls.class_word.findall(name)) == 1 - - @staticmethod - def dotted_name(module: Path, packages: set[Path]) -> str: - """The import name of a module inside a package, from the package directories above it.""" - top = module.parent - if top not in packages: - return "" - while top.parent in packages: - top = top.parent - parts = module.relative_to(top.parent).parts - return ".".join(parts[:-1] if module.name == Path(DeclaredSpellings.package_marker).stem else parts) - - @classmethod - def import_names(cls, paths: typing.Collection[str]) -> dict[str, str]: - """Each path's dotted import name inside a package; empty outside every package.""" - packages = {Path(path).parent for path in paths if Path(path).name == cls.package_marker} - return {path: cls.dotted_name(Path(path).with_suffix(""), packages) for path in paths} - - @classmethod - def declarations(cls, path: str, source: bytes) -> ModuleDeclarations: - tree = ast.parse(source, path) - functions = (node.name for node in tree.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))) - classes = (node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)) - return ModuleDeclarations( - tuple(filter(cls.multiword, functions)), - tuple((node.name, tuple(base.id if isinstance(base, ast.Name) else base.attr - for base in node.bases - if isinstance(base, (ast.Name, ast.Attribute)))) - for node in classes)) - - @classmethod - def read(cls, modules: dict[str, ModuleDeclarations]) -> DeclaredSpellings: - """The spellings declared by the modules, taken in the dictionary's order.""" - names: dict[str, str] = {} - bases: dict[str, set[str]] = {} - dotted_names = cls.import_names(modules) - for path, declared in modules.items(): - module = Path(path).with_suffix("") - if cls.multiword(module.stem): - names[module.stem] = f"module {path}" - if dotted := dotted_names[path]: - names[dotted] = f"module {path}" - for function in declared.functions: - names.setdefault(function, f"function {path}:{function}") - for name, parents in declared.classes: - names[name] = f"class {path}:{name}" - bases.setdefault(name, set()).update(parents) - enums = set(cls.boundary_enums) - while grown := {name for name, parents in bases.items() if parents & enums} - enums: - enums |= grown - ordinary = set(filter(cls.one_word_class, ( - name for name, target in names.items() if target.startswith("class ")))) - functions = {function for declared in modules.values() for function in declared.functions} - selected = {name: target for name, target in names.items() - if target.startswith("module ") and name not in functions} - positioned = ordinary | selected.keys() - return cls(names, frozenset(enums), - {name: target for name, target in names.items() if name not in positioned}, - selected) - - def exempt(self, tree: ast.AST) -> set[int]: - """Literals no reader takes as identity: prose, interpolation pieces, class-header - options, enum boundary declarations and the interpreter's entry-point guard.""" - exempt: set[int] = set() - for node in ast.walk(tree): - match node: - case ast.Module(body=[ast.Expr(value=ast.Constant() as prose), *_]) | ast.ClassDef( - body=[ast.Expr(value=ast.Constant() as prose), *_]) | ast.FunctionDef( - body=[ast.Expr(value=ast.Constant() as prose), *_]) | ast.AsyncFunctionDef( - body=[ast.Expr(value=ast.Constant() as prose), *_]): - exempt.add(id(prose)) - match node: - case ast.JoinedStr(): - exempt.update(map(id, ast.walk(node))) - case ast.ClassDef(name=name) if name in self.enums: - exempt.update(map(id, ast.walk(node))) - case ast.ClassDef(keywords=keywords): - exempt.update(id(part) for keyword in keywords for part in ast.walk(keyword)) - case ast.Compare() if ast.dump(node) == self.entry_guard: - exempt.update(map(id, ast.walk(node))) - return exempt - - def sites(self, source: bytes, filename: str) -> list[IdentitySite]: - tree = ast.parse(source, filename) - exempt = self.exempt(tree) - found: dict[int, IdentitySite] = {} - - def record(literal: ast.AST, reading: type[IdentityReading], target: str) -> None: - if (isinstance(literal, ast.Constant) and isinstance(literal.value, str) - and id(literal) not in exempt): - found.setdefault(id(literal), IdentitySite(literal.lineno, literal.value, reading, target)) - - for node in ast.walk(tree): - match node: - case ast.Call(func=ast.Name(id=call) | ast.Attribute(attr=call), args=arguments): - if call in self.named_calls: - position = int(call not in {__import__.__name__, importlib.import_module.__name__}) - for argument in arguments[position:position + 1]: - record(argument, NamedAccess, f"{call} by name") - if call in self.selecting_calls: - for argument in arguments: - record(argument, NamedAccess, f"family member selected by {call}") - case ast.Compare(left=left, ops=operators, comparators=comparators): - for operator, right in zip(operators, comparators, strict=True): - match operator, right: - case ast.Eq() | ast.NotEq(), _: - for side in (left, right): - if self.kind(side): - record(side, ComparedKind, "kind compared by spelling") - case ast.In() | ast.NotIn(), ast.Set(elts=items) | ast.Tuple( - elts=items) | ast.List(elts=items): - for item in filter(self.kind, items): - record(item, ComparedKind, "kind compared by spelling") - left = right - case ast.MatchValue(value=value) if self.kind(value): - record(value, ComparedKind, "kind matched by spelling") - for spellings, nodes in ((self.unmistakable, ast.walk(tree)), - (self.names, TypePositions.literals(tree)), - (self.modules, ModulePositions.literals(tree))): - for node in nodes: - if (isinstance(node, ast.Constant) and isinstance(node.value, str) - and node.value in spellings): - record(node, NamedDeclaration, spellings[node.value]) - return sorted(found.values(), key=lambda site: site.line) - - def kind(self, node: ast.AST) -> bool: - return (isinstance(node, ast.Constant) and isinstance(node.value, str) - and self.kind_spelling.fullmatch(node.value) is not None) - - -class PytestTestPaths: - """Where pytest's configuration declares the test tree; pytest owns these spellings.""" - - file = "pyproject.toml" - table = ("tool", "pytest", "ini_options") - key = "testpaths" - - @classmethod - def at(cls, repo: Path, ref: str) -> tuple[str, ...]: - """A revision without the configuration file declares no test tree.""" - if not git(repo, "ls-tree", "--name-only", ref, "--", cls.file).strip(): - return () - declared = tomllib.loads(git(repo, "show", f"{ref}:{cls.file}").decode()) - for section in cls.table: - declared = declared.get(section, {}) - return tuple(declared.get(cls.key, ())) - - -@cache -def production_paths(repo: Path, ref: str) -> frozenset[str]: - """Every Python path the revision tracks outside its declared test tree.""" - tests = PytestTestPaths.at(repo, ref) - return frozenset(path for path in python_paths(repo, ref, ".") - if not any(Path(path).is_relative_to(tree) for tree in tests)) - - -class StringIdentity(InventoryMeasure[ModuleDeclarations]): - """Literal strings standing for something the code declares, per production file. - - A class, module or function named by its spelling, a kind decided by comparing spellings, - and an attribute, module or family member reached by a literal name all bind by text, so a - rename or a second writer of the same spelling breaks them silently. The spellings and the - measured files are every production Python path, not only --root: the target of a spelling - and its use can sit in any tree, as tools/cutover's step predecessors did. - """ - - @classmethod - def inventory(cls, repo: Path, ref: str, root: str) -> frozenset[str]: - return production_paths(repo, ref) - - reading = DeclaredSpellings.declarations - - @classmethod - def measured(cls, repo: Path, ref: str, base: str, head: str, - readings: dict[str, ModuleDeclarations], root: str) -> Counts: - spellings = DeclaredSpellings.read(readings) - selected = readings.keys() & ChangedPaths.of(repo, base, head).touched - return {cls.at(path): len(spellings.sites(source, path)) - for path, source in GitMeasure.sources(repo, ref, selected)} - - align = PerFileMeasure.align - - -class ForeignFieldSpellings(PerFileOccurrenceMeasure): - """Literal identifier-spelling rosters used as loop inputs.""" - - @staticmethod - def occurrences(node: ast.AST) -> int: - match node: - case (ast.For(iter=ast.Tuple(elts=items)) - | ast.AsyncFor(iter=ast.Tuple(elts=items)) - | ast.comprehension(iter=ast.Tuple(elts=items))): - return int(len(items) > 1 and all( - isinstance(item, ast.Constant) and isinstance(item.value, str) - and item.value.isidentifier() for item in items - )) - return 0 - - position = RosterPosition() - - -class BroadExceptDefault(PerFileOccurrenceMeasure): - broad_names: typing.ClassVar[frozenset[str]] = frozenset({"Exception", "BaseException"}) - - @classmethod - def broad(cls, node: ast.AST | None, *, names: frozenset[str] | None = None) -> bool: - names = cls.broad_names if names is None else names - match node: - case None: - return True - case ast.Name(id=name) | ast.Attribute(attr=name): - return name in names - case ast.Tuple(elts=items): - return any(cls.broad(item, names=names) for item in items) - return False - - @staticmethod - def default(node: ast.AST | None) -> bool: - match node: - case None | ast.Constant(value=None | False | 0 | ""): - return True - case ast.List(elts=[]) | ast.Tuple(elts=[]) | ast.Set(elts=[]) | ast.Dict(keys=[]): - return True - return False - - @classmethod - def returns_default(cls, node: ast.AST) -> bool: - match node: - case ast.Return(value=value): - return cls.default(value) - case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef() | ast.Lambda(): - return False - return any(cls.returns_default(child) for child in ast.iter_child_nodes(node)) - - @classmethod - def occurrences(cls, node: ast.AST) -> int: - return int(isinstance(node, ast.ExceptHandler) and cls.broad(node.type) - and any(cls.returns_default(statement) for statement in node.body)) - - -class BroadSwallow(CleanChangedLines, BroadExceptDefault): - """A broad handler without a raise hides the failure, whatever it returns.""" - - broad_names = frozenset({ - "Exception", "BaseException", "ValueError", "RuntimeError", "OSError", "TypeError", - "KeyError", "AttributeError", "LookupError", - }) - - suppressed_names = broad_names | {"ConnectionError", "TimeoutError"} - - @classmethod - def suppresses(cls, node: ast.AST) -> bool: - match node: - case ast.Call(func=ast.Name(id="suppress") | ast.Attribute(attr="suppress"), args=arguments): - return any(cls.broad(argument, names=cls.suppressed_names) for argument in arguments) - return False - - @staticmethod - def carries(handler: ast.ExceptHandler) -> bool: - """Keep a typed outcome or hand the original failure to a reporting owner. - - A reporting method must run unconditionally and cannot turn the error into - a returned default. Formatting the exception for a log is not this transfer. - """ - match handler: - case ast.ExceptHandler(name=str(bound)): - constructed = any( - isinstance(name, ast.Name) and name.id == bound - for child in ast.walk(handler) - for argument in BroadSwallow.constructor_arguments(child) - for name in ast.walk(argument)) - returns_value = any(BroadSwallow.returns_replacement(node) - for node in ast.walk(handler)) - reported = any( - isinstance(argument, ast.Name) and argument.id == bound - for statement in handler.body - if isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call) - and isinstance(statement.value.func, ast.Attribute) - for argument in (*statement.value.args, - *(keyword.value for keyword in statement.value.keywords))) - return constructed or (reported and not returns_value) - return False - - @staticmethod - def returns_replacement(node: ast.AST) -> bool: - """A bare return ends a notification; a returned value replaces the failure.""" - match node: - case ast.Return(value=None | ast.Constant(value=None)): - return False - case ast.Return(): - return True - return False - - @staticmethod - def constructor_arguments(node: ast.AST) -> list[ast.expr]: - """The arguments of a call to a capitalized name: a class constructor.""" - match node: - case ast.Call(func=ast.Name(id=callee) | ast.Attribute(attr=callee), - args=args, keywords=keywords) if callee[:1].isupper(): - return [*args, *(keyword.value for keyword in keywords)] - return [] - - @classmethod - def occurrences(cls, node: ast.AST) -> int: - match node: - case ast.With(items=items) | ast.AsyncWith(items=items): - return sum(cls.suppresses(item.context_expr) for item in items) - case ast.ExceptHandler(type=error): - return int(cls.broad(error) and not cls.carries(node) and not any( - isinstance(child, ast.Raise) for child in ast.walk(node))) - return 0 - - -class FamilyFlattened(PerFileMeasure, ast.NodeTransformer): - """Keep audit behavior checks, excluding owned identity and exposing codec detours.""" - - def __init__(self) -> None: - self.owners: list[str] = [] - self.round_trips = 0 - - def visit_ClassDef(self, node: ast.ClassDef): - self.owners.append(node.name) - try: - return self.generic_visit(node) - finally: - self.owners.pop() - - def visit_Attribute(self, node: ast.Attribute): - if (node.attr == "declared_name" and isinstance(node.value, ast.Name) - and node.value.id in {"cls", "self", *self.owners[-1:]}): - return ast.copy_location(ast.Constant(value=None), node) - return self.generic_visit(node) - - def visit_Module(self, node: ast.Module) -> ast.AST: - # `python -m` runs a module through this guard; it names the interpreter, not a family. - for statement in node.body: - match statement: - case ast.If(test=ast.Compare(left=ast.Name(id="__name__"), ops=[ast.Eq()], - comparators=[ast.Constant(value="__main__")])): - statement.test = ast.copy_location(ast.Constant(value=None), statement.test) - return self.generic_visit(node) - - def visit_Call(self, node: ast.Call): - match node: - case ast.Call( - func=ast.Attribute(value=ast.Name(id="FieldCodec"), attr="decode"), - args=[ast.Name(id="str"), ast.Call( - func=ast.Attribute(value=ast.Name(id="FieldCodec"), attr="encode"), - args=[_], keywords=[], - )], keywords=[], - ): - self.round_trips += 1 - return ast.copy_location(ast.Constant(value=None), node) - return self.generic_visit(node) - - # GitMeasure supplies complete repository-relative paths. Only those exact - # original modules own codec/schema behavior; nested package tails do not. Our own - # modules are named by their files; Toad's live in another repository, named by path. - mechanism_modules = frozenset(( - *(Path("src", Path(inspect.getfile(module)).relative_to( - Path(inspect.getfile(agent_comms)).parents[1])) - for module in (field_codec, typed_table, coordination_schema)), - *(Path("src", "toad", module) for module in ("agent_schema.py", "db.py")), - )) - - @classmethod - def count(cls, source: bytes, filename: str) -> int: - if Path(filename) in cls.mechanism_modules: - return 0 - projection = cls() - tree = projection.visit(ast.parse(source, filename)) - return AuditFamilyFlattened.count(tree) + projection.round_trips - - -class MatchArms: - """The patterns each arm of a ``match`` tests: ``case A() | B() as x:`` tests two.""" - - @classmethod - def alternatives(cls, pattern: ast.pattern) -> Iterator[ast.pattern]: - match pattern: - case ast.MatchOr(patterns=patterns): - for alternative in patterns: - yield from cls.alternatives(alternative) - case ast.MatchAs(pattern=ast.pattern() as inner): - yield from cls.alternatives(inner) - case _: - yield pattern - - @classmethod - def arms(cls, node: ast.Match) -> Iterator[ast.pattern]: - for case in node.cases: - yield from cls.alternatives(case.pattern) - - -class DispatchCases(MroDispatch): - """Collect cases of one external Python AST without executing its source.""" - - minimum_arms = 3 - - def __init__(self) -> None: - self.cases: dict[str, set[str]] = {} - - def add(self, subject: ast.AST, case: str) -> None: - self.cases.setdefault(ast.dump(subject), set()).add(case) - - def groups(self) -> tuple[frozenset[str], ...]: - return tuple(frozenset(cases) for cases in self.cases.values() - if len(cases) >= self.minimum_arms) - - def read_function(self, function: ast.AST) -> None: - pending = list(ast.iter_child_nodes(function)) - while pending: - node = pending.pop() - # A nested scope's comparisons cannot combine with its parent's. - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)): - continue - self.dispatch_sync(node) - pending.extend(ast.iter_child_nodes(node)) - - -class LiteralDispatchCases(MatchArms, DispatchCases): - @staticmethod - def literal(node: ast.AST) -> bool: - return (isinstance(node, ast.Constant) - and isinstance(node.value, (str, int, float)) - and not isinstance(node.value, bool)) - - @handles(ast.Compare) - def comparison(self, node: ast.Compare) -> None: - left = node.left - for operator, right in zip(node.ops, node.comparators, strict=True): - match operator, right: - case (ast.Eq() | ast.NotEq(), literal) if self.literal(literal): - self.add(left, repr(literal.value)) - case (ast.In() | ast.NotIn(), ast.Set(elts=items) | ast.Tuple(elts=items) | ast.List(elts=items)): - for item in items: - if self.literal(item): - self.add(left, repr(item.value)) - left = right - - @handles(ast.Match) - def match_cases(self, node: ast.Match) -> None: - for arm in self.arms(node): - match arm: - case ast.MatchValue(value=value) if self.literal(value): - self.add(node.subject, repr(value.value)) - - -class TypeTests(MatchArms, MroDispatch): - """Every test of a value's type in Python syntax: isinstance, issubclass, ``type(x) is``, - ``x is C``, ``x in (A, B)`` and class patterns. A reader decides what a test means.""" - - instance_checks: typing.ClassVar[frozenset[str]] = frozenset( - check.__name__ for check in (isinstance, issubclass)) - exact: typing.ClassVar[str] = type.__name__ - - @abstractmethod - def tested(self, subject: ast.expr, kind: ast.expr, at: ast.expr | ast.pattern) -> None: - """One test of ``subject`` against ``kind``, spelled at ``at``.""" - - @handles(ast.Call) - def instance_check(self, node: ast.Call) -> None: - match node: - case ast.Call(func=ast.Name(id=check), args=[subject, kind], - keywords=[]) if check in self.instance_checks: - self.tested(subject, kind, node) - - @handles(ast.Compare) - def comparison(self, node: ast.Compare) -> None: - match node: - case ast.Compare(left=ast.Call(func=ast.Name(id=called), args=[subject]), - comparators=[kind]) if called == self.exact: - self.tested(subject, kind, node) - case ast.Compare(left=subject, ops=[ast.Is()], - comparators=[ast.Name() | ast.Attribute() as kind]): - self.tested(subject, kind, node) - case ast.Compare(left=subject, ops=[ast.In()], comparators=[ast.Tuple(elts=kinds)]) if ( - kinds and all(isinstance(kind, (ast.Name, ast.Attribute)) for kind in kinds)): - for kind in kinds: - self.tested(subject, kind, node) - - @handles(ast.Match) - def match_cases(self, node: ast.Match) -> None: - for arm in self.arms(node): - match arm: - case ast.MatchClass(cls=kind): - self.tested(node.subject, kind, arm) - - -class TypeDispatchCases(TypeTests, DispatchCases): - def tested(self, subject: ast.expr, kind: ast.expr, at: ast.expr | ast.pattern) -> None: - self.add(subject, ast.dump(kind)) - - -class DispatchMeasure(PerFileOccurrenceMeasure): - """Screen three-arm subjects per function, with per-file growth isolation. - - These are candidates, not a proof that an external taxonomy is ours. Review - ownership at each reported site; no exception roster or second registry. - """ - - @classmethod - @abstractmethod - def collector(cls) -> DispatchCases: ... - - @classmethod - def groups(cls, node: ast.AST) -> tuple[frozenset[str], ...]: - if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - return () - collector = cls.collector() - collector.read_function(node) - return collector.groups() - - @classmethod - def occurrences(cls, node: ast.AST) -> int: - return len(cls.groups(node)) - - -class StringDispatch(DispatchMeasure): - @classmethod - def collector(cls) -> DispatchCases: - return LiteralDispatchCases() - - -class TypeSwitch(DispatchMeasure): - @classmethod - def collector(cls) -> DispatchCases: - return TypeDispatchCases() - - -class DispatchArms: - """Count growth inside an existing candidate through the same collector.""" - - @classmethod - def occurrences(cls, node: ast.AST) -> int: - return sum(len(group) for group in cls.groups(node)) - - -class StringDispatchArms(DispatchArms, StringDispatch): - pass - - -class TypeSwitchArms(DispatchArms, TypeSwitch): - pass - - -class OwnTypeTests(TypeTests): - """The classes one module tests a value against, each reported by the final name it spells.""" - - def __init__(self, foreign: frozenset[str]) -> None: - self.foreign = foreign - self.found: list[tuple[int, str]] = [] - - def tested(self, subject: ast.expr, kind: ast.expr, at: ast.expr | ast.pattern) -> None: - for spelled in kind.elts if isinstance(kind, ast.Tuple) else (kind,): - match spelled: - case ast.Name(id=name) | ast.Attribute(attr=name) if ( - ast.unparse(spelled).partition(".")[0] not in self.foreign): - self.found.append((at.lineno, name)) - - @staticmethod - def foreign_bindings(tree: ast.Module, ours: frozenset[str]) -> frozenset[str]: - """Names this module binds from another program's modules: ``ast`` in ``ast.Import``.""" - bound: set[str] = set() - for node in ast.walk(tree): - match node: - case ast.Import(names=names): - bound.update((alias.asname or alias.name).partition(".")[0] for alias in names - if alias.name.partition(".")[0] not in ours) - case ast.ImportFrom(module=module, level=0, names=names) if ( - (module or "").partition(".")[0] not in ours): - bound.update(alias.asname or alias.name for alias in names) - return frozenset(bound) - - @classmethod - def of(cls, source: bytes, path: str, ours: frozenset[str]) -> list[tuple[int, str]]: - tree = ast.parse(source, path) - tests = cls(cls.foreign_bindings(tree, ours)) - for node in ast.walk(tree): - tests.dispatch_sync(node) - return tests.found - - -class OwnTypeTest(InventoryMeasure[ModuleDeclarations]): - """Type tests against a class this repository declares, per changed file under --root. - - A test of our own class re-derives at the call site what the class's method already - answers, so every one counts, a single arm included: the fix is a method on the base and an - override in the member. Foreign values are dispatched once by MroDispatch at the decoder, - which tests their foreign types, never ours. The declared classes are every production - file's, since a tested class and its test can sit in different trees. - """ - - @classmethod - def inventory(cls, repo: Path, ref: str, root: str) -> frozenset[str]: - return production_paths(repo, ref) - - reading = DeclaredSpellings.declarations - - @classmethod - def measured(cls, repo: Path, ref: str, base: str, head: str, - readings: dict[str, ModuleDeclarations], root: str) -> Counts: - declared = {name for module in readings.values() for name, _ in module.classes} - # A script outside every package, such as one in tools, is imported by its stem. - ours = frozenset((dotted or Path(path).stem).partition(".")[0] - for path, dotted in DeclaredSpellings.import_names(readings).items()) - selected = {path for path in readings.keys() & ChangedPaths.of(repo, base, head).touched - if path.startswith(root)} - return {cls.at(path): sum(name in declared for _, name in - OwnTypeTests.of(source, path, ours)) - for path, source in GitMeasure.sources(repo, ref, selected)} - - align = PerFileMeasure.align - - -class BuiltinHandlerTypeSwitch(BuiltinHandlerDeclarations, PerFileMeasure): - """Share the audit collector; per-file counts prevent moving primitive debt.""" - - @classmethod - def count(cls, source: bytes, filename: str) -> int: - if not cls.admits(filename): - return 0 - return cls.count_module(cls.tree(source, filename)) - - -class GodClassExcess(Advisory, InventoryMeasure[dict[MemberCount, int]]): - """Independent class lines beyond 500 in Black's layout, including newly introduced classes. - Lines are counted after the revision's own Black configuration formats the file, so - formatting a class never reads as growth while Unformatted asks for exactly that. - Size is an ownership lead, not evidence of a restated fact, so it warns.""" - - threshold = 500 - - @classmethod - def inventory(cls, repo: Path, ref: str, root: str) -> frozenset[str]: - # Complete inventories distinguish a moved declaration from another - # same-named class in an unchanged module. - return python_paths(repo, ref, root) - - @classmethod - def context(cls, repo: Path, ref: str) -> str: - """Black formats each file under the revision's own configuration.""" - return CheckedRevision.configuration(repo, ref) - - @classmethod - def read_fresh(cls, repo: Path, ref: str, paths: list[str]) -> dict[str, dict[MemberCount, int]]: - if not paths: - return {} - with tempfile.TemporaryDirectory(prefix="debt-ratchet-layout-") as scratch: - tree = Path(scratch) - Unformatted.extract(repo, ref, tree, paths) - if git(repo, "ls-tree", "--name-only", ref, "--", "pyproject.toml"): - (tree / "pyproject.toml").write_bytes(git(repo, "show", f"{ref}:pyproject.toml")) - subprocess.run( - (sys.executable, "-m", *Unformatted.black("-q", *paths)), - cwd=tree, - check=True, - timeout=600, - ) - return {path: cls.reading(path, (tree / path).read_bytes()) for path in paths} - - @classmethod - def measured(cls, repo: Path, ref: str, base: str, head: str, - readings: dict[str, dict[MemberCount, int]], root: str) -> Counts: - return {identity: value - for values in readings.values() for identity, value in values.items()} - - @classmethod - def reading(cls, path: str, source: bytes) -> dict[MemberCount, int]: - values = {} - def visit(node: ast.AST, path: str, scope: tuple[str, ...]) -> None: - match node: - case ast.ClassDef(name=name, lineno=start, end_lineno=end, decorator_list=decorators): - scope = (*scope, name) - start = min([start, *(decorator.lineno for decorator in decorators)]) - values[cls.at(path).member(".".join(scope))] = max(0, end - start + 1 - cls.threshold) - case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name): - scope = (*scope, name) - for child in ast.iter_child_nodes(node): - visit(child, path, scope) - visit(ast.parse(source, path), path, ()) - return values - - @staticmethod - def declaration(identity: MeasureCount) -> str: - """The class a count measures; reading() keys every count by its declaration.""" - return typing.cast(MemberCount, identity).name - - @classmethod - def align(cls, base: Counts, head: Counts) -> tuple[Counts, Counts]: - def names(values): - result = {} - for identity in values: - result.setdefault(cls.declaration(identity), []).append(identity) - return result - before, after = names(base), names(head) - matched = {} - consumed = set() - for identity in head: - if identity in base: - matched[identity] = base[identity] - consumed.add(identity) - continue - name = cls.declaration(identity) - previous = before.get(name, ()) - if len(previous) == 1 and len(after[name]) == 1: - matched[identity] = base[previous[0]] - consumed.add(previous[0]) - else: - # A new declaration must not introduce any god-class excess. - matched[identity] = 0 - current = dict(head) - for identity in [identity for identity in base if identity not in consumed]: - matched[identity] = base[identity] - current[identity] = 0 - return matched, current - - -class TypeIdentity(OccurrenceMeasure): - @staticmethod - def is_type_call(node: ast.AST) -> bool: - match node: - case ast.Call(func=ast.Name(id="type"), args=[_], keywords=[]): - return True - case _: - return False - - @staticmethod - def occurrences(node: ast.AST) -> int: - if not isinstance(node, ast.Compare): - return 0 - return sum( - isinstance(operator, (ast.Is, ast.IsNot)) - and (TypeIdentity.is_type_call(left) or TypeIdentity.is_type_call(right)) - for left, operator, right in zip( - [node.left, *node.comparators[:-1]], node.ops, node.comparators, strict=True - ) - ) - - -class LongBooleanChain(OccurrenceMeasure): - minimum_terms = 4 - - @staticmethod - def occurrences(node: ast.AST) -> int: - match node: - case ast.BoolOp(values=values): - return int(len(values) >= LongBooleanChain.minimum_terms) - return 0 - - -class BooleanChainTerms(PerFileOccurrenceMeasure): - @staticmethod - def occurrences(node: ast.AST) -> int: - match node: - case ast.BoolOp(values=values) if len(values) >= LongBooleanChain.minimum_terms: - return len(values) - return 0 - - -class ForeignAbsenceProbe(PerFileOccurrenceMeasure): - """Use the audit owner's explicit absence checks, not arbitrary truthiness.""" - - occurrences = AuditForeignAbsenceProbe.count - - -class CodecSubclass(PerFileOccurrenceMeasure): - """TIME-9 census measure; the sealed FieldCodec guard also resolves aliases.""" - - @staticmethod - def codec_base(node: ast.AST) -> bool: - match node: - case ast.Name(id=name) | ast.Attribute(attr=name): - return name.endswith("Codec") - return False - - @staticmethod - def occurrences(node: ast.AST) -> int: - match node: - case ast.ClassDef(bases=bases): - return int(any(CodecSubclass.codec_base(base) for base in bases)) - return 0 - - -class StoreLockCalls(PerFileOccurrenceMeasure): - """Lock calls outside the store modules that own locking. A store's guard is declared on - its family (plans/STORE-GUARDS-PLAN.md), so every lock a call site takes by hand restates - which lock guards which write; the count only falls, file by file, to zero.""" - - owners: typing.ClassVar[tuple[str, ...]] = tuple( - Path(inspect.getfile(module)).relative_to(Path(inspect.getfile(agent_comms)).parents[1]).as_posix() - for module in (store_files, locked_store, process_claim)) - functions: typing.ClassVar[frozenset[str]] = frozenset( - function.__name__ for function in (store_files._store_lock, store_files._async_store_lock)) - qualified: typing.ClassVar[frozenset[tuple[str, str]]] = frozenset( - {(fcntl.__name__, fcntl.flock.__name__)}) - - @classmethod - def lines(cls, source: bytes, filename: str) -> list[int]: - return [] if filename.endswith(cls.owners) else super().lines(source, filename) - - @staticmethod - def occurrences(node: ast.AST) -> int: - match node: - case ast.Call(func=ast.Attribute(value=ast.Name(id=owner), attr=name)) if ( - (owner, name) in StoreLockCalls.qualified): - return 1 - case ast.Call(func=ast.Attribute(attr=name)) | ast.Call(func=ast.Name(id=name)): - return int(name in StoreLockCalls.functions) - return 0 - - -class StringSubscript(OccurrenceMeasure): - @classmethod - def lines(cls, source: bytes, filename: str) -> list[int]: - tree = cls.tree(source, filename) - imports = TypingImports.of(tree) - found = [] - for node in ast.walk(tree): - if cls.occurrences(node): - reference = TypingReference(imports) - reference.dispatch_sync(node.value) - found.extend([cls.position.lines(node).start] * reference.raw_access_count()) - return found - - @staticmethod - def occurrences(node: ast.AST) -> int: - return int( - isinstance(node, ast.Subscript) - and isinstance(node.slice, ast.Constant) - and isinstance(node.slice.value, str) - ) - - -class AnyReference(TypingReference): - """A reference AnyName asks one question of: does it resolve to ``typing.Any``.""" - - @property - def is_any(self) -> bool: - return self.value is typing.Any - - -class AnyInTypeExpression(MroDispatch): - """Count ``typing.Any`` in one type expression without evaluating it. - - Only type-forming syntax is followed: a subscript, a union, a member list - and, in an annotation, a quoted forward reference. A call or a comparison - is a runtime value, so ``annotation is Any`` in introspection code is not a use. - """ - - def __init__(self, imports: dict[str, object], quoted: bool) -> None: - self.imports = imports - self.quoted = quoted - self.references: list[ast.expr] = [] - - @property - def found(self) -> int: - return len(self.references) - - def resolved(self, node: ast.AST) -> object: - reference = TypingReference(self.imports) - reference.dispatch_sync(node) - return reference.value - - @handles(ast.Name, ast.Attribute) - def reference(self, node: ast.expr) -> None: - reference = AnyReference(self.imports) - reference.dispatch_sync(node) - if reference.is_any: - self.references.append(node) - - @handles(ast.Subscript) - def parameterised(self, node: ast.Subscript) -> None: - origin = self.resolved(node.value) - members = [node.slice] - if origin is typing.Literal: - members = [] # Literal members are values, not types. - if origin is Annotated: - members = getattr(node.slice, "elts", members)[:1] # The rest is metadata. - for member in members: - self.dispatch_sync(member) - - @handles(ast.Tuple, ast.List) - def members(self, node: ast.Tuple | ast.List) -> None: - for member in node.elts: - self.dispatch_sync(member) - - @handles(ast.BinOp) - def union(self, node: ast.BinOp) -> None: - self.dispatch_sync(node.left) - self.dispatch_sync(node.right) - - @handles(ast.Constant) - def forward_reference(self, node: ast.Constant) -> None: - if not self.quoted or not isinstance(node.value, str): - return - try: - quoted = ast.parse(node.value.strip(), mode="eval") - except (SyntaxError, ValueError): - return - self.dispatch_sync(quoted.body) - - -@dataclass(frozen=True) -class AnyUse: - """One written ``typing.Any`` and the declaration or reference owning it.""" - - line: int - site: str - owner: str - - -class DeclaredTypeSites(MroDispatch): - """Written Any references, with declaration-owned quoted type parsing.""" - - def __init__(self, imports: dict[str, object]) -> None: - self.imports = imports - self.uses: list[AnyUse] = [] - self.counted: set[ast.AST] = set() - - def read(self, tree: ast.Module) -> None: - pending: list[tuple[ast.AST, tuple[ast.AST, ...]]] = [(tree, ())] - while pending: - node, scope = pending.pop() - self.dispatch_sync(node, scope) - if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): - scope = (*scope, node) - pending.extend((child, scope) for child in ast.iter_child_nodes(node)) - - def declare(self, site: str, expression: ast.AST | None, scope: tuple[ast.AST, ...], - quoted: bool = True) -> None: - if expression is None: - return - counted = AnyInTypeExpression(self.imports, quoted) - counted.dispatch_sync(expression) - owner = ".".join(enclosing.name for enclosing in scope) - self.counted.update(counted.references) - self.uses.extend([AnyUse(expression.lineno, site, owner)] * counted.found) - - @handles(ast.Name, ast.Attribute) - def reference(self, node: ast.Name | ast.Attribute, scope: tuple[ast.AST, ...]) -> None: - if not isinstance(node.ctx, ast.Load) or node in self.counted: - return - reference = AnyReference(self.imports) - reference.dispatch_sync(node) - if reference.is_any: - self.counted.add(node) - owner = ".".join(enclosing.name for enclosing in scope) - self.uses.append(AnyUse(node.lineno, "reference", owner)) - - @staticmethod - def record_class(scope: tuple[ast.AST, ...]) -> bool: - """Whether the innermost scope is a class whose annotations are its fields.""" - def last_name(expression: ast.AST) -> str: - return ast.unparse(getattr(expression, "func", expression)).rpartition(".")[2] - decorators = {last_name(decorator) for decorator in scope[-1].decorator_list} - bases = {last_name(base) for base in scope[-1].bases} - return "dataclass" in decorators or bool(bases & {"NamedTuple", "TypedDict"}) - - def assignment_site(self, scope: tuple[ast.AST, ...]) -> str: - if not scope or not isinstance(scope[-1], ast.ClassDef): - return "variable" - return "dataclass field" if self.record_class(scope) else "class attribute" - - @handles(ast.arg) - def parameter(self, node: ast.arg, scope: tuple[ast.AST, ...]) -> None: - self.declare("parameter", node.annotation, scope) - - @handles(ast.FunctionDef, ast.AsyncFunctionDef) - def result(self, node, scope: tuple[ast.AST, ...]) -> None: - self.declare("return", node.returns, (*scope, node)) - - @handles(ast.AnnAssign) - def annotated(self, node: ast.AnnAssign, scope: tuple[ast.AST, ...]) -> None: - self.declare(self.assignment_site(scope), node.annotation, scope) - alias = AnyInTypeExpression(self.imports, quoted=True).resolved(node.annotation) - if alias is typing.TypeAlias: - self.declare("alias", node.value, scope) - - @handles(ast.Assign) - def alias(self, node: ast.Assign, scope: tuple[ast.AST, ...]) -> None: - # ``Payload = dict[str, Any]`` names a type. A bare member list such as - # ``(Any, object)`` is a runtime collection, and runtime strings are not - # forward references. - if not isinstance(node.value, (ast.Tuple, ast.List)): - self.declare("alias", node.value, scope, quoted=False) - - # Honor the package's Python 3.11 contract without inventing parser nodes. - if hasattr(ast, "TypeAlias"): - @handles(ast.TypeAlias) - def alias_statement(self, node, scope: tuple[ast.AST, ...]) -> None: - self.declare("alias", node.value, scope) - - -class AnyName(PerFileMeasure, LocatedSource): - """Every written Any, including quoted declarations and loaded runtime references.""" - - @classmethod - def uses(cls, source: bytes, filename: str) -> tuple[AnyUse, ...]: - tree = cls.tree(source, filename) - sites = DeclaredTypeSites(TypingImports.at_any_depth(tree)) - sites.read(tree) - return tuple(sorted(sites.uses, key=lambda use: (use.line, use.site, use.owner))) - - @classmethod - def lines(cls, source: bytes, filename: str) -> list[int]: - return [use.line for use in cls.uses(source, filename)] - - -class UnannotatedParameter(PerFileOccurrenceMeasure): - """Every function parameter declares its type; self and cls name their owner.""" - - @staticmethod - def occurrences(node: ast.AST) -> int: - if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - return 0 - arguments = node.args - return sum( - argument.annotation is None and argument.arg not in ("self", "cls") - for argument in ( - *arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs, - *filter(None, (arguments.vararg, arguments.kwarg)), - ) - ) - - -class UncarriedRecordFormat(ChangeMeasure): - """A stored record's format changed without its version, or its version without a carry. - - Files in the old format stay on disk, so a change to a versioned record's stored form - bumps its version, and a bumped version arrives with a new carry step that rewrites the - old files. The stored form is what FieldCodec writes, not the annotation text: two - representations with the same wire spelling are the same format. - """ - - probe: typing.ClassVar[str] = ( - "import importlib.util, os, sys; sys.path[:0] = sys.argv[2].split(os.pathsep); " - "spec = importlib.util.spec_from_file_location('stored_formats', sys.argv[1]); " - "module = sys.modules[spec.name] = importlib.util.module_from_spec(spec); " - "spec.loader.exec_module(module); " - "module.main(sys.argv[3:])" - ) - """The revision's own reader, loaded by file, over only the revision's own source: a tools - directory on the path would resolve the revision's bare imports into this one's tree.""" - - @staticmethod - def declares_version(source: bytes) -> bool: - """Only a module with a class declaring `version` can hold a versioned record; others - are never imported.""" - for node in ast.walk(ast.parse(source)): - for item in node.body if isinstance(node, ast.ClassDef) else (): - match item: - case ast.AnnAssign(target=ast.Name(id="version")) | ast.Assign( - targets=[ast.Name(id="version")]): - return True - return False - - @classmethod - def formats(cls, repo: Path, ref: str, root: str, changed: set[str]) -> dict[str, StoredForm]: - """Each versioned record in the changed modules, measured by that revision's own code; - the commit fixes every input, its stamp included.""" - return ContentCache.memo(partial(cls.measured_formats, repo, ref, root, changed), - cls.__name__, revision(repo, ref), root, *sorted(changed)) - - @classmethod - def measured_formats(cls, repo: Path, ref: str, root: str, - changed: set[str]) -> dict[str, StoredForm]: - package = Path(root) - while git(repo, "ls-tree", "--name-only", ref, "--", str(package / "__init__.py")): - package = package.parent - modules = [".".join(Path(path).relative_to(package).with_suffix("").parts) - for path in sorted(changed & python_paths(repo, ref, root)) - if cls.declares_version(git(repo, "show", f"{ref}:{path}"))] - if not modules: - return {} - with tempfile.TemporaryDirectory(prefix="debt-ratchet-formats-") as scratch: - # Each side runs its own revision's probe script: the head's may import modules - # that only the head's package declares (#3576 moved field_codec under nominal). - source = git(repo, "archive", ref, "--", str(package), "tools/stored_formats.py") - subprocess.run(["tar", "-x", "-C", scratch], input=source, check=True, timeout=60) - tree = Path(scratch) - sources = cls.source_paths(root, tree) - for stamp in tree.rglob("package_stamp.py"): - WheelStamp.of_source(tree / package, revision(repo, ref), - source_paths=sources).write(stamp) - paths = dict.fromkeys((str(tree / package), *sources)) - result = SpawnedProcess.run( - (sys.executable, "-c", cls.probe, - str(tree / "tools/stored_formats.py"), - os.pathsep.join(paths), *modules), cwd=scratch, - env=cls.environment(root, tree), timeout=300, - ) - if result.returncode: - raise ValueError(f"{cls.__name__} could not measure {ref}: {result.stderr.decode()}") - return {name: StoredForm(**form) for name, form in json.loads(result.stdout).items()} - - @classmethod - def counted(cls, repo: Path, base: str, head: str, changed: set[str], root: str): - added = git(repo, "diff", "--name-only", "-z", "--diff-filter=A", base, head, "--", str(STEPS)) - carried = any(Path(path).stem.startswith("carry_") and path.endswith(".py") - for path in added.decode().split("\0")) - before = cls.formats(repo, base, root, changed) - after = cls.formats(repo, head, root, changed) - uncarried = sum(before[name].version == form.version or not carried - for name, form in after.items() if name in before and before[name] != form) - return {cls.total(): 0}, {cls.total(): uncarried} - - -class DonePhaseBroken(Measure): - """A plan phase whose evidence holds at the base and fails at the head: a done phase is a - standing guard, so a change re-adding what the phase deleted fails here, one key per site. - - The phases are the Plan family's registry and ``PlanPhase.done`` is their only reader; - a phase not yet done at the base is plan work in progress and blocks nothing. Evidence - that cannot be read here (GitHub's rules, with no token) holds at neither revision. Each - revision is read in one process, so its modules are parsed once for every phase, and kept - by the commit and the ratchet's code, which holds the phases' declarations. It runs for - the source root that declares the phases, read from where they are imported.""" - - @classmethod - def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: - declared = Path(inspect.getfile(PlanPhase)).resolve() - if root != f"{declared.relative_to(REPOSITORY).parts[0]}/": - return () - return tuple(partial(cls.failing, repo, revision(repo, ref)) for ref in (base, head)) - - @classmethod - def combine(cls, results: list, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Counts, Counts]: - """A failing pull request pays for reading its sites; a passing one never reads them.""" - if not results: - return {}, {} - before, after = results - reading = PlanReading((), MainTree(Repo.of(repo), revision(repo, head))) - broken: Counts = { - PhaseSiteCount(cls, phase, site): 1 - for phase in PlanPhase.members_with(PlanPhase) - if phase.__name__ in after - before - for site in phase.phase_evidence().sites(reading) - or (reading.describe(phase.phase_evidence()),) - } - return dict.fromkeys(broken, 0), broken - - @classmethod - def failing(cls, repo: Path, commit: str) -> frozenset[str]: - """The name of each phase not done at a commit.""" - return ContentCache.memo(partial(cls.read, repo, commit), cls.__name__, commit) - - @staticmethod - def read(repo: Path, commit: str) -> frozenset[str]: - reading = PlanReading((), MainTree(Repo.of(repo), commit)) - return frozenset(phase.__name__ for phase in PlanPhase.members_with(PlanPhase) - if not phase.done(reading)) - - -def git(repo: Path, *args: str, input: bytes = b"") -> bytes: - return subprocess.check_output(["git", "-C", str(repo), *args], input=input, timeout=20) - - -def revision(repo: Path, ref: str) -> str: - return ( - git(repo, "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}").decode().strip() - ) - - -# Listings are read once per process: the measures pass commit identities, whose trees never change. -@cache -def python_paths(repo: Path, ref: str, root: str) -> frozenset[str]: - return frozenset( - path.decode() - for path in git(repo, "ls-tree", "-rz", "--name-only", ref, "--", root).split(b"\0") - if path.endswith(b".py") - ) - - -@dataclass(frozen=True) -class ChangedPaths: - """The paths a change touches, and the moves Git pairs among them. - - Both sides of a move are touched, including moves into or out of the declared production - source boundary, so each revision measures the path it has. A moved path's per-file counts - then compare at its head path, so moving a file adds nothing it did not already hold.""" - - touched: frozenset[str] - moved: tuple[tuple[str, str], ...] - - @staticmethod - @cache - def of(repo: Path, base: str, head: str) -> ChangedPaths: - # -z name-status: "M\0path\0", or "R096\0old\0new\0" for a move Git detected. - fields = iter(git(repo, "diff", "--find-renames", "--name-status", "-z", base, head, - "--").split(b"\0")[:-1]) - touched: set[str] = set() - moved: list[tuple[str, str]] = [] - for status in fields: - paths = [next(fields).decode()] - if status.startswith(b"R"): - paths.append(next(fields).decode()) - moved.append((paths[0], paths[1])) - touched.update(paths) - return ChangedPaths(frozenset(touched), tuple(moved)) - - def paired(self, counts: Counts) -> Counts: - """Counts at their head paths; a moved file's key zero-filled at its other path adds 0.""" - moved = dict(self.moved) - result: Counts = {} - for identity, count in counts.items(): - at = identity.moved(moved) - result[at] = result.get(at, 0) + count - return result - - -class ReportExclude(ViewExclusion): - """A field the report shows in another form.""" - - -@dataclass(frozen=True) -class Comparison: - root: str - base_revision: str - head_revision: str - paths: tuple[str, ...] - base: Annotated[dict[MeasureCount, int], ReportExclude()] - head: Annotated[dict[MeasureCount, int], ReportExclude()] - introduced: Annotated[dict[FileCount, list[int]], ReportExclude()] - found: Annotated[dict[MeasureCount, list[Site]], ReportExclude()] - - @property - def delta(self) -> dict[MeasureCount, int]: - return {name: value - self.base[name] for name, value in self.head.items()} - - @staticmethod - def keyed[Count: MeasureCount, Value](counts: dict[Count, Value]) -> dict[str, Value]: - """The report's JSON keys: the one place a count's record becomes text.""" - return {identity.text: value for identity, value in counts.items()} - - @projected(view="report", name="base") - def base_report(self) -> dict[str, int]: - return self.keyed(self.base) - - @projected(view="report", name="head") - def head_report(self) -> dict[str, int]: - return self.keyed(self.head) - - @projected(view="report", name="introduced") - def introduced_report(self) -> dict[str, list[int]]: - return self.keyed(self.introduced) - - @projected(view="report", name="found") - def found_report(self) -> dict[str, list[Site]]: - return self.keyed(self.found) - - @projected(view="report", name="delta") - def delta_report(self) -> dict[str, int]: - return self.keyed(self.delta) - - @property - def failed(self) -> bool: - return (any(measure.blocks(self.delta) for measure in Measure.members_with(Measure)) - or any(self.introduced.values())) - - -def compare(repo: Path, base: str, head: str, root: str, workers: int, - measured: tuple[type[Measure], ...] = Measure.members_with(Measure)) -> Comparison: - source_root = Path(root) - if source_root.is_absolute() or ".." in source_root.parts or not source_root.parts: - raise ValueError("--root must be a repository-relative source directory") - root = source_root.as_posix().rstrip("/") + "/" - base, head = revision(repo, base), revision(repo, head) - changed = ChangedPaths.of(repo, base, head) - touched = changed.touched - paths = set(touched & (python_paths(repo, base, root) | python_paths(repo, head, root))) - before: Counts = {} - after: Counts = {} - introduced: dict[FileCount, list[int]] = {} - # Every measure reads production Python paths only, so with none touched each would - # report no change. - if not touched & (production_paths(repo, base) | production_paths(repo, head)): - return Comparison(root, base, head, (), before, after, introduced, {}) - - # A pass of a shared runner is measured once, by that runner. - measures = tuple(dict.fromkeys(measure.run_by() for measure in measured)) - # Forked workers start from this already-imported module instead of importing it again. - with ForkedProcessPool(workers) as pool: - parts = {measure: [pool.submit(part) - for part in measure.parts(repo, base, head, paths, root)] - for measure in measures} - sites = [pool.submit( - CleanChangedLines.introduced, repo, base, head, - tuple(sorted(paths & python_paths(repo, head, root))), - tuple(sorted(paths & python_paths(repo, base, root))), - )] - # Each measure combines as soon as its parts finish, while slower checkers still run. - combined = {} - owners = {future: measure for measure, futures in parts.items() for future in futures} - for measure in measures: - if not parts[measure]: - combined[measure] = measure.combine([], repo, base, head, paths, root) - for future in as_completed(owners): - measure = owners[future] - if measure not in combined and all(part.done() for part in parts[measure]): - combined[measure] = measure.combine([part.result() for part in parts[measure]], - repo, base, head, paths, root) - # A shared runner measures all its passes; the comparison holds only the asked ones. - for measure in measures: - for side, taken in zip((before, after), combined[measure], strict=True): - side.update({key: value for key, value in taken.items() - if any(asked.owns(key) for asked in measured)}) - for path_sites in sites: - introduced.update({identity: sorted(found) - for identity, found in path_sites.result().items()}) - comparison = Comparison(root, base, head, tuple(sorted(paths)), changed.paired(before), - changed.paired(after), introduced, {}) - # Only a grown measure is read for its sites, so a clean change pays for none. - grown = [measure for measure in LocatedSource.members_with(LocatedSource) - if measure in measured and measure.blocks(comparison.delta)] - if not grown: - return comparison - sources = GitMeasure.sources(repo, head, paths & python_paths(repo, head, root)) - return dataclasses.replace(comparison, found={ - key: sites for measure in grown for key, sites in measure.located(sources).items() - if comparison.delta[key] > 0}) +from audit import agent_comms_project +from audit.ratchet import ratchet +from audit.repository import Repository def main() -> int: - ContentCache.prune() parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", required=True, help="Repository-relative production source directory") parser.add_argument("--base", required=True) @@ -2898,21 +25,7 @@ def main() -> int: parser.add_argument("--workers", type=int, default=os.process_cpu_count() or 1, help="Pool width; a caller running ratchets in parallel narrows it") args = parser.parse_args() - result = compare(Path.cwd(), args.base, args.head, args.root, args.workers) - # Flushed first: stdout is block-buffered into a pipe, and the growth lines must end the log. - print(json.dumps(FieldCodec.project(result, "report"), indent=2), flush=True) - # The failing step's last lines become check annotations (.github/run-step.sh merges - # stderr), so what grew goes to stderr last; stdout stays the one JSON report. - for key, grown in sorted(result.delta.items(), key=lambda item: item[0].text): - if grown > 0: - print(key.measure.grew(key, grown), file=sys.stderr) - for name, sites in sorted(result.found_report.items()): - print(f"found {name} at {' '.join(f'{site.path}:{site.line}' for site in sites)}", - file=sys.stderr) - for identity, lines in sorted(result.introduced_report.items()): - if lines: - print(f"introduced {identity} at lines {lines}", file=sys.stderr) - return int(result.failed) + return ratchet(Repository(Path.cwd()), args.base, args.head, args.root, args.workers) if __name__ == "__main__": diff --git a/tools/module_layers.py b/tools/module_layers.py index d64dbc316b..0f1ffbf31b 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -39,7 +39,8 @@ from agent_comms.installer.runtime_wire import ClientImport, CrossesRuntimes -from typing_names import TypingImports, TypingReference +from agent_comms.pinned_audit import audit +from audit.typing_names import TypingImports, TypingReference from agent_comms.declared_family import DeclaredFamily diff --git a/tools/repairs.py b/tools/repairs.py index 16f1fd9a71..c3f0ce0e55 100644 --- a/tools/repairs.py +++ b/tools/repairs.py @@ -25,7 +25,11 @@ from pathlib import Path from typing import ClassVar -from debt_ratchet import FunctionLocalImport, LocatedSource, compare, production_paths +from agent_comms.pinned_audit import audit +from audit import agent_comms_project +from audit.ratchet import LocatedSource, compare +from audit.repository import Repository +from audit.source_measures import FunctionLocalImport from module_layers import Package, TopLevelNames from nominal_refactor_advisor.codemod import ( CodemodPlanSequence, @@ -421,13 +425,14 @@ def main() -> int: if args.all: files = { measure: sorted( - path for path in production_paths(repo, "HEAD") if path.startswith(root) + path for path in Repository(repo).production_paths("HEAD") + if path.startswith(root) ) for measure in (repair.repairs for repair in Repair.members_with(Repair)) } else: found = compare( - repo, + Repository(repo), args.base, args.head, args.root, diff --git a/tools/typing_names.py b/tools/typing_names.py deleted file mode 100644 index 5848bc60f5..0000000000 --- a/tools/typing_names.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Which typing declaration a name or attribute in a module stands for, read from the -module's imports and the stdlib's own typing modules, never by running the module.""" - -from __future__ import annotations - -import ast -import types -import typing - -import typing_extensions - -from metaclass_registry.mro_dispatch import ( - MroDispatch, - handles, -) - - -class Untyped: - """What a name stands for when it is bound to no typing declaration; TypingImports.declares - answers no for it, as for every other value the typing modules do not declare.""" - - -class TypingImports(MroDispatch): - """Resolve unshadowed module imports from the actual stdlib declarations. - - This bounded syntactic measure never executes inspected source. Nested - bindings conservatively disqualify a name rather than inferring its flow. - """ - - typing_modules: typing.ClassVar[dict[str, types.ModuleType]] = { - module.__name__: module for module in (typing, typing_extensions) - } - - def __init__(self, declarations: set[ast.AST]) -> None: - self.declarations = declarations - self.values: dict[str, object] = {} - self.shadowed: set[str] = set() - - def bind(self, node: ast.AST, name: str, value: object) -> None: - if node not in self.declarations or name in self.values: - self.shadowed.add(name) - self.values[name] = value - - @handles(ast.Import) - def modules(self, node: ast.Import) -> None: - for item in node.names: - self.bind( - node, - item.asname or item.name.split(".")[0], - self.typing_modules.get(item.name, Untyped), - ) - - @handles(ast.ImportFrom) - def members(self, node: ast.ImportFrom) -> None: - match node: - case ast.ImportFrom(level=0, module=str(name)) if name in self.typing_modules: - namespace = vars(self.typing_modules[name]) - case _: - namespace = {} - for item in node.names: - self.bind(node, item.asname or item.name, namespace.get(item.name, Untyped)) - - @handles(ast.Name) - def assignment(self, node: ast.Name) -> None: - if isinstance(node.ctx, (ast.Store, ast.Del)): - self.shadowed.add(node.id) - - @handles(ast.Attribute) - def mutation(self, node: ast.Attribute) -> None: - if isinstance(node.ctx, (ast.Store, ast.Del)): - for descendant in ast.walk(node.value): - if isinstance(descendant, ast.Name): - self.shadowed.add(descendant.id) - - @handles(ast.arg) - def parameter(self, node: ast.arg) -> None: - self.shadowed.add(node.arg) - - @handles(ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) - def named_binding(self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> None: - self.shadowed.add(node.name) - - @handles(ast.ExceptHandler) - def exception_binding(self, node: ast.ExceptHandler) -> None: - match node: - case ast.ExceptHandler(name=str(name)): - self.shadowed.add(name) - - @handles(ast.TypeVar, ast.ParamSpec, ast.TypeVarTuple) - def type_parameter(self, node: ast.TypeVar | ast.ParamSpec | ast.TypeVarTuple) -> None: - self.shadowed.add(node.name) - - @classmethod - def declares(cls, value: object) -> bool: - """Whether a bound value is one of the typing modules or a member they declare.""" - return any( - value is module or any(value is member for member in vars(module).values()) - for module in cls.typing_modules.values() - ) - - @classmethod - def of(cls, tree: ast.Module) -> dict[str, object]: - imports = cls(set(tree.body)) - for node in ast.walk(tree): - imports.dispatch_sync(node) - if "*" in imports.values: - return {} # A star import can replace any otherwise known binding. - return { - name: value for name, value in imports.values.items() if name not in imports.shadowed - } - - @classmethod - def at_any_depth(cls, tree: ast.Module) -> dict[str, object]: - """Every name an import binds to ``typing``, wherever the import is written. - - For a measure that counts uses of a typing name: skipping an import inside - ``if TYPE_CHECKING:`` or a function body would hide the uses it enables. - """ - imports = cls(set(ast.walk(tree))) - for node in ast.walk(tree): - imports.dispatch_sync(node) - return {name: value for name, value in imports.values.items() if cls.declares(value)} - - -class TypingReference(MroDispatch): - """One AST expression's resolved imported declaration, never evaluated code.""" - - def __init__(self, imports: dict[str, object]) -> None: - self.imports = imports - self.value: object = Untyped - - @property - def is_type_checking(self) -> bool: - """``typing.TYPE_CHECKING`` is the one typing declaration whose value is False.""" - return self.value is typing.TYPE_CHECKING - - @handles(ast.Name) - def name(self, node: ast.Name) -> None: - self.value = self.imports.get(node.id, Untyped) - - @handles(ast.Attribute) - def member(self, node: ast.Attribute) -> None: - parent = TypingReference(self.imports) - parent.dispatch_sync(node.value) - for module in TypingImports.typing_modules.values(): - if parent.value is module: - self.value = vars(module).get(node.attr, Untyped) - - def raw_access_count(self) -> int: - """Typing declarations contribute no raw-record operation; unknowns do.""" - return int(not TypingImports.declares(self.value)) From 79e389ed85ef829eefbb788b6b543a9fc08ce446 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:46:33 -0400 Subject: [PATCH 02/18] The pinned audit's place is pinned_audit's; the lock test reads its owners claude-code's review of 292f2dda: - PinnedPatternPresent carried the submodule as a str field, and plan_phases.py wrote the same path twice more. pinned_audit already derives the skill directory, so it now declares SKILL once. The new AuditPatternPresent(pattern) finds the gitlink from agent_comms.skills' module path plus SKILL's name, and the package from audit's own location under SKILL. - MainTree.pinned matched ls-tree's "commit" type string, which grew StringIdentity. It now reads the gitlink with rev-parse :. - The lock-owner test hand-wrote the three owners lock_owners() derives. It now asserts every derived owner exists and counts nothing there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/phase_evidence.py | 26 ++++++++++++------------ src/agent_comms/pinned_audit.py | 3 ++- src/agent_comms/plan_phases.py | 8 +++----- tests/guards/test_agent_comms_ratchet.py | 5 ++--- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index f0f8ecbd5b..70a4f7dd94 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -23,7 +23,8 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec from agent_comms.head_review import HeadReview -from agent_comms.pinned_audit import ForeignAbsenceProbe, GodFunction, ParsedModule +from agent_comms import skills +from agent_comms.pinned_audit import SKILL, ForeignAbsenceProbe, GodFunction, ParsedModule, audit from agent_comms.private_path import FileObservation, FileRevision from agent_comms.repo import Repo @@ -102,11 +103,8 @@ def matching_lines(self, pattern: str, paths: tuple[str, ...]) -> frozenset[tupl def pinned(self, path: str) -> MainTree: """The tree of the submodule this commit pins at ``path``: another repository's tree, read at exactly the commit its gitlink names, from that repository's own objects.""" - entry, _, _ = Repo.git(self.repo.checkout, "ls-tree", self.commit, "--", path).partition("\t") - match entry.split(): - case [_, "commit", commit]: - return MainTree(Repo.of(self.repo.checkout / path), commit) - raise ValueError(f"{path} is no submodule at {self.commit}") + commit = Repo.git(self.repo.checkout, "rev-parse", f"{self.commit}:{path}").strip() + return MainTree(Repo.of(self.repo.checkout / path), commit) def module(self, path: str) -> ParsedModule: source = Repo.git(self.repo.checkout, "show", f"{self.commit}:{path}") @@ -398,17 +396,19 @@ def describe(self) -> str: @dataclass(frozen=True) -class PinnedPatternPresent(PatternPresent): - """A pattern in the tree of a submodule main pins, at the commit it pins: code this - repository runs from another repository.""" - - submodule: str = field(kw_only=True) +class AuditPatternPresent(PatternPresent): + """A pattern in the audit package of the refactor-audit main pins, at the commit it pins: + code this repository runs from that repository. Where the submodule sits and where its + package sits inside it are pinned_audit's, read from the loaded skill.""" def read(self, main: MainTree) -> bool: - return self.satisfied(main.pinned(self.submodule).count(self.pattern, self.paths)) + submodule = main.repo.module_file(skills.__name__).with_suffix("") / SKILL.name + package = Path(audit.__path__[0]).relative_to(SKILL) + pinned = main.pinned(submodule.as_posix()) + return self.satisfied(pinned.count(self.pattern, (package.as_posix(),))) def describe(self) -> str: - return f"{self.pattern!r} in {' '.join(self.paths)} of the {self.submodule} main pins" + return f"{self.pattern!r} in the audit package of the refactor-audit main pins" @dataclass(frozen=True) diff --git a/src/agent_comms/pinned_audit.py b/src/agent_comms/pinned_audit.py index 9b63ebad2b..9d32ab378e 100644 --- a/src/agent_comms/pinned_audit.py +++ b/src/agent_comms/pinned_audit.py @@ -11,7 +11,8 @@ from agent_comms import skills -SCRIPTS = Path(str(files(skills) / "refactor-audit" / "scripts")) +SKILL = Path(str(files(skills) / "refactor-audit")) +SCRIPTS = SKILL / "scripts" if str(SCRIPTS) not in sys.path: sys.path.insert(0, str(SCRIPTS)) diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index 48ffd00991..78c30bd8e0 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -23,6 +23,7 @@ from agent_comms.field_codec import Id128, MessageId, NominalSpelling, NominalText from agent_comms.phase_evidence import ( AllEvidence, + AuditPatternPresent, BusStorageAbsent, ClassFlagsAbsent, QualifiedName, @@ -41,7 +42,6 @@ PatternAbsent, PatternPresent, PhaseEvidence, - PinnedPatternPresent, PlanReading, RestatedMembersAbsent, RulesetGates, @@ -552,8 +552,7 @@ class PlainStrIdRatchetPhase(Id128NominalPhase): """2: no file gains a plain ``str`` annotation on an id-named field or parameter.""" after = (NominalIdTypePhase,) - evidence = PinnedPatternPresent(r"^class PlainStrIdAnnotation\(", ("scripts/audit",), - submodule="src/agent_comms/skills/refactor-audit") + evidence = AuditPatternPresent(r"^class PlainStrIdAnnotation\(") class ClaimIdsPhase(Id128NominalPhase): @@ -1021,8 +1020,7 @@ class MaintenanceInWritePhase(StoreGuardsPhase): class LockCensusPhase(StoreGuardsPhase): """5: the debt ratchet counts lock calls outside the store owners and refuses growth.""" - evidence = PinnedPatternPresent(r"^class StoreLockCalls\(", ("scripts/audit",), - submodule="src/agent_comms/skills/refactor-audit") + evidence = AuditPatternPresent(r"^class StoreLockCalls\(") class ScopedReadsPhase(StoreGuardsPhase): diff --git a/tests/guards/test_agent_comms_ratchet.py b/tests/guards/test_agent_comms_ratchet.py index 1baedbe612..af00682cb7 100644 --- a/tests/guards/test_agent_comms_ratchet.py +++ b/tests/guards/test_agent_comms_ratchet.py @@ -122,9 +122,8 @@ def test_store_lock_calls_skip_the_store_owners(): """Locking is store_files' and locked_store's own mechanism, never a hand-placed copy.""" source = b"with _store_lock(path):\n pass" owners = AgentCommsProject.lock_owners() - assert owners == ("agent_comms/store_files.py", "agent_comms/locked_store.py", - "agent_comms/process_claim.py") - assert [StoreLockCalls.count(source, f"src/{owner}") for owner in owners] == [0, 0, 0] + assert all((SOURCE / "src" / owner).is_file() for owner in owners) + assert [StoreLockCalls.count(source, f"src/{owner}") for owner in owners] == [0] * len(owners) def test_a_new_store_lock_call_fails_on_its_own_file_with_its_line(repo): From 99317916b87ff8ab98cd7353d763546b750e666d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:17:38 -0400 Subject: [PATCH 03/18] agent-comms declares its own measured project; the entry only names it AgentCommsProject and ClientTypeErrors move out of the pinned audit into tools/, beside the entry: agent-comms' facts change in agent-comms. debt_ratchet.py is raise SystemExit(main(AgentCommsProject(Path.cwd()))); the audit owns the command line. Every `from audit import agent_comms_project` side-effect import is gone; repairs and the tests measure an AgentCommsProject instance. A carry step is a module under STEPS whose class derives from the Carry family, not a file named carry_*. GodFunction's limit is LongFunction's. The submodule pins refactor-audit 03ed7a0e (#7's head). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/phase_evidence.py | 2 +- src/agent_comms/skills/refactor-audit | 2 +- tests/guards/test_agent_comms_ratchet.py | 33 ++-- tests/test_boundary_decoding.py | 10 +- tests/test_carry.py | 9 +- tests/test_phase_evidence.py | 2 +- tools/agent_comms_project.py | 241 +++++++++++++++++++++++ tools/debt_ratchet.py | 30 +-- tools/repairs.py | 10 +- 9 files changed, 280 insertions(+), 59 deletions(-) create mode 100644 tools/agent_comms_project.py diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index 70a4f7dd94..da9a7d0623 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -678,7 +678,7 @@ def read(self, main: MainTree) -> bool: ) def describe(self) -> str: - return f"{self.name} in {self.path} at most {GodFunction.threshold} lines on main" + return f"{self.name} in {self.path} at most {GodFunction.threshold()} lines on main" @dataclass(frozen=True) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index 45b7fc272d..03ed7a0e23 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit 45b7fc272d7f41a5a0d2a6a6c12d9455abcf2f90 +Subproject commit 03ed7a0e23430b7690e16692180db48ad5d958f9 diff --git a/tests/guards/test_agent_comms_ratchet.py b/tests/guards/test_agent_comms_ratchet.py index af00682cb7..b8dd88a7b2 100644 --- a/tests/guards/test_agent_comms_ratchet.py +++ b/tests/guards/test_agent_comms_ratchet.py @@ -19,11 +19,10 @@ from agent_comms.child_process import ObservedProcess, SpawnedProcess from agent_comms.field_codec import FieldCodec -from agent_comms.pinned_audit import audit -from audit.agent_comms_project import AgentCommsProject +from agent_comms_project import AgentCommsProject +from audit.project import AUDIT_SCRIPTS from audit.project_measures import DonePhaseBroken, UncarriedRecordFormat -from audit.ratchet import AUDIT_SCRIPTS, Compared, ContentCache, compare -from audit.repository import Repository +from audit.ratchet import Compared, compare from audit.source_measures import BroadSwallow, FamilyFlattened, NoneAbsence, StoreLockCalls from fixture_git import IDENTITY from process_tree_fixture import TREE, identities, ready @@ -31,6 +30,8 @@ pytestmark = pytest.mark.refactor_guard SOURCE = Path(__file__).resolve().parents[2] +# This checkout, as the ratchet measures it. +PROJECT = AgentCommsProject(SOURCE) def search_path(*first: Path) -> str: @@ -86,7 +87,7 @@ def comparing(self, base: str, head: str) -> Iterator[SpawnedProcess]: def compare(self, base: str, head: str, *measured: type[Compared]) -> tuple[int, dict]: """The comparison's exit status and report, taking only the named measures' measurements. The command line itself is proven by the tests that launch it.""" - result = compare(Repository(self.path), base, head, self.root, 2, measured) + result = compare(AgentCommsProject(self.path), base, head, self.root, 2, measured) return int(result.failed), json.loads(json.dumps(result.report())) @@ -99,7 +100,7 @@ def repo(tmp_path: Path, request) -> Checkout: def test_agent_comms_broad_swallows_stay_within_their_budget(): root = SOURCE / "src/agent_comms" - assert sum(BroadSwallow.count(path.read_bytes(), str(path)) for path in root.rglob("*.py")) <= 29 + assert sum(BroadSwallow.count(PROJECT, path.read_bytes(), str(path)) for path in root.rglob("*.py")) <= 29 @pytest.mark.parametrize( @@ -115,15 +116,15 @@ def test_agent_comms_broad_swallows_stay_within_their_budget(): ], ) def test_store_lock_calls_count_calls_not_spellings(source, expected): - assert StoreLockCalls.count(source.encode(), "src/agent_comms/example.py") == expected + assert StoreLockCalls.count(PROJECT, source.encode(), "src/agent_comms/example.py") == expected def test_store_lock_calls_skip_the_store_owners(): """Locking is store_files' and locked_store's own mechanism, never a hand-placed copy.""" source = b"with _store_lock(path):\n pass" - owners = AgentCommsProject.lock_owners() + owners = PROJECT.lock_owners() assert all((SOURCE / "src" / owner).is_file() for owner in owners) - assert [StoreLockCalls.count(source, f"src/{owner}") for owner in owners] == [0] * len(owners) + assert [StoreLockCalls.count(PROJECT, source, f"src/{owner}") for owner in owners] == [0] * len(owners) def test_a_new_store_lock_call_fails_on_its_own_file_with_its_line(repo): @@ -212,7 +213,7 @@ def test_format_probe_keeps_parent_dependencies_and_reads_archived_source(tmp_pa "raise RuntimeError('not archived')\n" ) forms = UncarriedRecordFormat.formats( - Repository(tmp_path), revision, repo.root, {"src/agent_comms/probe_record.py"} + AgentCommsProject(tmp_path), revision, repo.root, {"src/agent_comms/probe_record.py"} ) assert forms["agent_comms.probe_record.Record"].version == 7 # The archive holds no Git checkout: the protocol's commit is the revision the stamp @@ -262,18 +263,18 @@ def test_family_flattening_cannot_offset_another_file_or_admit_a_named_facade( assert status == 1 assert report["delta"][FamilyFlattened.at(f"{repo.root}/first.py").text] == -2 assert report["delta"][FamilyFlattened.at(f"{repo.root}/second.py").text] == 1 - assert FamilyFlattened.count(original.encode(), "consumer.py") == ( + assert FamilyFlattened.count(PROJECT, original.encode(), "consumer.py") == ( FamilyFlattened.occurrences(ast.parse(original)) ) original_modules = tuple( - module for module in AgentCommsProject.mechanism_modules() if module.parent == Path(repo.root) + module for module in PROJECT.mechanism_modules() if module.parent == Path(repo.root) ) facades = { f"{directory}/{module.parent.name}/{module.name}" - for module in AgentCommsProject.mechanism_modules() + for module in PROJECT.mechanism_modules() for directory in ("nested", "vendor") } - facades.update(f"nested/{module.name}" for module in AgentCommsProject.mechanism_modules()) + facades.update(f"nested/{module.name}" for module in PROJECT.mechanism_modules()) moved = repo.commit( { "second.py": "def carry(value):\n return value\n", @@ -335,6 +336,6 @@ def test_content_cache_digest_covers_a_module_only_loaded_indirectly(): through its own imports. Its source is still one of the ratchet's measuring code, so a change to it is a different cache key.""" indirect = Path(sys.modules["agent_comms.coordination_tables.attempts"].__file__).resolve() - assert indirect in ContentCache.sources() - assert (AUDIT_SCRIPTS / "audit" / "findings.py").resolve() in ContentCache.sources() + assert indirect in PROJECT.sources() + assert (AUDIT_SCRIPTS / "audit" / "findings.py").resolve() in PROJECT.sources() assert indirect.is_relative_to(Path(__file__).resolve().parents[2] / "src") diff --git a/tests/test_boundary_decoding.py b/tests/test_boundary_decoding.py index d6a7ce3c01..333b33e0ee 100644 --- a/tests/test_boundary_decoding.py +++ b/tests/test_boundary_decoding.py @@ -1,10 +1,11 @@ """Boundary declarations reject malformed records without inventing another shape.""" from dataclasses import replace import hashlib +from pathlib import Path import pytest -from agent_comms.pinned_audit import audit +from agent_comms_project import AgentCommsProject from audit.source_measures import RawRecordShapes from agent_comms.channel_coding_tools import CodingToolRequest @@ -81,8 +82,9 @@ def test_native_source_receipt_checks_bytes_without_installing(tmp_path): def test_raw_shape_guard_reuses_the_existing_overlay_detector(): + project = AgentCommsProject(Path(__file__).resolve().parents[1]) raw = b'def read(row):\n return row["a"], row["b"], row.get("c")\n' typed = b'def read(row):\n return row.a, row.b, row.c\n' - assert RawRecordShapes.count(raw, 'src/example.py') == 1 - assert RawRecordShapes.count(typed, 'src/example.py') == 0 - assert RawRecordShapes.count(raw, 'src/agent_comms/field_codec.py') == 1 + assert RawRecordShapes.count(project, raw, 'src/example.py') == 1 + assert RawRecordShapes.count(project, typed, 'src/example.py') == 0 + assert RawRecordShapes.count(project, raw, 'src/agent_comms/field_codec.py') == 1 diff --git a/tests/test_carry.py b/tests/test_carry.py index 00b32c096c..3760205fed 100644 --- a/tests/test_carry.py +++ b/tests/test_carry.py @@ -12,9 +12,7 @@ import pytest -from agent_comms.pinned_audit import audit -from audit import agent_comms_project -from audit import repository as audit_git +from agent_comms_project import AgentCommsProject from audit.project_measures import UncarriedRecordFormat from agent_comms.locked_store import LiveFilesRequest @@ -122,15 +120,14 @@ def record_source(version: int, fields: str) -> str: def test_a_stored_record_format_change_bumps_its_version_and_ships_a_carry( tmp_path, before, version, fields, step, uncarried): repo = Repository(tmp_path) - package = audit_git.Repository(REPO).read("archive", "HEAD", "--", "src/agent_comms") - subprocess.run(["tar", "-x", "-C", str(tmp_path)], input=package, check=True, timeout=60) + AgentCommsProject(REPO).extract("HEAD", tmp_path, "src/agent_comms") record = tmp_path / "src" / "agent_comms" / "record.py" record.write_text(record_source(1, before)) base = repo.commit({}) record.write_text(record_source(version, fields)) head = repo.commit(step) changed = {"src/agent_comms/record.py"} - assert UncarriedRecordFormat.compare(audit_git.Repository(tmp_path), base, head, changed, + assert UncarriedRecordFormat.counted(AgentCommsProject(tmp_path), base, head, changed, "src/agent_comms") == ( {UncarriedRecordFormat.total(): 0}, {UncarriedRecordFormat.total(): uncarried}) diff --git a/tests/test_phase_evidence.py b/tests/test_phase_evidence.py index acf3259646..9e76503d1c 100644 --- a/tests/test_phase_evidence.py +++ b/tests/test_phase_evidence.py @@ -200,7 +200,7 @@ def test_source_shape_evidence_reads_declarations_on_main(tmp_path): reading = main_of(tmp_path, { "src/a.py": "class Store:\n" + copy + "\nclass Child(Store):\n pass\n", "src/b.py": "class Other:\n" + copy.replace("path", "root") + "\n" - "def long():\n" + " x = 1\n" * GodFunction.threshold + "\n" + "def long():\n" + " x = 1\n" * GodFunction.threshold() + "\n" "def probe(other):\n return other.value is None\n" "def check(value):\n return isinstance(value, (int, Store))\n", "src/caps.py": "class Shared:\n pass\n\nclass Lonely:\n pass\n", diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py new file mode 100644 index 0000000000..7794b78773 --- /dev/null +++ b/tools/agent_comms_project.py @@ -0,0 +1,241 @@ +"""agent-comms as the refactor-audit's measured project: its facts, each derived from the +declaration that owns it here, and the one measure only it has, the type check of its stack +clients. + +The debt ratchet's entry (debt_ratchet.py) measures an AgentCommsProject of its checkout; the +audit package itself never imports agent-comms.""" + +from __future__ import annotations + +import ast +import atexit +import inspect +import json +import os +import shutil +import sys +import tempfile +from collections import Counter +from collections.abc import Callable, Mapping +from concurrent.futures import Executor +from dataclasses import dataclass +from functools import cache, partial +from pathlib import Path +from subprocess import CompletedProcess + +import agent_comms +import stored_formats +from agent_comms import coordination_schema, field_codec, locked_store, process_claim, store_files, typed_table +from agent_comms.child_process import ForkedProcessPool, SpawnedProcess +from agent_comms.core_stamp import WheelStamp +from agent_comms.declared_family import DeclaredFamily +from agent_comms.field_codec import boundary_decoder +from agent_comms.installer.carry import STEPS +from agent_comms.installer.probes.carry import Carry +from agent_comms.phase_evidence import MainTree, PlanReading +from agent_comms.pinned_audit import audit +from agent_comms.plan_phases import PlanPhase +from agent_comms.repo import Repo +from audit.checkers import CheckerSite, SideCounts, TypeErrors +from audit.project import Project, StoredFormat +from audit.ratchet import BaseSide, Compared, FileCount, HeadSide, Side +from audit.source_measures import DecoratorBinding +from module_layers import PACKAGE, StackClient + +SOURCES = Path(inspect.getfile(agent_comms)).parents[1] +REPOSITORY = SOURCES.parent + + +@dataclass(frozen=True) +class AgentCommsProject(Project[PlanPhase]): + """agent-comms' own facts, read from the modules that declare them.""" + + probe = ( + "import importlib.util, os, sys; sys.path[:0] = sys.argv[2].split(os.pathsep); " + "spec = importlib.util.spec_from_file_location('stored_formats', sys.argv[1]); " + "module = sys.modules[spec.name] = importlib.util.module_from_spec(spec); " + "spec.loader.exec_module(module); " + "module.main(sys.argv[3:])" + ) + """The revision's own reader, loaded by file, over only the revision's own source: a tools + directory on the path would resolve the revision's bare imports into this one's tree.""" + + def cache_directory(self) -> Path: + return Path.home() / ".cache/agent-comms/debt-ratchet" + + def source_roots(self) -> tuple[Path, ...]: + return (Path(inspect.getfile(stored_formats)).parent, SOURCES) + + def pool(self, workers: int) -> Executor: + return ForkedProcessPool(workers) + + def run(self, command: tuple[str, ...], *, cwd: Path, env: Mapping[str, str], + timeout: float) -> CompletedProcess[bytes]: + return SpawnedProcess.run(command, cwd=cwd, env=env, timeout=timeout) + + def lock_owners(self) -> tuple[str, ...]: + return tuple(Path(inspect.getfile(module)).relative_to(SOURCES).as_posix() + for module in (store_files, locked_store, process_claim)) + + def lock_functions(self) -> frozenset[str]: + return frozenset(function.__name__ + for function in (store_files._store_lock, store_files._async_store_lock)) + + def absence_decoders(self) -> tuple[Callable[..., object], ...]: + return (boundary_decoder,) + + def mechanism_modules(self) -> frozenset[Path]: + # Our own modules are named by their files; Toad's live in another repository, named by path. + return frozenset(( + *(Path(inspect.getfile(module)).relative_to(REPOSITORY) + for module in (field_codec, typed_table, coordination_schema)), + *(Path("src", "toad", module) for module in ("agent_schema.py", "db.py")), + )) + + def selecting_calls(self) -> frozenset[str]: + return frozenset({DeclaredFamily.member_named.__name__}) + + def stored_formats(self, ref: str, root: str, changed: set[str]) -> dict[str, StoredFormat]: + package = Path(root) + while self.tracks(ref, str(package / "__init__.py")): + package = package.parent + modules = [".".join(Path(path).relative_to(package).with_suffix("").parts) + for path in sorted(changed)] + reader = Path(inspect.getfile(stored_formats)).relative_to(REPOSITORY) + with tempfile.TemporaryDirectory(prefix="ratchet-formats-") as scratch: + # Each side runs its own revision's probe script: the head's may import modules + # that only the head's package declares (#3576 moved field_codec under nominal). + tree = Path(scratch) + self.extract(ref, tree, str(package), str(reader)) + sources = Compared.source_paths(root, tree) + for stamp in tree.rglob("package_stamp.py"): + WheelStamp.of_source(tree / package, self.revision(ref), + source_paths=sources).write(stamp) + paths = dict.fromkeys((str(tree / package), *sources)) + result = SpawnedProcess.run( + (sys.executable, "-c", self.probe, str(tree / reader), + os.pathsep.join(paths), *modules), cwd=scratch, + env=Compared.environment(root, tree), timeout=300, + ) + if result.returncode: + raise ValueError(f"stored formats could not be read at {ref}: {result.stderr.decode()}") + return {name: StoredFormat(**form) for name, form in json.loads(result.stdout).items()} + + def carried(self, base: str, head: str) -> bool: + """A carry step is a module under STEPS declaring a member of the Carry family: one of + its classes derives from Carry or one of Carry's own subclasses, as that module binds it.""" + added = [path for change in self.changes(base, head, str(STEPS)) for path in change.added() + if self.python(path)] + families = (Carry, *Carry.__subclasses__()) + for path, source in self.sources(head, set(added)): + tree = ast.parse(source, path) + bindings = [DecoratorBinding.of(family, tree, path) for family in families] + if any(binding.marks(base) for node in ast.walk(tree) if isinstance(node, ast.ClassDef) + for base in node.bases for binding in bindings): + return True + return False + + def guard_roots(self) -> frozenset[str]: + declared = Path(inspect.getfile(PlanPhase)).resolve() + return frozenset({f"{declared.relative_to(REPOSITORY).parts[0]}/"}) + + @cache + def reading(self, commit: str) -> PlanReading: + return PlanReading((), MainTree(Repo.of(self.path), commit)) + + def failing_guards(self, commit: str) -> frozenset[type[PlanPhase]]: + reading = self.reading(commit) + return frozenset(phase for phase in PlanPhase.members_with(PlanPhase) + if not phase.done(reading)) + + def guard_sites(self, head: str, guard: type[PlanPhase]) -> tuple[str, ...]: + reading = self.reading(head) + evidence = guard.phase_evidence() + return tuple(evidence.sites(reading)) or (reading.describe(evidence),) + + +@dataclass(frozen=True) +class ClientFileCount(FileCount): + """Type errors in one stack client file, which the client's own code cannot add: both sides + check the same client commit.""" + + def grew(self, grown: int) -> str: + return (f"grew {self.text} +{grown}: this change removes or changes an agent_comms name " + "that client file uses; run mypy on it to see which") + + +class ClientTypeErrors(SideCounts): + """Mypy errors in each stack client file that imports agent_comms, checked against each + revision's package. Both sides read the same client commit, so the client's own errors cancel + and an error the head adds is a name or signature the client uses that the change took away: + #3110 deleted TurnState.settles as unused while Toad's conversation_turn.py:262 called it, and + only the running app found it.""" + + finding = TypeErrors.finding + + @staticmethod + @cache + def clients(repo: AgentCommsProject, head: str) -> tuple[StackClient, ...]: + """The head's stack clients, cloned once per run and read by both sides; a revision + declaring no stack has none.""" + stack = "stack/pyproject.toml" + if not repo.tracks(head, stack): + return () + into = Path(tempfile.mkdtemp(prefix="ratchet-clients-")) + atexit.register(shutil.rmtree, into, True) + (into / "stack").mkdir() + (into / stack).write_bytes(repo.blob(head, stack)) + return StackClient.fetched(into / "stack", into) + + @staticmethod + def selected(client: StackClient) -> list[str]: + return sorted({entry.path for entry in client.imports + if entry.module.partition(".")[0] == PACKAGE}) + + @classmethod + def findings(cls, repo: AgentCommsProject, ref: str, client: StackClient, + side: type[Side]) -> tuple[type[Side], tuple[CheckerSite, ...]]: + # The pinned audit's git boundary, which pinned_audit puts on the path. + commit = audit.repository.Repository(client.checkout).revision("HEAD") + return side, repo.memo(partial(cls.checked, repo, ref, client, side), + cls.__name__, client.name, commit, repo.identity(ref, "src")) + + @classmethod + def checked(cls, repo: AgentCommsProject, ref: str, client: StackClient, + side: type[Side]) -> tuple[CheckerSite, ...]: + selected = cls.selected(client) + cache = repo.beside("mypy-clients") / client.name / side.family_name + with tempfile.TemporaryDirectory(prefix="ratchet-client-") as scratch: + repo.extract(ref, Path(scratch), "src") + # The client checks under its own configuration, from its checkout. + result = SpawnedProcess.run( + (sys.executable, "-m", *TypeErrors.mypy( + cache, "--follow-imports=silent", "--ignore-missing-imports", *selected)), + cwd=client.checkout, timeout=600, + env={**os.environ, "MYPYPATH": os.pathsep.join( + (str(Path(scratch, "src")), str(client.checkout / "src")))}, + ) + output = cls.checker_output(result, f"{client.name} at {ref}") + return tuple(CheckerSite(f"{client.name}/{match['path']}", int(match["line"]), + match["message"]) + for match in cls.finding.finditer(output) if match["path"] in selected) + + @classmethod + def parts(cls, repo: AgentCommsProject, base: str, head: str, changed: set[str], + root: str) -> tuple[Callable[[], object], ...]: + # Only a change to the package the clients import can change what they see. + if not any(path.startswith(f"src/{PACKAGE}/") for path in changed): + return () + return tuple(partial(cls.findings, repo, side.ref(base, head), client, side) + for client in cls.clients(repo, head) + if cls.selected(client) + for side in Side.sides()) + + @classmethod + def measured(cls, found: dict[type[Side], Counter[str]], changed: set[str]) -> list[str]: + """The client files with an error on either side; changed paths are the package's.""" + return sorted(found[BaseSide].keys() | found[HeadSide].keys()) + + @classmethod + def at(cls, path: str) -> ClientFileCount: + return ClientFileCount(cls, path) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 8848b98999..6bb939e604 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -1,32 +1,14 @@ #!/usr/bin/env python3 -"""The debt ratchet's CI entry: agent-comms compared by the refactor-audit this checkout pins. - -Every measure, the comparison, its JSON report and its exit status belong to the audit package; -agent-comms' own facts are its declaration there, audit/agent_comms_project.py, which importing -declares. This entry only reads the command line.""" +"""The debt ratchet's CI entry: this checkout, measured as AgentCommsProject by the refactor-audit +it pins. Every measure, the command line, the comparison, its JSON report and its exit status are +the audit's; the one fact this entry owns is which project it measures.""" from __future__ import annotations -import argparse -import os from pathlib import Path -from agent_comms.pinned_audit import audit -from audit import agent_comms_project -from audit.ratchet import ratchet -from audit.repository import Repository - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", required=True, help="Repository-relative production source directory") - parser.add_argument("--base", required=True) - parser.add_argument("--head", required=True) - parser.add_argument("--workers", type=int, default=os.process_cpu_count() or 1, - help="Pool width; a caller running ratchets in parallel narrows it") - args = parser.parse_args() - return ratchet(Repository(Path.cwd()), args.base, args.head, args.root, args.workers) - +from agent_comms_project import AgentCommsProject +from audit.ratchet import main if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main(AgentCommsProject(Path.cwd()))) diff --git a/tools/repairs.py b/tools/repairs.py index c3f0ce0e55..470daf068b 100644 --- a/tools/repairs.py +++ b/tools/repairs.py @@ -25,10 +25,8 @@ from pathlib import Path from typing import ClassVar -from agent_comms.pinned_audit import audit -from audit import agent_comms_project +from agent_comms_project import AgentCommsProject from audit.ratchet import LocatedSource, compare -from audit.repository import Repository from audit.source_measures import FunctionLocalImport from module_layers import Package, TopLevelNames from nominal_refactor_advisor.codemod import ( @@ -149,7 +147,7 @@ def __init__(self, repo: Path, paths: Iterable[str]) -> None: def file_operations(self, path: str, source: str) -> list[Operation]: if path not in self.modules: - self.left[OutsidePackage] += FunctionLocalImport.count(source.encode(), path) + self.left[OutsidePackage] += FunctionLocalImport.count(AgentCommsProject(self.repo), source.encode(), path) return [] hoisted = HoistedFile(self, path, self.modules[path], source) for local in hoisted.local_imports(hoisted.tree, []): @@ -425,14 +423,14 @@ def main() -> int: if args.all: files = { measure: sorted( - path for path in Repository(repo).production_paths("HEAD") + path for path in AgentCommsProject(repo).production_paths("HEAD") if path.startswith(root) ) for measure in (repair.repairs for repair in Repair.members_with(Repair)) } else: found = compare( - Repository(repo), + AgentCommsProject(repo), args.base, args.head, args.root, From 16aee66176a44cca3d698a06c737589ff143a7c0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:26:32 -0400 Subject: [PATCH 04/18] A carry step's base may be any class of the Carry family; pin the sources fix carried() bound only Carry and its direct subclasses, so a step deriving from another step's base (carry_toad_arrow_keys.py's ToadSettingsCarry, 0f896c5f0) read as no carry. It binds the whole family now: 0f896c5f0, 0455ac64b and 01cc13cd6 read carried, merge 4ca5c1971 does not. The content-cache test reads measuring_sources. The submodule pins refactor-audit 48b33bb0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/skills/refactor-audit | 2 +- tests/guards/test_agent_comms_ratchet.py | 4 ++-- tools/agent_comms_project.py | 13 ++++++++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index 03ed7a0e23..48b33bb011 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit 03ed7a0e23430b7690e16692180db48ad5d958f9 +Subproject commit 48b33bb011c0fd6bc8ef02ede473629cce7d4072 diff --git a/tests/guards/test_agent_comms_ratchet.py b/tests/guards/test_agent_comms_ratchet.py index b8dd88a7b2..8f69e62f0c 100644 --- a/tests/guards/test_agent_comms_ratchet.py +++ b/tests/guards/test_agent_comms_ratchet.py @@ -336,6 +336,6 @@ def test_content_cache_digest_covers_a_module_only_loaded_indirectly(): through its own imports. Its source is still one of the ratchet's measuring code, so a change to it is a different cache key.""" indirect = Path(sys.modules["agent_comms.coordination_tables.attempts"].__file__).resolve() - assert indirect in PROJECT.sources() - assert (AUDIT_SCRIPTS / "audit" / "findings.py").resolve() in PROJECT.sources() + assert indirect in PROJECT.measuring_sources() + assert (AUDIT_SCRIPTS / "audit" / "findings.py").resolve() in PROJECT.measuring_sources() assert indirect.is_relative_to(Path(__file__).resolve().parents[2] / "src") diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index 7794b78773..c55e620dd8 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -126,7 +126,7 @@ def carried(self, base: str, head: str) -> bool: its classes derives from Carry or one of Carry's own subclasses, as that module binds it.""" added = [path for change in self.changes(base, head, str(STEPS)) for path in change.added() if self.python(path)] - families = (Carry, *Carry.__subclasses__()) + families = self.carry_family() for path, source in self.sources(head, set(added)): tree = ast.parse(source, path) bindings = [DecoratorBinding.of(family, tree, path) for family in families] @@ -135,6 +135,17 @@ def carried(self, base: str, head: str) -> bool: return True return False + @staticmethod + @cache + def carry_family() -> tuple[type[Carry], ...]: + """Carry and every class derived from it, its steps' shared bases among them; reading + the members imports every step module, where those bases are declared.""" + Carry.members_with(Carry) + found: list[type[Carry]] = [Carry] + for family in found: + found.extend(derived for derived in family.__subclasses__() if derived not in found) + return tuple(found) + def guard_roots(self) -> frozenset[str]: declared = Path(inspect.getfile(PlanPhase)).resolve() return frozenset({f"{declared.relative_to(REPOSITORY).parts[0]}/"}) From db41b7f9882f9bc5c345a8efdeb9ec350f37af80 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:35:25 -0400 Subject: [PATCH 05/18] The pinned audit is reached through pinned_audit's package, never by import order Review round 3 (claude-code e95b7b02ff80): tools and tests read every audit name as audit.. through `from agent_comms.pinned_audit import audit`, so no module needs another to have put the scripts dir on sys.path first; module_layers' import is now used. AuditPatternPresent reads the skills dir from the loaded package, not a stripped '.py'. The hoist repair builds its AgentCommsProject once. The submodule pins refactor-audit b024c5f8. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/phase_evidence.py | 5 ++- src/agent_comms/skills/refactor-audit | 2 +- tests/guards/test_agent_comms_ratchet.py | 57 +++++++++++------------- tests/test_boundary_decoding.py | 8 ++-- tests/test_carry.py | 6 +-- tools/agent_comms_project.py | 36 +++++++-------- tools/debt_ratchet.py | 4 +- tools/module_layers.py | 5 +-- tools/repairs.py | 14 +++--- 9 files changed, 67 insertions(+), 70 deletions(-) diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index da9a7d0623..b7c332f927 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -23,6 +23,7 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec from agent_comms.head_review import HeadReview +import agent_comms from agent_comms import skills from agent_comms.pinned_audit import SKILL, ForeignAbsenceProbe, GodFunction, ParsedModule, audit from agent_comms.private_path import FileObservation, FileRevision @@ -402,7 +403,9 @@ class AuditPatternPresent(PatternPresent): package sits inside it are pinned_audit's, read from the loaded skill.""" def read(self, main: MainTree) -> bool: - submodule = main.repo.module_file(skills.__name__).with_suffix("") / SKILL.name + # The skills package's place under the source root, read from the loaded package. + installed = Path(skills.__path__[0]).relative_to(Path(agent_comms.__path__[0]).parent) + submodule = Path(main.repo.source, installed, SKILL.name) package = Path(audit.__path__[0]).relative_to(SKILL) pinned = main.pinned(submodule.as_posix()) return self.satisfied(pinned.count(self.pattern, (package.as_posix(),))) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index 48b33bb011..b024c5f8fa 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit 48b33bb011c0fd6bc8ef02ede473629cce7d4072 +Subproject commit b024c5f8fae0cf783a5f32c8cc98131f03c2d9bf diff --git a/tests/guards/test_agent_comms_ratchet.py b/tests/guards/test_agent_comms_ratchet.py index 8f69e62f0c..9e5dd6ba4a 100644 --- a/tests/guards/test_agent_comms_ratchet.py +++ b/tests/guards/test_agent_comms_ratchet.py @@ -20,10 +20,7 @@ from agent_comms.child_process import ObservedProcess, SpawnedProcess from agent_comms.field_codec import FieldCodec from agent_comms_project import AgentCommsProject -from audit.project import AUDIT_SCRIPTS -from audit.project_measures import DonePhaseBroken, UncarriedRecordFormat -from audit.ratchet import Compared, compare -from audit.source_measures import BroadSwallow, FamilyFlattened, NoneAbsence, StoreLockCalls +from agent_comms.pinned_audit import audit from fixture_git import IDENTITY from process_tree_fixture import TREE, identities, ready @@ -84,10 +81,10 @@ def comparing(self, base: str, head: str) -> Iterator[SpawnedProcess]: output=subprocess.PIPE) as child: yield child - def compare(self, base: str, head: str, *measured: type[Compared]) -> tuple[int, dict]: + def compare(self, base: str, head: str, *measured: type[audit.ratchet.Compared]) -> tuple[int, dict]: """The comparison's exit status and report, taking only the named measures' measurements. The command line itself is proven by the tests that launch it.""" - result = compare(AgentCommsProject(self.path), base, head, self.root, 2, measured) + result = audit.ratchet.compare(AgentCommsProject(self.path), base, head, self.root, 2, measured) return int(result.failed), json.loads(json.dumps(result.report())) @@ -100,7 +97,7 @@ def repo(tmp_path: Path, request) -> Checkout: def test_agent_comms_broad_swallows_stay_within_their_budget(): root = SOURCE / "src/agent_comms" - assert sum(BroadSwallow.count(PROJECT, path.read_bytes(), str(path)) for path in root.rglob("*.py")) <= 29 + assert sum(audit.source_measures.BroadSwallow.count(PROJECT, path.read_bytes(), str(path)) for path in root.rglob("*.py")) <= 29 @pytest.mark.parametrize( @@ -116,7 +113,7 @@ def test_agent_comms_broad_swallows_stay_within_their_budget(): ], ) def test_store_lock_calls_count_calls_not_spellings(source, expected): - assert StoreLockCalls.count(PROJECT, source.encode(), "src/agent_comms/example.py") == expected + assert audit.source_measures.StoreLockCalls.count(PROJECT, source.encode(), "src/agent_comms/example.py") == expected def test_store_lock_calls_skip_the_store_owners(): @@ -124,7 +121,7 @@ def test_store_lock_calls_skip_the_store_owners(): source = b"with _store_lock(path):\n pass" owners = PROJECT.lock_owners() assert all((SOURCE / "src" / owner).is_file() for owner in owners) - assert [StoreLockCalls.count(PROJECT, source, f"src/{owner}") for owner in owners] == [0] * len(owners) + assert [audit.source_measures.StoreLockCalls.count(PROJECT, source, f"src/{owner}") for owner in owners] == [0] * len(owners) def test_a_new_store_lock_call_fails_on_its_own_file_with_its_line(repo): @@ -133,10 +130,10 @@ def test_a_new_store_lock_call_fails_on_its_own_file_with_its_line(repo): head = repo.commit({"turns.py": "def save(store):\n pass\n", "other.py": "def read(store):\n with _store_lock(store.path, mode, contention):\n pass\n" " with _store_lock(store.path, mode, contention):\n pass\n"}) - status, report = repo.compare(base, head, StoreLockCalls) + status, report = repo.compare(base, head, audit.source_measures.StoreLockCalls) assert status == 1 - grown = StoreLockCalls.at(f"{repo.root}/other.py").text - assert report["delta"] == {StoreLockCalls.at(f"{repo.root}/turns.py").text: -1, grown: 1} + grown = audit.source_measures.StoreLockCalls.at(f"{repo.root}/other.py").text + assert report["delta"] == {audit.source_measures.StoreLockCalls.at(f"{repo.root}/turns.py").text: -1, grown: 1} assert report["found"] == {grown: [{"path": f"{repo.root}/other.py", "line": line} for line in (2, 4)]} @@ -191,7 +188,7 @@ def test_format_probe_keeps_parent_dependencies_and_reads_archived_source(tmp_pa subprocess.run(["tar", "-x", "-C", str(tmp_path)], input=source, check=True, timeout=60) # A Git archive carries no submodule: the complete producer stamp's child reaches the # pinned audit only through the measurement's own source paths. - assert not (tmp_path / AUDIT_SCRIPTS.resolve().relative_to(SOURCE) / "audit").exists() + assert not (tmp_path / audit.project.AUDIT_SCRIPTS.resolve().relative_to(SOURCE) / "audit").exists() dependency = tmp_path / "dependencies" dependency.mkdir() (dependency / "parent_only_dependency.py").write_text("VERSION = 7\n") @@ -212,7 +209,7 @@ def test_format_probe_keeps_parent_dependencies_and_reads_archived_source(tmp_pa (tmp_path / "src/agent_comms/probe_record.py").write_text( "raise RuntimeError('not archived')\n" ) - forms = UncarriedRecordFormat.formats( + forms = audit.project_measures.UncarriedRecordFormat.formats( AgentCommsProject(tmp_path), revision, repo.root, {"src/agent_comms/probe_record.py"} ) assert forms["agent_comms.probe_record.Record"].version == 7 @@ -236,14 +233,14 @@ def test_a_done_phase_fails_the_change_that_breaks_it(tmp_path: Path) -> None: base = repo.commit({"agent_comms/owned.py": owned}) head = repo.commit({"agent_comms/owned.py": owned + "\n\ndef owns(value):\n" " return isinstance(value, Owned)\n"}) - status, report = repo.compare(base, head, DonePhaseBroken) + status, report = repo.compare(base, head, audit.project_measures.DonePhaseBroken) assert status == 1 assert report["delta"] == { "DonePhaseBroken:OwnTypeChecksPhase::src/agent_comms/owned.py:6": 1, } clean = repo.commit({"agent_comms/owned.py": owned + "\n\ndef owns(value):\n" " return value.owned()\n"}) - assert repo.compare(base, clean, DonePhaseBroken)[0] == 0 + assert repo.compare(base, clean, audit.project_measures.DonePhaseBroken)[0] == 0 @pytest.mark.parametrize("root", ["src/agent_comms", "src/toad", "src/textual"]) @@ -259,12 +256,12 @@ def test_family_flattening_cannot_offset_another_file_or_admit_a_named_facade( "second.py": "def carry(value):\n return value.declared_name\n", } ) - status, report = repo.compare(base, head, FamilyFlattened) + status, report = repo.compare(base, head, audit.source_measures.FamilyFlattened) assert status == 1 - assert report["delta"][FamilyFlattened.at(f"{repo.root}/first.py").text] == -2 - assert report["delta"][FamilyFlattened.at(f"{repo.root}/second.py").text] == 1 - assert FamilyFlattened.count(PROJECT, original.encode(), "consumer.py") == ( - FamilyFlattened.occurrences(ast.parse(original)) + assert report["delta"][audit.source_measures.FamilyFlattened.at(f"{repo.root}/first.py").text] == -2 + assert report["delta"][audit.source_measures.FamilyFlattened.at(f"{repo.root}/second.py").text] == 1 + assert audit.source_measures.FamilyFlattened.count(PROJECT, original.encode(), "consumer.py") == ( + audit.source_measures.FamilyFlattened.occurrences(ast.parse(original)) ) original_modules = tuple( module for module in PROJECT.mechanism_modules() if module.parent == Path(repo.root) @@ -283,13 +280,13 @@ def test_family_flattening_cannot_offset_another_file_or_admit_a_named_facade( **{path: original for path in facades}, } ) - status, report = repo.compare(head, moved, FamilyFlattened) + status, report = repo.compare(head, moved, audit.source_measures.FamilyFlattened) assert status == 1 - assert report["delta"][FamilyFlattened.at(f"{repo.root}/another_codec.py").text] == 2 + assert report["delta"][audit.source_measures.FamilyFlattened.at(f"{repo.root}/another_codec.py").text] == 2 for module in original_modules: - assert report["head"][FamilyFlattened.at(module.as_posix()).text] == 0 + assert report["head"][audit.source_measures.FamilyFlattened.at(module.as_posix()).text] == 0 for path in facades: - assert report["head"][FamilyFlattened.at(f"{repo.root}/{path}").text] == 2 + assert report["head"][audit.source_measures.FamilyFlattened.at(f"{repo.root}/{path}").text] == 2 def test_none_as_absence_fails_with_its_line_except_in_a_declared_boundary_decoder(repo): @@ -306,13 +303,13 @@ def test_none_as_absence_fails_with_its_line_except_in_a_declared_boundary_decod head = repo.commit( {name: source.replace("value: str", "value: str | None") for name, source in files.items()} ) - status, report = repo.compare(base, head, NoneAbsence) + status, report = repo.compare(base, head, audit.source_measures.NoneAbsence) assert status == 1 assert {identity: lines for identity, lines in report["introduced"].items() if lines} == { - NoneAbsence.at(f"{repo.root}/owner.py").text: [1], - NoneAbsence.at(f"{repo.root}/foreign.py").text: [5], + audit.source_measures.NoneAbsence.at(f"{repo.root}/owner.py").text: [1], + audit.source_measures.NoneAbsence.at(f"{repo.root}/foreign.py").text: [5], } - plan = NoneAbsence.at(f"{repo.root}/plan.py").text + plan = audit.source_measures.NoneAbsence.at(f"{repo.root}/plan.py").text assert report["introduced"][plan] == [] and report["delta"][plan] == 0 @@ -337,5 +334,5 @@ def test_content_cache_digest_covers_a_module_only_loaded_indirectly(): change to it is a different cache key.""" indirect = Path(sys.modules["agent_comms.coordination_tables.attempts"].__file__).resolve() assert indirect in PROJECT.measuring_sources() - assert (AUDIT_SCRIPTS / "audit" / "findings.py").resolve() in PROJECT.measuring_sources() + assert (audit.project.AUDIT_SCRIPTS / "audit" / "findings.py").resolve() in PROJECT.measuring_sources() assert indirect.is_relative_to(Path(__file__).resolve().parents[2] / "src") diff --git a/tests/test_boundary_decoding.py b/tests/test_boundary_decoding.py index 333b33e0ee..63d5c443c4 100644 --- a/tests/test_boundary_decoding.py +++ b/tests/test_boundary_decoding.py @@ -6,7 +6,7 @@ import pytest from agent_comms_project import AgentCommsProject -from audit.source_measures import RawRecordShapes +from agent_comms.pinned_audit import audit from agent_comms.channel_coding_tools import CodingToolRequest from agent_comms.input_key import BusInputKey @@ -85,6 +85,6 @@ def test_raw_shape_guard_reuses_the_existing_overlay_detector(): project = AgentCommsProject(Path(__file__).resolve().parents[1]) raw = b'def read(row):\n return row["a"], row["b"], row.get("c")\n' typed = b'def read(row):\n return row.a, row.b, row.c\n' - assert RawRecordShapes.count(project, raw, 'src/example.py') == 1 - assert RawRecordShapes.count(project, typed, 'src/example.py') == 0 - assert RawRecordShapes.count(project, raw, 'src/agent_comms/field_codec.py') == 1 + assert audit.source_measures.RawRecordShapes.count(project, raw, 'src/example.py') == 1 + assert audit.source_measures.RawRecordShapes.count(project, typed, 'src/example.py') == 0 + assert audit.source_measures.RawRecordShapes.count(project, raw, 'src/agent_comms/field_codec.py') == 1 diff --git a/tests/test_carry.py b/tests/test_carry.py index 3760205fed..934dcb0833 100644 --- a/tests/test_carry.py +++ b/tests/test_carry.py @@ -13,7 +13,7 @@ import pytest from agent_comms_project import AgentCommsProject -from audit.project_measures import UncarriedRecordFormat +from agent_comms.pinned_audit import audit from agent_comms.locked_store import LiveFilesRequest from agent_comms.cli_verbs.stop import StopCliCommand @@ -127,9 +127,9 @@ def test_a_stored_record_format_change_bumps_its_version_and_ships_a_carry( record.write_text(record_source(version, fields)) head = repo.commit(step) changed = {"src/agent_comms/record.py"} - assert UncarriedRecordFormat.counted(AgentCommsProject(tmp_path), base, head, changed, + assert audit.project_measures.UncarriedRecordFormat.counted(AgentCommsProject(tmp_path), base, head, changed, "src/agent_comms") == ( - {UncarriedRecordFormat.total(): 0}, {UncarriedRecordFormat.total(): uncarried}) + {audit.project_measures.UncarriedRecordFormat.total(): 0}, {audit.project_measures.UncarriedRecordFormat.total(): uncarried}) def test_a_store_compares_by_its_rows_and_not_by_its_companion_files(tmp_path): diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index c55e620dd8..d8e0c9ce51 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -36,10 +36,6 @@ from agent_comms.pinned_audit import audit from agent_comms.plan_phases import PlanPhase from agent_comms.repo import Repo -from audit.checkers import CheckerSite, SideCounts, TypeErrors -from audit.project import Project, StoredFormat -from audit.ratchet import BaseSide, Compared, FileCount, HeadSide, Side -from audit.source_measures import DecoratorBinding from module_layers import PACKAGE, StackClient SOURCES = Path(inspect.getfile(agent_comms)).parents[1] @@ -47,7 +43,7 @@ @dataclass(frozen=True) -class AgentCommsProject(Project[PlanPhase]): +class AgentCommsProject(audit.project.Project[PlanPhase]): """agent-comms' own facts, read from the modules that declare them.""" probe = ( @@ -95,7 +91,7 @@ def mechanism_modules(self) -> frozenset[Path]: def selecting_calls(self) -> frozenset[str]: return frozenset({DeclaredFamily.member_named.__name__}) - def stored_formats(self, ref: str, root: str, changed: set[str]) -> dict[str, StoredFormat]: + def stored_formats(self, ref: str, root: str, changed: set[str]) -> dict[str, audit.project.StoredFormat]: package = Path(root) while self.tracks(ref, str(package / "__init__.py")): package = package.parent @@ -107,7 +103,7 @@ def stored_formats(self, ref: str, root: str, changed: set[str]) -> dict[str, St # that only the head's package declares (#3576 moved field_codec under nominal). tree = Path(scratch) self.extract(ref, tree, str(package), str(reader)) - sources = Compared.source_paths(root, tree) + sources = audit.ratchet.Compared.source_paths(root, tree) for stamp in tree.rglob("package_stamp.py"): WheelStamp.of_source(tree / package, self.revision(ref), source_paths=sources).write(stamp) @@ -115,11 +111,11 @@ def stored_formats(self, ref: str, root: str, changed: set[str]) -> dict[str, St result = SpawnedProcess.run( (sys.executable, "-c", self.probe, str(tree / reader), os.pathsep.join(paths), *modules), cwd=scratch, - env=Compared.environment(root, tree), timeout=300, + env=audit.ratchet.Compared.environment(root, tree), timeout=300, ) if result.returncode: raise ValueError(f"stored formats could not be read at {ref}: {result.stderr.decode()}") - return {name: StoredFormat(**form) for name, form in json.loads(result.stdout).items()} + return {name: audit.project.StoredFormat(**form) for name, form in json.loads(result.stdout).items()} def carried(self, base: str, head: str) -> bool: """A carry step is a module under STEPS declaring a member of the Carry family: one of @@ -129,7 +125,7 @@ def carried(self, base: str, head: str) -> bool: families = self.carry_family() for path, source in self.sources(head, set(added)): tree = ast.parse(source, path) - bindings = [DecoratorBinding.of(family, tree, path) for family in families] + bindings = [audit.source_measures.DecoratorBinding.of(family, tree, path) for family in families] if any(binding.marks(base) for node in ast.walk(tree) if isinstance(node, ast.ClassDef) for base in node.bases for binding in bindings): return True @@ -166,7 +162,7 @@ def guard_sites(self, head: str, guard: type[PlanPhase]) -> tuple[str, ...]: @dataclass(frozen=True) -class ClientFileCount(FileCount): +class ClientFileCount(audit.ratchet.FileCount): """Type errors in one stack client file, which the client's own code cannot add: both sides check the same client commit.""" @@ -175,14 +171,14 @@ def grew(self, grown: int) -> str: "that client file uses; run mypy on it to see which") -class ClientTypeErrors(SideCounts): +class ClientTypeErrors(audit.checkers.SideCounts): """Mypy errors in each stack client file that imports agent_comms, checked against each revision's package. Both sides read the same client commit, so the client's own errors cancel and an error the head adds is a name or signature the client uses that the change took away: #3110 deleted TurnState.settles as unused while Toad's conversation_turn.py:262 called it, and only the running app found it.""" - finding = TypeErrors.finding + finding = audit.checkers.TypeErrors.finding @staticmethod @cache @@ -205,7 +201,7 @@ def selected(client: StackClient) -> list[str]: @classmethod def findings(cls, repo: AgentCommsProject, ref: str, client: StackClient, - side: type[Side]) -> tuple[type[Side], tuple[CheckerSite, ...]]: + side: type[audit.ratchet.Side]) -> tuple[type[audit.ratchet.Side], tuple[audit.checkers.CheckerSite, ...]]: # The pinned audit's git boundary, which pinned_audit puts on the path. commit = audit.repository.Repository(client.checkout).revision("HEAD") return side, repo.memo(partial(cls.checked, repo, ref, client, side), @@ -213,21 +209,21 @@ def findings(cls, repo: AgentCommsProject, ref: str, client: StackClient, @classmethod def checked(cls, repo: AgentCommsProject, ref: str, client: StackClient, - side: type[Side]) -> tuple[CheckerSite, ...]: + side: type[audit.ratchet.Side]) -> tuple[audit.checkers.CheckerSite, ...]: selected = cls.selected(client) cache = repo.beside("mypy-clients") / client.name / side.family_name with tempfile.TemporaryDirectory(prefix="ratchet-client-") as scratch: repo.extract(ref, Path(scratch), "src") # The client checks under its own configuration, from its checkout. result = SpawnedProcess.run( - (sys.executable, "-m", *TypeErrors.mypy( + (sys.executable, "-m", *audit.checkers.TypeErrors.mypy( cache, "--follow-imports=silent", "--ignore-missing-imports", *selected)), cwd=client.checkout, timeout=600, env={**os.environ, "MYPYPATH": os.pathsep.join( (str(Path(scratch, "src")), str(client.checkout / "src")))}, ) output = cls.checker_output(result, f"{client.name} at {ref}") - return tuple(CheckerSite(f"{client.name}/{match['path']}", int(match["line"]), + return tuple(audit.checkers.CheckerSite(f"{client.name}/{match['path']}", int(match["line"]), match["message"]) for match in cls.finding.finditer(output) if match["path"] in selected) @@ -240,12 +236,12 @@ def parts(cls, repo: AgentCommsProject, base: str, head: str, changed: set[str], return tuple(partial(cls.findings, repo, side.ref(base, head), client, side) for client in cls.clients(repo, head) if cls.selected(client) - for side in Side.sides()) + for side in audit.ratchet.Side.sides()) @classmethod - def measured(cls, found: dict[type[Side], Counter[str]], changed: set[str]) -> list[str]: + def measured(cls, found: dict[type[audit.ratchet.Side], Counter[str]], changed: set[str]) -> list[str]: """The client files with an error on either side; changed paths are the package's.""" - return sorted(found[BaseSide].keys() | found[HeadSide].keys()) + return sorted(found[audit.ratchet.BaseSide].keys() | found[audit.ratchet.HeadSide].keys()) @classmethod def at(cls, path: str) -> ClientFileCount: diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 6bb939e604..292acaf2d2 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -8,7 +8,7 @@ from pathlib import Path from agent_comms_project import AgentCommsProject -from audit.ratchet import main +from agent_comms.pinned_audit import audit if __name__ == "__main__": - raise SystemExit(main(AgentCommsProject(Path.cwd()))) + raise SystemExit(audit.ratchet.main(AgentCommsProject(Path.cwd()))) diff --git a/tools/module_layers.py b/tools/module_layers.py index 0f1ffbf31b..b652f10274 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -40,7 +40,6 @@ from agent_comms.installer.runtime_wire import ClientImport, CrossesRuntimes from agent_comms.pinned_audit import audit -from audit.typing_names import TypingImports, TypingReference from agent_comms.declared_family import DeclaredFamily @@ -206,11 +205,11 @@ def visit_Lambda(self, node: ast.Lambda) -> None: self.visit_scope(node, [node.body]) def visit_Module(self, node: ast.Module) -> None: - self.typing = TypingImports.of(node) + self.typing = audit.typing_names.TypingImports.of(node) self.generic_visit(node) def visit_If(self, node: ast.If) -> None: - test = TypingReference(self.typing) + test = audit.typing_names.TypingReference(self.typing) test.dispatch_sync(node.test) if test.is_type_checking: self.visit(node.test) diff --git a/tools/repairs.py b/tools/repairs.py index 470daf068b..8c12803486 100644 --- a/tools/repairs.py +++ b/tools/repairs.py @@ -26,8 +26,7 @@ from typing import ClassVar from agent_comms_project import AgentCommsProject -from audit.ratchet import LocatedSource, compare -from audit.source_measures import FunctionLocalImport +from agent_comms.pinned_audit import audit from module_layers import Package, TopLevelNames from nominal_refactor_advisor.codemod import ( CodemodPlanSequence, @@ -63,7 +62,7 @@ class OutsidePackage(Kept): class Repair(DeclaredFamily, ABC): """One measure's repair over some files of the working tree.""" - repairs: ClassVar[type[LocatedSource]] + repairs: ClassVar[type[audit.ratchet.LocatedSource]] def __init__(self, repo: Path, paths: Iterable[str]) -> None: self.repo, self.paths = repo, sorted(paths) @@ -132,11 +131,12 @@ class ImportHoist(Repair): load leads back to the module, judged with every move already accepted: two local imports naming each other each pass alone and deadlock together.""" - repairs = FunctionLocalImport + repairs = audit.source_measures.FunctionLocalImport def __init__(self, repo: Path, paths: Iterable[str]) -> None: super().__init__(repo, paths) self.package = Package(repo) + self.project = AgentCommsProject(repo) self.graph = self.package.load_graph() self.modules = { str(path.relative_to(repo)): module for module, path in self.package.files.items() @@ -147,7 +147,9 @@ def __init__(self, repo: Path, paths: Iterable[str]) -> None: def file_operations(self, path: str, source: str) -> list[Operation]: if path not in self.modules: - self.left[OutsidePackage] += FunctionLocalImport.count(AgentCommsProject(self.repo), source.encode(), path) + self.left[OutsidePackage] += audit.source_measures.FunctionLocalImport.count( + self.project, source.encode(), path + ) return [] hoisted = HoistedFile(self, path, self.modules[path], source) for local in hoisted.local_imports(hoisted.tree, []): @@ -429,7 +431,7 @@ def main() -> int: for measure in (repair.repairs for repair in Repair.members_with(Repair)) } else: - found = compare( + found = audit.ratchet.compare( AgentCommsProject(repo), args.base, args.head, From 08930305722dbbf941ede0a366dae8dc4b21b36f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:40:58 -0400 Subject: [PATCH 06/18] The client check reads audit's decoded findings; the moved module types to Project debt-tools on db41b7f9 refused tools/agent_comms_project.py: StringSubscript +4 and RawRecordShapes +1 for match["path"/"line"/"message"] in ClientTypeErrors, and TypeErrors +3 (Mapping env, an abstract Carry in a type[Carry] list, parts narrowing repo to AgentCommsProject). The client check reads audit's TypeErrors.reported(); ClientTypeErrors types its repo as the audit Project; run takes a dict env. The submodule pins ca1670f7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/skills/refactor-audit | 2 +- tools/agent_comms_project.py | 55 ++++++++++++++++++--------- tools/repairs.py | 2 +- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index b024c5f8fa..ca1670f7c0 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit b024c5f8fae0cf783a5f32c8cc98131f03c2d9bf +Subproject commit ca1670f7c0c0a281742d8583a635601ee8779016 diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index d8e0c9ce51..a5a0dea86f 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -9,6 +9,7 @@ import ast import atexit +import dataclasses import inspect import json import os @@ -16,7 +17,7 @@ import sys import tempfile from collections import Counter -from collections.abc import Callable, Mapping +from collections.abc import Callable from concurrent.futures import Executor from dataclasses import dataclass from functools import cache, partial @@ -25,7 +26,14 @@ import agent_comms import stored_formats -from agent_comms import coordination_schema, field_codec, locked_store, process_claim, store_files, typed_table +from agent_comms import ( + coordination_schema, + field_codec, + locked_store, + process_claim, + store_files, + typed_table, +) from agent_comms.child_process import ForkedProcessPool, SpawnedProcess from agent_comms.core_stamp import WheelStamp from agent_comms.declared_family import DeclaredFamily @@ -65,7 +73,7 @@ def source_roots(self) -> tuple[Path, ...]: def pool(self, workers: int) -> Executor: return ForkedProcessPool(workers) - def run(self, command: tuple[str, ...], *, cwd: Path, env: Mapping[str, str], + def run(self, command: tuple[str, ...], *, cwd: Path, env: dict[str, str], timeout: float) -> CompletedProcess[bytes]: return SpawnedProcess.run(command, cwd=cwd, env=env, timeout=timeout) @@ -81,7 +89,7 @@ def absence_decoders(self) -> tuple[Callable[..., object], ...]: return (boundary_decoder,) def mechanism_modules(self) -> frozenset[Path]: - # Our own modules are named by their files; Toad's live in another repository, named by path. + # Our own modules are named by their files; Toad's live in another repository, by path. return frozenset(( *(Path(inspect.getfile(module)).relative_to(REPOSITORY) for module in (field_codec, typed_table, coordination_schema)), @@ -91,7 +99,9 @@ def mechanism_modules(self) -> frozenset[Path]: def selecting_calls(self) -> frozenset[str]: return frozenset({DeclaredFamily.member_named.__name__}) - def stored_formats(self, ref: str, root: str, changed: set[str]) -> dict[str, audit.project.StoredFormat]: + def stored_formats( + self, ref: str, root: str, changed: set[str] + ) -> dict[str, audit.project.StoredFormat]: package = Path(root) while self.tracks(ref, str(package / "__init__.py")): package = package.parent @@ -115,7 +125,10 @@ def stored_formats(self, ref: str, root: str, changed: set[str]) -> dict[str, au ) if result.returncode: raise ValueError(f"stored formats could not be read at {ref}: {result.stderr.decode()}") - return {name: audit.project.StoredFormat(**form) for name, form in json.loads(result.stdout).items()} + return { + name: audit.project.StoredFormat(**form) + for name, form in json.loads(result.stdout).items() + } def carried(self, base: str, head: str) -> bool: """A carry step is a module under STEPS declaring a member of the Carry family: one of @@ -125,7 +138,9 @@ def carried(self, base: str, head: str) -> bool: families = self.carry_family() for path, source in self.sources(head, set(added)): tree = ast.parse(source, path) - bindings = [audit.source_measures.DecoratorBinding.of(family, tree, path) for family in families] + bindings = [ + audit.source_measures.DecoratorBinding.of(family, tree, path) for family in families + ] if any(binding.marks(base) for node in ast.walk(tree) if isinstance(node, ast.ClassDef) for base in node.bases for binding in bindings): return True @@ -133,11 +148,11 @@ def carried(self, base: str, head: str) -> bool: @staticmethod @cache - def carry_family() -> tuple[type[Carry], ...]: + def carry_family() -> tuple[type, ...]: """Carry and every class derived from it, its steps' shared bases among them; reading the members imports every step module, where those bases are declared.""" Carry.members_with(Carry) - found: list[type[Carry]] = [Carry] + found: list[type] = [Carry] for family in found: found.extend(derived for derived in family.__subclasses__() if derived not in found) return tuple(found) @@ -182,7 +197,7 @@ class ClientTypeErrors(audit.checkers.SideCounts): @staticmethod @cache - def clients(repo: AgentCommsProject, head: str) -> tuple[StackClient, ...]: + def clients(repo: audit.project.Project, head: str) -> tuple[StackClient, ...]: """The head's stack clients, cloned once per run and read by both sides; a revision declaring no stack has none.""" stack = "stack/pyproject.toml" @@ -200,15 +215,17 @@ def selected(client: StackClient) -> list[str]: if entry.module.partition(".")[0] == PACKAGE}) @classmethod - def findings(cls, repo: AgentCommsProject, ref: str, client: StackClient, - side: type[audit.ratchet.Side]) -> tuple[type[audit.ratchet.Side], tuple[audit.checkers.CheckerSite, ...]]: + def findings( + cls, repo: audit.project.Project, ref: str, client: StackClient, + side: type[audit.ratchet.Side], + ) -> tuple[type[audit.ratchet.Side], tuple[audit.checkers.CheckerSite, ...]]: # The pinned audit's git boundary, which pinned_audit puts on the path. commit = audit.repository.Repository(client.checkout).revision("HEAD") return side, repo.memo(partial(cls.checked, repo, ref, client, side), cls.__name__, client.name, commit, repo.identity(ref, "src")) @classmethod - def checked(cls, repo: AgentCommsProject, ref: str, client: StackClient, + def checked(cls, repo: audit.project.Project, ref: str, client: StackClient, side: type[audit.ratchet.Side]) -> tuple[audit.checkers.CheckerSite, ...]: selected = cls.selected(client) cache = repo.beside("mypy-clients") / client.name / side.family_name @@ -223,12 +240,12 @@ def checked(cls, repo: AgentCommsProject, ref: str, client: StackClient, (str(Path(scratch, "src")), str(client.checkout / "src")))}, ) output = cls.checker_output(result, f"{client.name} at {ref}") - return tuple(audit.checkers.CheckerSite(f"{client.name}/{match['path']}", int(match["line"]), - match["message"]) - for match in cls.finding.finditer(output) if match["path"] in selected) + return tuple(dataclasses.replace(site, path=f"{client.name}/{site.path}") + for site in audit.checkers.TypeErrors.reported(output) + if site.path in selected) @classmethod - def parts(cls, repo: AgentCommsProject, base: str, head: str, changed: set[str], + def parts(cls, repo: audit.project.Project, base: str, head: str, changed: set[str], root: str) -> tuple[Callable[[], object], ...]: # Only a change to the package the clients import can change what they see. if not any(path.startswith(f"src/{PACKAGE}/") for path in changed): @@ -239,7 +256,9 @@ def parts(cls, repo: AgentCommsProject, base: str, head: str, changed: set[str], for side in audit.ratchet.Side.sides()) @classmethod - def measured(cls, found: dict[type[audit.ratchet.Side], Counter[str]], changed: set[str]) -> list[str]: + def measured( + cls, found: dict[type[audit.ratchet.Side], Counter[str]], changed: set[str] + ) -> list[str]: """The client files with an error on either side; changed paths are the package's.""" return sorted(found[audit.ratchet.BaseSide].keys() | found[audit.ratchet.HeadSide].keys()) diff --git a/tools/repairs.py b/tools/repairs.py index 8c12803486..531837a6a5 100644 --- a/tools/repairs.py +++ b/tools/repairs.py @@ -25,8 +25,8 @@ from pathlib import Path from typing import ClassVar -from agent_comms_project import AgentCommsProject from agent_comms.pinned_audit import audit +from agent_comms_project import AgentCommsProject from module_layers import Package, TopLevelNames from nominal_refactor_advisor.codemod import ( CodemodPlanSequence, From b1d6c5f8fd5d323835fad2d1f04d13c0725a15d2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:52:33 -0400 Subject: [PATCH 07/18] The carry family is its members' MROs; the client check's unread pattern goes Review round 4 (claude-code ce65f96693c8): carry_family reads Carry's members and takes each one's MRO up to Carry, instead of calling members_with only to import the steps and then walking subclasses. ClientTypeErrors' `finding` was read by nothing since the check reads TypeErrors.reported(). The submodule pins the round-4 audit head. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/skills/refactor-audit | 2 +- tools/agent_comms_project.py | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index ca1670f7c0..bbab931405 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit ca1670f7c0c0a281742d8583a635601ee8779016 +Subproject commit bbab931405dbfe3cbc3a9c93ab6387661c511ba7 diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index a5a0dea86f..a35574da0d 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -149,13 +149,12 @@ def carried(self, base: str, head: str) -> bool: @staticmethod @cache def carry_family() -> tuple[type, ...]: - """Carry and every class derived from it, its steps' shared bases among them; reading - the members imports every step module, where those bases are declared.""" - Carry.members_with(Carry) - found: list[type] = [Carry] - for family in found: - found.extend(derived for derived in family.__subclasses__() if derived not in found) - return tuple(found) + """Carry and every class a step derives from on its way to Carry: each member's MRO up to + Carry, so a base another step declares, such as ToadSettingsCarry, is one of them.""" + return tuple(dict.fromkeys( + base for member in Carry.members_with(Carry) + for base in member.__mro__[:member.__mro__.index(Carry) + 1] + )) def guard_roots(self) -> frozenset[str]: declared = Path(inspect.getfile(PlanPhase)).resolve() @@ -193,8 +192,6 @@ class ClientTypeErrors(audit.checkers.SideCounts): #3110 deleted TurnState.settles as unused while Toad's conversation_turn.py:262 called it, and only the running app found it.""" - finding = audit.checkers.TypeErrors.finding - @staticmethod @cache def clients(repo: audit.project.Project, head: str) -> tuple[StackClient, ...]: From 19de7bcc60b15ed9c2dc767c8b59930ab83c7b9b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:17:01 -0400 Subject: [PATCH 08/18] Absent reader and absent pin are answers, not git crashes claude-code's run of the ratchet's own tests (which CI never runs) found two crashes and one regression: - stored_formats archived tools/stored_formats.py at any revision; at a revision before the reader (164c80ca9) git archive died on the pathspec. That revision declares no stored format: it answers {}. The fixtures that model agent-comms now ship the reader the way the repository does (test_carry, test_agent_comms_ratchet). - AuditPatternPresent asked git for the submodule pin at a commit with no submodule. A commit that pins no audit holds none of its patterns. - Pin bump to refactor-audit 342e2690: CensusRun takes its mechanism owners; Undeclared answers no project. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/phase_evidence.py | 3 +++ src/agent_comms/skills/refactor-audit | 2 +- tests/guards/test_agent_comms_ratchet.py | 2 +- tests/test_carry.py | 2 +- tools/agent_comms_project.py | 3 +++ 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index b7c332f927..d2964dcd12 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -407,6 +407,9 @@ def read(self, main: MainTree) -> bool: installed = Path(skills.__path__[0]).relative_to(Path(agent_comms.__path__[0]).parent) submodule = Path(main.repo.source, installed, SKILL.name) package = Path(audit.__path__[0]).relative_to(SKILL) + if not main.has(submodule.as_posix()): + # A commit that pins no audit holds none of its patterns. + return self.satisfied(0) pinned = main.pinned(submodule.as_posix()) return self.satisfied(pinned.count(self.pattern, (package.as_posix(),))) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index bbab931405..342e2690a1 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit bbab931405dbfe3cbc3a9c93ab6387661c511ba7 +Subproject commit 342e2690a1c33e3f18b3f2938bdcd0bfa860f2ae diff --git a/tests/guards/test_agent_comms_ratchet.py b/tests/guards/test_agent_comms_ratchet.py index 9e5dd6ba4a..0c6cb1f52e 100644 --- a/tests/guards/test_agent_comms_ratchet.py +++ b/tests/guards/test_agent_comms_ratchet.py @@ -183,7 +183,7 @@ def test_compare_timeout_retires_pool_probe_and_defiant_grandchild(tmp_path: Pat def test_format_probe_keeps_parent_dependencies_and_reads_archived_source(tmp_path, monkeypatch): repo = Checkout(tmp_path, "src/agent_comms") source = subprocess.check_output( - ["git", "archive", "HEAD", "--", "src/agent_comms"], timeout=20 + ["git", "archive", "HEAD", "--", "src/agent_comms", "tools/stored_formats.py"], timeout=20 ) subprocess.run(["tar", "-x", "-C", str(tmp_path)], input=source, check=True, timeout=60) # A Git archive carries no submodule: the complete producer stamp's child reaches the diff --git a/tests/test_carry.py b/tests/test_carry.py index 934dcb0833..947cbc55e4 100644 --- a/tests/test_carry.py +++ b/tests/test_carry.py @@ -120,7 +120,7 @@ def record_source(version: int, fields: str) -> str: def test_a_stored_record_format_change_bumps_its_version_and_ships_a_carry( tmp_path, before, version, fields, step, uncarried): repo = Repository(tmp_path) - AgentCommsProject(REPO).extract("HEAD", tmp_path, "src/agent_comms") + AgentCommsProject(REPO).extract("HEAD", tmp_path, "src/agent_comms", "tools/stored_formats.py") record = tmp_path / "src" / "agent_comms" / "record.py" record.write_text(record_source(1, before)) base = repo.commit({}) diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index a35574da0d..14973f0c43 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -108,6 +108,9 @@ def stored_formats( modules = [".".join(Path(path).relative_to(package).with_suffix("").parts) for path in sorted(changed)] reader = Path(inspect.getfile(stored_formats)).relative_to(REPOSITORY) + if not self.tracks(ref, str(reader)): + # A revision older than its reader (164c80ca9) declares no stored format. + return {} with tempfile.TemporaryDirectory(prefix="ratchet-formats-") as scratch: # Each side runs its own revision's probe script: the head's may import modules # that only the head's package declares (#3576 moved field_codec under nominal). From c6a40f2940ddb0e00edfada5d20efd1c0d8d5e38 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:56:58 -0400 Subject: [PATCH 09/18] P924: AgentCommsProject declares absence_boundaries; carried lookup reads audit.lexical The audit's Project renames absence_decoders to absence_boundaries (a decoder or an encoder marks where absence meets the outside) and moves DecoratorBinding into audit.lexical (refactor-audit#7, ceb14906). This head needs the pin bump to that audit merge: at the current pin the abstract method is still absence_decoders. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tools/agent_comms_project.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index 14973f0c43..05c6a7ccf3 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -85,7 +85,7 @@ def lock_functions(self) -> frozenset[str]: return frozenset(function.__name__ for function in (store_files._store_lock, store_files._async_store_lock)) - def absence_decoders(self) -> tuple[Callable[..., object], ...]: + def absence_boundaries(self) -> tuple[Callable[..., object], ...]: return (boundary_decoder,) def mechanism_modules(self) -> frozenset[Path]: @@ -142,7 +142,7 @@ def carried(self, base: str, head: str) -> bool: for path, source in self.sources(head, set(added)): tree = ast.parse(source, path) bindings = [ - audit.source_measures.DecoratorBinding.of(family, tree, path) for family in families + audit.lexical.DecoratorBinding.of(family, tree, path) for family in families ] if any(binding.marks(base) for node in ast.walk(tree) if isinstance(node, ast.ClassDef) for base in node.bases for binding in bindings): From bf4b76f47a872cb7a2f8529f1602512355282845 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:10:24 -0400 Subject: [PATCH 10/18] P924 review: carried lookup builds one ModuleScope per module refactor-audit 3a3a8378 moves module facts to audit.lexical.ModuleScope and DecoratorBinding.of takes it; one walk serves every Carry family. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tools/agent_comms_project.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index 05c6a7ccf3..39700400f3 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -141,9 +141,8 @@ def carried(self, base: str, head: str) -> bool: families = self.carry_family() for path, source in self.sources(head, set(added)): tree = ast.parse(source, path) - bindings = [ - audit.lexical.DecoratorBinding.of(family, tree, path) for family in families - ] + module = audit.lexical.ModuleScope(tree, path) + bindings = [audit.lexical.DecoratorBinding.of(family, module) for family in families] if any(binding.marks(base) for node in ast.walk(tree) if isinstance(node, ast.ClassDef) for base in node.bases for binding in bindings): return True From 2f6a1c97f9b6de6aa8db376c8e5c5b9e600ece52 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:20:41 -0400 Subject: [PATCH 11/18] P872/P924: pin refactor-audit to its #7 merge (d7401d67) refactor-audit#7 merged as d7401d6797feb839b014a258b9a44b9aba4d14c4 (reviewed head 785659a7, root 114/114 controls). Its Project declares absence_boundaries and ModuleScope, which AgentCommsProject already implements on this branch, so debt-src and debt-tools stop failing on the missing absence_decoders. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/skills/refactor-audit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index 342e2690a1..d7401d6797 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit 342e2690a1c33e3f18b3f2938bdcd0bfa860f2ae +Subproject commit d7401d6797feb839b014a258b9a44b9aba4d14c4 From bfe7c2ab7993e60f035795b8f0372f8ee495139e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:47:36 -0400 Subject: [PATCH 12/18] Stored formats: the measured project declares its reader; no silent empty answer AgentCommsProject.stored_formats probed each revision for tools/stored_formats.py and answered {} when it was missing, so a client repo (Toad ships no reader) had its stored-format check measure nothing and report clean on every run. - StoredFormatReader family: RevisionReader (agent-comms measuring itself: each revision runs its own reader, since a head reader may import head-only modules; a revision without one fails loud) and CheckoutReader (a client repo borrows the ratchet checkout's reader, importing agent_comms from that checkout). - AgentCommsProject.reader declares it, RevisionReader by default; the tracks() probe and the {} are deleted. - tools/client_ratchet.py: the client entry, AgentCommsProject(cwd, CheckoutReader()). Toad's workflow switches its one ratchet line to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tools/agent_comms_project.py | 42 +++++++++++++++++++++++++++++------- tools/client_ratchet.py | 15 +++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) create mode 100755 tools/client_ratchet.py diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index 39700400f3..cd16e564e7 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -16,6 +16,7 @@ import shutil import sys import tempfile +from abc import ABC, abstractmethod from collections import Counter from collections.abc import Callable from concurrent.futures import Executor @@ -48,12 +49,42 @@ SOURCES = Path(inspect.getfile(agent_comms)).parents[1] REPOSITORY = SOURCES.parent +READER = Path(inspect.getfile(stored_formats)).relative_to(REPOSITORY) + + +class StoredFormatReader(ABC): + """Where the reader that declares a revision's stored formats comes from. The measured + project declares it once; nothing probes a revision for one.""" + + @abstractmethod + def place(self, project: AgentCommsProject, ref: str, tree: Path) -> None: + """Write the reader to its path under the extracted revision.""" + + +class RevisionReader(StoredFormatReader): + """agent-comms measuring itself: each revision runs its own reader, because a head's + reader may import modules only that head declares (#3576 moved field_codec under + nominal). A revision without one fails loud.""" + + def place(self, project: AgentCommsProject, ref: str, tree: Path) -> None: + project.extract(ref, tree, str(READER)) + + +class CheckoutReader(StoredFormatReader): + """A client repository such as Toad ships no reader; the ratchet checkout's own reads its + modules, importing agent_comms from that checkout.""" + + def place(self, project: AgentCommsProject, ref: str, tree: Path) -> None: + (tree / READER).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(REPOSITORY / READER, tree / READER) @dataclass(frozen=True) class AgentCommsProject(audit.project.Project[PlanPhase]): """agent-comms' own facts, read from the modules that declare them.""" + reader: StoredFormatReader = RevisionReader() + probe = ( "import importlib.util, os, sys; sys.path[:0] = sys.argv[2].split(os.pathsep); " "spec = importlib.util.spec_from_file_location('stored_formats', sys.argv[1]); " @@ -107,22 +138,17 @@ def stored_formats( package = package.parent modules = [".".join(Path(path).relative_to(package).with_suffix("").parts) for path in sorted(changed)] - reader = Path(inspect.getfile(stored_formats)).relative_to(REPOSITORY) - if not self.tracks(ref, str(reader)): - # A revision older than its reader (164c80ca9) declares no stored format. - return {} with tempfile.TemporaryDirectory(prefix="ratchet-formats-") as scratch: - # Each side runs its own revision's probe script: the head's may import modules - # that only the head's package declares (#3576 moved field_codec under nominal). tree = Path(scratch) - self.extract(ref, tree, str(package), str(reader)) + self.extract(ref, tree, str(package)) + self.reader.place(self, ref, tree) sources = audit.ratchet.Compared.source_paths(root, tree) for stamp in tree.rglob("package_stamp.py"): WheelStamp.of_source(tree / package, self.revision(ref), source_paths=sources).write(stamp) paths = dict.fromkeys((str(tree / package), *sources)) result = SpawnedProcess.run( - (sys.executable, "-c", self.probe, str(tree / reader), + (sys.executable, "-c", self.probe, str(tree / READER), os.pathsep.join(paths), *modules), cwd=scratch, env=audit.ratchet.Compared.environment(root, tree), timeout=300, ) diff --git a/tools/client_ratchet.py b/tools/client_ratchet.py new file mode 100755 index 0000000000..f76328eedf --- /dev/null +++ b/tools/client_ratchet.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""The debt ratchet's CI entry for a client repository such as Toad: the checkout it runs in, +measured as AgentCommsProject by the refactor-audit this agent-comms checkout pins. A client +ships no stored-format reader, so it borrows this checkout's; that is the one fact this entry +owns beside which project it measures.""" + +from __future__ import annotations + +from pathlib import Path + +from agent_comms_project import AgentCommsProject, CheckoutReader +from agent_comms.pinned_audit import audit + +if __name__ == "__main__": + raise SystemExit(audit.ratchet.main(AgentCommsProject(Path.cwd(), CheckoutReader()))) From bff583d4ed644df51dec9dc24733fb1c6bcfa925 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:53:11 -0400 Subject: [PATCH 13/18] Stored formats: required keys are a set where they are made, encoded once claude-code #3606 review (9734177925da): StoredForm.of built required as a list at :39/:41, a __post_init__ patched it into the frozenset the field declares, and json.dumps(default=sorted) sorted anything JSON could not encode. - StoredForm.of builds frozenset at both construction sites; __post_init__ deleted. - StoredForm.wire() is the one JSON encoder (required as a sorted list, since JSON has no set); default=sorted deleted. - AgentCommsProject decodes the list into frozenset once. The audit field becomes frozenset[str] in refactor-audit#8; the pin moves after it merges. Encoder probe on a real versioned record: required arrives as a sorted list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tools/agent_comms_project.py | 2 +- tools/stored_formats.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index cd16e564e7..da9f025421 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -155,7 +155,7 @@ def stored_formats( if result.returncode: raise ValueError(f"stored formats could not be read at {ref}: {result.stderr.decode()}") return { - name: audit.project.StoredFormat(**form) + name: audit.project.StoredFormat(form["version"], form["fields"], frozenset(form["required"])) for name, form in json.loads(result.stdout).items() } diff --git a/tools/stored_formats.py b/tools/stored_formats.py index 5281406e78..7a485025e1 100644 --- a/tools/stored_formats.py +++ b/tools/stored_formats.py @@ -36,14 +36,15 @@ def of(cls, record: type) -> StoredForm | None: declared = vars(record).get("version") match FieldCodec.record_schema(record): case {"properties": {"version": {"enum": [stamped]}, **stored}, "required": required}: - return cls(stamped, stored, [key for key in required if key in stored]) + return cls(stamped, stored, frozenset(key for key in required if key in stored)) case {"properties": properties, "required": required} if isinstance(declared, int): - return cls(declared, properties, required) + return cls(declared, properties, frozenset(required)) return None - def __post_init__(self) -> None: - """Each revision's probe prints the keys as a JSON list; their order means nothing.""" - object.__setattr__(self, "required", frozenset(self.required)) + def wire(self) -> dict[str, object]: + """The JSON the ratchet decodes. JSON has no set, so the required keys travel as a + sorted list and the decoder makes them a set again.""" + return {"version": self.version, "fields": self.fields, "required": sorted(self.required)} def stored_formats(module_name: str) -> dict[str, StoredForm]: @@ -65,10 +66,9 @@ def main(modules: list[str]) -> None: print( json.dumps( { - name: dataclasses.asdict(form) + name: form.wire() for module in modules for name, form in stored_formats(module).items() - }, - default=sorted, + } ) ) From 4d884271c2668cc8f8e73cd60003f0756266f62d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:00:52 -0400 Subject: [PATCH 14/18] Ratchet: a measured revision runs beside the audit it pinned The merge queue ran debt-tools on a fresh main commit and failed: stored_formats extracts the base revision with git archive, which leaves the audit submodule out, so the base's pinned_audit imported this checkout's audit and died on ForeignAbsenceProbe (moved by refactor-audit#7). The stamp died first, but the reader imports base modules that reach pinned_audit too (phase_evidence.py:28), so any fresh base would have died the same way. - PinnedAudit (this checkout's audit clone) places the audit at a revision's pin into the extracted tree, fetching the pin first when the clone lacks it. - RevisionReader places the revision's reader and its pinned audit. Replay: stored_formats at origin/main (audit pin 5a969d3a) from this checkout at d7401d67 now returns instead of raising ImportError. Also merges origin/main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tools/agent_comms_project.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index da9f025421..068b8784d6 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -14,6 +14,7 @@ import json import os import shutil +import subprocess import sys import tempfile from abc import ABC, abstractmethod @@ -42,7 +43,7 @@ from agent_comms.installer.carry import STEPS from agent_comms.installer.probes.carry import Carry from agent_comms.phase_evidence import MainTree, PlanReading -from agent_comms.pinned_audit import audit +from agent_comms.pinned_audit import SKILL, audit from agent_comms.plan_phases import PlanPhase from agent_comms.repo import Repo from module_layers import PACKAGE, StackClient @@ -50,6 +51,24 @@ SOURCES = Path(inspect.getfile(agent_comms)).parents[1] REPOSITORY = SOURCES.parent READER = Path(inspect.getfile(stored_formats)).relative_to(REPOSITORY) +AUDIT = SKILL.relative_to(REPOSITORY) + + +class PinnedAudit(audit.repository.Repository): + """This checkout's clone of the audit submodule, which every agent-comms revision pins by + commit. git archive leaves a submodule out of a revision's tree, so a revision run from an + archive would import this checkout's audit instead of the one it pinned.""" + + def place(self, pin: str, into: Path) -> None: + """The audit at the pin, fetched into this clone first when the clone lacks it.""" + if not self.has(pin): + self._run("fetch", "--quiet", "origin", pin) + into.mkdir(parents=True, exist_ok=True) + self.extract(pin, into) + + def has(self, commit: str) -> bool: + return not subprocess.run(["git", "-C", str(self.path), "cat-file", "-e", f"{commit}^{{commit}}"], + capture_output=True, timeout=20).returncode class StoredFormatReader(ABC): @@ -64,10 +83,12 @@ def place(self, project: AgentCommsProject, ref: str, tree: Path) -> None: class RevisionReader(StoredFormatReader): """agent-comms measuring itself: each revision runs its own reader, because a head's reader may import modules only that head declares (#3576 moved field_codec under - nominal). A revision without one fails loud.""" + nominal), beside the audit that revision pinned, which its modules import through + pinned_audit. A revision without a reader fails loud.""" def place(self, project: AgentCommsProject, ref: str, tree: Path) -> None: project.extract(ref, tree, str(READER)) + PinnedAudit(REPOSITORY / AUDIT).place(project.identity(ref, str(AUDIT)), tree / AUDIT) class CheckoutReader(StoredFormatReader): From f25539d41798a3d482af813cf65e0e2f5e9e3908 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:24:08 -0400 Subject: [PATCH 15/18] Ratchet: one stored-format record, fresh type counts, carried controls Pin refactor-audit 705ebb65 (audit #10 carrying #9). - tools/stored_formats.py: StoredForm deleted. It restated the audit's StoredFormat (three fields) to carry its own wire form, and treated only the top-level required as a set, so #3607's nested RegistryDocument required list reordering read as a format change. stored_format(record) builds StoredFormat.declared, and the audit owns sets at every depth and wire(). claude-code 8bfa094055fd, 580764ff8059, d98616a29150. - tools/agent_comms_project.py: the probe output decodes through StoredFormat.declared(**form), with no subscripts. PinnedAudit.has and its bare subprocess git are deleted for Repository.has. - ClientTypeErrors.findings runs mypy each comparison. The client commit read and the src identity, which existed only for the memo key, are gone. Mypy reads PEP 561 packages on PYTHONPATH that no content key names. Codex 1a5fa2bdbb7a, 073483f8114b; carrier 3d3a357bb. - tests: test_type_counts_follow_the_installed_dependency_not_a_stored_count is carried from 3d3a357bb onto the TypeErrors/ClientTypeErrors CheckerSite API, for source and client. The 12-case test_declared_decoder_boundary_is_shared_by_both_contextual_absence_measures (imported, aliased, qualified, module, relative, undecorated, different-owner, duplicate, conditional, star, class-shadowed, parameter-shadowed) is carried for NoneAbsence and ForeignAbsenceProbe through AgentCommsProject.absence_boundaries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/skills/refactor-audit | 2 +- tests/guards/test_agent_comms_ratchet.py | 99 +++++++++++++++++++++++- tools/agent_comms_project.py | 18 ++--- tools/stored_formats.py | 44 ++++------- 4 files changed, 118 insertions(+), 45 deletions(-) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index d7401d6797..705ebb65dd 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit d7401d6797feb839b014a258b9a44b9aba4d14c4 +Subproject commit 705ebb65ddf5026a53ede17a476a667a51f87c3e diff --git a/tests/guards/test_agent_comms_ratchet.py b/tests/guards/test_agent_comms_ratchet.py index 0c6cb1f52e..d72ee5fe09 100644 --- a/tests/guards/test_agent_comms_ratchet.py +++ b/tests/guards/test_agent_comms_ratchet.py @@ -19,9 +19,10 @@ from agent_comms.child_process import ObservedProcess, SpawnedProcess from agent_comms.field_codec import FieldCodec -from agent_comms_project import AgentCommsProject +from agent_comms_project import AgentCommsProject, ClientTypeErrors from agent_comms.pinned_audit import audit from fixture_git import IDENTITY +from module_layers import StackClient from process_tree_fixture import TREE, identities, ready pytestmark = pytest.mark.refactor_guard @@ -313,6 +314,102 @@ def test_none_as_absence_fails_with_its_line_except_in_a_declared_boundary_decod assert report["introduced"][plan] == [] and report["delta"][plan] == 0 +@pytest.mark.parametrize("declaration,expected", [ + pytest.param("from agent_comms.field_codec import boundary_decoder\n@boundary_decoder\n{function}", 0, + id="imported"), + pytest.param("from agent_comms.field_codec import boundary_decoder as decode\n@decode\n{function}", 0, + id="aliased"), + pytest.param("import agent_comms.field_codec as codec\n@codec.boundary_decoder\n{function}", 0, + id="qualified"), + pytest.param("import agent_comms.field_codec\n@agent_comms.field_codec.boundary_decoder\n{function}", 0, + id="module"), + pytest.param("from .field_codec import boundary_decoder\n@boundary_decoder\n{function}", 0, + id="relative"), + pytest.param("{function}", 1, id="undecorated"), + pytest.param("from elsewhere import boundary_decoder\n@boundary_decoder\n{function}", 1, + id="different-owner"), + pytest.param("from agent_comms.field_codec import boundary_decoder\n" + "from elsewhere import boundary_decoder\n@boundary_decoder\n{function}", 1, + id="duplicate"), + pytest.param("if enabled:\n from agent_comms.field_codec import boundary_decoder\n" + "@boundary_decoder\n{function}", 1, id="conditional"), + pytest.param("from agent_comms.field_codec import *\n@boundary_decoder\n{function}", 1, + id="star"), + pytest.param("from agent_comms.field_codec import boundary_decoder\nclass Decoder:\n" + " boundary_decoder = lambda function: function\n @boundary_decoder\n" + " def decode(external):\n return external.marker\n", 1, id="class-shadowed"), + pytest.param("from agent_comms.field_codec import boundary_decoder\ndef outer(boundary_decoder):\n" + " @boundary_decoder\n def decode(external):\n return external.marker\n", 1, + id="parameter-shadowed"), +]) +def test_declared_decoder_boundary_is_shared_by_both_contextual_absence_measures( + tmp_path: Path, declaration: str, expected: int, +) -> None: + """AgentCommsProject.absence_boundaries declares boundary_decoder once; both contextual + absence measures read it through the same binding, so an alias admits and a shadow refuses + in both.""" + repository = Checkout(tmp_path, "src/agent_comms") + source = declaration.format(function="def decode(external):\n return external.marker\n") + base = repository.commit({"boundary.py": source}) + head = repository.commit( + {"boundary.py": source.replace("return external.marker", "return external.marker is None")}) + measures = (audit.source_measures.NoneAbsence, audit.source_measures.ForeignAbsenceProbe) + status, report = repository.compare(base, head, *measures) + assert status == int(bool(expected)) + for measure in measures: + identity = measure.at(f"{repository.root}/boundary.py").text + assert report["head"][identity] == expected + assert report["delta"][identity] == expected + + +USES_EXTERNAL_VALUE = "import extdep\n\nvalue: int = extdep.VALUE\n" + + +def typed_dependency(directory: Path, annotation: str, value: str) -> Path: + """An installed PEP 561 package outside every committed root, found through sys.path.""" + package = directory / "extdep" + package.mkdir(parents=True) + (package / "py.typed").write_text("") + (package / "__init__.py").write_text(f"VALUE: {annotation} = {value}\n") + return directory + + +def source_type_errors(repo: Checkout, client: StackClient) -> int: + revision = repo.commit({"uses_external.py": USES_EXTERNAL_VALUE}) + selected = [f"{repo.root}/uses_external.py"] + _, sites = audit.checkers.TypeErrors.findings( + AgentCommsProject(repo.path), revision, revision, repo.root, selected, audit.ratchet.HeadSide) + return sum(site.path == selected[0] for site in sites) + + +def client_type_errors(repo: Checkout, client: StackClient) -> int: + revision = repo.commit({"__init__.py": ""}) + _, sites = ClientTypeErrors.findings( + AgentCommsProject(repo.path), revision, client, audit.ratchet.HeadSide) + return sum(site.path == f"{client.name}/src/uses_external.py" for site in sites) + + +@pytest.mark.parametrize("type_errors", [source_type_errors, client_type_errors]) +def test_type_counts_follow_the_installed_dependency_not_a_stored_count( + repo: Checkout, tmp_path: Path, monkeypatch, type_errors +) -> None: + """Mypy reads installed packages no content key names. With committed source, client and + configuration unchanged, switching the installed dependency must change the count, under + the same project, count cache and mypy's own cache: hosted #3538 compared a base count + stored under a Core-only environment with a head count from the full stack, and a PEP 561 + package on PYTHONPATH changes without any distribution metadata changing.""" + accepting = typed_dependency(tmp_path / "accepting", "int", "1") + rejecting = typed_dependency(tmp_path / "rejecting", "str", "'one'") + (tmp_path / "client").mkdir() + checkout = Checkout(tmp_path / "client", "src") + checkout.commit({"uses_external.py": "import agent_comms\n" + USES_EXTERNAL_VALUE}) + client = StackClient("probe-client", checkout.path) + monkeypatch.setenv("PYTHONPATH", search_path(accepting)) + assert type_errors(repo, client) == 0 + monkeypatch.setenv("PYTHONPATH", search_path(rejecting)) + assert type_errors(repo, client) == 1 + + def test_growth_lines_end_the_merged_log(repo: Checkout) -> None: """run-step.sh annotates the last lines of stdout and stderr merged into one pipe; the report is flushed first, so what grew is what a reader without the job log sees.""" diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index 068b8784d6..511bd97565 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -14,7 +14,6 @@ import json import os import shutil -import subprocess import sys import tempfile from abc import ABC, abstractmethod @@ -66,10 +65,6 @@ def place(self, pin: str, into: Path) -> None: into.mkdir(parents=True, exist_ok=True) self.extract(pin, into) - def has(self, commit: str) -> bool: - return not subprocess.run(["git", "-C", str(self.path), "cat-file", "-e", f"{commit}^{{commit}}"], - capture_output=True, timeout=20).returncode - class StoredFormatReader(ABC): """Where the reader that declares a revision's stored formats comes from. The measured @@ -175,10 +170,8 @@ def stored_formats( ) if result.returncode: raise ValueError(f"stored formats could not be read at {ref}: {result.stderr.decode()}") - return { - name: audit.project.StoredFormat(form["version"], form["fields"], frozenset(form["required"])) - for name, form in json.loads(result.stdout).items() - } + return {name: audit.project.StoredFormat.declared(**form) + for name, form in json.loads(result.stdout).items()} def carried(self, base: str, head: str) -> bool: """A carry step is a module under STEPS declaring a member of the Carry family: one of @@ -265,10 +258,9 @@ def findings( cls, repo: audit.project.Project, ref: str, client: StackClient, side: type[audit.ratchet.Side], ) -> tuple[type[audit.ratchet.Side], tuple[audit.checkers.CheckerSite, ...]]: - # The pinned audit's git boundary, which pinned_audit puts on the path. - commit = audit.repository.Repository(client.checkout).revision("HEAD") - return side, repo.memo(partial(cls.checked, repo, ref, client, side), - cls.__name__, client.name, commit, repo.identity(ref, "src")) + """Mypy resolves the client's other imports from the installed environment, which no + content key names, so each comparison checks afresh under mypy's own cache.""" + return side, cls.checked(repo, ref, client, side) @classmethod def checked(cls, repo: audit.project.Project, ref: str, client: StackClient, diff --git a/tools/stored_formats.py b/tools/stored_formats.py index 7a485025e1..cdddf36754 100644 --- a/tools/stored_formats.py +++ b/tools/stored_formats.py @@ -13,41 +13,25 @@ import importlib import inspect import json -from dataclasses import dataclass from agent_comms.field_codec import FieldCodec +from agent_comms.pinned_audit import audit -@dataclass(frozen=True) -class StoredForm: - """A versioned record as FieldCodec writes it: its format version, each stored key with - that field's wire schema, and the keys every record carries.""" +def stored_format(record: type) -> audit.project.StoredFormat | None: + """Decode the record schema once. The version is a single-valued stored field, or the + ClassVar the writer stamps beside the fields.""" + declared = vars(record).get("version") + match FieldCodec.record_schema(record): + case {"properties": {"version": {"enum": [stamped]}, **stored}, "required": required}: + return audit.project.StoredFormat.declared( + stamped, stored, (key for key in required if key in stored)) + case {"properties": properties, "required": required} if isinstance(declared, int): + return audit.project.StoredFormat.declared(declared, properties, required) + return None - version: int - fields: dict[object, object] - required: frozenset[str] - """The keys every record carries: a set, because declaring a field in a parent class - reorders the schema's list without changing what is written.""" - @classmethod - def of(cls, record: type) -> StoredForm | None: - """Decode the record schema once. The version is a single-valued stored field, or the - ClassVar the writer stamps beside the fields.""" - declared = vars(record).get("version") - match FieldCodec.record_schema(record): - case {"properties": {"version": {"enum": [stamped]}, **stored}, "required": required}: - return cls(stamped, stored, frozenset(key for key in required if key in stored)) - case {"properties": properties, "required": required} if isinstance(declared, int): - return cls(declared, properties, frozenset(required)) - return None - - def wire(self) -> dict[str, object]: - """The JSON the ratchet decodes. JSON has no set, so the required keys travel as a - sorted list and the decoder makes them a set again.""" - return {"version": self.version, "fields": self.fields, "required": sorted(self.required)} - - -def stored_formats(module_name: str) -> dict[str, StoredForm]: +def stored_formats(module_name: str) -> dict[str, audit.project.StoredFormat]: module = importlib.import_module(module_name) records = [ record @@ -56,7 +40,7 @@ def stored_formats(module_name: str) -> dict[str, StoredForm]: ] found = {} for record in filter(dataclasses.is_dataclass, records): - form = StoredForm.of(record) if vars(record).get("version") is not None else None + form = stored_format(record) if vars(record).get("version") is not None else None if form is not None: found[f"{module_name}.{record.__name__}"] = form return found From cf4b58f4adf30394f713cf25dc676b4c1b5054a4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:40:44 -0400 Subject: [PATCH 16/18] Ratchet review round 7: one versioned-record answer, no forwarder, cached plan reading by path claude-code BACK on f25539d4 (033285452d9a): - tools/stored_formats.py: "is this record versioned" was answered three times (`vars(record).get("version") is not None`, `return None`, `form is not None`) and debt-tools counted the new NoneAbsence. stored_format(record) now yields zero or one StoredFormat, matching the class's own `version` attribute and the schema in one match; stored_formats is a comprehension over it. All three None tests and the `| None` are deleted. - tools/agent_comms_project.py: ClientTypeErrors.checked folded into findings, so the one-line forwarder is gone. reading is a staticmethod cache over (path, commit), like carry_family (B019). - I001 import blocks sorted in agent_comms_project.py, client_ratchet.py and repairs.py. - Audit pin d1b1f1d1: unordered writes every nested required as a sorted list, and the listed encoder that re-tested frozensets is deleted (BOUND-1, project.py:80). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/skills/refactor-audit | 2 +- tools/agent_comms_project.py | 26 +++++++++--------- tools/client_ratchet.py | 1 + tools/repairs.py | 2 +- tools/stored_formats.py | 39 +++++++++++++-------------- 5 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index 705ebb65dd..d1b1f1d185 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit 705ebb65ddf5026a53ede17a476a667a51f87c3e +Subproject commit d1b1f1d185ae5a58365a7b1c48e7c25578d04eea diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index 511bd97565..c962eba36a 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -25,8 +25,10 @@ from pathlib import Path from subprocess import CompletedProcess -import agent_comms import stored_formats +from module_layers import PACKAGE, StackClient + +import agent_comms from agent_comms import ( coordination_schema, field_codec, @@ -45,7 +47,6 @@ from agent_comms.pinned_audit import SKILL, audit from agent_comms.plan_phases import PlanPhase from agent_comms.repo import Repo -from module_layers import PACKAGE, StackClient SOURCES = Path(inspect.getfile(agent_comms)).parents[1] REPOSITORY = SOURCES.parent @@ -202,17 +203,19 @@ def guard_roots(self) -> frozenset[str]: declared = Path(inspect.getfile(PlanPhase)).resolve() return frozenset({f"{declared.relative_to(REPOSITORY).parts[0]}/"}) + @staticmethod @cache - def reading(self, commit: str) -> PlanReading: - return PlanReading((), MainTree(Repo.of(self.path), commit)) + def reading(path: Path, commit: str) -> PlanReading: + """The plan phases as the commit declares them, read once per checkout and commit.""" + return PlanReading((), MainTree(Repo.of(path), commit)) def failing_guards(self, commit: str) -> frozenset[type[PlanPhase]]: - reading = self.reading(commit) + reading = self.reading(self.path, commit) return frozenset(phase for phase in PlanPhase.members_with(PlanPhase) if not phase.done(reading)) def guard_sites(self, head: str, guard: type[PlanPhase]) -> tuple[str, ...]: - reading = self.reading(head) + reading = self.reading(self.path, head) evidence = guard.phase_evidence() return tuple(evidence.sites(reading)) or (reading.describe(evidence),) @@ -260,11 +263,6 @@ def findings( ) -> tuple[type[audit.ratchet.Side], tuple[audit.checkers.CheckerSite, ...]]: """Mypy resolves the client's other imports from the installed environment, which no content key names, so each comparison checks afresh under mypy's own cache.""" - return side, cls.checked(repo, ref, client, side) - - @classmethod - def checked(cls, repo: audit.project.Project, ref: str, client: StackClient, - side: type[audit.ratchet.Side]) -> tuple[audit.checkers.CheckerSite, ...]: selected = cls.selected(client) cache = repo.beside("mypy-clients") / client.name / side.family_name with tempfile.TemporaryDirectory(prefix="ratchet-client-") as scratch: @@ -278,9 +276,9 @@ def checked(cls, repo: audit.project.Project, ref: str, client: StackClient, (str(Path(scratch, "src")), str(client.checkout / "src")))}, ) output = cls.checker_output(result, f"{client.name} at {ref}") - return tuple(dataclasses.replace(site, path=f"{client.name}/{site.path}") - for site in audit.checkers.TypeErrors.reported(output) - if site.path in selected) + return side, tuple(dataclasses.replace(site, path=f"{client.name}/{site.path}") + for site in audit.checkers.TypeErrors.reported(output) + if site.path in selected) @classmethod def parts(cls, repo: audit.project.Project, base: str, head: str, changed: set[str], diff --git a/tools/client_ratchet.py b/tools/client_ratchet.py index f76328eedf..ae62eb06c2 100755 --- a/tools/client_ratchet.py +++ b/tools/client_ratchet.py @@ -9,6 +9,7 @@ from pathlib import Path from agent_comms_project import AgentCommsProject, CheckoutReader + from agent_comms.pinned_audit import audit if __name__ == "__main__": diff --git a/tools/repairs.py b/tools/repairs.py index 531837a6a5..05c5585cef 100644 --- a/tools/repairs.py +++ b/tools/repairs.py @@ -25,7 +25,6 @@ from pathlib import Path from typing import ClassVar -from agent_comms.pinned_audit import audit from agent_comms_project import AgentCommsProject from module_layers import Package, TopLevelNames from nominal_refactor_advisor.codemod import ( @@ -38,6 +37,7 @@ ) from agent_comms.declared_family import DeclaredFamily +from agent_comms.pinned_audit import audit type FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef type ImportNode = ast.Import | ast.ImportFrom diff --git a/tools/stored_formats.py b/tools/stored_formats.py index cdddf36754..6325c739ee 100644 --- a/tools/stored_formats.py +++ b/tools/stored_formats.py @@ -13,37 +13,36 @@ import importlib import inspect import json +from collections.abc import Iterator from agent_comms.field_codec import FieldCodec from agent_comms.pinned_audit import audit -def stored_format(record: type) -> audit.project.StoredFormat | None: - """Decode the record schema once. The version is a single-valued stored field, or the - ClassVar the writer stamps beside the fields.""" - declared = vars(record).get("version") - match FieldCodec.record_schema(record): - case {"properties": {"version": {"enum": [stamped]}, **stored}, "required": required}: - return audit.project.StoredFormat.declared( - stamped, stored, (key for key in required if key in stored)) - case {"properties": properties, "required": required} if isinstance(declared, int): - return audit.project.StoredFormat.declared(declared, properties, required) - return None +def stored_format(record: type) -> Iterator[audit.project.StoredFormat]: + """The record's stored format, when the class itself declares a version: a single-valued + stored field, or the ClassVar the writer stamps beside the fields. A record declaring none + yields nothing, and its schema is never read.""" + match vars(record): + case {"version": declared}: + match declared, FieldCodec.record_schema(record): + case _, {"properties": {"version": {"enum": [stamped]}, **stored}, + "required": required}: + yield audit.project.StoredFormat.declared( + stamped, stored, (key for key in required if key in stored)) + case int(), {"properties": properties, "required": required}: + yield audit.project.StoredFormat.declared(declared, properties, required) def stored_formats(module_name: str) -> dict[str, audit.project.StoredFormat]: module = importlib.import_module(module_name) - records = [ - record + return { + f"{module_name}.{record.__name__}": form for record in vars(module).values() if inspect.isclass(record) and record.__module__ == module_name - ] - found = {} - for record in filter(dataclasses.is_dataclass, records): - form = stored_format(record) if vars(record).get("version") is not None else None - if form is not None: - found[f"{module_name}.{record.__name__}"] = form - return found + and dataclasses.is_dataclass(record) + for form in stored_format(record) + } def main(modules: list[str]) -> None: From 86585ec2104be1afdb53ddfac286d0d74a6ef6bb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:56:18 -0400 Subject: [PATCH 17/18] Pin refactor-audit 8659f6032d8e31beb6add0f18ff0cbf84c106e26: stored formats ignore schema annotations P918's derived text descriptions reword the documentation inside the ActiveRoute, WakeClaim and RegistryDocument record schemas; the audit's StoredFormat now drops description/title/examples/$comment, so that is not a format change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/skills/refactor-audit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index d1b1f1d185..8659f6032d 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit d1b1f1d185ae5a58365a7b1c48e7c25578d04eea +Subproject commit 8659f6032d8e31beb6add0f18ff0cbf84c106e26 From 69b27c412461dcaf35bb48307c3c6356b07faaff Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 21:12:29 -0400 Subject: [PATCH 18/18] Pin refactor-audit c69c4daf: audit main with #11 merged Replaces the orphan pin 8659f603 (claude-code d2452ae823e2) with the merge commit of refactor-audit #11: stored formats drop schema annotations and keep default/enum/const values as written. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/skills/refactor-audit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index 8659f6032d..c69c4daf17 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit 8659f6032d8e31beb6add0f18ff0cbf84c106e26 +Subproject commit c69c4daf175aa469545186b46df0c38e9a27fb44