diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd2704..9e0e2bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,47 +4,3 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [0.0.2] - 2025-01-20 - -### Added - -- History tracking with content-addressed snapshot store -- `pacta history show` command to view architecture timeline -- `pacta history export` command for JSON export of history data -- `pacta history trends` command with ASCII charts for visualizing metrics over time -- Optional matplotlib support for PNG/SVG chart export (`pip install pacta[viz]`) -- Human-readable violation explanations in text output -- Expanded contributing guide with project structure and development workflow - -### Changed - -- License changed to Apache-2.0 -- README repositioned to focus on architecture governance - -## [0.0.1] - 2025-01-15 - -### Added - -- Initial release -- Python AST analyzer for static code analysis -- Architecture model definition via `architecture.yml` - - System and container definitions - - Layer definitions with glob patterns - - Code mapping configuration -- Rules DSL via `rules.pacta.yml` - - Layer dependency constraints - - Severity levels (error, warning, info) - - Custom messages and suggestions -- `pacta scan` command for architecture validation -- Snapshot support for versioning architecture state -- Baseline mode for incremental adoption (fail only on new violations) -- `pacta snapshot save` and `pacta diff` commands -- Text and JSON output formats -- MkDocs documentation site - -[Unreleased]: https://github.com/akhundMurad/pacta/compare/v0.0.2...HEAD -[0.0.2]: https://github.com/akhundMurad/pacta/compare/v0.0.1...v0.0.2 -[0.0.1]: https://github.com/akhundMurad/pacta/releases/tag/v0.0.1 diff --git a/README.md b/README.md index 0026998..09a0caa 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ Codebases rot. Architecture degrades through small changes no one tracks. Pacta | Git | Pacta | |-----|-------| -| `git commit` | `pacta scan` — capture an architectural snapshot | +| `git add` | `pacta snapshot save` — capture an architectural snapshot | +| `git commit --verify` | `pacta check` — evaluate rules against a snapshot | +| `git commit` | `pacta scan` — snapshot + check in one step | | `git log` | `pacta history` — timeline and trends of architectural states | | `git diff` | `pacta diff` — compare two snapshots | | branch protection | `rules.pacta.yml` — governance that prevents drift | diff --git a/docs/ci-integration.md b/docs/ci-integration.md index 547a0a0..a6eaa2e 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -221,15 +221,23 @@ repos: Add Pacta to your Makefile for easy local runs: ```makefile -.PHONY: arch arch-baseline +.PHONY: arch arch-snapshot arch-check arch-baseline arch-ci -# Run architecture check +# Full scan (snapshot + check in one step) arch: pacta scan . --model architecture.yml --rules rules.pacta.yml +# Two-step workflow +arch-snapshot: + pacta snapshot save . --model architecture.yml + +arch-check: + pacta check . --rules rules.pacta.yml + # Update baseline arch-baseline: - pacta scan . --model architecture.yml --rules rules.pacta.yml --save-ref baseline + pacta snapshot save . --model architecture.yml --ref baseline + pacta check . --ref baseline --rules rules.pacta.yml # Check against baseline (CI mode) arch-ci: diff --git a/docs/cli.md b/docs/cli.md index ff724de..11911a9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -61,6 +61,64 @@ Violations are displayed with human-readable explanations: - **For dependency violations:** Shows which module imports/calls/uses another, with their respective layers - **For node violations:** Shows the element type and where it was found (layer, container, context) +## check + +Evaluate architectural rules against an existing snapshot and write violations back into it. + +This separates the "capture" step (`snapshot save`) from the "verify" step (`check`), allowing you to snapshot your architecture once and check it against different rule sets or at different times. The existing snapshot object is updated in-place — no new snapshot is created. + +```bash +pacta check [PATH] [OPTIONS] +``` + +**Arguments:** + +| Argument | Default | Description | +|----------|---------|-------------| +| `PATH` | `.` | Repository root | + +**Options:** + +| Option | Default | Description | +|--------|---------|-------------| +| `--ref REF` | `latest` | Snapshot ref to check | +| `--model FILE` | `architecture.yml` | Architecture model file | +| `--rules FILE` | `rules.pacta.yml` | Rules file (repeatable) | +| `--format {text,json}` | `text` | Output format | +| `--baseline REF` | - | Compare against baseline snapshot | +| `--save-ref REF` | - | Also save result under this ref | +| `-q, --quiet` | - | Summary only | +| `-v, --verbose` | - | Include all details | + +**Examples:** + +```bash +# Check latest snapshot against rules +pacta check . --rules rules.pacta.yml + +# Check a specific snapshot ref +pacta check . --ref v1 --rules rules.pacta.yml + +# Check against baseline (only new violations fail) +pacta check . --baseline baseline --rules rules.pacta.yml + +# JSON output +pacta check . --rules rules.pacta.yml --format json +``` + +**Typical workflow:** + +```bash +# Step 1: Capture architecture +pacta snapshot save . --model architecture.yml + +# Step 2: Evaluate rules against the snapshot +pacta check . --rules rules.pacta.yml + +# Or do both in one step: +pacta scan . --model architecture.yml --rules rules.pacta.yml +``` + ## snapshot save Save architecture snapshot without running rules. diff --git a/docs/configuration.md b/docs/configuration.md index 4173dd4..2df254b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -295,7 +295,7 @@ References are named pointers to snapshot hashes: | Ref | Description | |-----|-------------| -| `latest` | Automatically updated on each scan | +| `latest` | Automatically updated on each scan/check | | `baseline` | Created with `--save-ref baseline` | | Custom | Any name you choose with `--save-ref ` | diff --git a/docs/getting-started.md b/docs/getting-started.md index 145fafb..8f40309 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -38,7 +38,7 @@ containers: Now capture a snapshot: ```bash -pacta snapshot . --model architecture.yml +pacta snapshot save . --model architecture.yml ``` Pacta just analyzed every module and dependency in your codebase and stored a content-addressed snapshot in `.pacta/`. This snapshot is immutable — a permanent record of your architecture at this moment. @@ -50,7 +50,7 @@ Pacta just analyzed every module and dependency in your codebase and stored a co A week passes. Your team ships features, fixes bugs, refactors code. Run another snapshot: ```bash -pacta snapshot . --model architecture.yml +pacta snapshot save . --model architecture.yml ``` Now you have two points in time. See what changed: @@ -122,9 +122,13 @@ rule: message: Domain layer must not import from Infrastructure ``` -Run a scan with rules: +Run a check against your snapshot: ```bash +# Option A: Check the snapshot you already have +pacta check . --rules rules.pacta.yml + +# Option B: Or do snapshot + check in one step pacta scan . --model architecture.yml --rules rules.pacta.yml ``` @@ -145,12 +149,19 @@ Two violations. But wait — this is a legacy codebase. You can't fix everything Save the current state as a baseline: ```bash +# One-step: pacta scan . --model architecture.yml --rules rules.pacta.yml --save-ref baseline + +# Or two-step: +pacta snapshot save . --model architecture.yml --ref baseline +pacta check . --ref baseline --rules rules.pacta.yml ``` -Now future scans compare against this baseline: +Now future checks compare against this baseline: ```bash +pacta check . --rules rules.pacta.yml --baseline baseline +# Or equivalently: pacta scan . --model architecture.yml --rules rules.pacta.yml --baseline baseline ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d2dfbb8..7cf0afd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -115,6 +115,9 @@ Error: Baseline 'baseline' not found 1. Create a baseline first: ```bash pacta scan . --model architecture.yml --rules rules.pacta.yml --save-ref baseline + # Or using the two-step workflow: + pacta snapshot save . --model architecture.yml --ref baseline + pacta check . --ref baseline --rules rules.pacta.yml ``` 2. Check that `.pacta/` directory exists and contains snapshots: diff --git a/examples/README.md b/examples/README.md index d3834e1..f1c2c77 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,7 +22,13 @@ To run any example: ```bash cd examples/ + +# One-step (scan = snapshot + check): pacta scan . --model architecture.yml --rules rules.pacta.yml + +# Or two-step: +pacta snapshot save . --model architecture.yml +pacta check . --rules rules.pacta.yml ``` ## Creating Your Own diff --git a/pacta/cli/check.py b/pacta/cli/check.py new file mode 100644 index 0000000..2fbf381 --- /dev/null +++ b/pacta/cli/check.py @@ -0,0 +1,79 @@ +from pathlib import Path + +from pacta.cli._io import default_model_file, default_rules_files, ensure_repo_root +from pacta.cli.exitcodes import exit_code_from_report_dict +from pacta.core.config import EngineConfig +from pacta.core.engine import DefaultPactaEngine +from pacta.reporting.renderers.json import JsonReportRenderer +from pacta.reporting.renderers.text import TextReportRenderer +from pacta.snapshot.store import FsSnapshotStore + + +def run( + *, + path: str, + ref: str, + fmt: str, + rules: tuple[str, ...] | None, + model: str | None, + baseline: str | None, + save_ref: str | None, + verbosity: str = "normal", + tool_version: str | None, +) -> int: + """ + Evaluate rules against an existing snapshot. + + Loads the snapshot identified by --ref, evaluates architecture rules, + and saves the updated snapshot (with violations) back to the store. + """ + repo_root = ensure_repo_root(path) + + rules_files = rules if rules is not None else default_rules_files(repo_root) + model_file = model if model is not None else default_model_file(repo_root) + + # Load existing snapshot + store = FsSnapshotStore(repo_root=repo_root) + if not store.exists(ref): + import sys + + print(f"pacta: error: snapshot ref '{ref}' not found. Run `pacta snapshot save` first.", file=sys.stderr) + from pacta.cli.exitcodes import EXIT_ENGINE_ERROR + + return EXIT_ENGINE_ERROR + + snapshot = store.load(ref) + + # Build config + cfg = EngineConfig( + repo_root=Path(repo_root), + model_file=Path(model_file) if model_file else None, + rules_files=tuple(Path(f) for f in rules_files), + baseline=baseline, + changed_only=False, + save_ref=save_ref, + ) + + # Run check + engine = DefaultPactaEngine() + result = engine.check(cfg, snapshot) + + # Update existing snapshot object in-place with violations + short_hash = store.resolve_ref(ref) + if short_hash is None: + # ref was a direct hash + short_hash = ref + store.update_object(short_hash, result.snapshot) + + # Optionally save under an additional ref + if save_ref and save_ref != ref: + store.save(result.snapshot, refs=[save_ref]) + + # Render report + if fmt == "json": + out = JsonReportRenderer().render(result.report) + else: + out = TextReportRenderer(verbosity=verbosity).render(result.report) # type: ignore[arg-type] + print(out, end="") + + return exit_code_from_report_dict(result.report.to_dict()) diff --git a/pacta/cli/main.py b/pacta/cli/main.py index ea6ee17..81d0bde 100644 --- a/pacta/cli/main.py +++ b/pacta/cli/main.py @@ -1,7 +1,7 @@ import argparse import sys -from pacta.cli import diff, history, scan, snapshot +from pacta.cli import check, diff, history, scan, snapshot from pacta.cli.exitcodes import EXIT_ENGINE_ERROR @@ -26,6 +26,19 @@ def build_parser() -> argparse.ArgumentParser: verbosity.add_argument("-q", "--quiet", action="store_true", help="Minimal output (summary only).") verbosity.add_argument("-v", "--verbose", action="store_true", help="Verbose output (include all details).") + # check + check_p = sub.add_parser("check", help="Evaluate rules against a snapshot.") + check_p.add_argument("path", nargs="?", default=".", help="Repository root (default: .)") + check_p.add_argument("--ref", default="latest", help="Snapshot ref to check (default: latest).") + check_p.add_argument("--format", choices=["text", "json"], default="text", help="Output format.") + check_p.add_argument("--rules", action="append", default=None, help="Rules file path (repeatable).") + check_p.add_argument("--model", default=None, help="Architecture model file (architecture.yaml).") + check_p.add_argument("--baseline", default=None, help="Baseline snapshot ref.") + check_p.add_argument("--save-ref", dest="save_ref", default=None, help="Also save snapshot under this ref.") + check_verbosity = check_p.add_mutually_exclusive_group() + check_verbosity.add_argument("-q", "--quiet", action="store_true", help="Minimal output (summary only).") + check_verbosity.add_argument("-v", "--verbose", action="store_true", help="Verbose output (include all details).") + # snapshot snap = sub.add_parser("snapshot", help="Snapshot operations.") snap_sub = snap.add_subparsers(dest="snapshot_cmd", required=True) @@ -96,6 +109,21 @@ def main(argv: list[str] | None = None) -> int: tool_version=args.tool_version, ) + if args.cmd == "check": + rules = tuple(args.rules) if args.rules is not None else None + verbosity = "quiet" if args.quiet else ("verbose" if args.verbose else "normal") + return check.run( + path=args.path, + ref=args.ref, + fmt=args.format, + rules=rules, + model=args.model, + baseline=args.baseline, + save_ref=args.save_ref, + verbosity=verbosity, + tool_version=args.tool_version, + ) + if args.cmd == "snapshot": if args.snapshot_cmd == "save": return snapshot.save( diff --git a/pacta/core/engine.py b/pacta/core/engine.py index b0c4219..4a0d5ba 100644 --- a/pacta/core/engine.py +++ b/pacta/core/engine.py @@ -34,6 +34,13 @@ class ScanResult: diff: SnapshotDiff | None +@dataclass(frozen=True) +class CheckResult: + snapshot: Snapshot + report: Report + diff: SnapshotDiff | None + + # Default engine wiring @@ -364,3 +371,147 @@ def build_ir(self, cfg: EngineConfig) -> ArchitectureIR: enriched_ir = self.enricher.enrich(normalized_ir, model) return enriched_ir + + def check(self, cfg: EngineConfig, snapshot: Snapshot) -> CheckResult: + """ + Evaluate rules against an existing snapshot. + + This runs only the rules pipeline (steps 6-9): + 6. Load + parse + compile rules + 7. Evaluate rules → violations + 8. Baseline comparison (optional) + 9. Build report + + The snapshot's nodes/edges are reconstructed into an ArchitectureIR + for rule evaluation. + + Args: + cfg: Engine configuration (rules_files, baseline, etc.) + snapshot: Existing snapshot to check + + Returns: + CheckResult with updated snapshot (including violations) and report + """ + engine_errors: list[EngineError] = [] + violations: tuple[Violation, ...] = tuple() + diff: SnapshotDiff | None = None + + snapshot_store = FsSnapshotStore(repo_root=str(cfg.repo_root)) + + # Reconstruct IR from snapshot + ir = ArchitectureIR( + schema_version=snapshot.schema_version, + produced_by="pacta-snapshot", + repo_root=snapshot.meta.repo_root, + nodes=snapshot.nodes, + edges=snapshot.edges, + ) + + # Optionally load + enrich with model + model: ArchitectureModel | None = None + enriched_ir = ir + if cfg.model_file and cfg.model_file.exists(): + try: + model = self.model_loader.load(cfg.model_file) + engine_errors.extend(self.model_validator.validate(model)) + model = self.model_resolver.resolve(model) + except Exception as e: + engine_errors.append( + EngineError.from_dict( + { + "type": "config_error", + "message": "Failed to load architecture model", + "location": {"file": str(cfg.model_file), "start": {"line": 1, "column": 1}}, + "details": {"error": repr(e)}, + } + ) + ) + model = None + + if model is not None: + try: + enriched_ir = self.enricher.enrich(ir, model) + except Exception as e: + engine_errors.append( + EngineError( + type="runtime_error", + message="Failed to enrich IR with architecture model mapping", + location=None, + details={"error": repr(e)}, + ) + ) + + # 6) Load + parse + compile rules + try: + sources = self.rule_source_loader.load_sources(cfg.rules_files) + ast_file = self.dsl_parser.parse_many(sources, source_names=[str(p) for p in cfg.rules_files]) + ruleset: RuleSet = self.rule_compiler.compile(ast_file) + except Exception as e: + engine_errors.append( + EngineError( + type="rules_error", + message="Failed to load/parse/compile rules", + location=None, + details={"error": repr(e)}, + ) + ) + ruleset = RuleSet() + + # 7) Evaluate rules -> violations + try: + violations = self.rule_evaluator.evaluate(enriched_ir, ruleset) + except Exception as e: + engine_errors.append( + EngineError( + type="runtime_error", + message="Rule evaluation failed", + location=None, + details={"error": repr(e)}, + ) + ) + violations = tuple() + + # 8) Build updated snapshot with violations, compare baseline + + updated_snapshot = self.snapshot_builder.build( + enriched_ir, + meta=snapshot.meta, + violations=violations, + ) + + baseline_snapshot: Snapshot | None = None + if cfg.baseline: + if snapshot_store.exists(cfg.baseline): + baseline_snapshot = snapshot_store.load(cfg.baseline) + diff = self.diff_engine.diff(baseline_snapshot, updated_snapshot) + violations = self.baseline_service.mark_status( + violations=violations, + baseline_snapshot=baseline_snapshot, + key_factory=self.key_factory, + ) + else: + engine_errors.append( + EngineError( + type="config_error", + message=f"Baseline snapshot not found: {cfg.baseline}", + location=None, + details={"hint": "Create a baseline with `pacta snapshot save --ref `"}, + ) + ) + + # 9) Build report + report = self.report_builder.build( + run=RunInfo( + repo_root=str(cfg.repo_root), + commit=snapshot.meta.commit, + model_file=str(cfg.model_file) if cfg.model_file else None, + rules_files=tuple(str(p) for p in cfg.rules_files), + baseline_ref=cfg.baseline, + mode="changed_only" if cfg.changed_only else "full", + ), + violations=violations, + engine_errors=engine_errors, + diff=diff, + ) + + return CheckResult(snapshot=updated_snapshot, report=report, diff=diff) diff --git a/pacta/snapshot/store.py b/pacta/snapshot/store.py index d2d4a55..47bd15b 100644 --- a/pacta/snapshot/store.py +++ b/pacta/snapshot/store.py @@ -108,6 +108,26 @@ def save( refs_updated=tuple(refs_updated), ) + def update_object(self, short_hash: str, snapshot: Snapshot) -> None: + """ + Overwrite an existing object file in-place. + + This is used by `pacta check` to write violations back into + an existing snapshot without creating a new object. + """ + path = self._object_path(short_hash) + if not path.exists(): + raise FileNotFoundError(f"Snapshot object not found: {short_hash}") + + data = snapshot.to_dict() + # Preserve the original hash as the identifier + data["_hash"] = short_hash + + path.write_text( + json.dumps(data, sort_keys=True, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + def load_object(self, short_hash: str) -> Snapshot: """Load snapshot by short hash.""" path = self._object_path(short_hash) diff --git a/tests/cli/test_check.py b/tests/cli/test_check.py new file mode 100644 index 0000000..b125003 --- /dev/null +++ b/tests/cli/test_check.py @@ -0,0 +1,219 @@ +from unittest.mock import patch + +from pacta.cli.main import main +from pacta.core.engine import CheckResult +from pacta.reporting.types import Report, RuleRef, RunInfo, Severity, Summary, Violation +from pacta.snapshot.types import Snapshot, SnapshotMeta + + +def _empty_snapshot(repo_root: str) -> Snapshot: + return Snapshot( + schema_version=1, + meta=SnapshotMeta(repo_root=repo_root, commit="abc123", branch="main"), + nodes=(), + edges=(), + violations=(), + ) + + +def _make_report(repo_root: str, violations=None, engine_errors=None) -> Report: + violations = violations or [] + engine_errors = engine_errors or [] + + by_severity = {} + by_status = {} + by_rule = {} + for v in violations: + sev_key = v.rule.severity.value + by_severity[sev_key] = by_severity.get(sev_key, 0) + 1 + by_status[v.status] = by_status.get(v.status, 0) + 1 + by_rule[v.rule.id] = by_rule.get(v.rule.id, 0) + 1 + + return Report( + tool="pacta", + version="0.0.4", + run=RunInfo( + repo_root=repo_root, + commit=None, + branch=None, + model_file=None, + rules_files=(), + baseline_ref=None, + mode="full", + created_at=None, + tool_version="0.0.4", + metadata={}, + ), + summary=Summary( + total_violations=len(violations), + by_severity=by_severity, + by_status=by_status, + by_rule=by_rule, + engine_errors=len(engine_errors), + ), + violations=tuple(violations), + engine_errors=tuple(engine_errors), + diff=None, + ) + + +class TestCheckCommand: + """Tests for the check CLI command.""" + + def test_check_parser_accepts_valid_args(self, tmp_path): + """Test that check command parses arguments correctly.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + + snapshot = _empty_snapshot(str(repo_root)) + report = _make_report(str(repo_root)) + check_result = CheckResult(snapshot=snapshot, report=report, diff=None) + + with ( + patch("pacta.cli.check.FsSnapshotStore") as mock_store_cls, + patch("pacta.cli.check.DefaultPactaEngine") as mock_engine_cls, + ): + store = mock_store_cls.return_value + store.exists.return_value = True + store.load.return_value = snapshot + mock_engine_cls.return_value.check.return_value = check_result + + exit_code = main(["check", str(repo_root)]) + + assert exit_code == 0 + + def test_check_with_custom_ref(self, tmp_path): + """Test check command with --ref flag.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + + snapshot = _empty_snapshot(str(repo_root)) + report = _make_report(str(repo_root)) + check_result = CheckResult(snapshot=snapshot, report=report, diff=None) + + with ( + patch("pacta.cli.check.FsSnapshotStore") as mock_store_cls, + patch("pacta.cli.check.DefaultPactaEngine") as mock_engine_cls, + ): + store = mock_store_cls.return_value + store.exists.return_value = True + store.load.return_value = snapshot + mock_engine_cls.return_value.check.return_value = check_result + + exit_code = main(["check", str(repo_root), "--ref", "baseline"]) + + assert exit_code == 0 + store.load.assert_called_once_with("baseline") + + def test_check_missing_snapshot_returns_error(self, tmp_path): + """Test that check returns error when snapshot ref doesn't exist.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + + with patch("pacta.cli.check.FsSnapshotStore") as mock_store_cls: + store = mock_store_cls.return_value + store.exists.return_value = False + + exit_code = main(["check", str(repo_root), "--ref", "nonexistent"]) + + assert exit_code == 2 + + def test_check_with_violations_returns_exit_code_1(self, tmp_path): + """Test that check returns exit code 1 when violations are found.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + + snapshot = _empty_snapshot(str(repo_root)) + violation = Violation( + rule=RuleRef(id="test", name="Test", severity=Severity.ERROR), + message="bad import", + location=None, + status="new", + ) + report = _make_report(str(repo_root), violations=[violation]) + check_result = CheckResult(snapshot=snapshot, report=report, diff=None) + + with ( + patch("pacta.cli.check.FsSnapshotStore") as mock_store_cls, + patch("pacta.cli.check.DefaultPactaEngine") as mock_engine_cls, + ): + store = mock_store_cls.return_value + store.exists.return_value = True + store.load.return_value = snapshot + mock_engine_cls.return_value.check.return_value = check_result + + exit_code = main(["check", str(repo_root)]) + + assert exit_code == 1 + + def test_check_updates_existing_snapshot(self, tmp_path): + """Test that check updates the existing snapshot object in-place.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + + snapshot = _empty_snapshot(str(repo_root)) + report = _make_report(str(repo_root)) + check_result = CheckResult(snapshot=snapshot, report=report, diff=None) + + with ( + patch("pacta.cli.check.FsSnapshotStore") as mock_store_cls, + patch("pacta.cli.check.DefaultPactaEngine") as mock_engine_cls, + ): + store = mock_store_cls.return_value + store.exists.return_value = True + store.load.return_value = snapshot + store.resolve_ref.return_value = "abcd1234" + mock_engine_cls.return_value.check.return_value = check_result + + main(["check", str(repo_root), "--ref", "myref"]) + + store.update_object.assert_called_once_with("abcd1234", check_result.snapshot) + + def test_check_with_save_ref_creates_additional_ref(self, tmp_path): + """Test that --save-ref saves under an additional ref.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + + snapshot = _empty_snapshot(str(repo_root)) + report = _make_report(str(repo_root)) + check_result = CheckResult(snapshot=snapshot, report=report, diff=None) + + with ( + patch("pacta.cli.check.FsSnapshotStore") as mock_store_cls, + patch("pacta.cli.check.DefaultPactaEngine") as mock_engine_cls, + ): + store = mock_store_cls.return_value + store.exists.return_value = True + store.load.return_value = snapshot + store.resolve_ref.return_value = "abcd1234" + mock_engine_cls.return_value.check.return_value = check_result + + main(["check", str(repo_root), "--ref", "myref", "--save-ref", "extra"]) + + store.update_object.assert_called_once() + store.save.assert_called_once() + assert "extra" in store.save.call_args.kwargs.get( + "refs", store.save.call_args[1] if len(store.save.call_args) > 1 else [] + ) + + def test_check_json_format(self, tmp_path): + """Test check command with JSON output.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + + snapshot = _empty_snapshot(str(repo_root)) + report = _make_report(str(repo_root)) + check_result = CheckResult(snapshot=snapshot, report=report, diff=None) + + with ( + patch("pacta.cli.check.FsSnapshotStore") as mock_store_cls, + patch("pacta.cli.check.DefaultPactaEngine") as mock_engine_cls, + ): + store = mock_store_cls.return_value + store.exists.return_value = True + store.load.return_value = snapshot + mock_engine_cls.return_value.check.return_value = check_result + + exit_code = main(["check", str(repo_root), "--format", "json"]) + + assert exit_code == 0