From 045dad7502efee4cd5f5029b860674ec5c4466b1 Mon Sep 17 00:00:00 2001 From: stacknil Date: Sun, 23 Aug 2026 08:20:26 +0800 Subject: [PATCH 1/3] test(artifact-diff): define core comparison contract --- tests/test_artifact_contract_diff_core.py | 196 ++++++++++++++++++++++ tests/test_artifact_regeneration_check.py | 21 +++ 2 files changed, 217 insertions(+) create mode 100644 tests/test_artifact_contract_diff_core.py diff --git a/tests/test_artifact_contract_diff_core.py b/tests/test_artifact_contract_diff_core.py new file mode 100644 index 0000000..5f57a85 --- /dev/null +++ b/tests/test_artifact_contract_diff_core.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from telemetry_lab.artifact_contract_diff import ( + ArtifactContractDiffError, + ArtifactContractDiffReport, + ArtifactDifference, + ArtifactSnapshot, + compare_artifact_trees, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = REPO_ROOT / "scripts" / "artifact_contract_diff.py" + + +@pytest.fixture +def artifact_roots(tmp_path: Path) -> tuple[Path, Path]: + expected = tmp_path / "expected" + actual = tmp_path / "actual" + expected.mkdir() + actual.mkdir() + return expected, actual + + +def _write_json(path: Path, value: object, *, newline: str = "\n") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes((json.dumps(value, sort_keys=True) + newline).encode()) + + +def _run_cli(expected: Path, actual: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "--expected", + str(expected), + "--actual", + str(actual), + ], + cwd=REPO_ROOT, + text=True, + capture_output=True, + ) + + +def test_compare_normalizes_text_and_treats_binary_as_presence_only( + artifact_roots: tuple[Path, Path], +) -> None: + expected, actual = artifact_roots + (expected / "report.md").write_bytes(b"same\r\nline\r") + (actual / "report.md").write_bytes(b"same\nline\n") + _write_json(expected / "summary.json", {"count": 1}, newline="\r\n") + _write_json(actual / "summary.json", {"count": 1}) + (expected / "plot.png").write_bytes(b"renderer-a") + (actual / "plot.png").write_bytes(b"renderer-b") + + report = compare_artifact_trees(expected, actual) + + assert not report.has_differences + assert report.unchanged_files == 2 + assert report.presence_only_paths == ("plot.png",) + assert report.expected_files == report.actual_files == 3 + + +def test_compare_reports_missing_and_extra_in_stable_order( + artifact_roots: tuple[Path, Path], +) -> None: + expected, actual = artifact_roots + _write_json(expected / "z-common.json", {"same": True}) + _write_json(actual / "z-common.json", {"same": True}) + _write_json(expected / "nested" / "a-missing.json", {"old": True}) + _write_json(actual / "b-extra.json", {"new": True}) + + report = compare_artifact_trees(expected, actual) + + assert [difference.path for difference in report.differences] == [ + "b-extra.json", + "nested/a-missing.json", + ] + assert [difference.status for difference in report.differences] == [ + "extra", + "missing", + ] + assert report.unchanged_files == 1 + assert report.missing_files == report.extra_files == 1 + + +def test_difference_constructor_rejects_contradictory_state() -> None: + snapshot = ArtifactSnapshot("sha256:" + "0" * 64, 1) + + with pytest.raises(ArtifactContractDiffError, match="missing difference"): + ArtifactDifference( + path="artifact.json", + status="missing", + artifact_kind="json", + change_reasons=("content-changed",), + expected=snapshot, + ) + + with pytest.raises(ArtifactContractDiffError, match="changed difference"): + ArtifactDifference( + path="artifact.json", + status="changed", + artifact_kind="json", + change_reasons=("content-changed", "extra-in-actual"), + expected=snapshot, + actual=snapshot, + ) + + +def test_report_rejects_difference_presence_overlap() -> None: + snapshot = ArtifactSnapshot("sha256:" + "0" * 64, 1) + difference = ArtifactDifference( + path="artifact.json", + status="missing", + artifact_kind="json", + change_reasons=("missing-from-actual",), + expected=snapshot, + ) + + with pytest.raises(ArtifactContractDiffError, match="paths overlap"): + ArtifactContractDiffReport( + expected_files=2, + actual_files=1, + unchanged_files=0, + missing_files=1, + extra_files=0, + changed_files=0, + differences=(difference,), + presence_only_paths=("artifact.json",), + ) + + +def test_compare_rejects_invalid_utf8_text( + artifact_roots: tuple[Path, Path], +) -> None: + expected, actual = artifact_roots + (expected / "summary.json").write_bytes(b"valid\n") + (actual / "summary.json").write_bytes(b"invalid:\xff\n") + + with pytest.raises(ArtifactContractDiffError, match="not valid UTF-8"): + compare_artifact_trees(expected, actual) + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="requires POSIX mkfifo") +def test_compare_rejects_special_files( + artifact_roots: tuple[Path, Path], +) -> None: + expected, actual = artifact_roots + os.mkfifo(expected / "blocked.json") + + with pytest.raises(ArtifactContractDiffError, match="non-regular artifact"): + compare_artifact_trees(expected, actual) + + +def test_compare_rejects_symlink_root( + artifact_roots: tuple[Path, Path], tmp_path: Path +) -> None: + expected, actual = artifact_roots + linked = tmp_path / "linked-expected" + try: + linked.symlink_to(expected, target_is_directory=True) + except OSError: + pytest.skip("directory symlinks are unavailable") + + with pytest.raises(ArtifactContractDiffError, match="non-symlink directory"): + compare_artifact_trees(linked, actual) + + +def test_human_cli_distinguishes_unchanged_changed_and_invalid( + artifact_roots: tuple[Path, Path], tmp_path: Path +) -> None: + expected, actual = artifact_roots + _write_json(expected / "summary.json", {"count": 1}) + _write_json(actual / "summary.json", {"count": 1}) + + unchanged = _run_cli(expected, actual) + _write_json(actual / "summary.json", {"count": 2}) + changed = _run_cli(expected, actual) + invalid = _run_cli(expected, tmp_path / "missing") + + assert unchanged.returncode == 0 + assert "[OK] No artifact contract differences" in unchanged.stdout + assert changed.returncode == 1 + assert "summary.json: changed" in changed.stdout + assert invalid.returncode == 2 + assert "[ERROR]" in invalid.stderr + assert str(expected) not in changed.stdout diff --git a/tests/test_artifact_regeneration_check.py b/tests/test_artifact_regeneration_check.py index 8e29676..a7723d5 100644 --- a/tests/test_artifact_regeneration_check.py +++ b/tests/test_artifact_regeneration_check.py @@ -6,6 +6,9 @@ import sys from pathlib import Path +import pytest + +from telemetry_lab.artifact_contract_diff import compare_artifact_trees from telemetry_lab.manifest import digest_file_bytes, digest_files @@ -117,3 +120,21 @@ def test_regenerate_artifacts_reports_mismatched_strict_artifact(tmp_path) -> No assert len(differences) == 1 assert differences[0].reason == "content differs" + + +@pytest.mark.parametrize("committed_newline", [b"\n", b"\r\n", b"\r"]) +def test_regeneration_and_triage_share_text_normalization( + tmp_path: Path, committed_newline: bytes +) -> None: + script = _load_regeneration_script() + committed_root = tmp_path / "committed" + generated_root = tmp_path / "generated" + committed_root.mkdir() + generated_root.mkdir() + committed_path = committed_root / "artifact.json" + generated_path = generated_root / "artifact.json" + committed_path.write_bytes(b'{"status":"same"}' + committed_newline) + generated_path.write_bytes(b'{"status":"same"}\n') + + assert script.artifacts_match(committed_path, generated_path) + assert not compare_artifact_trees(committed_root, generated_root).has_differences From 1ab8a7e23d62d08b4cdac9b8374ce2f83d7529f1 Mon Sep 17 00:00:00 2001 From: stacknil Date: Sun, 23 Aug 2026 08:32:58 +0800 Subject: [PATCH 2/3] feat(artifact-diff): add bounded comparison core --- scripts/artifact_contract_diff.py | 63 ++++ scripts/regenerate_artifacts.py | 11 +- src/telemetry_lab/artifact_contract_diff.py | 361 ++++++++++++++++++++ 3 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 scripts/artifact_contract_diff.py create mode 100644 src/telemetry_lab/artifact_contract_diff.py diff --git a/scripts/artifact_contract_diff.py b/scripts/artifact_contract_diff.py new file mode 100644 index 0000000..f475b9a --- /dev/null +++ b/scripts/artifact_contract_diff.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Sequence + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from telemetry_lab.artifact_contract_diff import ( # noqa: E402 + ArtifactContractDiffError, + ArtifactContractDiffReport, + compare_artifact_trees, +) + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + report = compare_artifact_trees(Path(args.expected), Path(args.actual)) + except ArtifactContractDiffError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + return 2 + _print_summary(report) + return 1 if report.has_differences else 0 + + +def _print_summary(report: ArtifactContractDiffReport) -> None: + if report.has_differences: + print(f"[DIFF] {len(report.differences)} artifact contract difference(s)") + for difference in report.differences: + reasons = ", ".join(difference.change_reasons) + print( + f"- {difference.path}: {difference.status} " + f"({difference.artifact_kind}; {reasons})" + ) + else: + print( + "[OK] No artifact contract differences " + f"({report.unchanged_files} comparable file(s))" + ) + if report.presence_only_paths: + print( + f"[INFO] {len(report.presence_only_paths)} binary artifact(s) " + "checked for presence only" + ) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Explain shallow contract differences between two artifact trees." + ) + parser.add_argument("--expected", required=True, help="Expected artifact directory") + parser.add_argument("--actual", required=True, help="Actual artifact directory") + return parser + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/regenerate_artifacts.py b/scripts/regenerate_artifacts.py index 91a3502..75779dd 100644 --- a/scripts/regenerate_artifacts.py +++ b/scripts/regenerate_artifacts.py @@ -19,6 +19,11 @@ if str(SRC_ROOT) not in sys.path: sys.path.insert(0, str(SRC_ROOT)) +from telemetry_lab.artifact_contract_diff import ( # noqa: E402 + TEXT_ARTIFACT_SUFFIXES, + normalize_artifact_text_bytes, +) + @dataclass(frozen=True) class ArtifactSet: @@ -172,7 +177,7 @@ def compare_artifact_set( def artifacts_match(committed_path: Path, generated_path: Path) -> bool: - if committed_path.suffix.lower() in {".csv", ".json", ".jsonl", ".md", ".txt"}: + if committed_path.suffix.lower() in TEXT_ARTIFACT_SUFFIXES: return _normalized_text(committed_path) == _normalized_text(generated_path) return committed_path.read_bytes() == generated_path.read_bytes() @@ -419,8 +424,8 @@ def _slug(value: str) -> str: return "".join(char if char.isalnum() else "-" for char in value.lower()).strip("-") -def _normalized_text(path: Path) -> str: - return path.read_bytes().decode("utf-8").replace("\r\n", "\n") +def _normalized_text(path: Path) -> bytes: + return normalize_artifact_text_bytes(path.read_bytes()) @contextmanager diff --git a/src/telemetry_lab/artifact_contract_diff.py b/src/telemetry_lab/artifact_contract_diff.py new file mode 100644 index 0000000..ea5809c --- /dev/null +++ b/src/telemetry_lab/artifact_contract_diff.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import codecs +import os +import re +import stat +from collections import Counter +from collections.abc import Iterable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from typing import BinaryIO, Final, Literal, TypeAlias + + +TEXT_ARTIFACT_SUFFIXES: Final = frozenset( + {".csv", ".json", ".jsonl", ".md", ".txt"} +) +MAX_FILES: Final = 10_000 +CHUNK_SIZE: Final = 64 * 1024 + +ArtifactKind: TypeAlias = Literal["json", "jsonl", "text", "binary"] +DifferenceStatus: TypeAlias = Literal["missing", "extra", "changed"] +ChangeReason: TypeAlias = Literal[ + "missing-from-actual", + "extra-in-actual", + "content-changed", +] + +_DIGEST = re.compile(r"sha256:[0-9a-f]{64}") +_RELATIVE_PATH = re.compile( + r"^(?![A-Za-z]:)(?!.*\\)(?!.*(?:^|/)\.\.?(?:/|$))[^/]+(?:/[^/]+)*$" +) +class ArtifactContractDiffError(ValueError): + """Raised when an artifact comparison cannot produce a safe result.""" + + +@dataclass(frozen=True) +class ArtifactSnapshot: + comparison_digest: str + comparison_size_bytes: int + + def __post_init__(self) -> None: + if _DIGEST.fullmatch(self.comparison_digest) is None: + raise ArtifactContractDiffError("invalid comparison digest") + if self.comparison_size_bytes < 0: + raise ArtifactContractDiffError("invalid comparison size") + + +@dataclass(frozen=True) +class ArtifactDifference: + path: str + status: DifferenceStatus + artifact_kind: ArtifactKind + change_reasons: tuple[ChangeReason, ...] + expected: ArtifactSnapshot | None = None + actual: ArtifactSnapshot | None = None + + def __post_init__(self) -> None: + if not _safe_relative_path(self.path): + raise ArtifactContractDiffError("difference path is unsafe") + if self.artifact_kind not in {"json", "jsonl", "text", "binary"}: + raise ArtifactContractDiffError("difference artifact kind is invalid") + if len(set(self.change_reasons)) != len(self.change_reasons): + raise ArtifactContractDiffError("difference reasons must be unique") + if self.status == "missing": + valid = ( + self.change_reasons == ("missing-from-actual",) + and self.expected is not None + and self.actual is None + ) + elif self.status == "extra": + valid = ( + self.change_reasons == ("extra-in-actual",) + and self.expected is None + and self.actual is not None + ) + elif self.status == "changed": + valid = ( + self.change_reasons == ("content-changed",) + and self.expected is not None + and self.actual is not None + and self.artifact_kind != "binary" + ) + else: + raise ArtifactContractDiffError("difference status is invalid") + if not valid: + raise ArtifactContractDiffError(f"{self.status} difference is inconsistent") + + +@dataclass(frozen=True) +class ArtifactContractDiffReport: + expected_files: int + actual_files: int + unchanged_files: int + missing_files: int + extra_files: int + changed_files: int + differences: tuple[ArtifactDifference, ...] + presence_only_paths: tuple[str, ...] + + def __post_init__(self) -> None: + status_counts = Counter(item.status for item in self.differences) + expected_parts = ( + self.unchanged_files + + self.missing_files + + self.changed_files + + len(self.presence_only_paths) + ) + actual_parts = ( + self.unchanged_files + + self.extra_files + + self.changed_files + + len(self.presence_only_paths) + ) + if min( + self.expected_files, + self.actual_files, + self.unchanged_files, + self.missing_files, + self.extra_files, + self.changed_files, + ) < 0: + raise ArtifactContractDiffError("report counts must be non-negative") + if self.expected_files > MAX_FILES or self.actual_files > MAX_FILES: + raise ArtifactContractDiffError("report exceeds the file limit") + if self.expected_files != expected_parts or self.actual_files != actual_parts: + raise ArtifactContractDiffError("report file counts are inconsistent") + if status_counts != Counter( + missing=self.missing_files, + extra=self.extra_files, + changed=self.changed_files, + ): + raise ArtifactContractDiffError("report difference counts are inconsistent") + difference_paths = tuple(item.path for item in self.differences) + if difference_paths != tuple(sorted(set(difference_paths))): + raise ArtifactContractDiffError("difference paths must be sorted and unique") + if self.presence_only_paths != tuple(sorted(set(self.presence_only_paths))): + raise ArtifactContractDiffError("presence-only paths must be sorted and unique") + if set(difference_paths) & set(self.presence_only_paths): + raise ArtifactContractDiffError("difference and presence-only paths overlap") + + @property + def has_differences(self) -> bool: + return bool(self.differences) + + +def normalize_artifact_text_bytes(content: bytes) -> bytes: + """Validate UTF-8 and canonicalize CRLF or lone CR to LF.""" + return b"".join(_normalized_chunks((content,))) + + +def compare_artifact_trees( + expected_root: Path, + actual_root: Path, +) -> ArtifactContractDiffReport: + """Compare two local artifact roots using stable relative-path identities.""" + expected = _inventory(_root(expected_root, "expected")) + actual = _inventory(_root(actual_root, "actual")) + differences: list[ArtifactDifference] = [] + presence_only: list[str] = [] + unchanged = missing = extra = changed = 0 + + for relative_path in sorted(expected.keys() | actual.keys()): + expected_path = expected.get(relative_path) + actual_path = actual.get(relative_path) + kind = _artifact_kind(relative_path) + if expected_path is None: + extra += 1 + differences.append( + ArtifactDifference( + relative_path, + "extra", + kind, + ("extra-in-actual",), + actual=_snapshot(actual_path, relative_path, kind), + ) + ) + elif actual_path is None: + missing += 1 + differences.append( + ArtifactDifference( + relative_path, + "missing", + kind, + ("missing-from-actual",), + expected=_snapshot(expected_path, relative_path, kind), + ) + ) + elif kind == "binary": + presence_only.append(relative_path) + else: + before = _identity(expected_path, relative_path, kind) + after = _identity(actual_path, relative_path, kind) + if ( + before.comparison_digest == after.comparison_digest + and before.comparison_size_bytes == after.comparison_size_bytes + ): + unchanged += 1 + continue + changed += 1 + differences.append( + ArtifactDifference( + relative_path, + "changed", + kind, + ("content-changed",), + expected=before, + actual=after, + ) + ) + + return ArtifactContractDiffReport( + len(expected), + len(actual), + unchanged, + missing, + extra, + changed, + tuple(differences), + tuple(presence_only), + ) + + +def _root(path: Path, label: str) -> Path: + try: + if _link_like(path) or not stat.S_ISDIR(path.lstat().st_mode): + raise OSError + return path.resolve(strict=True) + except OSError as exc: + raise ArtifactContractDiffError( + f"{label} root must be an existing non-symlink directory" + ) from exc + + +def _inventory(root: Path) -> dict[str, Path]: + inventory: dict[str, Path] = {} + try: + def fail(error: OSError) -> None: + raise error + + for current, directories, files in os.walk( + root, followlinks=False, onerror=fail + ): + current_path = Path(current) + directories.sort() + files.sort() + for name in directories: + if _link_like(current_path / name): + raise ArtifactContractDiffError("artifact tree contains a symlink") + for name in files: + path = current_path / name + relative = path.relative_to(root).as_posix() + mode = path.lstat().st_mode + if stat.S_ISLNK(mode) or _link_like(path): + raise ArtifactContractDiffError("artifact tree contains a symlink") + if not stat.S_ISREG(mode): + raise ArtifactContractDiffError( + f"artifact tree contains a non-regular artifact: {relative}" + ) + if not _safe_relative_path(relative): + raise ArtifactContractDiffError("artifact tree contains an unsafe path") + inventory[relative] = path + if len(inventory) > MAX_FILES: + raise ArtifactContractDiffError("artifact tree exceeds the file limit") + except OSError as exc: + raise ArtifactContractDiffError("cannot traverse artifact tree") from exc + return inventory + + +def _link_like(path: Path) -> bool: + info = path.lstat() + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + if stat.S_ISLNK(info.st_mode) or bool( + getattr(info, "st_file_attributes", 0) & reparse_point + ): + return True + junction = getattr(path, "is_junction", None) + return bool(junction is not None and junction()) + + +def _safe_relative_path(value: object) -> bool: + return ( + isinstance(value, str) + and 0 < len(value) <= 1_024 + and not any(ord(character) < 32 for character in value) + and _RELATIVE_PATH.fullmatch(value) is not None + ) + + +def _artifact_kind(relative_path: str) -> ArtifactKind: + suffix = Path(relative_path).suffix.lower() + if suffix == ".json": + return "json" + if suffix == ".jsonl": + return "jsonl" + return "text" if suffix in TEXT_ARTIFACT_SUFFIXES else "binary" + + +def _snapshot(path: Path | None, relative_path: str, kind: ArtifactKind) -> ArtifactSnapshot: + if path is None: + raise ArtifactContractDiffError(f"artifact is unavailable: {relative_path}") + return _identity(path, relative_path, kind) + + +def _identity(path: Path, relative_path: str, kind: ArtifactKind) -> ArtifactSnapshot: + digest = sha256() + size = 0 + try: + with _regular_file(path, relative_path) as handle: + chunks: Iterable[bytes] = iter(lambda: handle.read(CHUNK_SIZE), b"") + if kind != "binary": + chunks = _normalized_chunks(chunks) + for chunk in chunks: + digest.update(chunk) + size += len(chunk) + except UnicodeDecodeError as exc: + raise ArtifactContractDiffError( + f"text artifact is not valid UTF-8: {relative_path}" + ) from exc + except OSError as exc: + raise ArtifactContractDiffError(f"cannot read artifact: {relative_path}") from exc + return ArtifactSnapshot("sha256:" + digest.hexdigest(), size) + + +@contextmanager +def _regular_file(path: Path, relative_path: str) -> Iterator[BinaryIO]: + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_NONBLOCK", 0) + descriptor: int | None = None + try: + descriptor = os.open(path, flags) + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise ArtifactContractDiffError( + f"artifact is not a regular file: {relative_path}" + ) + with os.fdopen(descriptor, "rb") as handle: + descriptor = None + yield handle + finally: + if descriptor is not None: + os.close(descriptor) + + +def _normalized_chunks(chunks: Iterable[bytes]) -> Iterator[bytes]: + decoder = codecs.getincrementaldecoder("utf-8")("strict") + pending_cr = False + for chunk in chunks: + decoder.decode(chunk, final=False) + if pending_cr: + chunk = b"\r" + chunk + pending_cr = False + if chunk.endswith(b"\r"): + chunk = chunk[:-1] + pending_cr = True + normalized = chunk.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + if normalized: + yield normalized + decoder.decode(b"", final=True) + if pending_cr: + yield b"\n" From f687df6a82561c0dbd54de6b86469dbd582c9962 Mon Sep 17 00:00:00 2001 From: stacknil Date: Sun, 23 Aug 2026 08:33:46 +0800 Subject: [PATCH 3/3] docs(artifact-diff): document human triage workflow --- README.md | 10 ++++++++++ docs/reviewer-artifact-diff.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/README.md b/README.md index ad691f6..cefeb1f 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,16 @@ For the same reviewer-friendly gate with labeled steps, run: telemetry-lab verify ``` +When regeneration reports a mismatch, compare two artifact trees without +accepting either tree as authoritative: + +```bash +python scripts/artifact_contract_diff.py --expected path/to/committed --actual path/to/regenerated +``` + +See [`docs/reviewer-artifact-diff.md`](docs/reviewer-artifact-diff.md#executable-human-triage) +for comparison semantics, limits, and exit codes. + Other demo entrypoints: - `telemetry-lab run ai-assisted` diff --git a/docs/reviewer-artifact-diff.md b/docs/reviewer-artifact-diff.md index 74bc80e..e0dca0f 100644 --- a/docs/reviewer-artifact-diff.md +++ b/docs/reviewer-artifact-diff.md @@ -10,6 +10,34 @@ the local, file-based artifacts listed in [`docs/reviewer-pack.md`](reviewer-pac and the schema-covered evidence artifacts in [`docs/evidence-pipeline-contract.md`](evidence-pipeline-contract.md). +## Executable Human Triage + +Use the standalone comparator when a regeneration mismatch needs path-level +context: + +```bash +python scripts/artifact_contract_diff.py \ + --expected path/to/committed-artifacts \ + --actual path/to/regenerated-artifacts +``` + +The output lists missing, extra, and changed relative paths in stable order. +CSV, Markdown, text, JSON, and JSONL use strict UTF-8 and normalize CRLF and +lone CR to LF before comparison. A binary path that exists in both trees is +checked for presence only; renderer-dependent bytes are not treated as a +reproducibility contract. + +Exit status is `0` when comparable artifacts are unchanged, `1` when the tool +finds contract differences, and `2` when an input cannot be compared safely. +The output contains no artifact bodies, timestamps, or absolute checkout paths. +This tool explains a mismatch; it does not replace +`python scripts/regenerate_artifacts.py --check`, accept regenerated output, or +assign a release compatibility label. + +Each root is limited to 10,000 files. Symlink or reparse-point roots, linked +entries, special files, unsafe relative paths, unreadable files, and invalid +UTF-8 text fail closed with exit `2`. + ## Required Release Diff Sections Each release artifact diff must include: