diff --git a/Cargo.lock b/Cargo.lock index 95b024f..9005cae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -227,7 +227,9 @@ dependencies = [ name = "workshop-rs-cli" version = "0.1.1" dependencies = [ + "serde", "serde_json", + "sha2", "workshop-rs", ] diff --git a/crates/workshop-rs-cli/Cargo.toml b/crates/workshop-rs-cli/Cargo.toml index abefe01..53c5b51 100644 --- a/crates/workshop-rs-cli/Cargo.toml +++ b/crates/workshop-rs-cli/Cargo.toml @@ -12,7 +12,9 @@ workspace = true [dependencies] workshop-rs = { path = "../workshop-rs", version = "0.1.1" } +serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" [dev-dependencies] serde_json = "1" diff --git a/crates/workshop-rs-cli/src/corpus.rs b/crates/workshop-rs-cli/src/corpus.rs new file mode 100644 index 0000000..6b5607a --- /dev/null +++ b/crates/workshop-rs-cli/src/corpus.rs @@ -0,0 +1,366 @@ +//! Offline execution for provenance-linked real-project evidence manifests. +//! +//! The manifest describes source and expectation provenance; this runner only +//! measures the bundled Workshop text with the canonical parser and WIR +//! validation. It never creates an expectation from the observed output. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::Path; + +use workshop_rs::catalog::{Catalog, CatalogIdentity, Locale}; +use workshop_rs::conformance::{ + Comparison, ConformanceReason, ConformanceResult, ConformanceStatus, Equivalence, Evidence, + EvidenceArtifact, EvidenceClass, ExpectationSource, FeatureId, ReasonCode, +}; +use workshop_rs::{parser, validate}; + +#[derive(Debug, Deserialize)] +struct CorpusManifest { + #[serde(rename = "schemaVersion")] + schema_version: u32, + id: String, + locale: String, + expectation: ExpectationSource, + cases: Vec, +} + +#[derive(Debug, Deserialize)] +struct CorpusCase { + id: String, + class: EvidenceClass, + fixture: String, + source: EvidenceArtifact, + features: Vec, + #[serde(rename = "expectedStatus")] + expected_status: ExpectedStatus, + #[serde(rename = "failureContains")] + failure_contains: Option, + #[serde(rename = "knownGap")] + known_gap: Option, + #[serde(rename = "derivedFrom")] + derived_from: Option, + expectation: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum ExpectedStatus { + Success, +} + +#[derive(Debug, Deserialize)] +struct KnownGap { + detail: String, + #[serde(rename = "trackingRef")] + tracking_ref: String, +} + +#[derive(Debug, Serialize)] +pub(crate) struct CorpusReport { + #[serde(rename = "schemaVersion")] + schema_version: u32, + manifest: String, + catalog: CatalogIdentity, + results: Vec, + summary: CorpusSummary, +} + +#[derive(Debug, Default, Serialize)] +struct CorpusSummary { + matched: usize, + unsupported: usize, + #[serde(rename = "known-gap")] + known_gap: usize, + #[serde(rename = "unexpected-regression")] + unexpected_regression: usize, + inconclusive: usize, +} + +impl CorpusSummary { + fn record(&mut self, status: ConformanceStatus) { + match status { + ConformanceStatus::Matched => self.matched += 1, + ConformanceStatus::Unsupported => self.unsupported += 1, + ConformanceStatus::KnownGap => self.known_gap += 1, + ConformanceStatus::UnexpectedRegression => self.unexpected_regression += 1, + ConformanceStatus::Inconclusive => self.inconclusive += 1, + } + } +} + +impl CorpusReport { + pub(crate) fn has_unexpected_regression(&self) -> bool { + self.summary.unexpected_regression != 0 + } + + pub(crate) fn human_summary(&self) -> String { + let mut output = format!("manifest {}\n", self.manifest); + for result in &self.results { + output.push_str(&format!("{}: {:?}\n", result.case_id, result.status)); + } + output.push_str(&format!( + "summary: matched={}, unsupported={}, known-gap={}, unexpected-regression={}, inconclusive={}\n", + self.summary.matched, + self.summary.unsupported, + self.summary.known_gap, + self.summary.unexpected_regression, + self.summary.inconclusive, + )); + output + } +} + +pub(crate) fn run(manifest_path: &Path) -> Result { + let manifest_text = std::fs::read_to_string(manifest_path) + .map_err(|error| format!("cannot read {}: {error}", manifest_path.display()))?; + let manifest: CorpusManifest = serde_json::from_str(&manifest_text) + .map_err(|error| format!("invalid manifest JSON: {error}"))?; + if manifest.schema_version != 1 { + return Err(format!( + "unsupported manifest schema version {}; expected 1", + manifest.schema_version + )); + } + if manifest.cases.is_empty() { + return Err("manifest must contain at least one case".to_string()); + } + + let catalog = Catalog::builtin().map_err(|error| format!("catalog: {error}"))?; + let catalog_identity = catalog.identity(); + let locale = Locale::new(&manifest.locale); + let manifest_dir = manifest_path + .parent() + .ok_or_else(|| "manifest has no parent directory".to_string())?; + let mut results = Vec::with_capacity(manifest.cases.len()); + let mut summary = CorpusSummary::default(); + + for case in manifest.cases { + let fixture_path = manifest_dir.join(&case.fixture); + let input = std::fs::read_to_string(&fixture_path).map_err(|error| { + format!( + "cannot read case {} at {}: {error}", + case.id, + fixture_path.display() + ) + })?; + validate_source_artifact(&case.id, &case.source, &input)?; + let expectation = case + .expectation + .clone() + .unwrap_or_else(|| manifest.expectation.clone()); + validate_expected_artifact(&case.id, &expectation.artifact)?; + let evidence = Evidence { + class: case.class, + fixture: case.source.clone(), + expectation: expectation.clone(), + catalog: catalog_identity.clone(), + locale: Some(locale.clone()), + client: None, + implementation: Some(workshop_rs::conformance::ImplementationIdentity { + name: "workshop-rs".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + revision: None, + artifact: None, + }), + }; + let result = execute_case(&case, &input, &catalog, &locale, evidence, expectation)?; + result + .validate_against(&catalog) + .map_err(|error| format!("case {} produced invalid #18 result: {error}", case.id))?; + summary.record(result.status); + results.push(result); + } + + Ok(CorpusReport { + schema_version: 1, + manifest: manifest.id, + catalog: catalog_identity, + results, + summary, + }) +} + +fn execute_case( + case: &CorpusCase, + input: &str, + catalog: &Catalog, + locale: &Locale, + evidence: Evidence, + expectation: ExpectationSource, +) -> Result { + let expected = expectation.artifact.clone(); + let parsed = parser::parse_with_context(input, catalog, locale, catalog); + match (case.expected_status, parsed) { + (ExpectedStatus::Success, Ok(program)) => { + if let Err(error) = program.validate() { + return Ok(non_match( + case, + evidence, + ConformanceStatus::UnexpectedRegression, + expected, + format!("WIR validation failed: {error}"), + ReasonCode::UnexpectedRegression, + None, + )); + } + if let Err(error) = validate::validate_canonical_ids(&program, catalog) { + return Ok(non_match( + case, + evidence, + ConformanceStatus::UnexpectedRegression, + expected, + format!("canonical identity validation failed: {error}"), + ReasonCode::UnexpectedRegression, + None, + )); + } + let observed = EvidenceArtifact { + name: "workshop-rs canonical WIR dump".to_string(), + revision: None, + path: Some(case.fixture.clone()), + sha256: Some(sha256(&program.dump())), + license: Some("MIT".to_string()), + }; + Ok(ConformanceResult { + schema_version: 1, + case_id: case.id.clone(), + features: case.features.clone(), + status: ConformanceStatus::Matched, + comparison: Comparison { + mode: Equivalence::Semantic, + expected: Some(expected), + observed: Some(observed), + normalizer: Some("parse-validate-canonical-wir-v1".to_string()), + }, + evidence, + reason: None, + }) + } + (ExpectedStatus::Success, Err(error)) => { + let detail = format!("offline parser observation: {error}"); + let declared_gap = case.known_gap.as_ref().filter(|_gap| { + case.failure_contains + .as_ref() + .is_some_and(|needle| error.to_string().contains(needle)) + }); + if let Some(gap) = declared_gap { + Ok(non_match( + case, + evidence, + ConformanceStatus::KnownGap, + expected, + detail, + ReasonCode::KnownGap, + Some((gap.detail.clone(), gap.tracking_ref.clone())), + )) + } else { + Ok(non_match( + case, + evidence, + ConformanceStatus::UnexpectedRegression, + expected, + detail, + ReasonCode::UnexpectedRegression, + case.derived_from.as_ref().map(|source| { + (format!("case is derived from {source}"), "#20".to_string()) + }), + )) + } + } + } +} + +fn non_match( + case: &CorpusCase, + evidence: Evidence, + status: ConformanceStatus, + expected: EvidenceArtifact, + detail: String, + code: ReasonCode, + gap: Option<(String, String)>, +) -> ConformanceResult { + let reason = gap.map(|(detail, tracking_ref)| ConformanceReason { + code, + detail, + tracking_ref: Some(tracking_ref), + }); + let reason = reason.or(Some(ConformanceReason { + code, + detail, + tracking_ref: None, + })); + ConformanceResult { + schema_version: 1, + case_id: case.id.clone(), + features: case.features.clone(), + status, + comparison: Comparison { + mode: Equivalence::Semantic, + expected: Some(expected), + observed: None, + normalizer: Some("parse-validate-canonical-wir-v1".to_string()), + }, + evidence, + reason, + } +} + +fn sha256(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + format!("{digest:x}") +} + +fn validate_source_artifact( + case_id: &str, + artifact: &EvidenceArtifact, + input: &str, +) -> Result<(), String> { + validate_expected_artifact(case_id, artifact)?; + let Some(expected_digest) = artifact.sha256.as_deref() else { + return Err(format!("case {case_id} source must pin a SHA-256 digest")); + }; + let actual_digest = sha256(input); + if expected_digest != actual_digest { + return Err(format!( + "case {case_id} source digest mismatch: manifest {expected_digest}, fixture {actual_digest}" + )); + } + Ok(()) +} + +fn validate_expected_artifact(case_id: &str, artifact: &EvidenceArtifact) -> Result<(), String> { + if artifact.name.trim().is_empty() { + return Err(format!( + "case {case_id} evidence artifact has no repository identity" + )); + } + if artifact.revision.as_deref().is_none_or(str::is_empty) { + return Err(format!( + "case {case_id} evidence artifact must pin a revision" + )); + } + if artifact.path.as_deref().is_none_or(str::is_empty) { + return Err(format!("case {case_id} evidence artifact must pin a path")); + } + let Some(digest) = artifact.sha256.as_deref() else { + return Err(format!( + "case {case_id} evidence artifact must pin a SHA-256 digest" + )); + }; + if digest.len() != 64 + || !digest + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err(format!( + "case {case_id} evidence artifact has an invalid SHA-256 digest" + )); + } + if artifact.license.as_deref().is_none_or(str::is_empty) { + return Err(format!( + "case {case_id} evidence artifact must record a license" + )); + } + Ok(()) +} diff --git a/crates/workshop-rs-cli/src/lib.rs b/crates/workshop-rs-cli/src/lib.rs index 6402462..c7c4520 100644 --- a/crates/workshop-rs-cli/src/lib.rs +++ b/crates/workshop-rs-cli/src/lib.rs @@ -15,6 +15,8 @@ use workshop_rs::detect; use workshop_rs::emitter::{self, EmitOptions}; use workshop_rs::parser; +mod corpus; + /// The default locale override for parsing when the input locale is not /// specified explicitly. const USAGE: &str = "\ @@ -41,6 +43,10 @@ commands: census [--json] Run the deterministic offline Workshop feature census. Unexpected regressions exit with status 1; known gaps remain visible. + corpus [--json] + Run an offline provenance-linked real-project corpus manifest and print + its #18 conformance report. Known gaps remain visible and do not count + as matches; unexpected regressions return exit code 1. "; pub fn run(args: Vec) -> i32 { @@ -57,6 +63,7 @@ pub fn run(args: Vec) -> i32 { "locales" => locales_command(rest), "version" => version_command(rest), "census" => census_command(rest), + "corpus" => corpus_command(rest), "help" | "--help" | "-h" => { print!("{USAGE}"); 0 @@ -405,6 +412,47 @@ fn census_command(args: Vec) -> i32 { } } +fn corpus_command(args: Vec) -> i32 { + let mut parser = ArgParser::new(args); + let mut manifest: Option = None; + let mut json = false; + loop { + match parser.next() { + None => break, + Some("--json") => json = true, + Some(value) if manifest.is_none() => manifest = Some(PathBuf::from(value)), + Some(value) => return usage_error(&format!("unexpected argument '{value}'")), + } + } + let Some(manifest) = manifest else { + return usage_error("corpus requires a manifest file"); + }; + match corpus::run(&manifest) { + Ok(report) => { + if json { + match serde_json::to_string_pretty(&report) { + Ok(text) => println!("{text}"), + Err(error) => { + eprintln!("workshop-rs-cli: cannot serialize corpus report: {error}"); + return 1; + } + } + } else { + print!("{}", report.human_summary()); + } + if report.has_unexpected_regression() { + 1 + } else { + 0 + } + } + Err(error) => { + eprintln!("workshop-rs-cli: corpus: {error}"); + 1 + } + } +} + fn usage_error(message: &str) -> i32 { eprintln!("workshop-rs-cli: {message}"); eprintln!("{USAGE}"); diff --git a/crates/workshop-rs-cli/tests/cli.rs b/crates/workshop-rs-cli/tests/cli.rs index e78ebd3..7ce96de 100644 --- a/crates/workshop-rs-cli/tests/cli.rs +++ b/crates/workshop-rs-cli/tests/cli.rs @@ -194,3 +194,41 @@ fn census_runs_with_machine_readable_results() { let output = run(&["census", "--bogus"]); assert_eq!(output.status.code(), Some(2)); } + +#[test] +fn corpus_runs_full_and_minimized_cases_with_visible_gap() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../workshop-rs/tests/fixtures/corpus/real-projects.json"); + let output = run(&["corpus", manifest.to_str().unwrap()]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("real-project/overpy-cake/full: KnownGap"), + "{stdout}" + ); + assert!( + stdout.contains("minimized-regression/overpy-cake-loop: Matched"), + "{stdout}" + ); + assert!( + stdout.contains("matched=1") && stdout.contains("known-gap=1"), + "{stdout}" + ); + + let output = run(&["corpus", manifest.to_str().unwrap(), "--json"]); + assert!(output.status.success()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid report"); + assert_eq!(report["results"].as_array().unwrap().len(), 2); + assert_eq!(report["summary"]["known-gap"], 1); +} + +#[test] +fn corpus_missing_manifest_fails_explicitly() { + let output = run(&["corpus", "/no/such/workshop-rs-manifest.json"]); + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("cannot read")); +} diff --git a/crates/workshop-rs/tests/fixtures/README.md b/crates/workshop-rs/tests/fixtures/README.md index a1cd3cf..d622759 100644 --- a/crates/workshop-rs/tests/fixtures/README.md +++ b/crates/workshop-rs/tests/fixtures/README.md @@ -15,13 +15,16 @@ core migration (Issue #2). | `corpus/expressions-values.ws` | wright `compatibility/fixtures/synthetic/expressions-values/oracle.json` `compile.workshop` | OverPy 9.7.10, en-US emission | | `corpus/preprocessing.ws` | wright `compatibility/fixtures/synthetic/preprocessing/oracle.json` `compile.workshop` | OverPy 9.7.10, en-US emission | | `corpus/receiver-calls.ws` | wright `compatibility/fixtures/synthetic/receiver-calls/oracle.json` `compile.workshop` | OverPy 9.7.10, en-US emission | -| `corpus/overpy-cake.ws` | wright `compatibility/fixtures/real-world/overpy-cake/oracle.json` `compile.workshop` | OverPy 9.7.10, en-US emission | +| `corpus/overpy-cake.ws` | migrated in workshop-rs `44c04a1166f3df9a495fc04c79e0ae2adc4542d2` from wright `compatibility/fixtures/real-world/overpy-cake/oracle.json` `compile.workshop` | OverPy 9.7.10, en-US emission | +| `corpus/minimized/overpy-cake-loop.ws` | minimized from `corpus/overpy-cake.ws`, retaining its variable/loop/array/random-value interaction | workshop-rs migration `44c04a1166f3df9a495fc04c79e0ae2adc4542d2`, linked to the pinned wright OverPy 9.7.10 oracle | | `settings/pixelart.settings.ws` | wright `compatibility/fixtures/real-world/overpy-pixelart/oracle.json` `compile.workshop` settings section | OverPy 9.7.10, en-US emission | | `settings/santa.settings.ws` | wright `compatibility/fixtures/real-world/overpy-santa/oracle.json` `compile.workshop` settings section | OverPy 9.7.10, en-US emission | | `settings/pixelart.zh-CN.settings.ws` | deterministic conversion of `settings/pixelart.settings.ws` through the reviewed PR #9 locale corpus | reviewed `zh-CN` mappings, no fallback | -The original oracle snapshots record their generator version, source revision, -and language `en-US` in the corresponding `oracle.json` files. +The original wright oracle snapshot is pinned in the executable manifest by +its wright revision and SHA-256. It records OverPy 9.7.10, the original +OverPy source revision, and language `en-US`; the oracle JSON is not +redistributed here. ## Extraction @@ -42,3 +45,16 @@ evidence; the external JSON evidence artifact is not redistributed. Tests read these files via `env!("CARGO_MANIFEST_DIR")`; do not edit them incidentally. A corpus change is a reviewed, evidenced change like any catalog data change and must be recorded here. + +## Real-project admission + +`corpus/real-projects.json` is the executable #20 manifest. It keeps the +complete `overpy-cake.ws` project-level case and the minimized loop case as +separate complementary layers. Each source tuple points to the immutable +workshop-rs migration artifact, while its expectation tuple points to the +immutable wright oracle artifact. The original external oracle JSON is not +redistributed. The runner validates fixture digests from local files and the +required pinned oracle digest fields; it never recomputes a historical +expectation from current content. +The offline runner records parse/WIR behavior as #18 conformance results and +never replaces the pinned expectation with current implementation output. diff --git a/crates/workshop-rs/tests/fixtures/corpus/minimized/overpy-cake-loop.ws b/crates/workshop-rs/tests/fixtures/corpus/minimized/overpy-cake-loop.ws new file mode 100644 index 0000000..5e52c19 --- /dev/null +++ b/crates/workshop-rs/tests/fixtures/corpus/minimized/overpy-cake-loop.ws @@ -0,0 +1,17 @@ +variables { + global: + 0: candlePos + 1: i2 +} + +rule ("cake-minimized-loop") { + event { + Ongoing - Global; + } + actions { + Set Global Variable(candlePos, Empty Array); + For Global Variable(i2, 0, 1, 1); + Modify Global Variable(candlePos, Append To Array, Vector(Random Real(-1, 1), 0, Random Real(-1, 1))); + End; + } +} diff --git a/crates/workshop-rs/tests/fixtures/corpus/real-projects.json b/crates/workshop-rs/tests/fixtures/corpus/real-projects.json new file mode 100644 index 0000000..fee120b --- /dev/null +++ b/crates/workshop-rs/tests/fixtures/corpus/real-projects.json @@ -0,0 +1,76 @@ +{ + "schemaVersion": 1, + "id": "workshop-rs-real-projects-v1", + "locale": "en-US", + "expectation": { + "basis": "pinned-external-oracle", + "artifact": { + "name": "wrightkit/wright (OverPy 9.7.10 oracle)", + "revision": "af955449d4476a26f8ef25c76868fb0ceb0bcd2d", + "path": "compatibility/fixtures/real-world/overpy-cake/oracle.json", + "sha256": "cc84203e6c5de8eb3ead332c621e06d3639db2491dcf798177def627ee27ee9b", + "license": "GPL-3.0-only" + }, + "trackingRef": "#20" + }, + "cases": [ + { + "id": "real-project/overpy-cake/full", + "class": "real-project", + "fixture": "overpy-cake.ws", + "source": { + "name": "wrightkit/workshop-rs", + "revision": "44c04a1166f3df9a495fc04c79e0ae2adc4542d2", + "path": "crates/workshop-rs/tests/fixtures/corpus/overpy-cake.ws", + "sha256": "0be12ad51ac3fd77224eb037b55325951ee1b7737f59c155706ff40c794fca14", + "license": "MIT" + }, + "features": [ + {"namespace": "wir", "kind": "variable", "name": "global"}, + {"namespace": "wir", "kind": "control-flow", "name": "for-global-variable"}, + {"namespace": "wir", "kind": "control-flow", "name": "while"}, + {"namespace": "catalog", "kind": "action", "name": "createBeamEffect"}, + {"namespace": "catalog", "kind": "action", "name": "playEffect"}, + {"namespace": "catalog", "kind": "value", "name": "countOf"} + ], + "expectedStatus": "success", + "failureContains": "ambiguous enum member 'Up'", + "knownGap": { + "detail": "the preserved full project contains OverPy's bare Up member, which is ambiguous across canonical enum domains without the original source context", + "trackingRef": "#20" + } + }, + { + "id": "minimized-regression/overpy-cake-loop", + "class": "minimized-regression", + "fixture": "minimized/overpy-cake-loop.ws", + "source": { + "name": "wrightkit/workshop-rs", + "revision": "44c04a1166f3df9a495fc04c79e0ae2adc4542d2", + "path": "crates/workshop-rs/tests/fixtures/corpus/minimized/overpy-cake-loop.ws", + "sha256": "78a0d9103f42393b5c442e33bde0374f6135fe919604a99f2c612c0718d9ace8", + "license": "MIT" + }, + "features": [ + {"namespace": "wir", "kind": "variable", "name": "global"}, + {"namespace": "wir", "kind": "control-flow", "name": "for-global-variable"}, + {"namespace": "catalog", "kind": "action", "name": "modifyGlobalVariable"}, + {"namespace": "catalog", "kind": "value", "name": "randomReal"}, + {"namespace": "catalog", "kind": "value", "name": "vector"} + ], + "expectedStatus": "success", + "derivedFrom": "real-project/overpy-cake/full", + "expectation": { + "basis": "preserved-regression", + "artifact": { + "name": "wrightkit/wright (OverPy 9.7.10 oracle)", + "revision": "af955449d4476a26f8ef25c76868fb0ceb0bcd2d", + "path": "compatibility/fixtures/real-world/overpy-cake/oracle.json", + "sha256": "cc84203e6c5de8eb3ead332c621e06d3639db2491dcf798177def627ee27ee9b", + "license": "GPL-3.0-only" + }, + "trackingRef": "#20" + } + } + ] +} diff --git a/docs/adr/0004-real-project-evidence.md b/docs/adr/0004-real-project-evidence.md new file mode 100644 index 0000000..ed73842 --- /dev/null +++ b/docs/adr/0004-real-project-evidence.md @@ -0,0 +1,36 @@ +# ADR-0004: Provenance-linked real-project and minimized evidence + +## Status + +Accepted for the offline real-project evidence lane of #10. + +## Decision + +`tests/fixtures/corpus/real-projects.json` is the reviewed manifest for the +first preserved real-project evidence case. It retains the complete +`overpy-cake.ws` project-level fixture and a separate minimized loop fixture +derived from it. Each case records its own immutable `repository/revision/path` +tuple and pinned SHA-256: the migrated Workshop fixture is owned by +`wrightkit/workshop-rs` at the migration commit, while the expectation points +to the immutable `wrightkit/wright` OverPy 9.7.10 oracle JSON. The external +oracle artifact is referenced but not redistributed. + +`workshop-rs-cli corpus [--json]` runs the manifest offline. It +reads the preserved Workshop source, parses and validates it through the +canonical catalog/WIR path, and emits structured #18 results. A parse or WIR +failure becomes an unexpected regression unless an explicit known-gap record +matches the observed diagnostic. Known gaps and unsupported states remain +visible and do not count as matched. The runner never creates an expectation +from the current implementation output. + +Full-project and minimized cases are complementary: the minimized case makes +the feature-level regression easy to diagnose, while the complete project +preserves cross-rule and integration interactions. + +## Admission rules + +Future cases must identify their source repository, immutable revision, source +path, pinned digest, license, expectation basis, and canonical feature IDs. A minimized case +must retain a `derivedFrom` link to its complete project case. Any changed +expected status or evidence source requires independent review; a green local +run alone is not grounds to rewrite an expectation.