diff --git a/CHANGELOG.md b/CHANGELOG.md index cda2a681..b7324bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### Added -- **`project-standards packages check-release --staged` reports a mid-train working tree without a false red.** Between a landed payload cut and release prep, `.standards/`, the catalog projection, and the project version legitimately lag the catalog, so the command reported `PC-RELEASE-LEVEL`, `PC-RELEASE-PROJECTION`, and `PC-RELEASE-PROJECT-VERSION` and exited `1` on a tree that was correct for its phase ([#227](https://github.com/L3DigitalNet/project-standards/issues/227), [#236](https://github.com/L3DigitalNet/project-standards/issues/236)). `--staged` labels exactly those three codes expected pre-bump — still printed, prefixed `EXPECTED-PRE-BUMP` — and exits `0` when nothing else is found; every other code, including `PC-RELEASE-PAYLOAD-MUTATED`, `PC-CATALOG-DIGEST-REPLACED`, and `PC-RELEASE-PACKAGE-CURRENT`, still fails. Under `--json` the object additively gains `staged` and `expected_pre_bump`. Without the flag, output and exit status are unchanged. +- **`project-standards packages check-release --staged` reports a mid-train working tree without a false red.** Between a landed payload cut and release prep, `.standards/`, the catalog projection, and the project version legitimately lag the catalog, so the command reported `PC-RELEASE-PROJECTION`, `PC-RELEASE-PROJECT-VERSION`, and `PC-RELEASE-LEVEL` and exited `1` on a tree that was correct for its phase ([#227](https://github.com/L3DigitalNet/project-standards/issues/227), [#236](https://github.com/L3DigitalNet/project-standards/issues/236)). `--staged` labels those findings expected pre-bump — still printed, prefixed `EXPECTED-PRE-BUMP` — and exits `0` when nothing else is found. The expectation is keyed to the producer, not the code: `PC-RELEASE-LEVEL` has two producers, and only the one raised because the proposed tool version has not advanced past the baseline is excused, so a breaking default promotion, a catalog/tool major skew, or a wrong-level bump still fails under `--staged`, as does every other code including `PC-RELEASE-PAYLOAD-MUTATED` and `PC-CATALOG-DIGEST-REPLACED`. Under `--json` the object additively gains `staged` and `expected_pre_bump`; `classification` keeps its unstaged meaning, so a staged pass reads `"ok": true` with `"classification": "forbidden"`. Without the flag, output and exit status are unchanged. - **A committed release runbook** ([`docs/reference/release-runbook.md`](docs/reference/release-runbook.md)) replaces the per-train session log with a durable procedure: every step's command, exit evidence, and rollback, plus a Layer ownership table stating which gate layer (leg, integrated gate, release prep, hosted Check, publication, or periodic) owns each proof ([#236](https://github.com/L3DigitalNet/project-standards/issues/236)). - **`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)). diff --git a/docs/usage.md b/docs/usage.md index ad015157..b401b39b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -556,8 +556,8 @@ Options: - **`--baseline `** — Released Git tag or commit to compare. Required. Option-like and unresolved refs are rejected. - **`--root `** — Repository root. Default: the current directory. - **`--previous-version `** — Baseline tool SemVer. Required when `` is not a `vMAJOR.MINOR.PATCH` tag; otherwise derived from the tag. -- **`--staged`** — Report a mid-train working tree. Exactly `PC-RELEASE-LEVEL`, `PC-RELEASE-PROJECTION`, and `PC-RELEASE-PROJECT-VERSION` are labelled expected pre-bump rather than fatal, because a landed payload cut legitimately precedes the release-prep version bump and projection refresh. Every other code — including `PC-RELEASE-PAYLOAD-MUTATED`, `PC-CATALOG-DIGEST-REPLACED`, and `PC-RELEASE-PACKAGE-CURRENT` — still fails. Expected findings are still printed, prefixed `EXPECTED-PRE-BUMP` instead of `ERROR`. Omitting the flag leaves output and exit status exactly as they were. -- **`--json`** — Emit classification and stable findings as JSON. With `--staged`, the object additionally carries `"staged": true` and `"expected_pre_bump"`, the sorted codes that were labelled expected; `"ok"` then reports whether any other finding remains. +- **`--staged`** — Report a mid-train working tree, where a landed payload cut legitimately precedes the release-prep version bump and projection refresh. Three findings are labelled expected pre-bump rather than fatal: `PC-RELEASE-PROJECTION`, `PC-RELEASE-PROJECT-VERSION`, and the single `PC-RELEASE-LEVEL` that is raised because the proposed tool version has not advanced beyond the baseline yet. `PC-RELEASE-LEVEL` raised for any other reason — a breaking default promotion without an owner-designated major, a catalog/tool major skew, or a bump at the wrong level — is **not** excused, and neither is any other code, including `PC-RELEASE-PAYLOAD-MUTATED`, `PC-CATALOG-DIGEST-REPLACED`, and `PC-RELEASE-PACKAGE-CURRENT`. Expected findings are still printed, prefixed `EXPECTED-PRE-BUMP` instead of `ERROR`. Omitting the flag leaves output and exit status exactly as they were. +- **`--json`** — Emit classification and stable findings as JSON. With `--staged`, the object additionally carries `"staged": true` and `"expected_pre_bump"`, the sorted codes of the findings that were labelled expected; `"ok"` then reports whether any other finding remains. `"classification"` keeps its unstaged meaning — the ADR 0024 verdict on the tree **as it stands** — so a staged pass legitimately reads `"ok": true` with `"classification": "forbidden"`: the tree may not be released yet, and nothing but the pending bump stands in the way. Read `"ok"` for the gate result and `"classification"` for the release verdict; a code in `expected_pre_bump` is never grounds to treat the same code as benign in an unstaged run. The command reads the baseline through argument-vector Git calls and only loads catalog-declared family and payload paths. It never changes versions, catalogs, tags, or payloads. diff --git a/src/project_standards/package_contract/cli.py b/src/project_standards/package_contract/cli.py index 4b4251af..721927b7 100644 --- a/src/project_standards/package_contract/cli.py +++ b/src/project_standards/package_contract/cli.py @@ -48,15 +48,17 @@ _PACKAGES_COMMAND_HELP = { "check-release": "compare working payloads with a released tag", } -# The exact codes a correct mid-train tree reports before the release-prep bump: -# `.standards/catalog.toml`, `.standards/lock.toml`, the catalog projection, and -# pyproject's version all lag a landed payload cut by design. `--staged` labels -# these and nothing else as expected; every other release code stays fatal, so a -# real regression (payload mutation, digest replacement) cannot hide behind the -# flag. Keep this set in step with docs/usage.md's `--staged` entry. -_STAGED_EXPECTED_CODES = frozenset( +# The release-consistency codes a correct mid-train tree reports before the +# release-prep bump: `.standards/` and the catalog projection are regenerated by +# release prep, and the release-current prose still names the published version. +# The fourth expected finding, the pre-bump `PC-RELEASE-LEVEL`, is NOT listed +# here and must never be added: that code has two producers in +# `release.classify_catalog_diff`, and the breaking-default-promotion one is a +# genuinely forbidden transition. `--staged` takes it from `CatalogDiff.pre_bump_lag`, +# which names the individual finding, so keep this set to codes with a single +# producer. Keep both in step with docs/usage.md's `--staged` entry. +_STAGED_EXPECTED_CONSISTENCY_CODES = frozenset( { - "PC-RELEASE-LEVEL", "PC-RELEASE-PROJECTION", "PC-RELEASE-PROJECT-VERSION", } @@ -165,20 +167,22 @@ def _validated_repositories( def _format_findings( findings: tuple[PackageFinding, ...], *, - expected_codes: frozenset[str] = frozenset(), + expected: frozenset[PackageFinding] = frozenset(), ) -> str: - """Render findings one per line, labelling `expected_codes` as non-fatal. + """Render findings one per line, labelling members of `expected` as non-fatal. - `expected_codes` is empty on every path except `check-release --staged`, so - the default rendering — and therefore the byte-for-byte output of every - other command — is unchanged. + `expected` holds whole findings rather than codes because `PC-RELEASE-LEVEL` + is emitted both for the harmless pre-bump lag and for a forbidden breaking + promotion; matching on the code would mislabel the second. It is empty on + every path except `check-release --staged`, so the default rendering — and + therefore the byte-for-byte output of every other command — is unchanged. """ if not findings: return "OK package repository" lines: list[str] = [] for finding in findings: version = f"@{finding.version}" if finding.version else "" - label = "EXPECTED-PRE-BUMP" if finding.code in expected_codes else "ERROR" + label = "EXPECTED-PRE-BUMP" if finding in expected else "ERROR" lines.append( f"{label} {finding.code} {finding.standard_id}{version} " f"{finding.identity}: {finding.message}" @@ -190,7 +194,7 @@ def _emit_findings( findings: tuple[PackageFinding, ...], *, json_mode: bool, - expected_codes: frozenset[str] = frozenset(), + expected: frozenset[PackageFinding] = frozenset(), ) -> int: if json_mode: print( @@ -201,7 +205,7 @@ def _emit_findings( ) else: stream = sys.stderr if findings else sys.stdout - print(_format_findings(findings, expected_codes=expected_codes), file=stream) + print(_format_findings(findings, expected=expected), file=stream) return 1 if findings else 0 @@ -382,7 +386,9 @@ def _run_check_release(argv: list[str]) -> int: args = parser.parse_args(argv) json_mode = cast("bool", args.json) staged = cast("bool", args.staged) - expected_codes = _STAGED_EXPECTED_CODES if staged else frozenset[str]() + expected_consistency_codes = ( + _STAGED_EXPECTED_CONSISTENCY_CODES if staged else frozenset[str]() + ) root = _safe_root(cast("Path", args.root)) baseline = cast("str", args.baseline) previous_version = _previous_version( @@ -406,11 +412,9 @@ def _run_check_release(argv: list[str]) -> int: # a purely expected pre-bump result continues into classification, where the # baseline comparison is the evidence that matters mid-train. if consistency_findings and any( - finding.code not in expected_codes for finding in consistency_findings + finding.code not in expected_consistency_codes for finding in consistency_findings ): - return _emit_findings( - consistency_findings, json_mode=json_mode, expected_codes=expected_codes - ) + return _emit_findings(consistency_findings, json_mode=json_mode) carried = consistency_findings if staged else () previous = load_git_release_snapshot(root, baseline, previous_major) result = classify_catalog_diff( @@ -419,7 +423,13 @@ def _run_check_release(argv: list[str]) -> int: ToolVersions(previous=previous_version, current=current_version), ) findings = tuple(sort_findings([*carried, *result.findings])) - blocking = tuple(finding for finding in findings if finding.code not in expected_codes) + # The classifier names its own lag findings; the CLI never re-derives them + # from a code or a message, because `PC-RELEASE-LEVEL` also carries the + # forbidden breaking-promotion case, which must still fail under `--staged`. + expected = ( + frozenset([*carried, *result.pre_bump_lag]) if staged else frozenset[PackageFinding]() + ) + blocking = tuple(finding for finding in findings if finding not in expected) # `--staged` exits on the findings that survive the expected set rather than on # the classification: mid-train the classification is legitimately `forbidden` # because pyproject still carries the released version. Unstaged keeps the @@ -436,7 +446,7 @@ def _run_check_release(argv: list[str]) -> int: if staged: document["staged"] = True document["expected_pre_bump"] = sorted( - {finding.code for finding in findings if finding.code in expected_codes} + {finding.code for finding in findings if finding in expected} ) print(json.dumps(document, indent=2)) else: @@ -448,7 +458,7 @@ def _run_check_release(argv: list[str]) -> int: ) if findings: print( - _format_findings(findings, expected_codes=expected_codes), + _format_findings(findings, expected=expected), file=sys.stderr, ) return 1 if failed else 0 diff --git a/src/project_standards/package_contract/release.py b/src/project_standards/package_contract/release.py index 5bcac79f..e33a0046 100644 --- a/src/project_standards/package_contract/release.py +++ b/src/project_standards/package_contract/release.py @@ -108,10 +108,21 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class CatalogDiff: - """Pure release-policy result with stable diagnostics.""" + """Pure release-policy result with stable diagnostics. + + `pre_bump_lag` is the subset of `findings` produced solely because the + proposed tool version has not been bumped yet — the state every working tree + is in between a landed payload cut and release prep. It is a separate tuple + rather than a distinguishing field on the finding itself so that `findings`, + and therefore every rendered report, stays byte-identical: callers that do + not know about staging see exactly what they saw before. `PC-RELEASE-LEVEL` + has two producers and only one of them is lag, so a consumer must never + infer this from the code string or the message text. + """ classification: ReleaseClassification findings: tuple[PackageFinding, ...] + pre_bump_lag: tuple[PackageFinding, ...] = () def _finding( @@ -141,6 +152,7 @@ def classify_catalog_diff( ) -> CatalogDiff: """Classify one proposed repository/catalog transition under ADR 0024.""" findings: list[PackageFinding] = [] + pre_bump_lag: list[PackageFinding] = [] forbidden = False package_advance = _has_package_version_advance(previous.catalog, current.catalog) required = ReleaseClassification.MINOR if package_advance else ReleaseClassification.PATCH @@ -254,13 +266,20 @@ def classify_catalog_diff( required, ) if release_error is not None: - findings.append(_finding("PC-RELEASE-LEVEL", release_error)) + boundary_finding = _finding("PC-RELEASE-LEVEL", release_error.message) + findings.append(boundary_finding) + if release_error.pre_bump_lag: + pre_bump_lag.append(boundary_finding) forbidden = True elif _is_owner_major_designation(previous, current, previous_tool, current_tool): required = ReleaseClassification.MAJOR classification = ReleaseClassification.FORBIDDEN if forbidden else required - return CatalogDiff(classification, tuple(sort_findings(findings))) + return CatalogDiff( + classification, + tuple(sort_findings(findings)), + tuple(pre_bump_lag), + ) def _has_package_version_advance(previous: CatalogSource, current: CatalogSource) -> bool: @@ -284,6 +303,14 @@ def _has_package_version_advance(previous: CatalogSource, current: CatalogSource ) +@dataclass(frozen=True, slots=True) +class _BoundaryError: + """One ADR 0024 release-boundary refusal, with its message and its cause class.""" + + message: str + pre_bump_lag: bool = False + + def _is_owner_major_designation( previous: ReleaseSnapshot, current: ReleaseSnapshot, @@ -302,23 +329,33 @@ def _release_boundary_error( previous_tool: _ToolRelease, current_tool: _ToolRelease, required: ReleaseClassification, -) -> str | None: +) -> _BoundaryError | None: if previous.catalog.catalog_major != previous_tool.major: - return "released tool major does not match its catalog major" + return _BoundaryError("released tool major does not match its catalog major") if current.catalog.catalog_major != current_tool.major: - return "proposed tool major does not match its catalog major" + return _BoundaryError("proposed tool major does not match its catalog major") + # The only boundary error a correct mid-train tree can reach: the payload cut + # has landed but release prep has not bumped pyproject, so the proposed tool + # version still equals (or trails) the baseline. Every other branch here is a + # genuine misconfiguration — a wrong bump level or a catalog/tool major skew — + # and must keep failing even under `check-release --staged`. if current_tool <= previous_tool: - return "proposed tool release must advance beyond the released baseline" + return _BoundaryError( + "proposed tool release must advance beyond the released baseline", + pre_bump_lag=True, + ) if _is_owner_major_designation(previous, current, previous_tool, current_tool): return None if required is ReleaseClassification.MINOR and ( current_tool.major != previous_tool.major or current_tool.minor <= previous_tool.minor ): - return "a package-version advance requires exactly a tool minor release" + return _BoundaryError("a package-version advance requires exactly a tool minor release") if required is ReleaseClassification.PATCH and ( current_tool.major != previous_tool.major or current_tool.minor != previous_tool.minor ): - return "a release without a package-version advance requires exactly a tool patch release" + return _BoundaryError( + "a release without a package-version advance requires exactly a tool patch release" + ) return None diff --git a/tests/package_contract/test_cli.py b/tests/package_contract/test_cli.py index 6994527c..5614ed23 100644 --- a/tests/package_contract/test_cli.py +++ b/tests/package_contract/test_cli.py @@ -14,6 +14,12 @@ from project_standards.package_contract import PackageContractError, PackageFinding from project_standards.package_contract import cli as package_cli from project_standards.package_contract.cli import run_packages, run_standards +from project_standards.package_contract.release import ( + CatalogDiff, + ReleaseClassification, + ReleaseSnapshot, + ToolVersions, +) from project_standards.package_contract.repository import ( PackageRepository, ) @@ -606,10 +612,13 @@ def test_packages_check_release_staged__only_expected_pre_bump_codes__exits_zero repository = tmp_path / "repository" shutil.copytree(_FIXTURE, repository) _create_released_fixture(repository) - # 5.3.0 against a 5.2.0 baseline with an unchanged catalog is the mid-train - # shape: no package-version advance yet, so classification is forbidden with - # PC-RELEASE-LEVEL — the exact red `--staged` exists to reclassify. - monkeypatch.setattr(package_cli, "package_version", lambda: "5.3.0") + # A tool version still equal to the baseline is the mid-train shape: release + # prep has not bumped pyproject yet, so the classifier refuses the transition + # with the lag producer of PC-RELEASE-LEVEL — the exact red `--staged` exists + # to reclassify. A bumped-but-wrong-level version (5.3.0 here) would reach the + # other producer of the same code and must keep failing, which is why this + # value is load-bearing rather than arbitrary. + monkeypatch.setattr(package_cli, "package_version", lambda: "5.2.0") monkeypatch.setattr(package_cli, "validate_release_consistency", _staged_consistency_stub) assert ( @@ -774,3 +783,79 @@ def test_packages_check_release__without_staged__output_is_unchanged( "PC-RELEASE-PROJECTION", "PC-RELEASE-PROJECT-VERSION", ] + + +def test_packages_check_release_staged__breaking_default_promotion__still_exits_one( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`--staged` must not excuse the other producer of PC-RELEASE-LEVEL. + + The classifier reports a breaking default promotion under the same code as the + pre-bump lag, so a code-keyed expectation would silently pass a genuinely + forbidden transition. The stubbed diff leaves `pre_bump_lag` empty, which is + exactly what `classify_catalog_diff` does for that producer. + """ + repository = tmp_path / "repository" + shutil.copytree(_FIXTURE, repository) + _create_released_fixture(repository) + monkeypatch.setattr(package_cli, "package_version", lambda: "5.2.0") + monkeypatch.setattr(package_cli, "validate_release_consistency", _staged_consistency_stub) + promotion = PackageFinding( + code="PC-RELEASE-LEVEL", + severity="error", + standard_id="demo", + version="2.0", + path="catalogs", + identity="catalog-entry", + message="breaking default promotion requires an owner-designated tool and catalog major", + hint="preserve released payloads and follow ADR 0024 release boundaries", + ) + + def breaking_promotion( + _previous: ReleaseSnapshot, + _current: ReleaseSnapshot, + _tool_versions: ToolVersions, + ) -> CatalogDiff: + return CatalogDiff(ReleaseClassification.FORBIDDEN, (promotion,)) + + monkeypatch.setattr(package_cli, "classify_catalog_diff", breaking_promotion) + + assert ( + run_packages( + [ + "check-release", + "--root", + str(repository), + "--baseline", + "v5.2.0", + "--staged", + "--json", + ] + ) + == 1 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert payload["expected_pre_bump"] == [ + "PC-RELEASE-PROJECT-VERSION", + "PC-RELEASE-PROJECTION", + ] + + assert ( + run_packages( + [ + "check-release", + "--root", + str(repository), + "--baseline", + "v5.2.0", + "--staged", + ] + ) + == 1 + ) + captured = capsys.readouterr() + assert "ERROR PC-RELEASE-LEVEL demo@2.0 catalog-entry:" in captured.err + assert "expected pre-bump finding(s)" not in captured.out diff --git a/tests/package_contract/test_release.py b/tests/package_contract/test_release.py index e46627d1..8f3b87be 100644 --- a/tests/package_contract/test_release.py +++ b/tests/package_contract/test_release.py @@ -596,3 +596,62 @@ def test_git_baseline_loader_rejects_conflicting_digests_for_one_path(tmp_path: with pytest.raises(PackageContractError, match="conflicting digests"): load_git_release_snapshot(repository, "v5.0.0", 5) + + +def test_pre_bump_lag__unbumped_tool_version__is_named_as_lag() -> None: + """The one boundary refusal a correct mid-train tree reaches is reported as lag.""" + snapshot = _snapshot(5, _entry("1.2", CatalogRole.DEFAULT)) + + result = _classify(snapshot, snapshot, current_tool="5.2.0") + + assert result.classification is ReleaseClassification.FORBIDDEN + assert [finding.code for finding in result.pre_bump_lag] == ["PC-RELEASE-LEVEL"] + assert result.pre_bump_lag[0] in result.findings + + +def test_pre_bump_lag__breaking_default_promotion__is_not_lag() -> None: + """A forbidden breaking promotion shares PC-RELEASE-LEVEL but is never lag. + + This is what stops `check-release --staged` from excusing it: the CLI keys on + the findings named here, never on the code string the two producers share. + """ + previous = _snapshot( + 5, + _entry("1.2", CatalogRole.DEFAULT), + _entry("2.0", CatalogRole.CANDIDATE, _DIGEST_B), + ) + current = _snapshot( + 5, + _entry("1.2", CatalogRole.RETAINED), + _entry("2.0", CatalogRole.DEFAULT, _DIGEST_B), + ) + + result = _classify(previous, current) + + assert result.classification is ReleaseClassification.FORBIDDEN + assert {finding.code for finding in result.findings} == {"PC-RELEASE-LEVEL"} + assert result.pre_bump_lag == () + + +@pytest.mark.parametrize( + ("previous_tool", "current_tool"), + [ + pytest.param("5.2.0", "6.0.0", id="proposed-major-mismatch"), + pytest.param("5.2.0", "5.2.1", id="wrong-bump-level"), + ], +) +def test_pre_bump_lag__other_boundary_refusals__are_not_lag( + previous_tool: str, + current_tool: str, +) -> None: + previous = _snapshot(5, _entry("1.2", CatalogRole.DEFAULT)) + current = _snapshot( + 5, + _entry("1.2", CatalogRole.DEFAULT), + _entry("1.3", CatalogRole.RETAINED, _DIGEST_B), + ) + + result = _classify(previous, current, previous_tool, current_tool) + + assert result.classification is ReleaseClassification.FORBIDDEN + assert result.pre_bump_lag == ()