diff --git a/tests/test_integration.py b/tests/test_integration.py index 2c4474e77c..ee730bcbbf 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -29,7 +29,8 @@ from integration_source_fixture import MutualSources from source_revision_fixture import SourceRepository from fixture_git import ENVIRONMENT, SETTINGS -from debt_ratchet import ClientTypeErrors, compare +from agent_comms_project import AgentCommsProject, ClientTypeErrors +from agent_comms.pinned_audit import audit from module_layers import MaterializedClients, Package pytest_plugins = ("github_fixture", "test_holder_standing_rules", "integration_source_fixture") @@ -372,8 +373,9 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( source = inputs.source(pair.core.github) head = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() clients = materialized_clients(pair.output, inputs) - assert len(ClientTypeErrors.parts(pair.core.checkout, source.baseline.commit, head, - {"src/agent_comms/api.py"}, "src/", clients)) == 2 + project = AgentCommsProject(pair.core.checkout, clients=clients) + assert len(ClientTypeErrors.parts(project, source.baseline.commit, head, + {"src/agent_comms/api.py"}, "src/")) == 2 for side in (BaseSide, HeadSide): client, = clients.at(pair.core.checkout, source.baseline.commit, head, side) assert client.import_paths == tuple(str(client.checkout / path) for path in ("src", "tools")) @@ -387,8 +389,8 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( assert entry.returncode == 0, entry.stdout + entry.stderr # Both namespace files are selected under --explicit-package-bases: a duplicate # performance/tools.performance identity aborts the real checker, not a zero count. - result = compare(pair.core.checkout, source.baseline.commit, head, "src", 1, - measured=(ClientTypeErrors,), clients=clients) + result = audit.ratchet.compare(project, source.baseline.commit, head, "src", 1, + (ClientTypeErrors,)) assert not result.failed assert all(value == 0 for value in result.delta.values()) diff --git a/tests/test_module_layers_clients.py b/tests/test_module_layers_clients.py index 90aa7f89ed..57788fed81 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -13,7 +13,7 @@ import tomli_w from module_layers import MaterializedClients, OutgoingWire, Package, StackClient -from debt_ratchet import ClientTypeErrors +from agent_comms_project import AgentCommsProject, ClientTypeErrors from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side from agent_comms.todos import DraftListedPullRequest from github_fixture import GitHubFixture @@ -102,8 +102,9 @@ def test_actual_client_membership_stays_absent_on_its_own_cut( clients = resource.at(pair.core.checkout, core.baseline.commit, tested, side) assert len(clients) == int(present) assert all(client.checkout == side.checkout(pair.output, pair.client.github) for client in clients) - assert len(ClientTypeErrors.parts(pair.core.checkout, core.baseline.commit, tested, - {"src/agent_comms/api.py"}, "src/", resource)) == 1 + assert len(ClientTypeErrors.parts(AgentCommsProject(pair.core.checkout, clients=resource), + core.baseline.commit, tested, + {"src/agent_comms/api.py"}, "src/")) == 1 def test_materialized_client_files_come_from_each_actual_cut( diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index a65e5a0f99..f636dee82d 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -7,8 +7,8 @@ from __future__ import annotations +import argparse import ast -import atexit import dataclasses import inspect import json @@ -26,7 +26,7 @@ from subprocess import CompletedProcess import stored_formats -from module_layers import PACKAGE, StackClient +from module_layers import PACKAGE, ClientSources, MaterializedClients, StackClient import agent_comms from agent_comms import ( @@ -40,10 +40,11 @@ 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.head_review import Side as CutSide from agent_comms.field_codec import boundary_decoder -from agent_comms.installer.carry import STEPS from agent_comms.installer.commands import InstallCommands from agent_comms.installer.runtime_resolution import StackSources +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 @@ -103,6 +104,24 @@ class AgentCommsProject(audit.project.Project[PlanPhase]): """agent-comms' own facts, read from the modules that declare them.""" reader: StoredFormatReader = RevisionReader() + clients: ClientSources = ClientSources() + """Where the compared revisions' stack clients come from: the head's declared stack, + cloned once, unless CI already materialized each side's exact cut.""" + + @classmethod + def from_arguments(cls, root: Path, commands: InstallCommands) -> tuple[AgentCommsProject, list[str]]: + """This checkout measured with the clients its command line names, and the arguments + left for the audit's own command line. --ci-inputs names the integration entrypoint's + already-materialized exact source input.""" + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument( + "--ci-inputs", dest="clients", default=ClientSources(), + type=lambda directory: MaterializedClients.read( + Path(directory).resolve(), uv=StackSources.frontend(), + python=Path(sys.executable), commands=commands), + help="use the integration entrypoint's already-materialized exact source input") + known, rest = parser.parse_known_args() + return cls(root, clients=known.clients), rest probe = ( "import importlib.util, os, sys; sys.path[:0] = sys.argv[2].split(os.pathsep); " @@ -240,20 +259,13 @@ class ClientTypeErrors(audit.checkers.SideCounts): 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)) - with InstallCommands() as commands: - return StackClient.fetched(into / "stack", into, uv=StackSources.frontend(), - python=Path(sys.executable), commands=commands) + def cut(repo: AgentCommsProject, base: str, head: str, + side: type[audit.ratchet.Side]) -> tuple[StackClient, ...]: + """The clients of the materialized cut that selects the same revision as this compared + side. The audit and head_review each declare the base/head pair; until they are one + family the cut is chosen by the revision its own ref selects, never by its name.""" + return tuple(client for cut in CutSide.sides() if cut.ref(base, head) == side.ref(base, head) + for client in repo.clients.at(repo.path, base, head, cut)) @staticmethod def selected(client: StackClient) -> list[str]: @@ -285,15 +297,17 @@ def findings( if site.path in selected) @classmethod - def parts(cls, repo: audit.project.Project, base: str, head: str, changed: set[str], + def parts(cls, repo: AgentCommsProject, base: str, head: str, changed: set[str], root: str) -> tuple[Callable[[], object], ...]: - # Only a change to the package the clients import can change what they see. - if not any(path.startswith(f"src/{PACKAGE}/") for path in changed): + # Only a change to the package the clients import can change what they see, unless the + # clients are materialized per side: a companion can change while Core does not. + if not (repo.clients.requires_clients() + or 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()) + for side in audit.ratchet.Side.sides() + for client in cls.cut(repo, base, head, side) + if cls.selected(client)) @classmethod def measured( diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 292acaf2d2..238b877ec2 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 """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.""" +the audit's; the one fact this entry owns is which project it measures, and the project reads +where its own stack clients come from.""" from __future__ import annotations from pathlib import Path from agent_comms_project import AgentCommsProject +from agent_comms.installer.commands import InstallCommands from agent_comms.pinned_audit import audit if __name__ == "__main__": - raise SystemExit(audit.ratchet.main(AgentCommsProject(Path.cwd()))) + with InstallCommands() as commands: + project, arguments = AgentCommsProject.from_arguments(Path.cwd(), commands) + raise SystemExit(audit.ratchet.main(project, arguments))