From 8165673eaa63b7731f0e783586c48ce4cfb70254 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:34:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(v0.3):=20action=20planner=20=E2=80=94=20pl?= =?UTF-8?q?asma=20fix=20(plan/apply,=20backups)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first v0.3 roadmap item: a policy-driven remediation surface. - plasma-engine: pure plan(policy, evaluation, ctx) -> Plan maps each violation to a mechanical Action or a ManualItem with a reason (nothing silently unfixable). IO boundary apply(plan, root, opts) executes actions, writes .bak backups (unless --no-backup), and is idempotent. - First auto-action AddSpdxHeader; missing files / unparsable headers / prohibitions route to manual. Action enum and Plan shape are the stable contract for future action types. - CLI: plasma fix [PATH] [--policy F] [--license L] [--author A] [--apply] [--no-backup] [--format human|json]. Dry-run by default (exit 1 when work pending), --apply makes changes (exit 0 unless an action errored). - Docs: engine-v0-design action-planner section; cli-design; ROADMAP; CHANGELOG; STATE. Justfile gains a `fix` recipe. - Tests: 7 engine unit tests + 4 CLI integration tests (77 total, green). Purity split preserved: plan is pure/total; apply is the sole new IO boundary alongside facts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JSVSF9HhKUhDrEV4xkMEXq --- .machine_readable/6a2/STATE.a2ml | 4 +- CHANGELOG.md | 13 ++ Justfile | 4 + ROADMAP.adoc | 9 +- docs/cli-design.adoc | 41 ++++- docs/engine-v0-design.adoc | 31 ++++ plasma-engine/src/action.rs | 277 +++++++++++++++++++++++++++++++ plasma-engine/src/apply.rs | 203 ++++++++++++++++++++++ plasma-engine/src/lib.rs | 4 + src/fix.rs | 160 ++++++++++++++++++ src/main.rs | 48 ++++++ tests/fix_selftest.rs | 97 +++++++++++ 12 files changed, 881 insertions(+), 10 deletions(-) create mode 100644 plasma-engine/src/action.rs create mode 100644 plasma-engine/src/apply.rs create mode 100644 src/fix.rs create mode 100644 tests/fix_selftest.rs diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index 9e82236..c3498c3 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -9,8 +9,8 @@ status = "active" [project-context] name = "palimpsest-plasma" -completion-percentage = 50 -phase = "Engine v0 shipped (Rust); action planner and integration surfaces next" +completion-percentage = 55 +phase = "v0.3 in progress: action planner (plasma fix) landed; more action types + integration surfaces next" [identity] summary = "Deterministic, typed policy engine for the agentic era" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b891b1..a7c0fba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- feat(engine): action planner — pure `plan(policy, evaluation, ctx)` maps + violations to corrective actions or a manual list with reasons; IO + boundary `apply(plan, root, opts)` executes them with `.bak` backups and + idempotent skips (`plasma-engine/src/action.rs`, `apply.rs`) +- feat(cli): `plasma fix` — dry-run by default, `--apply` to make changes, + `--license`/`--author`/`--no-backup`/`--format human|json`. First + auto-action: AddSpdxHeader; missing files / unparsable headers → manual +- docs: "Action planner" section in `docs/engine-v0-design.adoc`; + `plasma fix` documented in `docs/cli-design.adoc` +- test: `tests/fix_selftest.rs` (dry-run, apply+backup, idempotency, json) + ## [0.2.0] - 2026-07-02 Identity pivot: palimpsest-plasma is a deterministic, typed policy engine diff --git a/Justfile b/Justfile index 893d7f2..14fe007 100644 --- a/Justfile +++ b/Justfile @@ -92,6 +92,10 @@ facts: audit: cargo run -- audit . +# Show the remediation plan for this repository (dry-run; add --apply to fix) +fix *args: + cargo run -- fix . {{args}} + # Validate the bundled policy file policy-check: cargo run -- policy validate plasma-engine/policies/repo-hygiene.plasma.toml diff --git a/ROADMAP.adoc b/ROADMAP.adoc index c31f85e..f72cf9e 100644 --- a/ROADMAP.adoc +++ b/ROADMAP.adoc @@ -36,11 +36,12 @@ future project; the earlier union-policy-parser sub-project was removed == Active Milestones -=== v0.3.0 — Action Planner +=== v0.3.0 — Action Planner [IN PROGRESS] -* [ ] Action types (AddSpdxHeader, CreateFile, UpdateField, …) -* [ ] `plasma fix` — plan (dry-run) and apply modes, with backups -* [ ] Findings → planned actions mapping +* [x] `plasma fix` — plan (dry-run) and apply modes, with `.bak` backups +* [x] Findings → planned actions mapping (pure `plan`; IO `apply`) +* [x] First action type: `AddSpdxHeader` (+ manual partition with reasons) +* [ ] More action types (CreateFile, UpdateField, …) * [ ] Overlay `modify-rules` / `override-rules` semantics * [ ] Content facts + `file-matches-pattern` condition semantics diff --git a/docs/cli-design.adoc b/docs/cli-design.adoc index af871cf..49f8f17 100644 --- a/docs/cli-design.adoc +++ b/docs/cli-design.adoc @@ -12,15 +12,17 @@ toc::[] One binary (`plasma`), narratively clear subcommands, machine-readable output where useful. -=== Implemented (v0.2) +=== Implemented [source] ---- plasma check [PATH] [--policy FILE] [--format human|json|sarif] [--severity error|warning|info] [--quiet] [--verbose] +plasma fix [PATH] [--policy FILE] [--license SPDX-ID] [--author NAME] + [--apply] [--no-backup] [--format human|json] # v0.3 plasma facts [PATH] plasma policy validate FILE -plasma audit [--path PATH] [--license SPDX-EXPR] [--fix] +plasma audit [PATH] [--license SPDX-EXPR] [--fix] plasma init [--path PATH] [--license SPDX-ID] [--author NAME] plasma badge [--license SPDX-ID] [--style STYLE] plasma migrate [--path PATH] [--from SPDX-ID] [--to SPDX-ID] @@ -42,6 +44,37 @@ Evaluate a repository against a policy. - `--quiet` — exit code only - `--verbose` — include satisfied (pass) findings +=== `plasma fix` + +Plan or apply corrective actions for policy violations. **Dry-run by +default** — it prints the plan and touches nothing; `--apply` makes the +changes, writing a `.bak` backup before modifying each file (unless +`--no-backup`). + +The planner is pure (`plasma_engine::plan`); applying is the IO boundary +(`plasma_engine::apply`). Every violation is either turned into a +mechanical action or listed under *Manual* with a reason — nothing is +silently unfixable. The v0.3 auto-action set is: + +* **AddSpdxHeader** — a source file that must carry an SPDX header but does + not gets one prepended, using `--license` (default MPL-2.0), `--author`, + and the correct comment prefix for the file type. + +Missing required files (LICENSE, README) and unparsable headers are +reported as manual (they need human-authored content). Applying is +idempotent: a file that already has a header is skipped. + +* **Flags:** `--policy FILE`, `--license SPDX-ID`, `--author NAME`, + `--apply`, `--no-backup`, `--format human|json` +* **Exit:** dry-run returns 1 when actions are pending (so CI can gate on a + clean tree), 0 when there is nothing to do; `--apply` returns 0 unless an + action errored. + +Relationship to `plasma audit --fix`: `audit --fix` is the license-specific +tool (zone-expected headers from `.plasma.toml`); `plasma fix` is the +general policy-remediation surface driven by engine findings — headers are +simply its first supported action class. + === `plasma facts` Dump the deterministic fact snapshot the evaluator sees, as JSON. This is @@ -70,8 +103,8 @@ proper `-`→`--` escaping. == Roadmap surfaces (not yet implemented) -* `plasma fix` — findings → planned corrective actions, dry-run and - `--apply` modes with backups (v0.3) +* More `plasma fix` actions — CreateFile / UpdateField (beyond the v0.3 + AddSpdxHeader action) * Git hooks (pre-commit/pre-push wrapping `plasma check --quiet`) and a reference GitHub Action / GitLab job (v0.4) * `plasma governance` / decision registries — awaits `consistent-with` diff --git a/docs/engine-v0-design.adoc b/docs/engine-v0-design.adoc index e530491..933d3b4 100644 --- a/docs/engine-v0-design.adoc +++ b/docs/engine-v0-design.adoc @@ -204,6 +204,37 @@ this evaluator against a formal semantics: interchange contract with sibling tooling (`somethings-fishy`, `did-you-actually-do-that`). +== Action planner (v0.3) + +The action planner turns an `Evaluation` into a remediation `Plan`, keeping +the same purity split as evaluation: + +* **`plan(policy, evaluation, ctx) -> Plan`** is **pure** (`action.rs`). For + each violation it looks up the rule by id and either synthesises a + mechanical `Action` or records a `ManualItem` with a reason — nothing is + silently dropped. `FixContext` carries the injected `license`, `author`, + and `year` (never clock-derived, so planning stays deterministic). +* **`apply(plan, root, opts) -> ApplyOutcome`** is the **IO boundary** + (`apply.rs`, the counterpart to `facts.rs`). It executes actions, writing + `.bak` backups first unless disabled, and is idempotent — an action + whose effect already holds is skipped, not repeated. + +=== Action vocabulary (v0.3) + +|=== +| Action | Fires for | Effect + +| `AddSpdxHeader` | violated `obligation + present` on a `header` resource | prepend `SPDX-License-Identifier` + copyright comment using the file's comment prefix +|=== + +Everything else is `manual`: missing required files (need human-authored +content), unparsable headers (need hand correction), prohibitions (removal +is not auto-applied). Future increments add `CreateFile`, `UpdateField`, +etc.; the enum and `Plan` shape are the stable contract. + +Determinism/totality carry over: `plan` is pure and total; `apply` never +panics and reports per-action `applied` / `skipped` / `errors`. + == Roadmap interaction Reserved constructs (`release` subjects/resources, `consistent-with`, diff --git a/plasma-engine/src/action.rs b/plasma-engine/src/action.rs new file mode 100644 index 0000000..4b73349 --- /dev/null +++ b/plasma-engine/src/action.rs @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// Action planner — map policy violations to corrective actions. +// +// `plan` is pure (like `eval`): it takes a policy, an evaluation, and a fix +// context, and returns a Plan. Every violation is either turned into a +// mechanical Action or recorded as a ManualItem with a reason — nothing is +// silently dropped. Applying the plan (IO) lives in `apply`. + +use crate::ast::{ActionKind, Modality, OverlayEffect, Policy, Resource, Rule}; +use crate::finding::{Evaluation, FindingStatus}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Inputs the planner needs to synthesise fixes. +#[derive(Debug, Clone)] +pub struct FixContext { + /// SPDX identifier written into newly added headers. + pub license: String, + /// Copyright holder line for newly added headers. + pub author: String, + /// Year written into newly added headers (injected, never clock-derived, + /// so planning stays deterministic). + pub year: String, +} + +impl Default for FixContext { + fn default() -> Self { + FixContext { + license: "MPL-2.0".to_string(), + author: "CHANGE-ME ".to_string(), + year: "2026".to_string(), + } + } +} + +/// A concrete corrective action. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum Action { + /// Prepend an SPDX header to a source file that lacks one. + AddSpdxHeader { + file: String, + license: String, + author: String, + year: String, + }, +} + +/// An action bound to the finding it resolves. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlannedAction { + pub rule_id: String, + pub subject: String, + pub action: Action, +} + +/// A violation with no mechanical fix, and why. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManualItem { + pub rule_id: String, + pub subject: String, + pub reason: String, +} + +/// The full remediation plan for an evaluation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Plan { + pub actions: Vec, + pub manual: Vec, +} + +impl Plan { + pub fn is_empty(&self) -> bool { + self.actions.is_empty() && self.manual.is_empty() + } +} + +/// Build a remediation plan from an evaluation. Pure: no IO. +pub fn plan(policy: &Policy, evaluation: &Evaluation, ctx: &FixContext) -> Plan { + let rules = rule_index(policy); + + let mut actions = Vec::new(); + let mut manual = Vec::new(); + + for finding in &evaluation.findings { + if finding.status != FindingStatus::Violation { + continue; + } + let Some(rule) = rules.get(finding.rule_id.as_str()) else { + // A finding whose rule we cannot see (should not happen for a + // plan built from the same policy) — record it as manual. + manual.push(ManualItem { + rule_id: finding.rule_id.clone(), + subject: finding.subject.clone(), + reason: "no rule found for finding".to_string(), + }); + continue; + }; + + match fix_for(rule, &finding.subject, ctx) { + Some(action) => actions.push(PlannedAction { + rule_id: finding.rule_id.clone(), + subject: finding.subject.clone(), + action, + }), + None => manual.push(ManualItem { + rule_id: finding.rule_id.clone(), + subject: finding.subject.clone(), + reason: manual_reason(rule), + }), + } + } + + Plan { actions, manual } +} + +/// Decide the mechanical fix for a violated rule at a concrete subject. +fn fix_for(rule: &Rule, subject: &str, ctx: &FixContext) -> Option { + match (&rule.modality, &rule.action, &rule.resource) { + // A file that must carry an SPDX header but does not: add one. + (Modality::Obligation, ActionKind::Present, Resource::Header) => { + Some(Action::AddSpdxHeader { + file: subject.to_string(), + license: ctx.license.clone(), + author: ctx.author.clone(), + year: ctx.year.clone(), + }) + } + _ => None, + } +} + +/// Explain why a violated rule has no mechanical fix in this version. +fn manual_reason(rule: &Rule) -> String { + match (&rule.modality, &rule.action, &rule.resource) { + (Modality::Obligation, ActionKind::Present, Resource::File { .. }) + | (Modality::Obligation, ActionKind::Present, Resource::Manifest { .. }) => { + "creating a required file needs human-authored content".to_string() + } + (Modality::Obligation, ActionKind::Valid, Resource::Header) => { + "an unparsable SPDX header must be corrected by hand".to_string() + } + (Modality::Prohibition, _, _) => { + "removing a prohibited resource is not auto-applied".to_string() + } + _ => "no automatic fix is available for this rule".to_string(), + } +} + +/// Index every effective rule (base + overlay-added) by id. +fn rule_index(policy: &Policy) -> BTreeMap<&str, &Rule> { + let mut map = BTreeMap::new(); + for rule in &policy.rules { + map.insert(rule.id.as_str(), rule); + } + for overlay in &policy.overlays { + for effect in &overlay.effects { + if let OverlayEffect::AddRules { rules } = effect { + for rule in rules { + map.insert(rule.id.as_str(), rule); + } + } + } + } + map +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eval::evaluate; + use crate::facts::FactSet; + use crate::schema::{load_policy_str, PolicyFormat}; + use std::collections::{BTreeMap, BTreeSet}; + + fn facts(files: &[&str], headers: &[(&str, Option<&str>)]) -> FactSet { + FactSet { + root: "root".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: BTreeMap::new(), + git: Default::default(), + } + } + + fn ctx() -> FixContext { + FixContext { + license: "MPL-2.0".to_string(), + author: "Tester ".to_string(), + year: "2026".to_string(), + } + } + + #[test] + fn test_missing_header_becomes_action() { + let policy = load_policy_str( + r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } + +[[rules]] +id = "headers" +modality = "obligation" +severity = "warning" +subject = { type = "file-pattern", patterns = ["**/*.rs"] } +resource = { type = "header" } +action = { type = "present" } +"#, + PolicyFormat::Toml, + ) + .unwrap(); + + let facts = facts( + &["a.rs", "b.rs"], + &[("a.rs", Some("MPL-2.0")), ("b.rs", None)], + ); + let eval = evaluate(&policy, &facts); + let p = plan(&policy, &eval, &ctx()); + + assert_eq!(p.actions.len(), 1); + assert!(p.manual.is_empty()); + assert_eq!( + p.actions[0].action, + Action::AddSpdxHeader { + file: "b.rs".to_string(), + license: "MPL-2.0".to_string(), + author: "Tester ".to_string(), + year: "2026".to_string(), + } + ); + } + + #[test] + fn test_missing_file_is_manual() { + let policy = load_policy_str( + r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } + +[[rules]] +id = "license-present" +modality = "obligation" +subject = { type = "repo" } +resource = { type = "file", path = "LICENSE*" } +action = { type = "present" } +"#, + PolicyFormat::Toml, + ) + .unwrap(); + + let eval = evaluate(&policy, &facts(&["README.md"], &[])); + let p = plan(&policy, &eval, &ctx()); + + assert!(p.actions.is_empty()); + assert_eq!(p.manual.len(), 1); + assert_eq!(p.manual[0].subject, "repo"); + } + + #[test] + fn test_clean_eval_empty_plan() { + let policy = crate::schema::builtin_repo_hygiene(); + let facts = facts( + &["LICENSE", "README.adoc", "a.rs"], + &[("a.rs", Some("MPL-2.0"))], + ); + let eval = evaluate(&policy, &facts); + let p = plan(&policy, &eval, &ctx()); + assert!(p.is_empty()); + } +} diff --git a/plasma-engine/src/apply.rs b/plasma-engine/src/apply.rs new file mode 100644 index 0000000..d5c9aab --- /dev/null +++ b/plasma-engine/src/apply.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// Apply — execute a remediation Plan against the filesystem. +// +// This is the IO boundary for the action planner (the counterpart to +// `facts` for collection). `plan` decides what to do purely; `apply` does +// it. Applying is idempotent where it can be: an action whose effect is +// already present is skipped, not repeated. + +use crate::action::{Action, Plan}; +use plasma_parser::audit::header::{comment_prefix, extract_spdx_raw_from_content}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +/// Options controlling how a plan is applied. +#[derive(Debug, Clone)] +pub struct ApplyOptions { + /// Write a `.bak` copy before modifying an existing file. + pub backup: bool, +} + +impl Default for ApplyOptions { + fn default() -> Self { + ApplyOptions { backup: true } + } +} + +/// The result of applying a plan. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApplyOutcome { + /// Files successfully modified (relative paths as given in the plan). + pub applied: Vec, + /// Actions skipped because they were already satisfied, with a reason. + pub skipped: Vec<(String, String)>, + /// Actions that failed, with the error message. + pub errors: Vec<(String, String)>, +} + +impl ApplyOutcome { + pub fn is_clean(&self) -> bool { + self.errors.is_empty() + } +} + +/// Apply every action in a plan under `root`. Manual items are ignored +/// (they carry no action). Returns a per-action outcome; never panics. +pub fn apply(plan: &Plan, root: &Path, opts: &ApplyOptions) -> ApplyOutcome { + let mut outcome = ApplyOutcome::default(); + + for planned in &plan.actions { + match &planned.action { + Action::AddSpdxHeader { + file, + license, + author, + year, + } => match add_spdx_header(root, file, license, author, year, opts) { + Ok(true) => outcome.applied.push(file.clone()), + Ok(false) => outcome + .skipped + .push((file.clone(), "already has an SPDX header".to_string())), + Err(e) => outcome.errors.push((file.clone(), e)), + }, + } + } + + outcome +} + +/// Prepend an SPDX header to `file`. Returns Ok(true) when the file was +/// modified, Ok(false) when it already had a header (idempotent skip). +fn add_spdx_header( + root: &Path, + file: &str, + license: &str, + author: &str, + year: &str, + opts: &ApplyOptions, +) -> Result { + let path = root.join(file); + let content = fs::read_to_string(&path).map_err(|e| format!("cannot read {file}: {e}"))?; + + // Idempotent: never double-add a header. + if extract_spdx_raw_from_content(&content).is_some() { + return Ok(false); + } + + let ext = Path::new(file) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + let prefix = comment_prefix(ext); + let header = format!( + "{prefix} SPDX-License-Identifier: {license}\n{prefix} Copyright (c) {year} {author}\n\n" + ); + + if opts.backup { + let backup = path.with_extension(match ext { + "" => "bak".to_string(), + other => format!("{other}.bak"), + }); + fs::copy(&path, &backup).map_err(|e| format!("cannot back up {file}: {e}"))?; + } + + fs::write(&path, format!("{header}{content}")) + .map_err(|e| format!("cannot write {file}: {e}"))?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::action::{Action, PlannedAction}; + + fn header_plan(file: &str) -> Plan { + Plan { + actions: vec![PlannedAction { + rule_id: "headers".to_string(), + subject: file.to_string(), + action: Action::AddSpdxHeader { + file: file.to_string(), + license: "MPL-2.0".to_string(), + author: "Tester ".to_string(), + year: "2026".to_string(), + }, + }], + manual: Vec::new(), + } + } + + #[test] + fn test_apply_adds_header_and_backup() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("plain.rs"), "fn main() {}\n").unwrap(); + + let outcome = apply( + &header_plan("plain.rs"), + dir.path(), + &ApplyOptions::default(), + ); + assert_eq!(outcome.applied, vec!["plain.rs".to_string()]); + assert!(outcome.is_clean()); + + let written = fs::read_to_string(dir.path().join("plain.rs")).unwrap(); + assert!(written.starts_with("// SPDX-License-Identifier: MPL-2.0\n")); + assert!(written.contains("Copyright (c) 2026 Tester")); + assert!(written.trim_end().ends_with("fn main() {}")); + // Backup captured the original. + let backup = fs::read_to_string(dir.path().join("plain.rs.bak")).unwrap(); + assert_eq!(backup, "fn main() {}\n"); + } + + #[test] + fn test_apply_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("done.rs"), + "// SPDX-License-Identifier: MPL-2.0\nfn main() {}\n", + ) + .unwrap(); + + let outcome = apply( + &header_plan("done.rs"), + dir.path(), + &ApplyOptions::default(), + ); + assert!(outcome.applied.is_empty()); + assert_eq!(outcome.skipped.len(), 1); + // No backup written for a skipped file. + assert!(!dir.path().join("done.rs.bak").exists()); + } + + #[test] + fn test_apply_no_backup_option() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("x.sh"), "echo hi\n").unwrap(); + + let outcome = apply( + &header_plan("x.sh"), + dir.path(), + &ApplyOptions { backup: false }, + ); + assert_eq!(outcome.applied, vec!["x.sh".to_string()]); + assert!(!dir.path().join("x.sh.bak").exists()); + let written = fs::read_to_string(dir.path().join("x.sh")).unwrap(); + assert!(written.starts_with("# SPDX-License-Identifier: MPL-2.0\n")); + } + + #[test] + fn test_apply_missing_file_errors() { + let dir = tempfile::tempdir().unwrap(); + let outcome = apply( + &header_plan("ghost.rs"), + dir.path(), + &ApplyOptions::default(), + ); + assert!(outcome.applied.is_empty()); + assert_eq!(outcome.errors.len(), 1); + assert!(!outcome.is_clean()); + } +} diff --git a/plasma-engine/src/lib.rs b/plasma-engine/src/lib.rs index d624ff4..99ec943 100644 --- a/plasma-engine/src/lib.rs +++ b/plasma-engine/src/lib.rs @@ -16,6 +16,8 @@ #![forbid(unsafe_code)] +pub mod action; +pub mod apply; pub mod ast; pub mod eval; pub mod facts; @@ -23,6 +25,8 @@ pub mod finding; pub mod report; pub mod schema; +pub use action::{plan, Action, FixContext, ManualItem, Plan, PlannedAction}; +pub use apply::{apply, ApplyOptions, ApplyOutcome}; pub use ast::{ ActionKind, Condition, Modality, Overlay, Policy, PolicyVersion, Resource, Rule, Subject, }; diff --git a/src/fix.rs b/src/fix.rs new file mode 100644 index 0000000..87c1e83 --- /dev/null +++ b/src/fix.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// plasma fix — plan or apply corrective actions for policy violations. +// +// Dry-run by default: it prints what it *would* do. With --apply it makes +// the changes, backing up each modified file first (unless --no-backup). + +use anyhow::{Context, Result}; +use plasma_engine::{ + apply, builtin_repo_hygiene, collect, evaluate, load_policy, plan, Action, ApplyOptions, + FixContext, +}; +use std::path::Path; + +/// Exit code when actions remain unapplied (dry-run) or an apply failed. +pub const EXIT_PENDING: i32 = 1; + +pub struct FixOptions<'a> { + pub path: &'a str, + pub policy: Option<&'a str>, + pub license: &'a str, + pub author: Option<&'a str>, + pub apply_changes: bool, + pub backup: bool, + pub format: &'a str, +} + +pub fn run(opts: &FixOptions) -> Result { + let policy = match opts.policy { + Some(file) => { + load_policy(Path::new(file)).with_context(|| format!("failed to load policy {file}"))? + } + None => builtin_repo_hygiene(), + }; + + let facts = collect(Path::new(opts.path)) + .with_context(|| format!("failed to collect facts from {}", opts.path))?; + let evaluation = evaluate(&policy, &facts); + + let ctx = FixContext { + license: opts.license.to_string(), + author: opts + .author + .unwrap_or("CHANGE-ME ") + .to_string(), + year: "2026".to_string(), + }; + let remediation = plan(&policy, &evaluation, &ctx); + + if opts.format == "json" { + // In JSON mode, emit the plan (and outcome if applied) as one object. + let outcome = if opts.apply_changes { + Some(apply( + &remediation, + Path::new(opts.path), + &ApplyOptions { + backup: opts.backup, + }, + )) + } else { + None + }; + let doc = serde_json::json!({ + "plan": remediation, + "outcome": outcome, + }); + println!("{}", serde_json::to_string_pretty(&doc)?); + return Ok(exit_code( + opts.apply_changes, + &remediation, + outcome.as_ref(), + )); + } + + // Human output. + if remediation.is_empty() { + println!("Nothing to fix."); + return Ok(0); + } + + if !opts.apply_changes { + println!("Plan (dry-run — re-run with --apply to make changes):\n"); + for planned in &remediation.actions { + println!(" {}", describe_action(&planned.action)); + } + if !remediation.manual.is_empty() { + println!("\nManual (no automatic fix):"); + for item in &remediation.manual { + println!(" [{}] {} — {}", item.rule_id, item.subject, item.reason); + } + } + println!( + "\n{} action(s) to apply, {} manual item(s).", + remediation.actions.len(), + remediation.manual.len() + ); + return Ok(exit_code(false, &remediation, None)); + } + + let outcome = apply( + &remediation, + Path::new(opts.path), + &ApplyOptions { + backup: opts.backup, + }, + ); + for file in &outcome.applied { + println!(" APPLIED: {file}"); + } + for (file, reason) in &outcome.skipped { + println!(" SKIPPED: {file} ({reason})"); + } + for (file, err) in &outcome.errors { + println!(" ERROR: {file} ({err})"); + } + if !remediation.manual.is_empty() { + println!("\nManual (no automatic fix):"); + for item in &remediation.manual { + println!(" [{}] {} — {}", item.rule_id, item.subject, item.reason); + } + } + println!( + "\n{} applied, {} skipped, {} error(s), {} manual.", + outcome.applied.len(), + outcome.skipped.len(), + outcome.errors.len(), + remediation.manual.len() + ); + + Ok(exit_code(true, &remediation, Some(&outcome))) +} + +fn describe_action(action: &Action) -> String { + match action { + Action::AddSpdxHeader { file, license, .. } => { + format!("add SPDX header ({license}) to {file}") + } + } +} + +/// Exit non-zero when the working tree is not clean after the command: +/// in dry-run, if there is anything to do; in apply, if any action errored. +fn exit_code( + applied: bool, + remediation: &plasma_engine::Plan, + outcome: Option<&plasma_engine::ApplyOutcome>, +) -> i32 { + if applied { + match outcome { + Some(o) if o.is_clean() => 0, + Some(_) => EXIT_PENDING, + None => 0, + } + } else if remediation.is_empty() { + 0 + } else { + EXIT_PENDING + } +} diff --git a/src/main.rs b/src/main.rs index 852c0f3..1f12a5b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ mod audit; mod badge; mod check; mod facts_cmd; +mod fix; mod init; mod migrate; mod policy_cmd; @@ -55,6 +56,31 @@ enum Commands { verbose: bool, }, + /// Plan or apply corrective actions for policy violations. + Fix { + /// Path to the repository root + #[arg(default_value = ".")] + path: String, + /// Policy file (.toml or .json); defaults to the bundled repo-hygiene policy + #[arg(long)] + policy: Option, + /// SPDX license identifier written into any headers added + #[arg(long, default_value = "MPL-2.0")] + license: String, + /// Author line for any headers added + #[arg(long)] + author: Option, + /// Actually make the changes (default is a dry-run plan) + #[arg(long)] + apply: bool, + /// Do not write .bak backups before modifying files + #[arg(long)] + no_backup: bool, + /// Output format: human or json + #[arg(long, default_value = "human")] + format: String, + }, + /// Dump the deterministic fact snapshot for a repository (JSON). Facts { /// Path to the repository root @@ -151,6 +177,28 @@ fn main() -> Result<()> { std::process::exit(code); } } + Commands::Fix { + path, + policy, + license, + author, + apply, + no_backup, + format, + } => { + let code = fix::run(&fix::FixOptions { + path: &path, + policy: policy.as_deref(), + license: &license, + author: author.as_deref(), + apply_changes: apply, + backup: !no_backup, + format: &format, + })?; + if code != 0 { + std::process::exit(code); + } + } Commands::Facts { path } => { facts_cmd::run(&path)?; } diff --git a/tests/fix_selftest.rs b/tests/fix_selftest.rs new file mode 100644 index 0000000..da1f2de --- /dev/null +++ b/tests/fix_selftest.rs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// End-to-end tests for `plasma fix`: dry-run reports, --apply mutates and +// backs up, and applying is idempotent. + +use std::fs; +use std::process::Command; + +fn plasma() -> Command { + Command::new(env!("CARGO_BIN_EXE_plasma")) +} + +/// A throwaway repo with one header-less source file, plus LICENSE + README +/// so only the header rule fires. +fn scratch_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("foo.rs"), "fn main() {}\n").unwrap(); + fs::write(dir.path().join("LICENSE"), "MPL-2.0\n").unwrap(); + fs::write(dir.path().join("README.md"), "# demo\n").unwrap(); + dir +} + +#[test] +fn dry_run_reports_but_does_not_write() { + let dir = scratch_repo(); + let out = plasma() + .args(["fix", dir.path().to_str().unwrap()]) + .output() + .unwrap(); + + // Pending work → exit 1, and the file is untouched. + assert_eq!(out.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("add SPDX header")); + assert_eq!( + fs::read_to_string(dir.path().join("foo.rs")).unwrap(), + "fn main() {}\n" + ); + assert!(!dir.path().join("foo.rs.bak").exists()); +} + +#[test] +fn apply_adds_header_with_backup() { + let dir = scratch_repo(); + let out = plasma() + .args([ + "fix", + dir.path().to_str().unwrap(), + "--apply", + "--author", + "Jane ", + ]) + .output() + .unwrap(); + + assert!(out.status.success()); + let foo = fs::read_to_string(dir.path().join("foo.rs")).unwrap(); + assert!(foo.starts_with("// SPDX-License-Identifier: MPL-2.0\n")); + assert!(foo.contains("Copyright (c) 2026 Jane")); + assert_eq!( + fs::read_to_string(dir.path().join("foo.rs.bak")).unwrap(), + "fn main() {}\n" + ); +} + +#[test] +fn apply_is_idempotent() { + let dir = scratch_repo(); + let run = || { + plasma() + .args(["fix", dir.path().to_str().unwrap(), "--apply"]) + .output() + .unwrap() + }; + assert!(run().status.success()); + // Second apply re-evaluates a now-clean repo: nothing to fix, exit 0, + // and no double header on the file. + let second = run(); + assert!(second.status.success()); + let stdout = String::from_utf8_lossy(&second.stdout); + assert!(stdout.contains("Nothing to fix.")); + let foo = fs::read_to_string(dir.path().join("foo.rs")).unwrap(); + assert_eq!(foo.matches("SPDX-License-Identifier").count(), 1); +} + +#[test] +fn json_format_emits_plan() { + let dir = scratch_repo(); + let out = plasma() + .args(["fix", dir.path().to_str().unwrap(), "--format", "json"]) + .output() + .unwrap(); + let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!(doc["plan"]["actions"].as_array().unwrap().len() == 1); + assert!(doc["outcome"].is_null()); // dry-run: no outcome +}