diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index 2d546d2736..1273c2b05b 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -25,7 +25,9 @@ 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 +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 from agent_comms.repo import GitCommandError, Repo @@ -100,6 +102,12 @@ 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.""" + 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: blob, = Repo.blobs(self.repo.checkout, f"{self.commit}:{path}") source = blob.decode() @@ -412,6 +420,27 @@ def describe(self) -> str: return f"{self.pattern!r} in {' '.join(self.paths)} on main" +@dataclass(frozen=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: + # 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) + 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(),))) + + def describe(self) -> str: + return f"{self.pattern!r} in the audit package of the refactor-audit main pins" + + @dataclass(frozen=True) class DocumentPatternPresent(PatternPresent): """A recorded document fact, rather than executable Python source.""" @@ -683,7 +712,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) @@ -696,7 +725,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..9d32ab378e 100644 --- a/src/agent_comms/pinned_audit.py +++ b/src/agent_comms/pinned_audit.py @@ -11,11 +11,12 @@ 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)) 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 78281e2f69..48589f7587 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, @@ -551,7 +552,7 @@ 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 = AuditPatternPresent(r"^class PlainStrIdAnnotation\(") class ClaimIdsPhase(Id128NominalPhase): @@ -1019,7 +1020,7 @@ 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 = AuditPatternPresent(r"^class StoreLockCalls\(") class ScopedReadsPhase(StoreGuardsPhase): diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index 5a969d3a7e..c69c4daf17 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit 5a969d3a7e6c414702e461e89c66b4e37a423646 +Subproject commit c69c4daf175aa469545186b46df0c38e9a27fb44 diff --git a/tests/guards/test_agent_comms_ratchet.py b/tests/guards/test_agent_comms_ratchet.py new file mode 100644 index 0000000000..d72ee5fe09 --- /dev/null +++ b/tests/guards/test_agent_comms_ratchet.py @@ -0,0 +1,435 @@ +"""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_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 + +SOURCE = Path(__file__).resolve().parents[2] +# This checkout, as the ratchet measures it. +PROJECT = AgentCommsProject(SOURCE) + + +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[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 = audit.ratchet.compare(AgentCommsProject(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(audit.source_measures.BroadSwallow.count(PROJECT, 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 audit.source_measures.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 = PROJECT.lock_owners() + assert all((SOURCE / "src" / owner).is_file() for owner in 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): + 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, audit.source_measures.StoreLockCalls) + assert status == 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)]} + + +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", "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 + # pinned audit only through the measurement's own source paths. + 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") + 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 = 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 + # 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, 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, audit.project_measures.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, audit.source_measures.FamilyFlattened) + assert status == 1 + 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) + ) + facades = { + f"{directory}/{module.parent.name}/{module.name}" + for module in PROJECT.mechanism_modules() + for directory in ("nested", "vendor") + } + facades.update(f"nested/{module.name}" for module in PROJECT.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, audit.source_measures.FamilyFlattened) + assert status == 1 + assert report["delta"][audit.source_measures.FamilyFlattened.at(f"{repo.root}/another_codec.py").text] == 2 + for module in original_modules: + assert report["head"][audit.source_measures.FamilyFlattened.at(module.as_posix()).text] == 0 + for path in facades: + 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): + 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, audit.source_measures.NoneAbsence) + assert status == 1 + assert {identity: lines for identity, lines in report["introduced"].items() if lines} == { + 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 = audit.source_measures.NoneAbsence.at(f"{repo.root}/plan.py").text + 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.""" + 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 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/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..63d5c443c4 100644 --- a/tests/test_boundary_decoding.py +++ b/tests/test_boundary_decoding.py @@ -1,9 +1,13 @@ """Boundary declarations reject malformed records without inventing another shape.""" from dataclasses import replace import hashlib +from pathlib import Path import pytest +from agent_comms_project import AgentCommsProject +from agent_comms.pinned_audit import audit + 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,10 +82,9 @@ 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 - + 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 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 d226e2defd..947cbc55e4 100644 --- a/tests/test_carry.py +++ b/tests/test_carry.py @@ -11,7 +11,9 @@ from pathlib import Path import pytest -from debt_ratchet import UncarriedRecordFormat, git + +from agent_comms_project import AgentCommsProject +from agent_comms.pinned_audit import audit from agent_comms.locked_store import LiveFilesRequest from agent_comms.cli_verbs.stop import StopCliCommand @@ -118,16 +120,16 @@ 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") - subprocess.run(["tar", "-x", "-C", str(tmp_path)], input=package, check=True, timeout=60) + 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({}) 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") == ( - {UncarriedRecordFormat.total(): 0}, {UncarriedRecordFormat.total(): uncarried}) + assert audit.project_measures.UncarriedRecordFormat.counted(AgentCommsProject(tmp_path), base, head, changed, + "src/agent_comms") == ( + {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/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..c962eba36a --- /dev/null +++ b/tools/agent_comms_project.py @@ -0,0 +1,303 @@ +"""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 dataclasses +import inspect +import json +import os +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 +from dataclasses import dataclass +from functools import cache, partial +from pathlib import Path +from subprocess import CompletedProcess + +import stored_formats +from module_layers import PACKAGE, StackClient + +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.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 SKILL, audit +from agent_comms.plan_phases import PlanPhase +from agent_comms.repo import Repo + +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) + + +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), 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): + """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]); " + "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: dict[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_boundaries(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, 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, audit.project.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)] + with tempfile.TemporaryDirectory(prefix="ratchet-formats-") as scratch: + tree = Path(scratch) + 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), + os.pathsep.join(paths), *modules), cwd=scratch, + 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: 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 + 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 = self.carry_family() + for path, source in self.sources(head, set(added)): + tree = ast.parse(source, path) + 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 + return False + + @staticmethod + @cache + def carry_family() -> tuple[type, ...]: + """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() + return frozenset({f"{declared.relative_to(REPOSITORY).parts[0]}/"}) + + @staticmethod + @cache + 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(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(self.path, head) + evidence = guard.phase_evidence() + return tuple(evidence.sites(reading)) or (reading.describe(evidence),) + + +@dataclass(frozen=True) +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.""" + + 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(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.""" + + @staticmethod + @cache + 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" + 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: audit.project.Project, ref: str, client: StackClient, + side: type[audit.ratchet.Side], + ) -> 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.""" + 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", *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 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], + 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 audit.ratchet.Side.sides()) + + @classmethod + 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()) + + @classmethod + def at(cls, path: str) -> ClientFileCount: + return ClientFileCount(cls, path) diff --git a/tools/client_ratchet.py b/tools/client_ratchet.py new file mode 100755 index 0000000000..ae62eb06c2 --- /dev/null +++ b/tools/client_ratchet.py @@ -0,0 +1,16 @@ +#!/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()))) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 301c03804a..292acaf2d2 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -1,2919 +1,14 @@ #!/usr/bin/env python3 -"""Compare AST debt in every changed production Python path at two Git revisions.""" +"""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 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_project import AgentCommsProject 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}) - - -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) - 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() - 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) - if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(audit.ratchet.main(AgentCommsProject(Path.cwd()))) diff --git a/tools/module_layers.py b/tools/module_layers.py index d64dbc316b..b652f10274 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -39,7 +39,7 @@ from agent_comms.installer.runtime_wire import ClientImport, CrossesRuntimes -from typing_names import TypingImports, TypingReference +from agent_comms.pinned_audit import audit from agent_comms.declared_family import DeclaredFamily @@ -205,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 16f1fd9a71..05c5585cef 100644 --- a/tools/repairs.py +++ b/tools/repairs.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import ClassVar -from debt_ratchet import FunctionLocalImport, LocatedSource, compare, production_paths +from agent_comms_project import AgentCommsProject from module_layers import Package, TopLevelNames from nominal_refactor_advisor.codemod import ( CodemodPlanSequence, @@ -37,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 @@ -61,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) @@ -130,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() @@ -145,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(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, []): @@ -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 AgentCommsProject(repo).production_paths("HEAD") + if path.startswith(root) ) for measure in (repair.repairs for repair in Repair.members_with(Repair)) } else: - found = compare( - repo, + found = audit.ratchet.compare( + AgentCommsProject(repo), args.base, args.head, args.root, diff --git a/tools/stored_formats.py b/tools/stored_formats.py index 5281406e78..6325c739ee 100644 --- a/tools/stored_formats.py +++ b/tools/stored_formats.py @@ -13,62 +13,45 @@ import importlib import inspect import json -from dataclasses import dataclass +from collections.abc import Iterator 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) -> 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) - 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, [key for key in required if key in stored]) - case {"properties": properties, "required": required} if isinstance(declared, int): - return cls(declared, properties, 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 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 + 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 = StoredForm.of(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: print( json.dumps( { - name: dataclasses.asdict(form) + name: form.wire() for module in modules for name, form in stored_formats(module).items() - }, - default=sorted, + } ) ) 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))