diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index c3498c3..06c56ff 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -3,7 +3,7 @@ [metadata] project = "palimpsest-plasma" -version = "0.2.0" +version = "0.4.0" last-updated = "2026-07-02" status = "active" diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c0fba..f5c530e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,22 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2026-07-16 + +### Added + +- feat(engine): `diff(before, after) -> FactsDiff` — pure, deterministic + comparison of two fact snapshots: files added/removed, SPDX header + transitions, metadata changes, git state (`plasma-engine/src/diff.rs`) +- feat(cli): `plasma diff before.json after.json` with diff(1) exit + semantics (0 identical, 1 differ, 2 error) — the before/after + verification protocol for agent claims +- docs: `docs/interchange-contracts.adoc` — pins the four wire contracts + (facts snapshot, facts diff, findings JSON, SARIF namespace) that + sibling tools build against; additive-only evolution within a major + +## [0.3.0] - 2026-07-16 + ### Added - feat(engine): action planner — pure `plan(policy, evaluation, ctx)` maps diff --git a/CITATION.cff b/CITATION.cff index d42c3b6..195e4d0 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,7 +7,7 @@ authors: family-names: "Jewell" email: "j.d.a.jewell@open.ac.uk" affiliation: "hyperpolymath" -version: 0.2.0 +version: 0.4.0 date-released: 2026-07-02 license: "MPL-2.0" repository-code: "https://github.com/hyperpolymath/palimpsest-plasma" diff --git a/Cargo.lock b/Cargo.lock index 735fe43..f0791c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,7 +258,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "palimpsest-plasma" -version = "0.2.0" +version = "0.4.0" dependencies = [ "anyhow", "clap", @@ -273,7 +273,7 @@ dependencies = [ [[package]] name = "plasma-engine" -version = "0.2.0" +version = "0.4.0" dependencies = [ "glob", "plasma-parser", @@ -287,7 +287,7 @@ dependencies = [ [[package]] name = "plasma-parser" -version = "0.2.0" +version = "0.4.0" dependencies = [ "glob", "serde", diff --git a/Cargo.toml b/Cargo.toml index 7b01d76..e108f56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = [".", "plasma-parser", "plasma-engine"] [package] name = "palimpsest-plasma" -version = "0.2.0" +version = "0.4.0" edition = "2021" rust-version = "1.74" authors = ["Jonathan D.A. Jewell "] diff --git a/Justfile b/Justfile index 14fe007..1999953 100644 --- a/Justfile +++ b/Justfile @@ -12,7 +12,7 @@ set positional-arguments := true import? "contractile.just" project := "palimpsest-plasma" -version := "0.2.0" +version := "0.4.0" tier := "infrastructure" state := ".machine_readable/6a2/STATE.a2ml" diff --git a/ROADMAP.adoc b/ROADMAP.adoc index f72cf9e..50cf9d4 100644 --- a/ROADMAP.adoc +++ b/ROADMAP.adoc @@ -45,13 +45,15 @@ future project; the earlier union-policy-parser sub-project was removed * [ ] Overlay `modify-rules` / `override-rules` semantics * [ ] Content facts + `file-matches-pattern` condition semantics -=== v0.4.0 — Integration Surfaces +=== v0.4.0 — Interchange Contracts & Integration Surfaces [IN PROGRESS] +* [x] Sibling-tool contracts pinned: facts snapshot, facts diff, findings, + SARIF namespace (docs/interchange-contracts.adoc) +* [x] `plasma diff before.json after.json` — deterministic facts diff with + diff(1) exit semantics (0 identical / 1 differ / 2 error) * [ ] Git hooks (pre-commit, pre-push) via `plasma check --quiet` * [ ] Reference GitHub Action / GitLab CI job * [ ] `.plasma.toml` policy references (project-pinned policies) -* [ ] Sibling-tool contracts: stable facts-diff format for - did-you-actually-do-that, finding-provenance for somethings-fishy * [ ] Release facts (`release` subjects/resources gain semantics) == Future Directions diff --git a/codemeta.json b/codemeta.json index 6a1f1d1..fd0cc68 100644 --- a/codemeta.json +++ b/codemeta.json @@ -30,7 +30,7 @@ "programmingLanguage": [ "Rust" ], - "version": "0.2.0", + "version": "0.4.0", "url": "https://github.com/hyperpolymath/palimpsest-plasma", "readme": "https://github.com/hyperpolymath/palimpsest-plasma/blob/main/README.adoc", "issueTracker": "https://github.com/hyperpolymath/palimpsest-plasma/issues", diff --git a/docs/cli-design.adoc b/docs/cli-design.adoc index 49f8f17..61742a7 100644 --- a/docs/cli-design.adoc +++ b/docs/cli-design.adoc @@ -21,6 +21,7 @@ plasma check [PATH] [--policy FILE] [--format human|json|sarif] plasma fix [PATH] [--policy FILE] [--license SPDX-ID] [--author NAME] [--apply] [--no-backup] [--format human|json] # v0.3 plasma facts [PATH] +plasma diff BEFORE.json AFTER.json [--format human|json] # v0.4 plasma policy validate FILE plasma audit [PATH] [--license SPDX-EXPR] [--fix] plasma init [--path PATH] [--license SPDX-ID] [--author NAME] @@ -80,6 +81,20 @@ simply its first supported action class. Dump the deterministic fact snapshot the evaluator sees, as JSON. This is the diffable before/after artifact for agent-verification tooling. +=== `plasma diff` + +Compare two fact snapshots produced by `plasma facts`. This is the +verification protocol for agent claims: snapshot before the agent runs, +snapshot after, diff the two — the result is exactly what changed as far +as policy evaluation is concerned (files added/removed, SPDX header +transitions, metadata changes, git state). + +* **Exit semantics follow `diff(1)`:** 0 = identical, 1 = differ, + 2 = error — scriptable without parsing output. +* Output is deterministic: identical snapshot pairs give byte-identical + JSON. The format is pinned in + link:interchange-contracts.adoc[interchange-contracts.adoc]. + === `plasma policy validate` Parse and schema-check a policy file; print rule counts on success. Fails diff --git a/docs/interchange-contracts.adoc b/docs/interchange-contracts.adoc new file mode 100644 index 0000000..218b2f0 --- /dev/null +++ b/docs/interchange-contracts.adoc @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell += Plasma Interchange Contracts (v0.4) +:toc: +:toc-placement!: +:sectnums: + +toc::[] + +== Purpose + +This document pins the wire formats sibling tools consume. Once published, +these contracts change only additively within a major version: new fields +may appear, existing fields keep their name, type, and meaning. + +The consumers: + +* **did-you-actually-do-that** (planned) — verifies agents did what they + claimed. Consumes *facts snapshots* (contract 1) and *facts diffs* + (contract 2), plus pass-findings as positive evidence (contract 3). +* **somethings-fishy** (planned) — forensic investigation of bot-inflicted + damage. Consumes *findings* (contract 3) and *SARIF* (contract 4). + +All four contracts share plasma's determinism guarantee: identical inputs +produce byte-identical output (BTree ordering, no timestamps, no ambient +state). Consumers may therefore hash, cache, and compare outputs directly. + +== Contract 1 — Facts snapshot (`plasma facts`) + +The serialized `FactSet` (`plasma-engine/src/facts.rs`): the complete, +deterministic view of repository state the evaluator sees. + +[source,json] +---- +{ + "root": "/path/given/by/caller", + "files": ["Cargo.toml", "src/main.rs", "..."], + "spdx_headers": { "src/main.rs": "MPL-2.0", "src/stub.rs": null }, + "metadata": { "version": "0.4.0" }, + "git": { "is_repo": true, "head_ref": "refs/heads/main" } +} +---- + +* `files` — every regular file, relative, `/`-separated, sorted. The walk + skip list is pinned in link:engine-v0-design.adoc[engine-v0-design.adoc]. +* `spdx_headers` — raw `SPDX-License-Identifier:` value per auditable file; + `null` = auditable but header-less. Non-auditable files do not appear. +* `metadata` — governance keys (v0: `version` from Cargo.toml). +* `git` — read from `.git/HEAD` directly; no subprocess. + +**Verification protocol:** snapshot before an agent runs +(`plasma facts . > before.json`), snapshot after, then apply contract 2. +`root` is informational; snapshots are comparable across checkouts. + +== Contract 2 — Facts diff (`plasma diff before.json after.json`) + +The serialized `FactsDiff` (`plasma-engine/src/diff.rs`): exactly what +changed between two snapshots, deterministically ordered. + +[source,json] +---- +{ + "root_before": "...", "root_after": "...", + "files_added": ["new.rs"], + "files_removed": ["gone.rs"], + "headers_changed": [ + { "file": "src/main.rs", "before": null, "after": "MPL-2.0" } + ], + "metadata_changed": [ + { "key": "version", "before": "0.3.0", "after": "0.4.0" } + ], + "git_changed": { "before": { "...": "..." }, "after": { "...": "..." } } +} +---- + +* `headers_changed` covers files present in **both** snapshots; header + state of added/removed files is implied by the file lists. +* `git_changed` is omitted (not `null`) when git state is unchanged. +* Exit semantics follow `diff(1)`: **0** identical, **1** differ, + **2** error — scriptable without parsing output. + +An agent's claim "I only added SPDX headers" verifies as: `files_added`, +`files_removed`, `metadata_changed`, `git_changed` all empty, and every +entry in `headers_changed` has `before: null`. + +== Contract 3 — Findings (`plasma check --format json`) + +The serialized `Evaluation` (`plasma-engine/src/finding.rs`): ordered +findings plus a summary. + +[source,json] +---- +{ + "root": "...", + "policy_id": "repo-hygiene", + "policy_version": { "major": 0, "minor": 1 }, + "schema_version": { "major": 0, "minor": 1 }, + "findings": [ + { + "rule_id": "spdx-header-required", + "modality": "obligation", + "severity": "warning", + "subject": "src/main.rs", + "message": "SPDX header present for src/main.rs", + "rationale": "...", + "source": "...", + "status": "pass" + } + ], + "summary": { "errors": 0, "warnings": 0, "info": 0, "passes": 96 } +} +---- + +* `rule_id` values are **stable identifiers**: forensic tooling keys on + them across runs and versions. +* `status: "pass"` findings are positive evidence — a satisfied obligation + is an attestation, not merely the absence of a complaint. +* `subject` is `"repo"`, a relative path, or `"metadata:"`. + +== Contract 4 — SARIF 2.1.0 (`plasma check --format sarif`) + +Standard SARIF for GitHub Code Scanning and any SARIF-native tool +(`plasma-engine/src/report/sarif.rs`): + +* `ruleId` = `plasma/` — the namespace is reserved and stable. +* `level` ∈ `error` | `warning` | `note` (never `info`). +* Pass findings appear only with `--verbose`, as `kind: "pass"`. +* Repo- and metadata-scoped results carry no artifact location. + +== Remediation plan (informative, stabilising) + +`plasma fix --format json` emits `{ "plan": Plan, "outcome": ApplyOutcome? }` +(`plasma-engine/src/action.rs`, `apply.rs`). This shape is expected to +stabilise in v0.5 once more action types exist; treat it as informative +until then. + +== Versioning + +Each contract inherits the crate version. Additive evolution only within a +major version; a field removal or meaning change requires a major bump and +an entry in link:release-notes.adoc[release-notes.adoc]. The policy +*schema* version (`schema_version`) is independent — see +link:engine-v0-design.adoc[engine-v0-design.adoc]. diff --git a/plasma-engine/Cargo.toml b/plasma-engine/Cargo.toml index 854ea28..a18b707 100644 --- a/plasma-engine/Cargo.toml +++ b/plasma-engine/Cargo.toml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: MPL-2.0 [package] name = "plasma-engine" -version = "0.2.0" +version = "0.4.0" edition = "2021" authors = ["Jonathan D.A. Jewell "] description = "Deterministic, typed policy evaluation — policy AST, fact collection, and a pure evaluator producing findings" diff --git a/plasma-engine/src/diff.rs b/plasma-engine/src/diff.rs new file mode 100644 index 0000000..2a9d380 --- /dev/null +++ b/plasma-engine/src/diff.rs @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// Facts diff — the before/after contract for agent verification. +// +// `diff` is pure (like `eval` and `plan`): two FactSets in, one FactsDiff +// out, deterministically ordered. Claim-verification tooling +// (did-you-actually-do-that) snapshots facts before an agent runs, again +// after, and diffs the two: the result is exactly what changed in the +// repository as far as policy evaluation is concerned — no more, no less. + +use crate::facts::{FactSet, GitFacts}; +use serde::{Deserialize, Serialize}; + +/// A field whose value changed between two snapshots. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Changed { + pub before: T, + pub after: T, +} + +/// A per-file SPDX header transition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeaderChange { + pub file: String, + /// None = file had no header (or was absent) in that snapshot. + pub before: Option, + pub after: Option, +} + +/// A governance metadata transition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MetadataChange { + pub key: String, + /// None = key absent in that snapshot. + pub before: Option, + pub after: Option, +} + +/// The deterministic difference between two fact snapshots. +/// +/// All lists are sorted (they derive from BTree iteration), so two +/// identical snapshot pairs always produce byte-identical JSON. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FactsDiff { + pub root_before: String, + pub root_after: String, + /// Files present after but not before. + pub files_added: Vec, + /// Files present before but not after. + pub files_removed: Vec, + /// SPDX header transitions on auditable files present in both + /// snapshots (added/removed files are already covered above). + pub headers_changed: Vec, + /// Governance metadata transitions (added, removed, or changed keys). + pub metadata_changed: Vec, + /// Git state transition, when it changed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_changed: Option>, +} + +impl FactsDiff { + /// True when the two snapshots describe identical repository state + /// (roots may differ — a snapshot is comparable across checkouts). + pub fn is_empty(&self) -> bool { + self.files_added.is_empty() + && self.files_removed.is_empty() + && self.headers_changed.is_empty() + && self.metadata_changed.is_empty() + && self.git_changed.is_none() + } + + /// Total number of recorded changes. + pub fn change_count(&self) -> usize { + self.files_added.len() + + self.files_removed.len() + + self.headers_changed.len() + + self.metadata_changed.len() + + usize::from(self.git_changed.is_some()) + } +} + +/// Compute the difference between two fact snapshots. Pure: no IO. +pub fn diff(before: &FactSet, after: &FactSet) -> FactsDiff { + let files_added: Vec = after.files.difference(&before.files).cloned().collect(); + let files_removed: Vec = before.files.difference(&after.files).cloned().collect(); + + // Header transitions on files common to both snapshots. Headers of + // added/removed files are implied by the file lists. + let mut headers_changed = Vec::new(); + for (file, before_header) in &before.spdx_headers { + if !after.files.contains(file) { + continue; + } + let after_header = after.spdx_headers.get(file).cloned().flatten(); + if before_header != &after_header { + headers_changed.push(HeaderChange { + file: file.clone(), + before: before_header.clone(), + after: after_header, + }); + } + } + // A file that became auditable only in the after snapshot (e.g. gained + // an extension mapping) but existed before: surface its header too. + for (file, after_header) in &after.spdx_headers { + if before.files.contains(file) + && !before.spdx_headers.contains_key(file) + && after_header.is_some() + { + headers_changed.push(HeaderChange { + file: file.clone(), + before: None, + after: after_header.clone(), + }); + } + } + headers_changed.sort_by(|a, b| a.file.cmp(&b.file)); + + // Metadata: union of keys, record any difference. + let mut metadata_changed = Vec::new(); + let keys: std::collections::BTreeSet<&String> = before + .metadata + .keys() + .chain(after.metadata.keys()) + .collect(); + for key in keys { + let b = before.metadata.get(key).cloned(); + let a = after.metadata.get(key).cloned(); + if b != a { + metadata_changed.push(MetadataChange { + key: key.clone(), + before: b, + after: a, + }); + } + } + + let git_changed = if before.git != after.git { + Some(Changed { + before: before.git.clone(), + after: after.git.clone(), + }) + } else { + None + }; + + FactsDiff { + root_before: before.root.clone(), + root_after: after.root.clone(), + files_added, + files_removed, + headers_changed, + metadata_changed, + git_changed, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{BTreeMap, BTreeSet}; + + fn facts( + files: &[&str], + headers: &[(&str, Option<&str>)], + metadata: &[(&str, &str)], + ) -> FactSet { + FactSet { + root: "r".to_string(), + files: files.iter().map(|s| s.to_string()).collect::>(), + spdx_headers: headers + .iter() + .map(|(k, v)| (k.to_string(), v.map(|s| s.to_string()))) + .collect::>(), + metadata: metadata + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(), + git: Default::default(), + } + } + + #[test] + fn test_identical_snapshots_empty_diff() { + let a = facts( + &["x.rs"], + &[("x.rs", Some("MPL-2.0"))], + &[("version", "1.0")], + ); + let d = diff(&a, &a.clone()); + assert!(d.is_empty()); + assert_eq!(d.change_count(), 0); + } + + #[test] + fn test_files_added_and_removed() { + let before = facts(&["a.rs", "gone.rs"], &[], &[]); + let after = facts(&["a.rs", "new.rs"], &[], &[]); + let d = diff(&before, &after); + assert_eq!(d.files_added, vec!["new.rs".to_string()]); + assert_eq!(d.files_removed, vec!["gone.rs".to_string()]); + } + + #[test] + fn test_header_transition() { + let before = facts(&["a.rs"], &[("a.rs", None)], &[]); + let after = facts(&["a.rs"], &[("a.rs", Some("MPL-2.0"))], &[]); + let d = diff(&before, &after); + assert_eq!( + d.headers_changed, + vec![HeaderChange { + file: "a.rs".to_string(), + before: None, + after: Some("MPL-2.0".to_string()), + }] + ); + // Reverse direction records the removal. + let d = diff(&after, &before); + assert_eq!(d.headers_changed[0].before, Some("MPL-2.0".to_string())); + assert_eq!(d.headers_changed[0].after, None); + } + + #[test] + fn test_removed_file_header_not_double_counted() { + let before = facts(&["a.rs"], &[("a.rs", Some("MPL-2.0"))], &[]); + let after = facts(&[], &[], &[]); + let d = diff(&before, &after); + assert_eq!(d.files_removed, vec!["a.rs".to_string()]); + assert!(d.headers_changed.is_empty()); + } + + #[test] + fn test_metadata_added_changed_removed() { + let before = facts(&[], &[], &[("version", "1.0"), ("old", "x")]); + let after = facts(&[], &[], &[("version", "2.0"), ("new", "y")]); + let d = diff(&before, &after); + assert_eq!(d.metadata_changed.len(), 3); + // BTree order: new, old, version. + assert_eq!(d.metadata_changed[0].key, "new"); + assert_eq!(d.metadata_changed[0].before, None); + assert_eq!(d.metadata_changed[1].key, "old"); + assert_eq!(d.metadata_changed[1].after, None); + assert_eq!(d.metadata_changed[2].key, "version"); + assert_eq!(d.metadata_changed[2].before, Some("1.0".to_string())); + assert_eq!(d.metadata_changed[2].after, Some("2.0".to_string())); + } + + #[test] + fn test_git_transition() { + let before = facts(&[], &[], &[]); + let mut after = facts(&[], &[], &[]); + after.git = GitFacts { + is_repo: true, + head_ref: Some("refs/heads/main".to_string()), + }; + let d = diff(&before, &after); + let git = d.git_changed.unwrap(); + assert!(!git.before.is_repo); + assert!(git.after.is_repo); + } + + #[test] + fn test_diff_is_deterministic() { + let before = facts(&["b.rs", "a.rs"], &[("b.rs", None)], &[("k", "1")]); + let after = facts(&["c.rs", "a.rs"], &[], &[("k", "2")]); + let d1 = diff(&before, &after); + let d2 = diff(&before, &after); + assert_eq!( + serde_json::to_string(&d1).unwrap(), + serde_json::to_string(&d2).unwrap() + ); + } +} diff --git a/plasma-engine/src/lib.rs b/plasma-engine/src/lib.rs index 99ec943..d455d76 100644 --- a/plasma-engine/src/lib.rs +++ b/plasma-engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod action; pub mod apply; pub mod ast; +pub mod diff; pub mod eval; pub mod facts; pub mod finding; @@ -30,6 +31,7 @@ pub use apply::{apply, ApplyOptions, ApplyOutcome}; pub use ast::{ ActionKind, Condition, Modality, Overlay, Policy, PolicyVersion, Resource, Rule, Subject, }; +pub use diff::{diff, FactsDiff, HeaderChange, MetadataChange}; pub use eval::evaluate; pub use facts::{collect, FactError, FactSet, GitFacts}; pub use finding::{Evaluation, Finding, FindingStatus, Severity, Summary}; diff --git a/plasma-parser/Cargo.toml b/plasma-parser/Cargo.toml index d172820..6e25587 100644 --- a/plasma-parser/Cargo.toml +++ b/plasma-parser/Cargo.toml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: MPL-2.0 [package] name = "plasma-parser" -version = "0.2.0" +version = "0.4.0" edition = "2021" authors = ["Jonathan D.A. Jewell "] description = "License/SPDX parsing library — SPDX expressions (including Palimpsest-family identifiers), zone auditing, compatibility checking" diff --git a/src/diff_cmd.rs b/src/diff_cmd.rs new file mode 100644 index 0000000..d34ccca --- /dev/null +++ b/src/diff_cmd.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// plasma diff — compare two fact snapshots. +// +// The before/after contract for agent verification: snapshot with +// `plasma facts > before.json`, let the agent run, snapshot again, then +// `plasma diff before.json after.json` shows exactly what changed. +// Exit semantics follow diff(1): 0 = identical, 1 = differ, 2 = error. + +use anyhow::{Context, Result}; +use plasma_engine::{diff, FactSet, FactsDiff}; +use std::path::Path; + +/// Exit code when the snapshots differ. +pub const EXIT_DIFFERENT: i32 = 1; + +pub fn run(before_file: &str, after_file: &str, format: &str) -> Result { + let before = load_snapshot(before_file)?; + let after = load_snapshot(after_file)?; + + let d = diff(&before, &after); + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&d)?), + _ => print!("{}", render_human(&d)), + } + + Ok(if d.is_empty() { 0 } else { EXIT_DIFFERENT }) +} + +fn load_snapshot(file: &str) -> Result { + let content = std::fs::read_to_string(Path::new(file)) + .with_context(|| format!("cannot read snapshot {file}"))?; + serde_json::from_str(&content).with_context(|| { + format!("{file} is not a valid facts snapshot (produce one with `plasma facts`)") + }) +} + +fn render_human(d: &FactsDiff) -> String { + let mut out = String::new(); + + if d.is_empty() { + out.push_str("Snapshots are identical.\n"); + return out; + } + + out.push_str(&format!( + "Snapshots differ ({} change(s)):\n\n", + d.change_count() + )); + + for file in &d.files_added { + out.push_str(&format!(" + added: {file}\n")); + } + for file in &d.files_removed { + out.push_str(&format!(" - removed: {file}\n")); + } + for h in &d.headers_changed { + out.push_str(&format!( + " ~ header: {} ({} -> {})\n", + h.file, + h.before.as_deref().unwrap_or(""), + h.after.as_deref().unwrap_or("") + )); + } + for m in &d.metadata_changed { + out.push_str(&format!( + " ~ metadata: {} ({} -> {})\n", + m.key, + m.before.as_deref().unwrap_or(""), + m.after.as_deref().unwrap_or("") + )); + } + if let Some(git) = &d.git_changed { + out.push_str(&format!( + " ~ git: {} -> {}\n", + git.before.head_ref.as_deref().unwrap_or(""), + git.after.head_ref.as_deref().unwrap_or("") + )); + } + + out +} diff --git a/src/main.rs b/src/main.rs index 1f12a5b..e5619ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ use clap::{Parser, Subcommand}; mod audit; mod badge; mod check; +mod diff_cmd; mod facts_cmd; mod fix; mod init; @@ -88,6 +89,17 @@ enum Commands { path: String, }, + /// Compare two fact snapshots (exit 0 identical, 1 differ). + Diff { + /// Snapshot taken before (JSON from `plasma facts`) + before: String, + /// Snapshot taken after (JSON from `plasma facts`) + after: String, + /// Output format: human or json + #[arg(long, default_value = "human")] + format: String, + }, + /// Policy file utilities. Policy { #[command(subcommand)] @@ -153,9 +165,17 @@ enum PolicyCommands { }, } -fn main() -> Result<()> { +fn main() { let cli = Cli::parse(); + if let Err(e) = run(cli) { + // Usage/IO errors exit 2, keeping exit 1 unambiguous ("violations + // found" / "snapshots differ") per docs/cli-design.adoc. + eprintln!("error: {e:#}"); + std::process::exit(2); + } +} +fn run(cli: Cli) -> Result<()> { match cli.command { Commands::Check { path, @@ -202,6 +222,16 @@ fn main() -> Result<()> { Commands::Facts { path } => { facts_cmd::run(&path)?; } + Commands::Diff { + before, + after, + format, + } => { + let code = diff_cmd::run(&before, &after, &format)?; + if code != 0 { + std::process::exit(code); + } + } Commands::Policy { command } => match command { PolicyCommands::Validate { file } => { policy_cmd::validate(&file)?; diff --git a/tests/diff_selftest.rs b/tests/diff_selftest.rs new file mode 100644 index 0000000..2669df2 --- /dev/null +++ b/tests/diff_selftest.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// End-to-end tests for `plasma diff`: the snapshot → mutate → snapshot → +// diff verification protocol, with diff(1) exit semantics. + +use std::fs; +use std::process::Command; + +fn plasma() -> Command { + Command::new(env!("CARGO_BIN_EXE_plasma")) +} + +fn snapshot(repo: &std::path::Path, out: &std::path::Path) { + let output = plasma() + .args(["facts", repo.to_str().unwrap()]) + .output() + .unwrap(); + assert!(output.status.success()); + fs::write(out, &output.stdout).unwrap(); +} + +#[test] +fn identical_snapshots_exit_zero() { + let repo = tempfile::tempdir().unwrap(); + fs::write(repo.path().join("a.rs"), "fn main() {}\n").unwrap(); + let dir = tempfile::tempdir().unwrap(); + let before = dir.path().join("before.json"); + let after = dir.path().join("after.json"); + snapshot(repo.path(), &before); + snapshot(repo.path(), &after); + + let out = plasma() + .args(["diff", before.to_str().unwrap(), after.to_str().unwrap()]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); + assert!(String::from_utf8_lossy(&out.stdout).contains("identical")); +} + +#[test] +fn agent_run_shows_up_in_diff() { + let repo = tempfile::tempdir().unwrap(); + fs::write(repo.path().join("a.rs"), "fn main() {}\n").unwrap(); + let dir = tempfile::tempdir().unwrap(); + let before = dir.path().join("before.json"); + let after = dir.path().join("after.json"); + snapshot(repo.path(), &before); + + // Simulate an agent: fix headers via plasma itself, and add a file. + let fix = plasma() + .args([ + "fix", + repo.path().to_str().unwrap(), + "--apply", + "--no-backup", + ]) + .output() + .unwrap(); + assert!(fix.status.success()); + fs::write(repo.path().join("extra.txt"), "hello\n").unwrap(); + + snapshot(repo.path(), &after); + + let out = plasma() + .args([ + "diff", + before.to_str().unwrap(), + after.to_str().unwrap(), + "--format", + "json", + ]) + .output() + .unwrap(); + // Snapshots differ → exit 1, per diff(1) semantics. + assert_eq!(out.status.code(), Some(1)); + + let d: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let added: Vec<&str> = d["files_added"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(added.contains(&"extra.txt")); + assert!(d["files_removed"].as_array().unwrap().is_empty()); + + let headers = d["headers_changed"].as_array().unwrap(); + assert_eq!(headers.len(), 1); + assert_eq!(headers[0]["file"], "a.rs"); + assert!(headers[0]["before"].is_null()); + assert_eq!(headers[0]["after"], "MPL-2.0"); +} + +#[test] +fn diff_json_is_deterministic() { + let repo = tempfile::tempdir().unwrap(); + fs::write(repo.path().join("a.rs"), "fn main() {}\n").unwrap(); + let dir = tempfile::tempdir().unwrap(); + let before = dir.path().join("before.json"); + let after = dir.path().join("after.json"); + snapshot(repo.path(), &before); + fs::write( + repo.path().join("b.rs"), + "// SPDX-License-Identifier: MIT\n", + ) + .unwrap(); + snapshot(repo.path(), &after); + + let run = || { + plasma() + .args([ + "diff", + before.to_str().unwrap(), + after.to_str().unwrap(), + "--format", + "json", + ]) + .output() + .unwrap() + .stdout + }; + assert_eq!(run(), run()); +} + +#[test] +fn garbage_snapshot_is_a_usage_error() { + let dir = tempfile::tempdir().unwrap(); + let bad = dir.path().join("bad.json"); + fs::write(&bad, "not json").unwrap(); + let out = plasma() + .args(["diff", bad.to_str().unwrap(), bad.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!out.status.success()); + assert_ne!(out.status.code(), Some(1)); // error, not "differ" +}