From 75f5d37fe5ac35fe5cf33169e844d6d7c69735d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 06:07:05 +0000 Subject: [PATCH] feat(v0.6): content facts + file-matches-pattern, and CreateFile fix action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two v0.5-depth increments (1 then 2 from the roadmap). CreateFile action: - a required file at a CONCRETE path (not a glob) that is missing is now created with obvious placeholder content, never overwriting an existing file; glob-named required files (LICENSE*, README*) stay manual since no single filename can be derived. Idempotent. Content facts + file-matches-pattern: - FactSet gains an opt-in file_contents map (collect_opts, valid-UTF-8 files <= 1 MiB). It is omitted from serialized output when empty, so the default `plasma facts` snapshot is byte-identical to v0.4/v0.5 — the frozen wire contract is preserved (additive field only). - file-matches-pattern is now evaluable: its regex is validated at load (InvalidRegex) so evaluation stays total; it is false when a file's content was not collected (binary/oversized/contents off). - plasma check/fix collect content automatically iff the policy uses file-matches-pattern (Policy::needs_content); plasma facts --with-contents forces it. Purity/determinism preserved: eval still operates only on the FactSet it is given. 100 tests (8 new); docs updated (engine-v0-design, interchange-contracts, cli-design); versions unified at 0.6.0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JSVSF9HhKUhDrEV4xkMEXq --- .machine_readable/6a2/STATE.a2ml | 2 +- CHANGELOG.md | 19 +++++++ CITATION.cff | 2 +- Cargo.lock | 45 ++++++++++++++-- Cargo.toml | 2 +- Justfile | 2 +- ROADMAP.adoc | 6 ++- codemeta.json | 2 +- docs/cli-design.adoc | 22 +++++--- docs/engine-v0-design.adoc | 29 ++++++---- docs/interchange-contracts.adoc | 5 ++ plasma-engine/Cargo.toml | 3 +- plasma-engine/src/action.rs | 77 +++++++++++++++++++++++++- plasma-engine/src/apply.rs | 53 ++++++++++++++++++ plasma-engine/src/ast.rs | 39 ++++++++++++-- plasma-engine/src/diff.rs | 1 + plasma-engine/src/eval.rs | 59 +++++++++++++++++++- plasma-engine/src/facts.rs | 93 +++++++++++++++++++++++++++++--- plasma-engine/src/lib.rs | 2 +- plasma-engine/src/schema.rs | 57 ++++++++++++++++++-- plasma-parser/Cargo.toml | 2 +- src/check.rs | 15 ++++-- src/facts_cmd.rs | 13 +++-- src/fix.rs | 16 ++++-- src/main.rs | 10 +++- 25 files changed, 516 insertions(+), 60 deletions(-) diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index f3cf531..07d7316 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -3,7 +3,7 @@ [metadata] project = "palimpsest-plasma" -version = "0.5.0" +version = "0.6.0" last-updated = "2026-07-02" status = "active" diff --git a/CHANGELOG.md b/CHANGELOG.md index cdbb05a..14638f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,25 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-07-17 + +### Added + +- feat(engine): content facts + `file-matches-pattern` — opt-in + `FactSet.file_contents` (`collect_opts`, valid-UTF-8 files ≤ 1 MiB), + omitted from the default snapshot so the v0.4 wire contract stays + byte-identical. The condition's regex is validated at load + (`InvalidRegex`) so evaluation stays total; false when a file's content + was not collected. `plasma check`/`fix` collect content automatically iff + the policy needs it; `plasma facts --with-contents` forces it + (`plasma-engine/src/{facts,eval,schema,ast}.rs`) +- feat(engine): `CreateFile` fix action — a required file at a *concrete* + path (not a glob) that is missing is created with obvious placeholder + content, never overwriting an existing file; glob-named required files + stay manual (`plasma-engine/src/{action,apply}.rs`) +- docs: content-facts + overlay/action updates across + `engine-v0-design.adoc`, `interchange-contracts.adoc`, `cli-design.adoc` + ## [0.5.0] - 2026-07-16 ### Added diff --git a/CITATION.cff b/CITATION.cff index 04e8cb0..5db773a 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.5.0 +version: 0.6.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 e5a631b..8ce6edc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -258,7 +267,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "palimpsest-plasma" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "clap", @@ -273,10 +282,11 @@ dependencies = [ [[package]] name = "plasma-engine" -version = "0.5.0" +version = "0.6.0" dependencies = [ "glob", "plasma-parser", + "regex", "serde", "serde_json", "tempfile", @@ -287,7 +297,7 @@ dependencies = [ [[package]] name = "plasma-parser" -version = "0.5.0" +version = "0.6.0" dependencies = [ "glob", "serde", @@ -332,6 +342,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustix" version = "1.1.4" diff --git a/Cargo.toml b/Cargo.toml index 8add1ed..d95ee4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = [".", "plasma-parser", "plasma-engine"] [package] name = "palimpsest-plasma" -version = "0.5.0" +version = "0.6.0" edition = "2021" rust-version = "1.74" authors = ["Jonathan D.A. Jewell "] diff --git a/Justfile b/Justfile index e02b856..84bd326 100644 --- a/Justfile +++ b/Justfile @@ -12,7 +12,7 @@ set positional-arguments := true import? "contractile.just" project := "palimpsest-plasma" -version := "0.5.0" +version := "0.6.0" tier := "infrastructure" state := ".machine_readable/6a2/STATE.a2ml" diff --git a/ROADMAP.adoc b/ROADMAP.adoc index 5e04bbb..2abed12 100644 --- a/ROADMAP.adoc +++ b/ROADMAP.adoc @@ -41,10 +41,12 @@ future project; the earlier union-policy-parser sub-project was removed * [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, …) +* [x] Action type `CreateFile` (v0.6) — create a missing required file at a + concrete path; `UpdateField` and others still to come * [x] Overlay `override-rules` semantics (v0.5) — remove/replace base rules per context; `modify-rules` remains reserved -* [ ] Content facts + `file-matches-pattern` condition semantics +* [x] Content facts + `file-matches-pattern` condition semantics (v0.6) — + opt-in `file_contents`, regex validated at load === v0.4.0 — Interchange Contracts & Integration Surfaces [IN PROGRESS] diff --git a/codemeta.json b/codemeta.json index 68de2dd..0766969 100644 --- a/codemeta.json +++ b/codemeta.json @@ -30,7 +30,7 @@ "programmingLanguage": [ "Rust" ], - "version": "0.5.0", + "version": "0.6.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 61742a7..304ab94 100644 --- a/docs/cli-design.adoc +++ b/docs/cli-design.adoc @@ -20,7 +20,7 @@ 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 facts [PATH] [--with-contents] plasma diff BEFORE.json AFTER.json [--format human|json] # v0.4 plasma policy validate FILE plasma audit [PATH] [--license SPDX-EXPR] [--fix] @@ -55,15 +55,18 @@ changes, writing a `.bak` backup before modifying each file (unless 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: +silently unfixable. The 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. +* **CreateFile** (v0.5) — a required file at a *concrete* path (not a glob) + that is missing is created with obvious placeholder content. Never + overwrites an existing file. -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. +A required file named by a *glob* (e.g. `LICENSE*` — no single filename to +create) and unparsable headers are reported as manual. Applying is +idempotent: a file that already has a header, or already exists, is skipped. * **Flags:** `--policy FILE`, `--license SPDX-ID`, `--author NAME`, `--apply`, `--no-backup`, `--format human|json` @@ -81,6 +84,11 @@ 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. +* `--with-contents` — also include text-file contents (`file_contents`), + needed for content-aware policies. Off by default so the snapshot stays + lean; `plasma check`/`fix` enable it automatically when the policy uses a + `file-matches-pattern` condition. + === `plasma diff` Compare two fact snapshots produced by `plasma facts`. This is the @@ -118,8 +126,8 @@ proper `-`→`--` escaping. == Roadmap surfaces (not yet implemented) -* More `plasma fix` actions — CreateFile / UpdateField (beyond the v0.3 - AddSpdxHeader action) +* More `plasma fix` actions — `UpdateField` (beyond the v0.3 `AddSpdxHeader` + and v0.5 `CreateFile` actions) * 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 e697de1..e00126b 100644 --- a/docs/engine-v0-design.adoc +++ b/docs/engine-v0-design.adoc @@ -89,9 +89,10 @@ at **load**, never mid-evaluation (`plasma-engine/src/schema.rs`): replacement of an overridden rule is legal * `override-rules` targeting an id that names no base rule → `OverrideUnknownRule` +* a `file-matches-pattern` regex that does not compile → `InvalidRegex` + (so evaluation never compiles-and-fails mid-run) * reserved constructs: subject `release`, resource `release`, action - `consistent-with`, condition `file-matches-pattern` (v0 facts carry no - file contents), overlay effect `modify-rules` (diff semantics + `consistent-with`, overlay effect `modify-rules` (diff semantics underspecified) Consequence: **any policy that loads, evaluates** — `evaluate` is total. @@ -131,6 +132,14 @@ reproduce identical facts: `[package]` table when present. * **Git**: `is_repo` and `head_ref` read directly from `.git/HEAD` — no subprocess, no clock. +* **File contents** (opt-in): `collect_opts(root, { contents: true })` also + reads each valid-UTF-8 file up to `MAX_CONTENT_BYTES` (1 MiB) into + `file_contents`. Binary and oversized files are simply absent. This layer + is **off by default** — `collect` and a plain `plasma facts` omit it, and + `file_contents` is skipped from serialized output when empty, so the + default snapshot is byte-identical to before (an additive wire change). + `plasma check`/`fix` turn it on automatically iff the policy uses + `file-matches-pattern`; `plasma facts --with-contents` forces it. * All collections are `BTreeMap`/`BTreeSet`: iteration order, and therefore finding order, is deterministic. The fact set contains **no timestamps**. @@ -161,6 +170,7 @@ reproduce identical facts: | `spdx-license-is e` | header of instance parses ∧ parse(header) = parse(e); if either side fails to parse, exact-text comparison | `governance-flag-set k v` | metadata[k] = v (absent key ⇒ false) | `version-at-least v` | numeric dotted comparison of metadata["version"] against v; absent or non-numeric ⇒ false +| `file-matches-pattern p re` | regex `re` matches file_contents[p]; ⇒ false when content for `p` was not collected (binary, oversized, or contents off). `re` compiles at load, so a bad regex is a load error, not a mid-run panic. | `not / all / any` | strict boolean composition |=== @@ -247,12 +257,13 @@ the same purity split as evaluation: | Action | Fires for | Effect | `AddSpdxHeader` | violated `obligation + present` on a `header` resource | prepend `SPDX-License-Identifier` + copyright comment using the file's comment prefix +| `CreateFile` | violated `obligation + present` on a `file`/`manifest` resource with a *concrete* path | create the missing file with obvious placeholder content (never overwrites an existing file) |=== -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. +Everything else is `manual`: a required file named by a *glob* (no single +filename to create), unparsable headers (need hand correction), prohibitions +(removal is not auto-applied). Future increments add `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`. @@ -260,8 +271,8 @@ panics and reports per-action `applied` / `skipped` / `errors`. == Roadmap interaction Reserved constructs (`release` subjects/resources, `consistent-with`, -`file-matches-pattern`, `modify-rules`/`override-rules`) parse today and -gain semantics in later minor versions alongside their fact sources -(release facts, content facts, decision registries). The schema version +`modify-rules`) parse today and gain semantics in later minor versions +alongside their fact sources (release facts, decision registries). The +schema version gates all of it: a policy using future semantics will not load on an engine that cannot honour it. diff --git a/docs/interchange-contracts.adoc b/docs/interchange-contracts.adoc index 218b2f0..9b1f566 100644 --- a/docs/interchange-contracts.adoc +++ b/docs/interchange-contracts.adoc @@ -47,6 +47,11 @@ deterministic view of repository state the evaluator sees. `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. +* `file_contents` — an **opt-in, additive** map (`plasma facts + --with-contents`, or auto when a policy uses `file-matches-pattern`). + Omitted from output when empty, so the default snapshot above is + byte-identical to before this field existed. Only valid-UTF-8 files up to + 1 MiB are included. **Verification protocol:** snapshot before an agent runs (`plasma facts . > before.json`), snapshot after, then apply contract 2. diff --git a/plasma-engine/Cargo.toml b/plasma-engine/Cargo.toml index f2e751c..33dbd7a 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.5.0" +version = "0.6.0" edition = "2021" authors = ["Jonathan D.A. Jewell "] description = "Deterministic, typed policy evaluation — policy AST, fact collection, and a pure evaluator producing findings" @@ -17,6 +17,7 @@ serde_json = "1" toml = "0.8" walkdir = "2" glob = "0.3" +regex = "1" thiserror = "2" [dev-dependencies] diff --git a/plasma-engine/src/action.rs b/plasma-engine/src/action.rs index e308e5d..c100c3b 100644 --- a/plasma-engine/src/action.rs +++ b/plasma-engine/src/action.rs @@ -46,6 +46,34 @@ pub enum Action { author: String, year: String, }, + /// Create a required file that is missing, with placeholder content the + /// user is expected to replace. + CreateFile { path: String, contents: String }, +} + +/// True when `path` contains glob metacharacters, so no single filename can +/// be derived from it. +fn is_glob(path: &str) -> bool { + path.contains(['*', '?', '[', ']', '{', '}']) +} + +/// Placeholder content for a newly created required file. Deliberately +/// obvious as a stub so it is not mistaken for finished content. +fn stub_for(path: &str, rule_id: &str) -> String { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + match ext { + "md" | "adoc" | "markdown" => format!( + "# TODO\n\nPlaceholder created by `plasma fix` to satisfy policy rule \ + `{rule_id}`. Replace this with real content.\n" + ), + _ => format!( + "Placeholder created by plasma fix to satisfy policy rule {rule_id}. \ + Replace this with real content.\n" + ), + } } /// An action bound to the finding it resolves. @@ -128,6 +156,17 @@ fn fix_for(rule: &Rule, subject: &str, ctx: &FixContext) -> Option { year: ctx.year.clone(), }) } + // A required file (at a concrete path) that is missing: create a + // placeholder. A glob path names no single file, so it stays manual. + (Modality::Obligation, ActionKind::Present, Resource::File { path }) + | (Modality::Obligation, ActionKind::Present, Resource::Manifest { path }) + if !is_glob(path) => + { + Some(Action::CreateFile { + path: path.clone(), + contents: stub_for(path, &rule.id), + }) + } _ => None, } } @@ -135,9 +174,11 @@ fn fix_for(rule: &Rule, subject: &str, ctx: &FixContext) -> Option { /// 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) { + // Only globs reach here now — concrete paths become CreateFile. (Modality::Obligation, ActionKind::Present, Resource::File { .. }) | (Modality::Obligation, ActionKind::Present, Resource::Manifest { .. }) => { - "creating a required file needs human-authored content".to_string() + "cannot create a required file from a glob pattern (specify a concrete path)" + .to_string() } (Modality::Obligation, ActionKind::Valid, Resource::Header) => { "an unparsable SPDX header must be corrected by hand".to_string() @@ -177,6 +218,7 @@ mod tests { .collect::>(), metadata: BTreeMap::new(), git: Default::default(), + file_contents: BTreeMap::new(), } } @@ -228,6 +270,39 @@ action = { type = "present" } ); } + #[test] + fn test_missing_concrete_file_becomes_create_action() { + let policy = load_policy_str( + r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } + +[[rules]] +id = "contributing-present" +modality = "obligation" +subject = { type = "repo" } +resource = { type = "file", path = "CONTRIBUTING.md" } +action = { type = "present" } +"#, + PolicyFormat::Toml, + ) + .unwrap(); + + let eval = evaluate(&policy, &facts(&["README.md"], &[])); + let p = plan(&policy, &eval, &ctx()); + assert!(p.manual.is_empty()); + assert_eq!(p.actions.len(), 1); + match &p.actions[0].action { + Action::CreateFile { path, contents } => { + assert_eq!(path, "CONTRIBUTING.md"); + assert!(contents.contains("contributing-present")); + assert!(contents.starts_with("# TODO")); + } + other => panic!("expected CreateFile, got {other:?}"), + } + } + #[test] fn test_missing_file_is_manual() { let policy = load_policy_str( diff --git a/plasma-engine/src/apply.rs b/plasma-engine/src/apply.rs index d5c9aab..284b003 100644 --- a/plasma-engine/src/apply.rs +++ b/plasma-engine/src/apply.rs @@ -63,12 +63,35 @@ pub fn apply(plan: &Plan, root: &Path, opts: &ApplyOptions) -> ApplyOutcome { .push((file.clone(), "already has an SPDX header".to_string())), Err(e) => outcome.errors.push((file.clone(), e)), }, + Action::CreateFile { path, contents } => match create_file(root, path, contents) { + Ok(true) => outcome.applied.push(path.clone()), + Ok(false) => outcome + .skipped + .push((path.clone(), "file already exists".to_string())), + Err(e) => outcome.errors.push((path.clone(), e)), + }, } } outcome } +/// Create `path` with the given contents. Returns Ok(true) when created, +/// Ok(false) when the file already exists (idempotent skip). Never +/// overwrites an existing file, so no backup is needed. +fn create_file(root: &Path, path: &str, contents: &str) -> Result { + let full = root.join(path); + if full.exists() { + return Ok(false); + } + if let Some(parent) = full.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("cannot create directories for {path}: {e}"))?; + } + fs::write(&full, contents).map_err(|e| format!("cannot create {path}: {e}"))?; + Ok(true) +} + /// 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( @@ -188,6 +211,36 @@ mod tests { assert!(written.starts_with("# SPDX-License-Identifier: MPL-2.0\n")); } + #[test] + fn test_create_file_action() { + let dir = tempfile::tempdir().unwrap(); + let plan = Plan { + actions: vec![PlannedAction { + rule_id: "sec".to_string(), + subject: "repo".to_string(), + action: Action::CreateFile { + path: "docs/SECURITY.md".to_string(), + contents: "# TODO\n".to_string(), + }, + }], + manual: Vec::new(), + }; + + // Creates the file (and its parent dir). + let outcome = apply(&plan, dir.path(), &ApplyOptions::default()); + assert_eq!(outcome.applied, vec!["docs/SECURITY.md".to_string()]); + assert_eq!( + fs::read_to_string(dir.path().join("docs/SECURITY.md")).unwrap(), + "# TODO\n" + ); + + // Idempotent: a second apply skips the now-existing file and never + // overwrites it. + let outcome = apply(&plan, dir.path(), &ApplyOptions::default()); + assert!(outcome.applied.is_empty()); + assert_eq!(outcome.skipped.len(), 1); + } + #[test] fn test_apply_missing_file_errors() { let dir = tempfile::tempdir().unwrap(); diff --git a/plasma-engine/src/ast.rs b/plasma-engine/src/ast.rs index f0b27cc..9d10099 100644 --- a/plasma-engine/src/ast.rs +++ b/plasma-engine/src/ast.rs @@ -71,8 +71,9 @@ pub enum Condition { Any { of: Vec }, /// At least one repository file matches the glob. RepoHasFile { path: String }, - /// Content predicate. Schema-reserved: rejected by the v0 loader - /// (v0 facts carry no file contents). + /// The file at `path` has content matching the regex `pattern`. False + /// when the file's content was not collected (binary, oversized, or + /// collected without contents). The regex is validated at load. FileMatchesPattern { path: String, pattern: String }, /// The subject file has an SPDX header (false for non-file subjects). HasSpdxHeader, @@ -160,9 +161,11 @@ pub struct Rule { pub enum OverlayEffect { /// Add rules to the effective rule set. AddRules { rules: Vec }, - /// Modify existing rules by id. Schema-reserved: rejected by the v0 loader. + /// Modify existing rules by id. Schema-reserved: rejected by the loader + /// (its diff semantics are underspecified). ModifyRules { ids: Vec }, - /// Override existing rules by id. Schema-reserved: rejected by the v0 loader. + /// Remove base rules with these ids from the effective set. An overlay + /// may pair this with `add-rules` reusing an id to replace a rule. OverrideRules { ids: Vec }, } @@ -188,3 +191,31 @@ pub struct Policy { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub overlays: Vec, } + +impl Policy { + /// Whether any rule uses a `file-matches-pattern` condition, so callers + /// know to collect file contents before evaluating. + pub fn needs_content(&self) -> bool { + let added = self + .overlays + .iter() + .flat_map(|o| &o.effects) + .flat_map(|e| match e { + OverlayEffect::AddRules { rules } => rules.as_slice(), + _ => &[], + }); + self.rules + .iter() + .chain(added) + .any(|r| condition_needs_content(&r.condition)) + } +} + +fn condition_needs_content(c: &Condition) -> bool { + match c { + Condition::FileMatchesPattern { .. } => true, + Condition::Not { of } => condition_needs_content(of), + Condition::All { of } | Condition::Any { of } => of.iter().any(condition_needs_content), + _ => false, + } +} diff --git a/plasma-engine/src/diff.rs b/plasma-engine/src/diff.rs index 2a9d380..d68f4e3 100644 --- a/plasma-engine/src/diff.rs +++ b/plasma-engine/src/diff.rs @@ -178,6 +178,7 @@ mod tests { .map(|(k, v)| (k.to_string(), v.to_string())) .collect::>(), git: Default::default(), + file_contents: BTreeMap::new(), } } diff --git a/plasma-engine/src/eval.rs b/plasma-engine/src/eval.rs index 5bdbfc9..5fc8bbc 100644 --- a/plasma-engine/src/eval.rs +++ b/plasma-engine/src/eval.rs @@ -167,8 +167,18 @@ fn eval_condition(condition: &Condition, instance: &Instance, facts: &FactSet) - .get("version") .and_then(|actual| compare_versions(actual, version)) .unwrap_or(false), - // Rejected at load; defined as false to keep evaluation total. - Condition::FileMatchesPattern { .. } => false, + // The regex was validated at load. False when the file's content + // was not collected (binary, oversized, or content facts absent) — + // a defined, total semantics documented in engine-v0-design.adoc. + Condition::FileMatchesPattern { path, pattern } => facts + .file_contents + .get(path) + .map(|content| { + regex::Regex::new(pattern) + .map(|re| re.is_match(content)) + .unwrap_or(false) + }) + .unwrap_or(false), } } @@ -376,6 +386,7 @@ mod tests { .collect::>(), metadata: BTreeMap::new(), git: Default::default(), + file_contents: BTreeMap::new(), } } @@ -661,6 +672,50 @@ action = { type = "present" } assert_eq!(eval.findings[0].source.as_deref(), Some("overlay:extra")); } + #[test] + fn test_file_matches_pattern() { + use std::collections::BTreeMap; + let p = policy( + r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } + +[[rules]] +id = "no-todo-in-shipped" +modality = "prohibition" +severity = "warning" +subject = { type = "repo" } +resource = { type = "file", path = "src/lib.rs" } +condition = { type = "file-matches-pattern", path = "src/lib.rs", pattern = "TODO" } +action = { type = "present" } +"#, + ); + + // Content present and matching → prohibition fires (violation). + let mut facts = facts_with(&["src/lib.rs"], &[]); + facts.file_contents.insert( + "src/lib.rs".to_string(), + "fn x() {} // TODO later\n".to_string(), + ); + let eval = evaluate(&p, &facts); + assert_eq!(eval.summary.warnings, 1); + + // Content present, no match → condition false → rule not applicable. + let mut clean = facts_with(&["src/lib.rs"], &[]); + clean + .file_contents + .insert("src/lib.rs".to_string(), "fn x() {}\n".to_string()); + assert_eq!(evaluate(&p, &clean).findings.len(), 0); + + // Content not collected → condition false → not applicable (total). + let no_content = FactSet { + file_contents: BTreeMap::new(), + ..facts_with(&["src/lib.rs"], &[]) + }; + assert_eq!(evaluate(&p, &no_content).findings.len(), 0); + } + #[test] fn test_determinism_identical_runs() { let p = crate::schema::builtin_repo_hygiene(); diff --git a/plasma-engine/src/facts.rs b/plasma-engine/src/facts.rs index 900bca6..094d171 100644 --- a/plasma-engine/src/facts.rs +++ b/plasma-engine/src/facts.rs @@ -33,6 +33,19 @@ pub struct GitFacts { pub head_ref: Option, } +/// Largest file whose contents are collected (bytes). Bigger text files +/// are treated as if absent from `file_contents`. +pub const MAX_CONTENT_BYTES: u64 = 1024 * 1024; + +/// Options controlling what a collection includes. +#[derive(Debug, Clone, Copy, Default)] +pub struct CollectOptions { + /// Also read text-file contents into `file_contents` (for + /// content-aware conditions like `file-matches-pattern`). Off by + /// default so the standard snapshot stays lean. + pub contents: bool, +} + /// A deterministic snapshot of repository state. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FactSet { @@ -45,6 +58,13 @@ pub struct FactSet { /// Governance metadata: v0 collects `version` from Cargo.toml when present. pub metadata: BTreeMap, pub git: GitFacts, + /// Text-file contents, present only when collected with + /// `CollectOptions { contents: true }`. Omitted from serialized output + /// when empty, so the default snapshot is unchanged (an additive field + /// per docs/interchange-contracts.adoc). Only valid-UTF-8 files at most + /// [`MAX_CONTENT_BYTES`] are included. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub file_contents: BTreeMap, } /// Directory/file names skipped during the walk. Identical to the audit @@ -58,14 +78,20 @@ fn skip_entry(name: &str) -> bool { || name == "deps" } -/// Collect a [`FactSet`] from a repository tree. +/// Collect a lean [`FactSet`] from a repository tree (no file contents). pub fn collect(root: &Path) -> Result { + collect_opts(root, &CollectOptions::default()) +} + +/// Collect a [`FactSet`], optionally including text-file contents. +pub fn collect_opts(root: &Path, opts: &CollectOptions) -> Result { if !root.exists() { return Err(FactError::MissingRoot(root.display().to_string())); } let mut files = BTreeSet::new(); let mut spdx_headers = BTreeMap::new(); + let mut file_contents = BTreeMap::new(); for entry in walkdir::WalkDir::new(root) .into_iter() @@ -89,12 +115,24 @@ pub fn collect(root: &Path) -> Result { continue; } + // Read once when we need either the header or the content. let ext = rel.extension().and_then(|e| e.to_str()).unwrap_or(""); - if is_auditable(ext) { - let header = fs::read_to_string(entry.path()) - .ok() - .and_then(|content| extract_spdx_raw_from_content(&content)); - spdx_headers.insert(rel_str.clone(), header); + let auditable = is_auditable(ext); + let want_content = opts.contents + && entry.metadata().map(|m| m.len()).unwrap_or(u64::MAX) <= MAX_CONTENT_BYTES; + + if auditable || want_content { + if let Ok(content) = fs::read_to_string(entry.path()) { + if auditable { + spdx_headers.insert(rel_str.clone(), extract_spdx_raw_from_content(&content)); + } + if want_content { + file_contents.insert(rel_str.clone(), content); + } + } else if auditable { + // Unreadable/binary auditable file: header absent. + spdx_headers.insert(rel_str.clone(), None); + } } files.insert(rel_str); @@ -113,6 +151,7 @@ pub fn collect(root: &Path) -> Result { spdx_headers, metadata, git: read_git_facts(root), + file_contents, }) } @@ -193,4 +232,46 @@ mod tests { fn test_missing_root() { assert!(collect(Path::new("/nonexistent/nowhere")).is_err()); } + + #[test] + fn test_contents_opt_in_and_serialization() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("a.rs"), "fn main() {}\n").unwrap(); + + // Default collection: no contents, and the field is omitted from JSON. + let lean = collect(dir.path()).unwrap(); + assert!(lean.file_contents.is_empty()); + let json = serde_json::to_string(&lean).unwrap(); + assert!(!json.contains("file_contents")); + + // Opt-in: contents present. + let full = collect_opts(dir.path(), &CollectOptions { contents: true }).unwrap(); + assert_eq!( + full.file_contents.get("a.rs"), + Some(&"fn main() {}\n".to_string()) + ); + // Lean and full agree on everything except contents (round-trips as + // the same shape the evaluator sees). + assert_eq!(lean.files, full.files); + assert_eq!(lean.spdx_headers, full.spdx_headers); + } + + #[test] + fn test_oversized_content_skipped() { + let dir = tempfile::tempdir().unwrap(); + let big = "x".repeat((MAX_CONTENT_BYTES as usize) + 1); + fs::write(dir.path().join("big.txt"), &big).unwrap(); + fs::write(dir.path().join("small.txt"), "ok\n").unwrap(); + + let facts = collect_opts(dir.path(), &CollectOptions { contents: true }).unwrap(); + // Both files are listed… + assert!(facts.files.contains("big.txt")); + assert!(facts.files.contains("small.txt")); + // …but only the small one's content is captured. + assert!(!facts.file_contents.contains_key("big.txt")); + assert_eq!( + facts.file_contents.get("small.txt"), + Some(&"ok\n".to_string()) + ); + } } diff --git a/plasma-engine/src/lib.rs b/plasma-engine/src/lib.rs index d455d76..1f4d457 100644 --- a/plasma-engine/src/lib.rs +++ b/plasma-engine/src/lib.rs @@ -33,7 +33,7 @@ pub use ast::{ }; pub use diff::{diff, FactsDiff, HeaderChange, MetadataChange}; pub use eval::evaluate; -pub use facts::{collect, FactError, FactSet, GitFacts}; +pub use facts::{collect, collect_opts, CollectOptions, FactError, FactSet, GitFacts}; pub use finding::{Evaluation, Finding, FindingStatus, Severity, Summary}; pub use schema::{ builtin_repo_hygiene, load_policy, load_policy_str, PolicyFormat, SchemaError, SCHEMA_VERSION, diff --git a/plasma-engine/src/schema.rs b/plasma-engine/src/schema.rs index baf5014..586ba8d 100644 --- a/plasma-engine/src/schema.rs +++ b/plasma-engine/src/schema.rs @@ -51,6 +51,12 @@ pub enum SchemaError { DuplicateRuleId(String), #[error("overlay {overlay_id}: override-rules targets unknown base rule id {rule_id}")] OverrideUnknownRule { overlay_id: String, rule_id: String }, + #[error("rule {rule_id}: file-matches-pattern regex {pattern:?} does not compile: {source}")] + InvalidRegex { + rule_id: String, + pattern: String, + source: regex::Error, + }, #[error("unknown policy file extension {0:?}: use .toml or .json")] UnknownExtension(String), } @@ -190,11 +196,15 @@ fn validate_rule(rule: &Rule) -> Result<(), SchemaError> { fn validate_condition(condition: &Condition, rule_id: &str) -> Result<(), SchemaError> { match condition { - Condition::FileMatchesPattern { .. } => Err(SchemaError::ReservedConstruct { - rule_id: rule_id.to_string(), - construct: "condition file-matches-pattern (v0 facts carry no file contents)" - .to_string(), - }), + // The regex must compile at load, so evaluation stays total. + Condition::FileMatchesPattern { pattern, .. } => match regex::Regex::new(pattern) { + Ok(_) => Ok(()), + Err(source) => Err(SchemaError::InvalidRegex { + rule_id: rule_id.to_string(), + pattern: pattern.clone(), + source, + }), + }, Condition::Not { of } => validate_condition(of, rule_id), Condition::All { of } | Condition::Any { of } => { for c in of { @@ -335,6 +345,43 @@ action = { type = "present" } assert!(load_policy_str(doc, PolicyFormat::Toml).is_ok()); } + #[test] + fn test_invalid_regex_rejected_at_load() { + let doc = r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } + +[[rules]] +id = "bad-regex" +modality = "prohibition" +subject = { type = "repo" } +resource = { type = "file", path = "a.rs" } +condition = { type = "file-matches-pattern", path = "a.rs", pattern = "( unbalanced" } +action = { type = "present" } +"#; + let err = load_policy_str(doc, PolicyFormat::Toml).unwrap_err(); + assert!(matches!(err, SchemaError::InvalidRegex { .. })); + } + + #[test] + fn test_valid_file_matches_pattern_loads() { + let doc = r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } + +[[rules]] +id = "ok-regex" +modality = "prohibition" +subject = { type = "repo" } +resource = { type = "file", path = "a.rs" } +condition = { type = "file-matches-pattern", path = "a.rs", pattern = "TODO|FIXME" } +action = { type = "present" } +"#; + assert!(load_policy_str(doc, PolicyFormat::Toml).is_ok()); + } + #[test] fn test_modify_rules_still_reserved() { let doc = r#" diff --git a/plasma-parser/Cargo.toml b/plasma-parser/Cargo.toml index ae10f4f..33c026b 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.5.0" +version = "0.6.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/check.rs b/src/check.rs index 5dabab6..6e44d1a 100644 --- a/src/check.rs +++ b/src/check.rs @@ -4,7 +4,9 @@ // plasma check — evaluate a repository against a policy. use anyhow::{Context, Result}; -use plasma_engine::{builtin_repo_hygiene, collect, evaluate, load_policy, report, Severity}; +use plasma_engine::{ + builtin_repo_hygiene, collect_opts, evaluate, load_policy, report, CollectOptions, Severity, +}; use std::path::Path; /// Exit code for a check with violations at or above the threshold. @@ -27,8 +29,15 @@ pub fn run(opts: &CheckOptions) -> Result { None => builtin_repo_hygiene(), }; - let facts = collect(Path::new(opts.path)) - .with_context(|| format!("failed to collect facts from {}", opts.path))?; + // Collect file contents only when the policy actually needs them + // (a `file-matches-pattern` condition), keeping the common case lean. + let facts = collect_opts( + Path::new(opts.path), + &CollectOptions { + contents: policy.needs_content(), + }, + ) + .with_context(|| format!("failed to collect facts from {}", opts.path))?; let evaluation = evaluate(&policy, &facts); diff --git a/src/facts_cmd.rs b/src/facts_cmd.rs index 81bc89b..c9810e2 100644 --- a/src/facts_cmd.rs +++ b/src/facts_cmd.rs @@ -6,12 +6,17 @@ // tooling can diff before/after an agent run. use anyhow::{Context, Result}; -use plasma_engine::collect; +use plasma_engine::{collect_opts, CollectOptions}; use std::path::Path; -pub fn run(path: &str) -> Result<()> { - let facts = - collect(Path::new(path)).with_context(|| format!("failed to collect facts from {path}"))?; +pub fn run(path: &str, with_contents: bool) -> Result<()> { + let facts = collect_opts( + Path::new(path), + &CollectOptions { + contents: with_contents, + }, + ) + .with_context(|| format!("failed to collect facts from {path}"))?; println!("{}", serde_json::to_string_pretty(&facts)?); Ok(()) } diff --git a/src/fix.rs b/src/fix.rs index 87c1e83..620380e 100644 --- a/src/fix.rs +++ b/src/fix.rs @@ -8,8 +8,8 @@ use anyhow::{Context, Result}; use plasma_engine::{ - apply, builtin_repo_hygiene, collect, evaluate, load_policy, plan, Action, ApplyOptions, - FixContext, + apply, builtin_repo_hygiene, collect_opts, evaluate, load_policy, plan, Action, ApplyOptions, + CollectOptions, FixContext, }; use std::path::Path; @@ -34,8 +34,13 @@ pub fn run(opts: &FixOptions) -> Result { None => builtin_repo_hygiene(), }; - let facts = collect(Path::new(opts.path)) - .with_context(|| format!("failed to collect facts from {}", opts.path))?; + let facts = collect_opts( + Path::new(opts.path), + &CollectOptions { + contents: policy.needs_content(), + }, + ) + .with_context(|| format!("failed to collect facts from {}", opts.path))?; let evaluation = evaluate(&policy, &facts); let ctx = FixContext { @@ -136,6 +141,9 @@ fn describe_action(action: &Action) -> String { Action::AddSpdxHeader { file, license, .. } => { format!("add SPDX header ({license}) to {file}") } + Action::CreateFile { path, .. } => { + format!("create {path} (placeholder content)") + } } } diff --git a/src/main.rs b/src/main.rs index e5619ca..2f163ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,6 +87,9 @@ enum Commands { /// Path to the repository root #[arg(default_value = ".")] path: String, + /// Also include text-file contents (for content-aware policies) + #[arg(long)] + with_contents: bool, }, /// Compare two fact snapshots (exit 0 identical, 1 differ). @@ -219,8 +222,11 @@ fn run(cli: Cli) -> Result<()> { std::process::exit(code); } } - Commands::Facts { path } => { - facts_cmd::run(&path)?; + Commands::Facts { + path, + with_contents, + } => { + facts_cmd::run(&path, with_contents)?; } Commands::Diff { before,