From c06c5ae8000ef7f2c52613f9987d13041f9af477 Mon Sep 17 00:00:00 2001 From: Chris Purcell <168346341+chrisdpurcell@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:33:38 -0400 Subject: [PATCH] feat(standards): add the cut-successor payload writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `project-standards standards cut-successor ` performs the mechanical half of a payload cut, which until now was a hand-run procedure with four declaration sites that had to move in one order: the copied `payload.toml`, the family index, the catalog major, and the two generated artifacts. The writer copies the predecessor tree, stamps the version and the per-file digests, computes the aggregate through the repository's own `validate_payload_integrity`, appends the `[[versions]]` entry, inserts the catalog entry immediately after the family's last one, and then reruns `sync-payload-projection` and `render-catalog` through the same entry points an author would type — so a cut can never generate a projection or catalog those commands would not. The successor inherits the predecessor's catalog role and only a `default` predecessor is demoted, which keeps reference-only and internal families from acquiring a default as a side effect. Inherited version references are reported, never rewritten: a permalink or a migration `from` endpoint is correct history. The one exception is a migration's `to` endpoint, which the payload contract requires to name its containing version — a copy that kept the predecessor there produces a manifest that does not load — so it is re-pointed and reported as an applied edit. Refs #227, #236 --- CHANGELOG.md | 2 + docs/usage.md | 26 +- src/project_standards/cli_contract.py | 1 + .../package_contract/cut_successor.py | 547 ++++++++++++++++++ src/project_standards/standards_graph/cli.py | 140 +++++ tests/package_contract/test_cut_successor.py | 154 +++++ tests/test_usage_doc_inventory.py | 1 + 7 files changed, 869 insertions(+), 2 deletions(-) create mode 100644 src/project_standards/package_contract/cut_successor.py create mode 100644 tests/package_contract/test_cut_successor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f10b08..a8478a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version - **Hosted CI collapses to one gate on `main`, no longer duplicated on pull requests** ([#236](https://github.com/L3DigitalNet/project-standards/issues/236)). `Check` and `Validate standards graph` now trigger on `push: branches: [main]` and `workflow_dispatch` only — neither runs on `pull_request` or on `testing` any more. `Coherence` is deleted outright: `tests/coherence` carries neither the `performance` nor the `compatibility` marker, so it was already selected by `Check`'s ordinary pytest step and the standalone workflow was pure duplication. The seven cheap workflows (format, lint-markdown, validate-markdown-frontmatter, validate-specs, validate-standards, go, and any other under two minutes) keep their `pull_request` triggers unchanged. - **`scripts/verify.sh` stops a battery at the first red lane and sizes the `--full` compatibility lane for the machine that runs it** ([#236](https://github.com/L3DigitalNet/project-standards/issues/236)). `--fail-fast` skips every remaining serial lane once one has come back red and is the default for `--full`, where roughly 35 minutes of compatibility matrix ran after the ordinary lane had already failed on the 2026-09-01 train; `--keep-going` restores the run-every-lane behaviour and stays the default for the fast gate, whose three lanes are already running when the first red appears. A lane cut short is reported in the lane table as `skipped (--fail-fast)`, never omitted. `VERIFY_FULL_COMPAT_WORKERS` now defaults to `16` instead of a literal tuned for the retired 21-core workstation. Repository tooling only: no package, payload, or consumer-visible byte changes. +- **`project-standards standards cut-successor ` performs the mechanical half of a payload cut.** Released payload bytes are immutable, so every fix and every feature reaches a published family as a new `versions//` directory copied from its predecessor — a procedure with four declaration sites that had to be moved by hand and in the right order. The command copies the predecessor tree, stamps the copied `payload.toml` with the new version and freshly computed per-file digests, computes the aggregate through the same `validate_payload_integrity` the repository validators use, indexes the payload in the family's `standard.toml`, adds the catalog entry immediately after the family's last one, and then reruns `sync-payload-projection` and `render-catalog`. The successor inherits the predecessor's catalog role and only a `default` predecessor is demoted to `retained`, so a reference-only or internal family does not acquire a default. Every line in the new tree that still names the predecessor is **reported, never rewritten** — a permalink or a migration `from` endpoint is correct history that a blanket substitution would corrupt — with one exception the payload contract leaves no choice about: a migration's `to` endpoint must name its containing version, and is re-pointed and reported as an applied edit. `--from` selects a predecessor other than the family's newest, `--dry-run` prints the plan and writes nothing, `--scaffold-test` writes the successor's contract-test module with the cut's mechanical assertions and a TODO block listing the predecessor test's behavior cases, and the command refuses outright when the successor directory already exists ([#227](https://github.com/L3DigitalNet/project-standards/issues/227)). + ## [5.28.0] — 2026-09-01 ### Added diff --git a/docs/usage.md b/docs/usage.md index 20b5f5d1..ad015157 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -53,7 +53,7 @@ project-standards {--help | --version} ## DESCRIPTION -`project-standards` is the unified command-line surface for this repository's tooling. It exposes 34 leaf commands under one entry point: two frontmatter operations (`validate`, `fix`), 5 control/adoption operations (`init`, `reconcile`, `render`, `adopt`, `list`), the `mcp` server, eleven `standards` operations, one repository-only `packages` release check, seven `spec` verbs, and seven `agent-handoff` verbs. +`project-standards` is the unified command-line surface for this repository's tooling. It exposes 35 leaf commands under one entry point: two frontmatter operations (`validate`, `fix`), 5 control/adoption operations (`init`, `reconcile`, `render`, `adopt`, `list`), the `mcp` server, twelve `standards` operations, one repository-only `packages` release check, seven `spec` verbs, and seven `agent-handoff` verbs. Under unified authority, `validate` and `fix` invoke the provider selected by the applied Markdown Frontmatter package. Read-only validation consumes one immutable file snapshot; `fix` applies only the provider's typed plan through the platform executor and then revalidates. After `project-standards fix`, run `project-standards reconcile --check`, review the digest-only plan, and run `project-standards reconcile --apply` before the final `project-standards validate`. The standalone schema, ID, reference, ID-fix, and format-write surfaces use the same selected payload while retaining their narrower output contracts. In v5 legacy-only repositories, these commands warn and retain the local validator sequence as a bounded compatibility path. The six standalone console-script names documented under [Standalone commands](#standalone-commands) remain installed for scripting and back-compatibility. @@ -354,7 +354,7 @@ project-standards agent-handoff delta [--repo ] --since [--json] Command group for V5 catalog selection plus V1 graph/catalog maintenance and V2 package authoring. Running `project-standards standards` with no verb prints usage to standard error and exits 2; `project-standards standards --help` prints usage and exits 0. ```text -project-standards standards {list | show | enable | disable | version | validate-graph | render-catalog | validate-packages | render-consumer-catalog | generate-package-schemas | sync-payload-projection} [...] +project-standards standards {list | show | enable | disable | version | validate-graph | render-catalog | cut-successor | validate-packages | render-consumer-catalog | generate-package-schemas | sync-payload-projection} [...] ``` There are no group-level options other than `-h` / `--help`; each verb defines its own flags. An unrecognized verb exits 2. @@ -441,6 +441,28 @@ Options: Exit status: `0` catalog written or fresh · `1` graph findings or stale output · `2` invalid invocation, unsafe output path, or load/write error. +### `standards cut-successor` + +Author `standards//versions//` as a copy of its predecessor, stamp the copied `payload.toml` with the new version and freshly computed per-file digests, index the payload in the family's `standard.toml`, advertise it in the catalog major that already carries the family, and then rerun `sync-payload-projection` and `render-catalog`. Released payload bytes are immutable, so this is how every fix and every feature reaches a published family. + +The successor inherits the predecessor's catalog role, and a `default` predecessor is demoted to `retained`. A reference-only or internal family therefore keeps its role rather than acquiring a default. + +```text +project-standards standards cut-successor [--root ] [--from ] [--scaffold-test] [--dry-run] [--json] +``` + +Options: + +- **`--root `** — Repository root. Default: the current directory. +- **`--from `** — Predecessor to copy. Default: the family's highest indexed version. +- **`--scaffold-test`** — Write `tests/package_contract/test__.py` carrying the cut's mechanical assertions and a TODO block listing the predecessor test's behavior cases. +- **`--dry-run`** — Print the resolved plan and write nothing. +- **`--json`** — Emit the plan, the aggregate digest, and the review list as JSON. + +Every line inside the new payload tree that still names the predecessor version is **reported, never rewritten**: a migration endpoint or a permalink into the predecessor's published documentation is correct history, and only the author can tell those from the references the cut must move. Family landing pages (`README.md`, `agent-summary.md`, `adopt.md` at the family root) are likewise left to the author. The command refuses to run when the successor directory already exists, so it can never overwrite work in progress or mutate released bytes. + +Exit status: `0` cut written and generated artifacts refreshed · `1` the follow-on projection or catalog writer reported findings · `2` invalid invocation, an existing successor, or a payload/catalog load or write error. + ### `standards validate-packages` Validate every discovered V2 package family, immutable payload, catalog source, and cross-package graph without executing providers or writing files. diff --git a/src/project_standards/cli_contract.py b/src/project_standards/cli_contract.py index 8898ab2b..ba3f440c 100644 --- a/src/project_standards/cli_contract.py +++ b/src/project_standards/cli_contract.py @@ -30,6 +30,7 @@ "standards": frozenset({0, 1, 2}), "standards validate-graph": frozenset({0, 1, 2}), "standards render-catalog": frozenset({0, 1, 2}), + "standards cut-successor": frozenset({0, 1, 2}), "standards validate-packages": frozenset({0, 1, 2}), "standards render-consumer-catalog": frozenset({0, 1, 2}), "standards generate-package-schemas": frozenset({0, 1, 2}), diff --git a/src/project_standards/package_contract/cut_successor.py b/src/project_standards/package_contract/cut_successor.py new file mode 100644 index 00000000..e6f15888 --- /dev/null +++ b/src/project_standards/package_contract/cut_successor.py @@ -0,0 +1,547 @@ +"""Author a successor payload version as an edited copy of its predecessor. + +Released payload bytes are immutable at every release level: `packages +check-release` classifies any change under an already-advertised +`standards//versions//` as forbidden. A fix or a feature is therefore +always cut as a *new* version directory copied from the predecessor, with four +declaration sites moved in lockstep — the copied `payload.toml`, the family +index, the catalog major, and the generated projection and catalog. This module +performs exactly the mechanical half of that procedure; the content edits and +the family landing pages stay with the author. + +Two deliberate non-goals, both because a wrong automatic answer here is worse +than no answer: + +Stale embedded version references are REPORTED, never rewritten. A successor +copy inherits every string naming the predecessor — provider-input constants, +migration ids and `from` endpoints, schema enums, documentation permalinks — and +only some of them must move. A permalink into the predecessor's own published +documentation, or a migration whose `from` endpoint names the version a consumer +is leaving, is correct history that a blanket substitution would silently +corrupt. The single exception is a migration's `to` endpoint, which the payload +contract requires to name the containing version and which +`_rewrite_payload_manifest` therefore re-points; it is reported as an applied +edit, not left for the author. + +Family landing pages (`standards//README.md`, `agent-summary.md`, +`adopt.md`) are untouched. They are prose mirrors whose "what changed" sections +cannot be generated, and a half-written mirror reads as finished work. + +The digest chain runs in one direction and the order below is load-bearing: the +per-resource digests are written into `payload.toml` first, because the +aggregate hashes `payload.toml`'s own bytes along with every declared file. The +aggregate is then computed by `validate_payload_integrity`, the same function +the repository validators use, so a manifest this module writes and a manifest a +human writes are verified by one implementation. +""" + +from __future__ import annotations + +import hashlib +import re +import shutil +from dataclasses import dataclass +from pathlib import Path + +from project_standards.package_contract._write import atomic_write +from project_standards.package_contract.catalog import ( + CatalogRole, + load_catalog_source, +) +from project_standards.package_contract.diagnostics import PackageContractError +from project_standards.package_contract.family import load_family_manifest +from project_standards.package_contract.integrity import validate_payload_integrity +from project_standards.package_contract.paths import PackageVersion, Sha256Digest +from project_standards.package_contract.payload import load_payload_manifest + +_SCALAR = re.compile( + r'^(?P[ \t]*)(?P[A-Za-z_][A-Za-z0-9_-]*)\s*=\s*"(?P[^"]*)"' +) +_TABLE_HEADER = re.compile(r"^\s*\[") +_DIGEST_TABLES = {"[[resources]]": "path", "[[artifacts]]": "source"} +_TEST_FUNCTION = re.compile(r"^def (test_[A-Za-z0-9_]+)\(", re.MULTILINE) + + +@dataclass(frozen=True, slots=True) +class VersionOccurrence: + """One line inside the new payload tree that still names the predecessor.""" + + path: str + line: int + text: str + + +@dataclass(frozen=True, slots=True) +class CutPlan: + """Every path and role decision the cut will apply, resolved before any write.""" + + root: Path + standard_id: str + predecessor: PackageVersion + successor: PackageVersion + source_dir: Path + target_dir: Path + family_index: Path + catalog_path: Path + successor_role: CatalogRole + predecessor_role: CatalogRole + predecessor_role_after: CatalogRole + predecessor_test: Path | None + scaffold_target: Path | None + + +@dataclass(frozen=True, slots=True) +class CutResult: + """What the applied cut wrote, plus the review work it hands back to the author.""" + + plan: CutPlan + aggregate_digest: Sha256Digest + file_count: int + occurrences: tuple[VersionOccurrence, ...] + undecodable: tuple[str, ...] + repointed_migrations: tuple[str, ...] + scaffold_written: Path | None + + +def _family_dir(root: Path, standard_id: str) -> Path: + directory = root / "standards" / standard_id + if directory.is_symlink() or not directory.is_dir(): + raise PackageContractError(f"standard family is not a directory: standards/{standard_id}") + return directory + + +def _catalog_for(root: Path, standard_id: str, predecessor: PackageVersion) -> Path: + """Return the one catalog major that advertises the predecessor. + + A family is expected to live in exactly one catalog major. Two majors + advertising it is a state this writer refuses rather than guesses at: the + successor would have to be added to both, and which of them takes the new + default is a release decision, not a mechanical one. + """ + catalogs = root / "catalogs" + matches = [ + path + for path in sorted(catalogs.glob("*.toml")) + if any( + entry.id == standard_id and entry.version.value == predecessor.value + for entry in load_catalog_source(path).packages + ) + ] + if not matches: + raise PackageContractError(f"no catalog major advertises {standard_id}@{predecessor.value}") + if len(matches) > 1: + names = ", ".join(path.name for path in matches) + raise PackageContractError( + f"{standard_id}@{predecessor.value} is advertised by several catalog majors: {names}" + ) + return matches[0] + + +def _catalog_role(catalog_path: Path, standard_id: str, version: PackageVersion) -> CatalogRole: + for entry in load_catalog_source(catalog_path).packages: + if entry.id == standard_id and entry.version.value == version.value: + return entry.role + raise PackageContractError( + f"{standard_id}@{version.value} is not advertised by {catalog_path.name}" + ) + + +def _test_module_name(standard_id: str, version: PackageVersion) -> str: + return f"test_{standard_id.replace('-', '_')}_{version.value.replace('.', '_')}.py" + + +def plan_cut( + root: Path, + standard_id: str, + successor: str, + *, + predecessor: str | None = None, + scaffold_test: bool = False, +) -> CutPlan: + """Resolve and validate every decision a cut makes, without writing anything. + + Refuses a successor whose version directory already exists, which is the one + guard that keeps this command from overwriting work in progress or, worse, + mutating already-released bytes. + """ + try: + successor_version = PackageVersion(successor) + except ValueError as exc: + raise PackageContractError( + f"successor version is not canonical MAJOR.MINOR: {successor}" + ) from exc + + family_dir = _family_dir(root, standard_id) + manifest = load_family_manifest(family_dir / "standard.toml") + declared = {entry.version.value: entry.version for entry in manifest.versions} + + if successor_version.value in declared: + raise PackageContractError( + f"{standard_id}@{successor_version.value} is already declared in the family index" + ) + + if predecessor is None: + predecessor_version = max(declared.values(), key=lambda version: version.sort_key) + elif predecessor in declared: + predecessor_version = declared[predecessor] + else: + raise PackageContractError( + f"{standard_id}@{predecessor} is not declared in the family index" + ) + + if successor_version.sort_key <= predecessor_version.sort_key: + raise PackageContractError( + f"successor {successor_version.value} does not follow " + f"predecessor {predecessor_version.value}" + ) + + source_dir = family_dir / "versions" / predecessor_version.value + target_dir = family_dir / "versions" / successor_version.value + if source_dir.is_symlink() or not source_dir.is_dir(): + raise PackageContractError(f"predecessor payload directory is missing: {source_dir}") + if target_dir.exists() or target_dir.is_symlink(): + raise PackageContractError(f"successor payload directory already exists: {target_dir}") + + catalog_path = _catalog_for(root, standard_id, predecessor_version) + if any( + entry.id == standard_id and entry.version.value == successor_version.value + for entry in load_catalog_source(catalog_path).packages + ): + raise PackageContractError( + f"{standard_id}@{successor_version.value} is already advertised by {catalog_path.name}" + ) + predecessor_role = _catalog_role(catalog_path, standard_id, predecessor_version) + # The successor inherits the predecessor's role, and only a `default` + # predecessor is demoted. That keeps reference-only and internal families — + # which have no default at all — from acquiring one as a side effect of a cut. + successor_role = predecessor_role + predecessor_role_after = ( + CatalogRole.RETAINED if predecessor_role is CatalogRole.DEFAULT else predecessor_role + ) + + tests_dir = root / "tests" / "package_contract" + predecessor_test = tests_dir / _test_module_name(standard_id, predecessor_version) + scaffold_target = tests_dir / _test_module_name(standard_id, successor_version) + if scaffold_test and scaffold_target.exists(): + raise PackageContractError(f"scaffold target already exists: {scaffold_target}") + + return CutPlan( + root=root, + standard_id=standard_id, + predecessor=predecessor_version, + successor=successor_version, + source_dir=source_dir, + target_dir=target_dir, + family_index=family_dir / "standard.toml", + catalog_path=catalog_path, + successor_role=successor_role, + predecessor_role=predecessor_role, + predecessor_role_after=predecessor_role_after, + predecessor_test=predecessor_test if predecessor_test.is_file() else None, + scaffold_target=scaffold_target if scaffold_test else None, + ) + + +def _sha256_of(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return f"sha256:{digest.hexdigest()}" + + +def _rewrite_payload_manifest(plan: CutPlan) -> tuple[str, ...]: + """Stamp the successor version, re-digest every declared file, re-point migrations. + + Returns the migration ids whose `to` endpoint was moved onto the successor. + + Edits the manifest as text rather than re-serializing the parsed model: the + checked-in manifests carry comments and a hand-chosen layout that a + round-trip through the strict model would erase, and a cut whose diff is the + whole file hides the content edits a reviewer is looking for. + + Migration `to` endpoints are the one embedded version reference this module + does rewrite, because the payload contract admits exactly one value: every + migration must connect to its containing payload version, so a copied + `to = "package:"` produces a manifest that does not load at all. + `from` endpoints are left alone — they name the versions a consumer is + migrating away from, which the cut does not change — and so are migration + ids, which routinely spell a version but are opaque to the contract; both + surface in the predecessor-reference report instead. + """ + manifest_path = plan.target_dir / "payload.toml" + lines = manifest_path.read_text(encoding="utf-8").splitlines() + header = "" + file_key: str | None = None + migration_id: str | None = None + pending_digest: int | None = None + replacements: dict[int, str] = {} + repointed: list[str] = [] + + def resolve(block_end: int) -> None: + if pending_digest is None: + return + if file_key is None: + raise PackageContractError( + f"payload manifest declares a digest with no source path near line {block_end}" + ) + target = plan.target_dir / file_key + if not target.is_file(): + raise PackageContractError(f"declared payload file is missing: {file_key}") + declaration = _SCALAR.match(lines[pending_digest]) + if declaration is None: # pragma: no cover - the index came from this same match + raise PackageContractError("payload manifest digest line could not be re-read") + replacements[pending_digest] = ( + f'{declaration.group("indent")}digest = "{_sha256_of(target)}"' + ) + + for index, line in enumerate(lines): + if _TABLE_HEADER.match(line): + resolve(index) + header, file_key, pending_digest = line.strip(), None, None + migration_id = None + continue + match = _SCALAR.match(line) + if match is None: + continue + key, value = match.group("key"), match.group("value") + if header == "[payload]" and key == "version": + replacements[index] = f'{match.group("indent")}version = "{plan.successor.value}"' + elif header == "[[migrations]]": + if key == "id": + migration_id = value + elif key == "to" and value == f"package:{plan.predecessor.value}": + replacements[index] = ( + f'{match.group("indent")}to = "package:{plan.successor.value}"' + ) + repointed.append(migration_id or f"line {index + 1}") + elif header in _DIGEST_TABLES: + if key == _DIGEST_TABLES[header]: + file_key = value + elif key == "digest": + pending_digest = index + resolve(len(lines)) + + for index, text in replacements.items(): + lines[index] = text + atomic_write(manifest_path, ("\n".join(lines) + "\n").encode("utf-8")) + + written = load_payload_manifest(manifest_path) + if written.payload.version.value != plan.successor.value: + raise PackageContractError( + "payload manifest did not take the successor version; " + "its [payload] table may declare version unconventionally" + ) + return tuple(repointed) + + +def _append_version_entry(plan: CutPlan, digest: Sha256Digest) -> None: + text = plan.family_index.read_text(encoding="utf-8") + if not text.endswith("\n"): + text += "\n" + entry = ( + "\n[[versions]]\n" + f'version = "{plan.successor.value}"\n' + f'payload = "versions/{plan.successor.value}/payload.toml"\n' + f'digest = "{digest.value}"\n' + ) + atomic_write(plan.family_index, (text + entry).encode("utf-8")) + + +def _insert_catalog_entry(plan: CutPlan, digest: Sha256Digest) -> None: + """Add the successor entry directly after the family's last entry. + + Locality is the point: several families can be cut in one release train, and + an entry appended to the end of the catalog would put every one of those cuts + in the same diff hunk. + """ + lines = plan.catalog_path.read_text(encoding="utf-8").splitlines() + starts = [index for index, line in enumerate(lines) if line.strip() == "[[packages]]"] + blocks: list[tuple[int, int]] = [ + (start, starts[position + 1] if position + 1 < len(starts) else len(lines)) + for position, start in enumerate(starts) + ] + + def field(block: tuple[int, int], key: str) -> str | None: + for index in range(block[0], block[1]): + match = _SCALAR.match(lines[index]) + if match is not None and match.group("key") == key: + return match.group("value") + return None + + family_blocks = [block for block in blocks if field(block, "id") == plan.standard_id] + if not family_blocks: + raise PackageContractError(f"{plan.catalog_path.name} declares no {plan.standard_id} entry") + + if plan.predecessor_role_after is not plan.predecessor_role: + for block in family_blocks: + if field(block, "version") != plan.predecessor.value: + continue + for index in range(block[0], block[1]): + match = _SCALAR.match(lines[index]) + if match is not None and match.group("key") == "role": + lines[index] = f'role = "{plan.predecessor_role_after.value}"' + + insertion = family_blocks[-1][1] + entry = [ + "[[packages]]", + f'id = "{plan.standard_id}"', + f'version = "{plan.successor.value}"', + f'digest = "{digest.value}"', + f'role = "{plan.successor_role.value}"', + "", + ] + if insertion >= len(lines): + entry = ["", *entry[:-1]] + lines[insertion:insertion] = entry + atomic_write(plan.catalog_path, ("\n".join(lines) + "\n").encode("utf-8")) + + +def _predecessor_occurrences( + plan: CutPlan, +) -> tuple[tuple[VersionOccurrence, ...], tuple[str, ...]]: + """Report, never rewrite, every line in the new tree naming the predecessor. + + The lookarounds keep `1.8` from matching inside `1.80` or `v1.8.1` while + still matching the path segments (`versions/1.8/`) and bare mentions that + carry most of the real staleness. + """ + pattern = re.compile(rf"(? str: + behaviors = ( + _TEST_FUNCTION.findall(plan.predecessor_test.read_text(encoding="utf-8")) + if plan.predecessor_test is not None + else [] + ) + predecessor_name = ( + plan.predecessor_test.name if plan.predecessor_test is not None else "(none found)" + ) + todo = "\n".join(f"# - {name}" for name in behaviors) or "# (none found)" + module = plan.standard_id.replace("-", "_") + return f'''"""Contract tests for {plan.standard_id}@{plan.successor.value}. + +Scaffolded by `project-standards standards cut-successor` from +{predecessor_name}. The assertions below are the mechanical half of a cut — the +registration, role, and digest facts that hold for every successor. The behavior +assertions that make the cut worth shipping are still to be written; see the +TODO block at the end of this module. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[2] +_STANDARD = "{plan.standard_id}" +_VERSION = "{plan.successor.value}" +_PREDECESSOR = "{plan.predecessor.value}" +_CATALOG = _ROOT / "catalogs" / "{plan.catalog_path.name}" + + +def _family_versions() -> dict[str, dict[str, object]]: + raw = tomllib.loads( + (_ROOT / "standards" / _STANDARD / "standard.toml").read_text(encoding="utf-8") + ) + return {{entry["version"]: entry for entry in raw["versions"]}} + + +def _payload_dir() -> Path: + return _ROOT / "standards" / _STANDARD / "versions" / _VERSION + + +def _catalog_entries() -> dict[str, dict[str, object]]: + raw = tomllib.loads(_CATALOG.read_text(encoding="utf-8")) + return {{entry["version"]: entry for entry in raw["packages"] if entry["id"] == _STANDARD}} + + +def test_{module}_{plan.successor.value.replace(".", "_")}_is_indexed_by_its_family() -> None: + entry = _family_versions()[_VERSION] + + assert entry["payload"] == f"versions/{{_VERSION}}/payload.toml" + assert _payload_dir().joinpath("payload.toml").is_file() + + +def test_{module}_{plan.successor.value.replace(".", "_")}_payload_declares_its_own_version() -> None: + manifest = tomllib.loads(_payload_dir().joinpath("payload.toml").read_text(encoding="utf-8")) + + assert manifest["payload"]["standard"] == _STANDARD + assert manifest["payload"]["version"] == _VERSION + + +def test_{module}_{plan.successor.value.replace(".", "_")}_catalog_roles_moved_with_the_cut() -> None: + entries = _catalog_entries() + + assert entries[_VERSION]["role"] == "{plan.successor_role.value}" + assert entries[_PREDECESSOR]["role"] == "{plan.predecessor_role_after.value}" + + +def test_{module}_{plan.successor.value.replace(".", "_")}_catalog_and_family_agree_on_the_digest() -> None: + # The catalog digest and the family index digest are two independent + # declarations of one payload aggregate; a cut that moves only one of them + # is exactly the drift PC-CATALOG-DIGEST-REPLACED reports at release time. + assert _catalog_entries()[_VERSION]["digest"] == _family_versions()[_VERSION]["digest"] + + +# TODO(before this cut is admitted): port the behavior assertions from +# {predecessor_name}, which pins these cases: +{todo} +''' + + +def apply_cut(plan: CutPlan) -> CutResult: + """Execute the planned cut and return the aggregate digest plus the review list. + + A refused cut leaves nothing behind. The copied tree is removed and the + family index is restored on any failure, because a half-written successor + directory would be advertised by nothing and yet block the retry — `plan_cut` + refuses when the target already exists, which is the guard that makes the + command safe to rerun. + """ + index_before = plan.family_index.read_bytes() + try: + shutil.copytree(plan.source_dir, plan.target_dir, symlinks=True) + repointed = _rewrite_payload_manifest(plan) + manifest = load_payload_manifest(plan.target_dir / "payload.toml") + integrity = validate_payload_integrity(plan.target_dir, manifest) + _append_version_entry(plan, integrity.aggregate_digest) + _insert_catalog_entry(plan, integrity.aggregate_digest) + except BaseException: + shutil.rmtree(plan.target_dir, ignore_errors=True) + atomic_write(plan.family_index, index_before) + raise + occurrences, undecodable = _predecessor_occurrences(plan) + + scaffold_written: Path | None = None + if plan.scaffold_target is not None: + atomic_write(plan.scaffold_target, _scaffold_source(plan).encode("utf-8")) + scaffold_written = plan.scaffold_target + + return CutResult( + plan=plan, + aggregate_digest=integrity.aggregate_digest, + file_count=len(integrity.inventory), + occurrences=occurrences, + undecodable=undecodable, + repointed_migrations=repointed, + scaffold_written=scaffold_written, + ) diff --git a/src/project_standards/standards_graph/cli.py b/src/project_standards/standards_graph/cli.py index 743d0b28..dcfcf0ea 100644 --- a/src/project_standards/standards_graph/cli.py +++ b/src/project_standards/standards_graph/cli.py @@ -13,6 +13,8 @@ from project_standards.cli_contract import PACKAGE_AUTHORING_COMMAND_HELP from project_standards.control_plane.diagnostics import ControlPlaneError from project_standards.control_plane.locking import ControlPlaneBusyError +from project_standards.package_contract.cut_successor import CutPlan, apply_cut, plan_cut +from project_standards.package_contract.diagnostics import PackageContractError from project_standards.standard_manifest import StandardManifestError from project_standards.standards_graph.catalog import load_contract_defaults, render_catalog from project_standards.standards_graph.discovery import build_graph @@ -27,6 +29,7 @@ "version": "set one standard's desired version selector", "validate-graph": "validate standard manifests as one graph", "render-catalog": "write or freshness-check standards/catalog.md", + "cut-successor": "author a successor payload version from its predecessor", **PACKAGE_AUTHORING_COMMAND_HELP, } @@ -131,6 +134,141 @@ def _run_render_catalog(argv: list[str]) -> int: return _emit_error(False, "catalog_error", str(exc)) +def _describe_plan(plan: CutPlan) -> list[str]: + root = plan.root + lines = [ + f"Cut {plan.standard_id} {plan.predecessor.value} -> {plan.successor.value}", + f" copy {plan.source_dir.relative_to(root)} -> {plan.target_dir.relative_to(root)}", + f" index {plan.family_index.relative_to(root)}: add [[versions]] " + f"{plan.successor.value}", + f" catalog {plan.catalog_path.relative_to(root)}: add {plan.successor.value} as " + f"{plan.successor_role.value}", + ] + if plan.predecessor_role_after is not plan.predecessor_role: + lines.append( + f" catalog {plan.catalog_path.relative_to(root)}: {plan.predecessor.value} " + f"{plan.predecessor_role.value} -> {plan.predecessor_role_after.value}" + ) + if plan.scaffold_target is not None: + lines.append(f" scaffold {plan.scaffold_target.relative_to(root)}") + lines.append(" then standards sync-payload-projection, standards render-catalog") + return lines + + +def _plan_jsonable(plan: CutPlan) -> dict[str, object]: + return { + "standard_id": plan.standard_id, + "predecessor": plan.predecessor.value, + "successor": plan.successor.value, + "source_dir": plan.source_dir.relative_to(plan.root).as_posix(), + "target_dir": plan.target_dir.relative_to(plan.root).as_posix(), + "family_index": plan.family_index.relative_to(plan.root).as_posix(), + "catalog": plan.catalog_path.relative_to(plan.root).as_posix(), + "successor_role": plan.successor_role.value, + "predecessor_role": plan.predecessor_role_after.value, + "scaffold_target": ( + plan.scaffold_target.relative_to(plan.root).as_posix() + if plan.scaffold_target is not None + else None + ), + } + + +def _run_cut_successor(argv: list[str]) -> int: + """Cut a successor payload version, then rerun the two generated-artifact writers. + + The follow-on `sync-payload-projection` and `render-catalog` calls go through + the same entry points an author would type, so a cut can never produce a + projection or catalog that differs from the one those commands generate. + Their exit status is propagated: a cut whose generated artifacts did not + write is not a successful cut, even though the payload bytes already landed. + """ + ap = _Parser(prog="project-standards standards cut-successor") + ap.add_argument("standard_id") + ap.add_argument("version") + ap.add_argument("--root", type=Path, default=Path.cwd()) + ap.add_argument("--from", dest="predecessor") + ap.add_argument("--scaffold-test", action="store_true") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--json", action="store_true") + json_mode = "--json" in argv + try: + args = ap.parse_args(argv) + json_mode = cast("bool", args.json) + root = cast("Path", args.root).resolve() + plan = plan_cut( + root, + cast("str", args.standard_id), + cast("str", args.version), + predecessor=cast("str | None", args.predecessor), + scaffold_test=cast("bool", args.scaffold_test), + ) + if cast("bool", args.dry_run): + if json_mode: + print( + json.dumps( + {"ok": True, "dry_run": True, "plan": _plan_jsonable(plan)}, indent=2 + ) + ) + else: + print("\n".join(_describe_plan(plan))) + return 0 + result = apply_cut(plan) + except _ArgparseError as exc: + return _emit_error(json_mode, "bad_args", str(exc)) + except (OSError, ValueError, PackageContractError) as exc: + return _emit_error(json_mode, "cut_error", str(exc)) + + from project_standards.package_contract.cli import run_standards + + generated = run_standards(["sync-payload-projection", "--root", str(root)]) + if generated == 0: + generated = _run_render_catalog(["--root", str(root)]) + + occurrences = [ + {"path": item.path, "line": item.line, "text": item.text} for item in result.occurrences + ] + if json_mode: + print( + json.dumps( + { + "ok": generated == 0, + "plan": _plan_jsonable(plan), + "aggregate_digest": result.aggregate_digest.value, + "files": result.file_count, + "predecessor_references": occurrences, + "repointed_migrations": list(result.repointed_migrations), + "undecodable_files": list(result.undecodable), + "scaffold_written": ( + result.scaffold_written.relative_to(root).as_posix() + if result.scaffold_written is not None + else None + ), + }, + indent=2, + ) + ) + else: + print("\n".join(_describe_plan(plan))) + print(f" aggregate {result.aggregate_digest.value} over {result.file_count} files") + for migration in result.repointed_migrations: + print(f" migration {migration}: to -> package:{plan.successor.value}") + if result.occurrences: + # Reported, never rewritten: only the author knows which of these name + # the version being cut and which are correct history. + print( + f"REVIEW: {len(result.occurrences)} line(s) in the new tree still name " + f"{plan.predecessor.value}:" + ) + for item in result.occurrences: + print(f" {item.path}:{item.line}: {item.text}") + else: + print(f"REVIEW: no line in the new tree names {plan.predecessor.value}") + for name in result.undecodable: + print(f" (not text, unscanned) {name}") + return 1 if generated else 0 + + def _control_parser(command: str) -> _Parser: parser = _Parser(prog=f"project-standards standards {command}") if command != "list": @@ -300,6 +438,8 @@ def run(argv: list[str] | None = None) -> int: return _run_validate_graph(rest) if command == "render-catalog": return _run_render_catalog(rest) + if command == "cut-successor": + return _run_cut_successor(rest) if command in { "validate-packages", "render-consumer-catalog", diff --git a/tests/package_contract/test_cut_successor.py b/tests/package_contract/test_cut_successor.py new file mode 100644 index 00000000..a86ebd55 --- /dev/null +++ b/tests/package_contract/test_cut_successor.py @@ -0,0 +1,154 @@ +"""`standards cut-successor` behavior, proved against a copy of this repository. + +Each case copies the whole repository (~200 MB) once, so the assertions are +deliberately packed into as few test functions as they will fit: splitting them +would let xdist schedule each one on a different worker and pay for the copy +again on every one of them. + +The copy excludes `.git`, `build/`, `.venv/`, and the caches — none of them is +read by the package-contract validators, and `.git` alone doubles the cost. +""" + +from __future__ import annotations + +import importlib.util +import shutil +import tomllib +from pathlib import Path + +import pytest + +from project_standards.cli import main +from project_standards.package_contract.cut_successor import apply_cut, plan_cut +from project_standards.package_contract.diagnostics import PackageContractError + +_ROOT = Path(__file__).resolve().parents[2] +# python-coding is the smallest family in the repository (four payload files) and +# is reference-only, which is what pins the role-inheritance rule below: a family +# with no default must not acquire one as a side effect of a cut. +_SMALLEST_FAMILY = "python-coding" +_SKIPPED = {".git", "build", ".venv", "node_modules", ".pytest_cache", ".ruff_cache", "__pycache__"} + + +def _copy_repository(destination: Path) -> Path: + def ignore(_directory: str, names: list[str]) -> list[str]: + return [name for name in names if name in _SKIPPED] + + repository = destination / "repo" + shutil.copytree(_ROOT, repository, symlinks=True, ignore=ignore) + return repository + + +def _next_version(repository: Path, standard_id: str) -> tuple[str, str]: + """Return the family's highest indexed version and the successor after it.""" + raw = tomllib.loads( + (repository / "standards" / standard_id / "standard.toml").read_text(encoding="utf-8") + ) + latest = max( + (str(entry["version"]) for entry in raw["versions"]), + key=lambda value: tuple(int(part) for part in value.split(".")), + ) + major, minor = (int(part) for part in latest.split(".")) + return latest, f"{major}.{minor + 1}" + + +def _run_module(path: Path) -> None: + """Import one generated test module and run every test function it defines.""" + spec = importlib.util.spec_from_file_location(path.stem, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cases = [name for name in dir(module) if name.startswith("test_")] + assert cases, f"scaffolded module defines no test: {path}" + for name in cases: + getattr(module, name)() + + +def test_cut_successor__end_to_end__leaves_every_repository_validator_green( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + repository = _copy_repository(tmp_path) + predecessor, successor = _next_version(repository, _SMALLEST_FAMILY) + catalog_path = repository / "catalogs" / "5.toml" + before = catalog_path.read_bytes() + invocation = ["standards", "cut-successor", _SMALLEST_FAMILY, successor, "--root"] + + assert main([*invocation, str(repository), "--dry-run"]) == 0 + assert catalog_path.read_bytes() == before + assert not (repository / "standards" / _SMALLEST_FAMILY / "versions" / successor).exists() + + assert main([*invocation, str(repository), "--scaffold-test"]) == 0 + capsys.readouterr() + + payload_dir = repository / "standards" / _SMALLEST_FAMILY / "versions" / successor + manifest = tomllib.loads((payload_dir / "payload.toml").read_text(encoding="utf-8")) + assert manifest["payload"]["version"] == successor + + family = tomllib.loads( + (repository / "standards" / _SMALLEST_FAMILY / "standard.toml").read_text(encoding="utf-8") + ) + indexed = {entry["version"]: entry for entry in family["versions"]} + catalog = tomllib.loads(catalog_path.read_text(encoding="utf-8")) + advertised = { + entry["version"]: entry for entry in catalog["packages"] if entry["id"] == _SMALLEST_FAMILY + } + assert indexed[successor]["digest"] == advertised[successor]["digest"] + # Role inheritance, not promotion: python-coding advertises no default, so + # neither the successor nor the retained predecessor may acquire one. + assert advertised[successor]["role"] == advertised[predecessor]["role"] == "reference-only" + + for verb in ( + ["standards", "validate-packages"], + ["standards", "validate-graph"], + ["standards", "render-catalog", "--check"], + ["standards", "sync-payload-projection", "--check"], + ): + assert main([*verb, "--root", str(repository)]) == 0, capsys.readouterr().out + + module_name = f"test_python_coding_{successor.replace('.', '_')}.py" + _run_module(repository / "tests" / "package_contract" / module_name) + + # A rerun must refuse rather than overwrite the tree it just wrote, and so + # must a cut whose directory exists without being indexed — the half-written + # state a previous interrupted attempt would leave. + with pytest.raises(PackageContractError, match="already declared"): + plan_cut(repository, _SMALLEST_FAMILY, successor) + _indexed, beyond = _next_version(repository, _SMALLEST_FAMILY) + (repository / "standards" / _SMALLEST_FAMILY / "versions" / beyond).mkdir() + with pytest.raises(PackageContractError, match="already exists"): + plan_cut(repository, _SMALLEST_FAMILY, beyond) + + +def test_cut_successor__migration_endpoints__move_onto_the_successor(tmp_path: Path) -> None: + # python-tooling carries package-to-package migrations whose `to` endpoint the + # payload contract requires to name the containing version; a copy that kept + # the predecessor there would not load at all. + repository = _copy_repository(tmp_path) + predecessor, successor = _next_version(repository, "python-tooling") + + result = apply_cut(plan_cut(repository, "python-tooling", successor)) + + manifest = tomllib.loads( + ( + repository / "standards" / "python-tooling" / "versions" / successor / "payload.toml" + ).read_text(encoding="utf-8") + ) + assert {migration["to"] for migration in manifest["migrations"]} == {f"package:{successor}"} + assert result.repointed_migrations + # `from` endpoints name the versions a consumer is leaving; the cut does not + # rewrite history, so they carry over from the predecessor unchanged. + inherited = tomllib.loads( + ( + repository / "standards" / "python-tooling" / "versions" / predecessor / "payload.toml" + ).read_text(encoding="utf-8") + ) + assert {migration["from"] for migration in manifest["migrations"]} == { + migration["from"] for migration in inherited["migrations"] + } + # The predecessor's own bytes are never touched: mutating a released payload + # is what `packages check-release` classifies as forbidden. + source = repository / "standards" / "python-tooling" / "versions" / predecessor + assert (source / "payload.toml").read_bytes() == ( + _ROOT / "standards" / "python-tooling" / "versions" / predecessor / "payload.toml" + ).read_bytes() diff --git a/tests/test_usage_doc_inventory.py b/tests/test_usage_doc_inventory.py index 83740692..ccbc3159 100644 --- a/tests/test_usage_doc_inventory.py +++ b/tests/test_usage_doc_inventory.py @@ -55,6 +55,7 @@ "version", "validate-graph", "render-catalog", + "cut-successor", "validate-packages", "render-consumer-catalog", "generate-package-schemas",