diff --git a/CHANGELOG.md b/CHANGELOG.md index 4241405..64dfade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,13 @@ summary: Chronological history of repository and skill changes. # Changelog -## 2026-07-21 — Carve-changesets identity and operating contract +## 2026-07-21 — Carve-changesets live validation, identity, and contract +- fix: bind changeset validation to current live refs +- feat: validate changeset chains from live git + (`df24771983819b05110670a8d03d43e003d23d28`) - feat: add self-describing changeset identity + (`e88bf87e9cb1a4e04bdf8b051ce8ca0f0dcb96e6`) - fix: clarify published terminal evidence (`edeb2f5f5f7b4cfa4e73e8289d34157b192f92ab`) - docs: define the carve-changesets operating contract diff --git a/skills/carve-changesets/references/SPEC.md b/skills/carve-changesets/references/SPEC.md index 5138fac..cdd9ef1 100644 --- a/skills/carve-changesets/references/SPEC.md +++ b/skills/carve-changesets/references/SPEC.md @@ -141,6 +141,21 @@ the reconstructed full chain. A temporary local integration branch may be used for this proof. Any local reference created to simplify comparison must remain local, must not mutate the source branch, and must not become a truth source. +#### Live chain validation + +Validation derives every invariant from the rehydrated chain and current git +objects. It must prove that the first changeset descends from the base, every +later changeset descends from its immediate predecessor, and the final changeset +tree equals the source commit stamped in every `Changeset-Source` trailer. + +The current source ref is classified against that stamped source commit. An +unchanged ref is clean. A descendant is reported as `source_advanced`, while the +chain remains validated against its stamped source. A ref that does not descend +from the stamped commit is a `source_history_mismatch` error because the chain +was built against a different source history. Legitimate downstream rebase +propagation therefore validates cleanly: validation compares live ancestry and +trees, never recorded branch heads. + ### Plain git and GitHub stack shape Every materialized and published chain must remain ordinary git and GitHub: @@ -446,12 +461,12 @@ Return `blocked` without widening scope when: ### Compatibility -No backwards compatibility is provided for `.prepare-changesets/state.json`, old -plan files, old commit conventions, or chains created by `prepare-changesets`. -Those artifacts are neither migrated nor accepted as authoritative evidence. -Live branches and PRs may be adopted only when they independently satisfy the -`carve-changesets` contract and are explicitly brought into scope; old metadata -alone never qualifies them. +No backwards compatibility is provided for cached `prepare-changesets` chain +snapshots, old plan files, old commit conventions, or chains created by +`prepare-changesets`. Those artifacts are ignored rather than migrated or +accepted as authoritative evidence. Live branches and PRs may be adopted only +when they independently satisfy the `carve-changesets` contract and are +explicitly brought into scope; old metadata alone never qualifies them. ### Non-goals diff --git a/skills/carve-changesets/scripts/rehydrate.py b/skills/carve-changesets/scripts/rehydrate.py index a083c32..00c9b37 100644 --- a/skills/carve-changesets/scripts/rehydrate.py +++ b/skills/carve-changesets/scripts/rehydrate.py @@ -69,9 +69,11 @@ def _git(cwd: Path, *args: str) -> str: return result.stdout -def _discover_heads( +def discover_changeset_heads( cwd: Path, source_branch: str, remote: str ) -> dict[int, tuple[str, str]]: + """Resolve current changeset refs and reject local/remote ambiguity.""" + output = _git( cwd, "for-each-ref", @@ -147,7 +149,7 @@ def rehydrate_chain( if not source_branch.strip(): raise RehydrationError("Source branch must not be empty.") repo = Path(cwd) - heads = _discover_heads(repo, source_branch, remote) + heads = discover_changeset_heads(repo, source_branch, remote) if not heads: raise RehydrationError( f"No changeset branches named {source_branch}-N were found locally or on {remote}." diff --git a/skills/carve-changesets/scripts/tests/test_validate.py b/skills/carve-changesets/scripts/tests/test_validate.py new file mode 100644 index 0000000..107e50e --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_validate.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import helpers +from metadata import ChangesetMetadata, stamp_commit_message +from rehydrate import rehydrate_chain +from validate import validate_live_chain + + +class LiveValidationTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = Path(tempfile.mkdtemp()) + self.repo, _, _ = helpers.init_repo(self.temp_dir) + helpers.run(self.repo, "git", "checkout", "feature/report") + (self.repo / "second.txt").write_text("second source part\n") + (self.repo / "third.txt").write_text("third source part\n") + helpers.run(self.repo, "git", "add", "second.txt", "third.txt") + self.source_sha = helpers.commit(self.repo, "complete source result") + + def tearDown(self) -> None: + shutil.rmtree(self.temp_dir) + + def _stamp(self, index: int, *, detail: str | None = None) -> str: + message = f"feat: changeset {index}" + if detail is not None: + message += f"\n\n{detail}" + return stamp_commit_message( + message, + ChangesetMetadata( + slug=f"part-{index}", + index=index, + source_branch="feature/report", + source_sha=self.source_sha, + ), + ) + + def _materialize_equivalent_chain(self) -> dict[int, str]: + heads: dict[int, str] = {} + previous = "main" + files = ("source.txt", "second.txt", "third.txt") + for index, path in enumerate(files, start=1): + branch = f"feature/report-{index}" + helpers.run(self.repo, "git", "checkout", "-b", branch, previous) + content = helpers.run(self.repo, "git", "show", f"feature/report:{path}") + (self.repo / path).write_text(content + "\n") + helpers.run(self.repo, "git", "add", path) + heads[index] = helpers.commit(self.repo, self._stamp(index)) + previous = branch + return heads + + def _rehydrate(self): + return rehydrate_chain( + source_branch="feature/report", base_branch="main", cwd=self.repo + ) + + def test_legitimate_propagation_validates_without_drift_diagnostics(self) -> None: + heads = self._materialize_equivalent_chain() + helpers.run(self.repo, "git", "checkout", "feature/report-2") + amended_message = self._stamp(2, detail="Refresh the propagated commit.") + helpers.run( + self.repo, + "git", + "commit", + "--amend", + "-F", + "-", + input_text=amended_message, + ) + propagated_second = helpers.run(self.repo, "git", "rev-parse", "HEAD") + self.assertNotEqual(heads[2], propagated_second) + helpers.run(self.repo, "git", "checkout", "feature/report-3") + helpers.run( + self.repo, + "git", + "rebase", + "--onto", + propagated_second, + heads[2], + "feature/report-3", + ) + propagated_third = helpers.run(self.repo, "git", "rev-parse", "HEAD") + self.assertNotEqual(heads[3], propagated_third) + + result = validate_live_chain(self._rehydrate(), cwd=self.repo) + + self.assertTrue(result.valid) + self.assertEqual("unchanged", result.source_status) + self.assertEqual((), result.diagnostics) + + def test_rewritten_mid_chain_branch_breaks_live_ancestry(self) -> None: + self._materialize_equivalent_chain() + chain = self._rehydrate() + helpers.run(self.repo, "git", "checkout", "-b", "replacement", "main") + for path in ("source.txt", "second.txt"): + content = helpers.run(self.repo, "git", "show", f"feature/report:{path}") + (self.repo / path).write_text(content + "\n") + helpers.run(self.repo, "git", "add", "source.txt", "second.txt") + replacement = helpers.commit(self.repo, self._stamp(2)) + helpers.run( + self.repo, + "git", + "update-ref", + "refs/heads/feature/report-2", + replacement, + ) + + result = validate_live_chain(chain, cwd=self.repo) + + self.assertFalse(result.valid) + self.assertIn("changeset_ref_moved", {item.code for item in result.errors}) + self.assertIn( + "predecessor_ancestry_broken", {item.code for item in result.errors} + ) + + def test_non_equivalent_chain_tip_is_rejected(self) -> None: + self._materialize_equivalent_chain() + helpers.run(self.repo, "git", "checkout", "feature/report-3") + (self.repo / "extra.txt").write_text("not in source\n") + helpers.run(self.repo, "git", "add", "extra.txt") + helpers.commit(self.repo, self._stamp(3, detail="Rewrite the chain tip.")) + + result = validate_live_chain(self._rehydrate(), cwd=self.repo) + + self.assertFalse(result.valid) + self.assertIn( + "source_equivalence_mismatch", {item.code for item in result.errors} + ) + + def test_advanced_source_is_distinct_from_different_source_history(self) -> None: + self._materialize_equivalent_chain() + helpers.run(self.repo, "git", "checkout", "feature/report") + (self.repo / "later.txt").write_text("later source work\n") + helpers.run(self.repo, "git", "add", "later.txt") + helpers.commit(self.repo, "feat: advance source") + + advanced = validate_live_chain(self._rehydrate(), cwd=self.repo) + + self.assertTrue(advanced.valid) + self.assertEqual("advanced", advanced.source_status) + self.assertEqual(["source_advanced"], [item.code for item in advanced.warnings]) + + helpers.run(self.repo, "git", "checkout", "-b", "alternate-source", "main") + (self.repo / "other.txt").write_text("different source\n") + helpers.run(self.repo, "git", "add", "other.txt") + different_head = helpers.commit(self.repo, "feat: different source") + helpers.run( + self.repo, + "git", + "update-ref", + "refs/heads/feature/report", + different_head, + ) + + different = validate_live_chain(self._rehydrate(), cwd=self.repo) + + self.assertFalse(different.valid) + self.assertEqual("different", different.source_status) + self.assertIn( + "source_history_mismatch", {item.code for item in different.errors} + ) + + def test_source_ancestry_command_failure_is_not_reported_as_divergence( + self, + ) -> None: + self._materialize_equivalent_chain() + helpers.run(self.repo, "git", "checkout", "feature/report") + (self.repo / "later.txt").write_text("later source work\n") + helpers.run(self.repo, "git", "add", "later.txt") + helpers.commit(self.repo, "feat: advance source") + + with mock.patch("validate._is_ancestor", side_effect=[True, True, True, None]): + result = validate_live_chain(self._rehydrate(), cwd=self.repo) + + self.assertFalse(result.valid) + self.assertEqual("unavailable", result.source_status) + self.assertIn( + "source_ancestry_check_failed", {item.code for item in result.errors} + ) + self.assertNotIn( + "source_history_mismatch", {item.code for item in result.errors} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/validate.py b/skills/carve-changesets/scripts/validate.py new file mode 100644 index 0000000..8621ce0 --- /dev/null +++ b/skills/carve-changesets/scripts/validate.py @@ -0,0 +1,288 @@ +"""Validate a rehydrated changeset chain against live git evidence.""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from rehydrate import Chain, RehydrationError, discover_changeset_heads + +Severity = Literal["error", "warning"] +SourceStatus = Literal["unchanged", "advanced", "different", "unavailable"] + + +@dataclass(frozen=True) +class ValidationDiagnostic: + """One candidate-bound live invariant result.""" + + code: str + severity: Severity + message: str + + +@dataclass(frozen=True) +class ChainValidation: + """Aggregate result for one rehydrated chain.""" + + source_status: SourceStatus + stamped_source: str + current_source: str | None + diagnostics: tuple[ValidationDiagnostic, ...] + + @property + def errors(self) -> tuple[ValidationDiagnostic, ...]: + return tuple(item for item in self.diagnostics if item.severity == "error") + + @property + def warnings(self) -> tuple[ValidationDiagnostic, ...]: + return tuple(item for item in self.diagnostics if item.severity == "warning") + + @property + def valid(self) -> bool: + return not self.errors + + +def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ["git", *args], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + except FileNotFoundError as exc: + raise RuntimeError("Git is required to validate a changeset chain.") from exc + + +def _resolve(cwd: Path, ref: str) -> str | None: + result = _git(cwd, "rev-parse", "--verify", ref) + if result.returncode != 0: + return None + return result.stdout.strip() + + +def _resolve_branch(cwd: Path, branch: str, remote: str) -> str | None: + local = _resolve(cwd, f"refs/heads/{branch}^{{commit}}") + if local is not None: + return local + return _resolve(cwd, f"refs/remotes/{remote}/{branch}^{{commit}}") + + +def _is_ancestor(cwd: Path, ancestor: str, descendant: str) -> bool | None: + result = _git(cwd, "merge-base", "--is-ancestor", ancestor, descendant) + if result.returncode == 0: + return True + if result.returncode == 1: + return False + return None + + +def validate_live_chain( + chain: Chain, + *, + cwd: Path | str = Path.cwd(), + remote: str = "origin", +) -> ChainValidation: + """Check ancestry, source identity, and equivalence using only live git.""" + + repo = Path(cwd) + diagnostics: list[ValidationDiagnostic] = [] + source_status: SourceStatus = "unavailable" + + if not chain.changesets: + diagnostics.append( + ValidationDiagnostic( + "empty_chain", "error", "The changeset chain has no branches." + ) + ) + + stamped_source = _resolve(repo, f"{chain.source_sha}^{{commit}}") + if stamped_source is None: + diagnostics.append( + ValidationDiagnostic( + "stamped_source_missing", + "error", + f"Stamped source commit {chain.source_sha} is not available in live git.", + ) + ) + + for changeset in chain.changesets: + metadata = changeset.metadata + if ( + metadata.source_branch != chain.source_branch + or metadata.source_sha != chain.source_sha + ): + diagnostics.append( + ValidationDiagnostic( + "source_stamp_mismatch", + "error", + f"Changeset branch {changeset.branch} names source " + f"{metadata.source_branch} @ {metadata.source_sha}; expected " + f"{chain.source_branch} @ {chain.source_sha}.", + ) + ) + + live_heads: dict[int, tuple[str, str]] | None + try: + live_heads = discover_changeset_heads(repo, chain.source_branch, remote) + except RehydrationError as exc: + live_heads = None + diagnostics.append( + ValidationDiagnostic( + "changeset_ref_ambiguous", + "error", + f"Current changeset refs are ambiguous: {exc}", + ) + ) + + expected_indices = {item.metadata.index for item in chain.changesets} + if live_heads is not None and set(live_heads) != expected_indices: + diagnostics.append( + ValidationDiagnostic( + "chain_shape_changed", + "error", + "Current changeset branch indices differ from the rehydrated chain: " + f"expected {sorted(expected_indices)}, found {sorted(live_heads)}.", + ) + ) + + base_head = _resolve_branch(repo, chain.base_branch, remote) + if base_head is None: + diagnostics.append( + ValidationDiagnostic( + "base_missing", + "error", + f"Base branch {chain.base_branch!r} is not available in live git.", + ) + ) + + predecessor = base_head + predecessor_name = chain.base_branch + for changeset in chain.changesets: + live = ( + live_heads.get(changeset.metadata.index) if live_heads is not None else None + ) + if live is None: + diagnostics.append( + ValidationDiagnostic( + "changeset_ref_missing", + "error", + f"Changeset branch {changeset.branch} is not available in live git.", + ) + ) + head = None + else: + live_branch, head = live + if live_branch != changeset.branch or head != changeset.head: + diagnostics.append( + ValidationDiagnostic( + "changeset_ref_moved", + "error", + f"Changeset branch {changeset.branch} moved from rehydrated head " + f"{changeset.head} to current head {head}.", + ) + ) + + if head is not None and predecessor is not None: + ancestry = _is_ancestor(repo, predecessor, head) + if ancestry is False: + diagnostics.append( + ValidationDiagnostic( + "predecessor_ancestry_broken", + "error", + f"Changeset branch {changeset.branch} at {head} is not a " + f"descendant of predecessor {predecessor_name} at {predecessor}.", + ) + ) + elif ancestry is None: + diagnostics.append( + ValidationDiagnostic( + "ancestry_check_failed", + "error", + f"Git could not compare predecessor {predecessor_name} at " + f"{predecessor} with {changeset.branch} at {head}.", + ) + ) + predecessor = head + predecessor_name = changeset.branch + + if stamped_source is not None and chain.changesets and live_heads is not None: + tip_record = chain.changesets[-1] + live_tip = live_heads.get(tip_record.metadata.index) + tip = live_tip[1] if live_tip is not None else None + source_tree = _resolve(repo, f"{stamped_source}^{{tree}}") + tip_tree = _resolve(repo, f"{tip}^{{tree}}") if tip is not None else None + if source_tree is None or tip_tree is None: + diagnostics.append( + ValidationDiagnostic( + "equivalence_check_failed", + "error", + "Git could not resolve both trees required for source equivalence.", + ) + ) + elif source_tree != tip_tree: + diagnostics.append( + ValidationDiagnostic( + "source_equivalence_mismatch", + "error", + f"Changeset tip {tip_record.branch} at {tip} does not " + f"recompose to stamped source {chain.source_branch} at " + f"{stamped_source}.", + ) + ) + + current_source = _resolve_branch(repo, chain.source_branch, remote) + if current_source is None: + diagnostics.append( + ValidationDiagnostic( + "source_branch_missing", + "error", + f"Source branch {chain.source_branch!r} is not available in live git.", + ) + ) + elif stamped_source is not None and current_source == stamped_source: + source_status = "unchanged" + elif stamped_source is not None: + advanced = _is_ancestor(repo, stamped_source, current_source) + if advanced is True: + source_status = "advanced" + diagnostics.append( + ValidationDiagnostic( + "source_advanced", + "warning", + f"Source branch {chain.source_branch} legitimately advanced from " + f"stamped commit {stamped_source} to {current_source}; the chain " + "remains validated against the stamped source.", + ) + ) + elif advanced is False: + source_status = "different" + diagnostics.append( + ValidationDiagnostic( + "source_history_mismatch", + "error", + f"Source branch {chain.source_branch} at {current_source} does not " + f"descend from stamped commit {stamped_source}; the chain was built " + "against a different source history.", + ) + ) + else: + source_status = "unavailable" + diagnostics.append( + ValidationDiagnostic( + "source_ancestry_check_failed", + "error", + f"Git could not compare stamped source {stamped_source} with " + f"current source {current_source}; source history is unavailable.", + ) + ) + + return ChainValidation( + source_status=source_status, + stamped_source=chain.source_sha, + current_source=current_source, + diagnostics=tuple(diagnostics), + )