diff --git a/Cargo.lock b/Cargo.lock index ea7ffc5..c48e78b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -316,6 +316,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "dissimilar" version = "1.0.11" @@ -832,6 +838,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1370,6 +1386,16 @@ dependencies = [ "spar-hir-def", ] +[[package]] +name = "spar-variants" +version = "0.6.0" +dependencies = [ + "pretty_assertions", + "serde", + "serde_json", + "spar-hir-def", +] + [[package]] name = "spar-verify" version = "0.6.0" @@ -1911,6 +1937,12 @@ dependencies = [ "wasmparser 0.245.1", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Cargo.toml b/Cargo.toml index 052b351..ef5ce53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/spar-analysis", "crates/spar-network", "crates/spar-transform", + "crates/spar-variants", "crates/spar-cli", "crates/spar-codegen", "crates/spar-render", @@ -39,6 +40,7 @@ spar-render = { path = "crates/spar-render" } spar-solver = { path = "crates/spar-solver" } spar-sysml2 = { path = "crates/spar-sysml2" } spar-transform = { path = "crates/spar-transform" } +spar-variants = { path = "crates/spar-variants" } spar-codegen = { path = "crates/spar-codegen" } rowan = "0.16" salsa = "0.26" diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index 8445fa5..6e58593 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -1186,4 +1186,22 @@ artifacts: status: planned tags: [network, wctt, v080] + # ── Track B: rivet ↔ spar variant binding (v0.7.x) ───────────────── + + - id: REQ-VARIANT-001 + type: requirement + title: Consume rivet's variant context blob (v1) and filter HIR + description: > + System shall consume rivet's variant context blob (v1 contract per + docs/contracts/rivet-spar-variant-v1.md) and filter HIR items by + intersection-semantics binding rules. The reader strictly accepts + rivet_spar_context_version "1" only — v2+ blobs are refused per + the contract's compatibility section. File-scoped (artifact) and + symbol-scoped bindings are honoured; an item is kept iff every + matching binding has its requires-set satisfied by the variant's + active features. Items with no matching binding are kept + unconditionally as variant-independent infrastructure. + status: planned + tags: [variants, track-b, v07x] + # Research findings tracked separately in research/findings.yaml diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index 42e84ae..689c144 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -1502,3 +1502,33 @@ artifacts: links: - type: satisfies target: REQ-NETWORK-005 + + - id: TEST-VARIANTS-CONSUMER + type: feature + title: spar-variants consumer-side unit tests + description: > + Unit tests in crates/spar-variants/src/{context,binding,filter}.rs + that exercise the v1 contract reader end-to-end on the consumer + side. Covers: round-trip of a minimal v1 blob; strict rejection of + any rivet_spar_context_version other than "1"; deserialization of + both artifact- and symbol-scoped bindings (untagged enum); the + §"Example" blob from docs/contracts/rivet-spar-variant-v1.md + parses and populates every field; artifact-binding match on exact + and dot-slash-normalized paths plus non-match on a sibling path or + missing item path; symbol-binding match on self and on textually + nested FQNs (boundary-aware so DieselV2 is not matched by Diesel); + keep_in_variant intersection semantics (one unsatisfied requires + drops the item even if other bindings are satisfied); + keep_in_variant treats items with no matching binding as + variant-independent infrastructure (kept unconditionally); + keep_in_variant accepts an empty requires list as vacuously + satisfied. + fields: + method: automated-test + steps: + - run: cargo test -p spar-variants + status: passing + tags: [variants, track-b, v07x] + links: + - type: satisfies + target: REQ-VARIANT-001 diff --git a/crates/spar-variants/Cargo.toml b/crates/spar-variants/Cargo.toml new file mode 100644 index 0000000..86e150a --- /dev/null +++ b/crates/spar-variants/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "spar-variants" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Consumer-side adapter for rivet's variant context blob (v1) — filters spar HIR by binding rules" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +spar-hir-def.workspace = true + +[dev-dependencies] +pretty_assertions = "1" diff --git a/crates/spar-variants/src/binding.rs b/crates/spar-variants/src/binding.rs new file mode 100644 index 0000000..c469c88 --- /dev/null +++ b/crates/spar-variants/src/binding.rs @@ -0,0 +1,212 @@ +//! Binding match logic. +//! +//! Implements the "matches the item" half of the contract's +//! §"Binding resolution semantics" — given a HIR item described via +//! [`HasBindingIdentity`] and a [`Binding`], decide whether the binding +//! applies. The "kept iff requires ⊆ features for every match" half +//! lives in [`crate::filter`]. + +use crate::context::Binding; + +/// Identity adapter that HIR items implement to participate in variant +/// filtering. +/// +/// We keep this trait deliberately narrow — just enough to evaluate the +/// two binding shapes — so the bridge from spar-hir-def's +/// `ComponentInstance` (and friends) is a thin, mechanical mapping +/// rather than a deep dependency. The actual `impl HasBindingIdentity` +/// for spar-hir-def types lives outside this commit; tests use a local +/// stub. +pub trait HasBindingIdentity { + /// Project-relative source-file path the item was declared in, or + /// `None` if the item is synthetic (no source file). Returning + /// `None` makes [`Binding::Artifact`] always non-match. + fn artifact_path(&self) -> Option<&str>; + + /// Fully-qualified AADL name, shape `Package::Type` or + /// `Package::Type.Implementation` (for top-level classifiers) or a + /// dotted extension thereof for nested items + /// (`Package::Type.Impl.subcomponent`). `None` for items with no + /// resolvable FQN, in which case [`Binding::Symbol`] is always + /// non-match. + fn fully_qualified_symbol(&self) -> Option; +} + +/// Normalize an artifact-binding path or an item path to a canonical +/// form for comparison: drop leading `./`, fold any backslash separators +/// to forward-slash. We do **not** resolve `..` or absolutize — the +/// contract specifies "relative to the project root" on both sides, so +/// a textual normalization is sufficient. Future work: case-folding on +/// case-insensitive filesystems if the need arises. +fn normalize_path(p: &str) -> String { + let trimmed = p.strip_prefix("./").unwrap_or(p); + trimmed.replace('\\', "/") +} + +impl Binding { + /// True iff this binding's scope encompasses the given item, per + /// §"Binding resolution semantics" of the v1 contract. + /// + /// For [`Binding::Symbol`], this commit implements prefix-matching: + /// the binding matches if the item's FQN equals the symbol or + /// starts with `.` / `::`. That covers the + /// "subcomponents, connections, properties, modes, flow specs + /// textually nested inside its body" clause of the contract. + /// + /// TODO(track-b-commit-2+): the contract's §"Symbol granularity" + /// also notes inheritance is **orthogonal** to variant binding — + /// classifiers that `extends` a bound symbol must NOT inherit the + /// binding. Prefix-matching as implemented here cannot tell apart + /// "nested in body" from "named under" if a project ever uses + /// dot-separated FQNs that aren't true textual nesting (none today + /// in spar-hir-def, so this is safe for commit 1). The eventual + /// fix is to plumb a richer "contains" relation through the HIR + /// adapter rather than relying on string-shape. + pub fn matches(&self, item: &I) -> bool { + match self { + Binding::Artifact { artifact, .. } => match item.artifact_path() { + Some(item_path) => normalize_path(item_path) == normalize_path(artifact), + None => false, + }, + Binding::Symbol { symbol, .. } => match item.fully_qualified_symbol() { + Some(fqn) => symbol_matches(&fqn, symbol), + None => false, + }, + } + } + + /// The `requires` list, regardless of binding kind. + pub fn requires(&self) -> &[String] { + match self { + Binding::Artifact { requires, .. } | Binding::Symbol { requires, .. } => requires, + } + } +} + +/// Prefix-match an item FQN against a binding's symbol per the +/// "self-or-nested-in-body" rule. Boundary character must be `.` or +/// `::` so that `Engines::Engine.Diesel` does NOT match +/// `Engines::Engine.DieselV2`. +fn symbol_matches(item_fqn: &str, binding_symbol: &str) -> bool { + if item_fqn == binding_symbol { + return true; + } + let Some(rest) = item_fqn.strip_prefix(binding_symbol) else { + return false; + }; + rest.starts_with('.') || rest.starts_with("::") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test stub — the spar-hir-def adapter ships with the spar-cli + /// commit (Track B commit 2), per design. + struct StubItem { + path: Option, + fqn: Option, + } + + impl HasBindingIdentity for StubItem { + fn artifact_path(&self) -> Option<&str> { + self.path.as_deref() + } + + fn fully_qualified_symbol(&self) -> Option { + self.fqn.clone() + } + } + + fn artifact_binding(path: &str) -> Binding { + Binding::Artifact { + artifact: path.to_string(), + requires: vec![], + } + } + + fn symbol_binding(sym: &str) -> Binding { + Binding::Symbol { + symbol: sym.to_string(), + requires: vec![], + } + } + + #[test] + fn artifact_binding_matches_exact_path() { + let b = artifact_binding("spec/engines/diesel.aadl"); + let item = StubItem { + path: Some("spec/engines/diesel.aadl".to_string()), + fqn: None, + }; + assert!(b.matches(&item)); + } + + #[test] + fn artifact_binding_matches_after_dot_slash_normalization() { + // Defensive: emitter or HIR could carry `./` prefix on either + // side. Normalization should make both forms equivalent. + let b = artifact_binding("./spec/engines/diesel.aadl"); + let item = StubItem { + path: Some("spec/engines/diesel.aadl".to_string()), + fqn: None, + }; + assert!(b.matches(&item)); + } + + #[test] + fn artifact_binding_doesnt_match_other_path() { + let b = artifact_binding("spec/engines/diesel.aadl"); + let item = StubItem { + path: Some("spec/engines/electric.aadl".to_string()), + fqn: None, + }; + assert!(!b.matches(&item)); + } + + #[test] + fn artifact_binding_doesnt_match_when_item_has_no_path() { + let b = artifact_binding("spec/engines/diesel.aadl"); + let item = StubItem { + path: None, + fqn: Some("Engines::Engine.Diesel".to_string()), + }; + assert!(!b.matches(&item)); + } + + #[test] + fn symbol_binding_matches_self() { + let b = symbol_binding("Engines::Engine.Diesel"); + let item = StubItem { + path: None, + fqn: Some("Engines::Engine.Diesel".to_string()), + }; + assert!(b.matches(&item)); + } + + #[test] + fn symbol_binding_matches_nested() { + // A subcomponent textually nested inside Engine.Diesel — e.g. + // `cylinder1: device …` declared in the implementation body. + // The HIR adapter is responsible for assembling this dotted + // FQN; the binding matcher only sees strings. + let b = symbol_binding("Engines::Engine.Diesel"); + let item = StubItem { + path: None, + fqn: Some("Engines::Engine.Diesel.cylinder1".to_string()), + }; + assert!(b.matches(&item)); + } + + #[test] + fn symbol_binding_doesnt_match_sibling_with_shared_prefix() { + // Boundary check: `Diesel` MUST NOT match `DieselV2` just + // because the latter starts with the former. + let b = symbol_binding("Engines::Engine.Diesel"); + let item = StubItem { + path: None, + fqn: Some("Engines::Engine.DieselV2".to_string()), + }; + assert!(!b.matches(&item)); + } +} diff --git a/crates/spar-variants/src/context.rs b/crates/spar-variants/src/context.rs new file mode 100644 index 0000000..c8f6d6d --- /dev/null +++ b/crates/spar-variants/src/context.rs @@ -0,0 +1,258 @@ +//! Schema and parsing for the v1 variant context blob. +//! +//! Mirrors the JSON shape documented in §"The variant context blob" +//! of `docs/contracts/rivet-spar-variant-v1.md`. The reader is strict +//! on the contract version (see [`VariantContext::from_json`]) and +//! tolerant — per `serde` defaults — of unknown sibling fields, so v2 +//! emitters that add new optional fields without bumping semantics +//! still parse cleanly. Semantic-changing v2 emitters are required by +//! the contract to bump the version, which v1 readers refuse. + +use std::fmt; + +use serde::Deserialize; + +/// The current (and only) supported contract version. +/// +/// Per the contract, v1 readers MUST accept `"1"` and MAY reject other +/// values. spar takes the strict route: anything else returns +/// [`ContextError::UnknownVersion`]. +pub const SUPPORTED_VERSION: &str = "1"; + +/// One resolved variant context, deserialized from the rivet blob. +/// +/// See `docs/contracts/rivet-spar-variant-v1.md` §"Fields" for the +/// authoritative description of each member. +#[derive(Debug, Clone, Deserialize)] +pub struct VariantContext { + /// Contract version; MUST equal [`SUPPORTED_VERSION`] for v1 + /// readers. Stored as a `String` rather than parsed-and-discarded + /// so downstream diagnostics can echo it back. + pub rivet_spar_context_version: String, + /// Name of the resolved variant — matches a `variants/.yaml` + /// in rivet. + pub variant: String, + /// Flat list of feature names active in this variant. Order- and + /// duplicate-insensitive at the contract level; spar treats it as + /// a set. + pub features: Vec, + /// File- or symbol-scoped bindings. May be empty. + pub bindings: Vec, + /// Stable hash of the feature model that produced this resolution. + /// Salsa cache key for variant-aware queries. + pub feature_model_hash: String, + /// RFC 3339 timestamp of resolution. Audit-only. + pub resolved_at: String, + /// Emitter tool + version. Diagnostics-only. + pub generated_by: String, +} + +/// One binding entry. Either file-scoped (`artifact`) or symbol-scoped +/// (`symbol`), never both. Discriminated structurally by which key is +/// present in the JSON object — `serde(untagged)` performs the +/// disambiguation. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Binding { + /// File-scoped: applies to every item declared in the named source + /// file, after path normalization. + Artifact { + /// Project-relative path to the source file. + artifact: String, + /// Feature names that MUST all appear in + /// [`VariantContext::features`] for this binding to be + /// satisfied. + requires: Vec, + }, + /// Symbol-scoped: applies to the named AADL classifier and every + /// item textually nested inside its body. + Symbol { + /// Fully-qualified AADL name, shape `Package::Type` or + /// `Package::Type.Implementation`. + symbol: String, + /// Feature names that MUST all appear in + /// [`VariantContext::features`] for this binding to be + /// satisfied. + requires: Vec, + }, +} + +/// Error returned by [`VariantContext::from_json`]. +#[derive(Debug)] +pub enum ContextError { + /// `rivet_spar_context_version` was something other than `"1"`. + /// Per the contract, v1 readers refuse v2 blobs (and any other + /// value) — this is correct behaviour, not a bug. + UnknownVersion(String), + /// Underlying JSON parse failure. + JsonError(serde_json::Error), +} + +impl fmt::Display for ContextError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ContextError::UnknownVersion(v) => write!( + f, + "unsupported rivet_spar_context_version {v:?}; \ + this build of spar speaks v1 only \ + (see docs/contracts/rivet-spar-variant-v1.md)" + ), + ContextError::JsonError(err) => write!(f, "invalid variant context JSON: {err}"), + } + } +} + +impl std::error::Error for ContextError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ContextError::JsonError(err) => Some(err), + ContextError::UnknownVersion(_) => None, + } + } +} + +impl From for ContextError { + fn from(err: serde_json::Error) -> Self { + ContextError::JsonError(err) + } +} + +impl VariantContext { + /// Parse a JSON blob into a [`VariantContext`], rejecting any + /// `rivet_spar_context_version` other than [`SUPPORTED_VERSION`]. + pub fn from_json(s: &str) -> Result { + let ctx: VariantContext = serde_json::from_str(s)?; + if ctx.rivet_spar_context_version != SUPPORTED_VERSION { + return Err(ContextError::UnknownVersion(ctx.rivet_spar_context_version)); + } + Ok(ctx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn minimal_v1_blob() -> &'static str { + r#"{ + "rivet_spar_context_version": "1", + "variant": "diesel-eu5", + "features": ["engine_diesel"], + "bindings": [], + "feature_model_hash": "sha256:abc", + "resolved_at": "2026-04-23T12:00:00Z", + "generated_by": "rivet 0.3.x" + }"# + } + + #[test] + fn parse_minimal_v1_context() { + let ctx = VariantContext::from_json(minimal_v1_blob()).expect("parses"); + assert_eq!(ctx.rivet_spar_context_version, "1"); + assert_eq!(ctx.variant, "diesel-eu5"); + assert_eq!(ctx.features, vec!["engine_diesel".to_string()]); + assert!(ctx.bindings.is_empty()); + assert_eq!(ctx.feature_model_hash, "sha256:abc"); + assert_eq!(ctx.resolved_at, "2026-04-23T12:00:00Z"); + assert_eq!(ctx.generated_by, "rivet 0.3.x"); + } + + #[test] + fn reject_unknown_version() { + let blob = r#"{ + "rivet_spar_context_version": "2", + "variant": "x", + "features": [], + "bindings": [], + "feature_model_hash": "sha256:0", + "resolved_at": "2026-04-23T12:00:00Z", + "generated_by": "rivet 99" + }"#; + match VariantContext::from_json(blob) { + Err(ContextError::UnknownVersion(v)) => assert_eq!(v, "2"), + other => panic!("expected UnknownVersion(\"2\"), got {other:?}"), + } + } + + #[test] + fn parse_artifact_binding() { + let blob = r#"{ + "rivet_spar_context_version": "1", + "variant": "v", + "features": [], + "bindings": [ + { "artifact": "spec/engines/diesel.aadl", "requires": ["engine_diesel"] } + ], + "feature_model_hash": "sha256:0", + "resolved_at": "2026-04-23T12:00:00Z", + "generated_by": "rivet 0.3.x" + }"#; + let ctx = VariantContext::from_json(blob).expect("parses"); + assert_eq!(ctx.bindings.len(), 1); + match &ctx.bindings[0] { + Binding::Artifact { artifact, requires } => { + assert_eq!(artifact, "spec/engines/diesel.aadl"); + assert_eq!(requires, &vec!["engine_diesel".to_string()]); + } + other => panic!("expected Artifact, got {other:?}"), + } + } + + #[test] + fn parse_symbol_binding() { + let blob = r#"{ + "rivet_spar_context_version": "1", + "variant": "v", + "features": [], + "bindings": [ + { "symbol": "Engines::Engine.Diesel", "requires": ["engine_diesel"] } + ], + "feature_model_hash": "sha256:0", + "resolved_at": "2026-04-23T12:00:00Z", + "generated_by": "rivet 0.3.x" + }"#; + let ctx = VariantContext::from_json(blob).expect("parses"); + assert_eq!(ctx.bindings.len(), 1); + match &ctx.bindings[0] { + Binding::Symbol { symbol, requires } => { + assert_eq!(symbol, "Engines::Engine.Diesel"); + assert_eq!(requires, &vec!["engine_diesel".to_string()]); + } + other => panic!("expected Symbol, got {other:?}"), + } + } + + #[test] + fn parse_contract_example_blob() { + // This is the §"Example" blob from + // docs/contracts/rivet-spar-variant-v1.md, verbatim. Acts as a + // canary that the documented schema actually deserializes under + // the types we ship. + let blob = r#"{ + "rivet_spar_context_version": "1", + "variant": "diesel-eu5", + "features": [ + "engine_diesel", + "emissions_eu5", + "platform_zephyr_v3", + "target_cortex_m4" + ], + "bindings": [ + { "artifact": "spec/engines/diesel.aadl", "requires": ["engine_diesel"] }, + { "artifact": "spec/engines/electric.aadl", "requires": ["engine_electric"] }, + { "symbol": "Engines::Engine.Diesel", "requires": ["engine_diesel"] } + ], + "feature_model_hash": "sha256:abc123", + "resolved_at": "2026-04-23T12:00:00Z", + "generated_by": "rivet 0.3.x" + }"#; + let ctx = VariantContext::from_json(blob).expect("contract example parses"); + assert_eq!(ctx.variant, "diesel-eu5"); + assert_eq!(ctx.features.len(), 4); + assert_eq!(ctx.bindings.len(), 3); + assert!(matches!(ctx.bindings[0], Binding::Artifact { .. })); + assert!(matches!(ctx.bindings[1], Binding::Artifact { .. })); + assert!(matches!(ctx.bindings[2], Binding::Symbol { .. })); + } +} diff --git a/crates/spar-variants/src/filter.rs b/crates/spar-variants/src/filter.rs new file mode 100644 index 0000000..57fca1c --- /dev/null +++ b/crates/spar-variants/src/filter.rs @@ -0,0 +1,183 @@ +//! HIR-filter predicate. +//! +//! Implements the "kept iff requires ⊆ features for every matching +//! binding" rule from §"Binding resolution semantics" of +//! `docs/contracts/rivet-spar-variant-v1.md`. +//! +//! Per §"Why intersection, not union", v1 is intersection-only — +//! multiple bindings that match the same item are treated conjunctively. +//! The conservative choice: a stricter binding can only ever drop an +//! item, never reintroduce it. + +use std::collections::HashSet; + +use crate::binding::HasBindingIdentity; +use crate::context::VariantContext; + +/// Returns `true` if `item` should be kept under `context`. +/// +/// Algorithm: +/// +/// 1. Walk every binding in the context and collect the matchers (per +/// [`crate::binding::Binding::matches`]). +/// 2. If no binding matches, the item is variant-independent +/// infrastructure — keep it unconditionally. +/// 3. Otherwise, every matching binding's `requires` must be a subset +/// of `context.features`. A single unmet `requires` drops the item. +pub fn keep_in_variant(item: &I, context: &VariantContext) -> bool { + let features: HashSet<&str> = context.features.iter().map(String::as_str).collect(); + + let mut matched_any = false; + for binding in &context.bindings { + if !binding.matches(item) { + continue; + } + matched_any = true; + for required in binding.requires() { + if !features.contains(required.as_str()) { + return false; + } + } + } + + // Either no binding scoped this item (keep unconditionally) or all + // matching bindings had their requires satisfied (keep). + let _ = matched_any; + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::Binding; + + /// Test stub — see binding.rs for the rationale. + struct StubItem { + path: Option, + fqn: Option, + } + + impl HasBindingIdentity for StubItem { + fn artifact_path(&self) -> Option<&str> { + self.path.as_deref() + } + + fn fully_qualified_symbol(&self) -> Option { + self.fqn.clone() + } + } + + fn ctx(features: &[&str], bindings: Vec) -> VariantContext { + VariantContext { + rivet_spar_context_version: "1".to_string(), + variant: "test".to_string(), + features: features.iter().map(|s| s.to_string()).collect(), + bindings, + feature_model_hash: "sha256:test".to_string(), + resolved_at: "2026-04-23T12:00:00Z".to_string(), + generated_by: "spar-variants tests".to_string(), + } + } + + #[test] + fn keep_unbound_item_unconditional() { + // §"Binding resolution semantics" rule 4: zero matching + // bindings means "variant-independent infrastructure" — kept + // regardless of which features are or aren't active. + let context = ctx( + &[], + vec![Binding::Artifact { + artifact: "spec/other.aadl".to_string(), + requires: vec!["never_active".to_string()], + }], + ); + let item = StubItem { + path: Some("spec/infra.aadl".to_string()), + fqn: Some("Infra::Bus".to_string()), + }; + assert!(keep_in_variant(&item, &context)); + } + + #[test] + fn keep_intersection_semantics() { + // Two matching bindings: one's requires satisfied, the other's + // not. Conjunctive semantics → drop. This is the v1 + // intersection rule's whole reason for existing — see + // §"Why intersection, not union". + let context = ctx( + &["engine_diesel"], + vec![ + Binding::Artifact { + artifact: "spec/engines/diesel.aadl".to_string(), + requires: vec!["engine_diesel".to_string()], + }, + Binding::Symbol { + symbol: "Engines::Engine.Diesel".to_string(), + requires: vec!["emissions_eu5".to_string()], + }, + ], + ); + let item = StubItem { + path: Some("spec/engines/diesel.aadl".to_string()), + fqn: Some("Engines::Engine.Diesel".to_string()), + }; + assert!(!keep_in_variant(&item, &context)); + } + + #[test] + fn keep_when_all_matching_bindings_satisfied() { + let context = ctx( + &["engine_diesel", "emissions_eu5"], + vec![ + Binding::Artifact { + artifact: "spec/engines/diesel.aadl".to_string(), + requires: vec!["engine_diesel".to_string()], + }, + Binding::Symbol { + symbol: "Engines::Engine.Diesel".to_string(), + requires: vec!["emissions_eu5".to_string()], + }, + ], + ); + let item = StubItem { + path: Some("spec/engines/diesel.aadl".to_string()), + fqn: Some("Engines::Engine.Diesel".to_string()), + }; + assert!(keep_in_variant(&item, &context)); + } + + #[test] + fn drop_when_lone_binding_unsatisfied() { + let context = ctx( + &["engine_electric"], + vec![Binding::Artifact { + artifact: "spec/engines/diesel.aadl".to_string(), + requires: vec!["engine_diesel".to_string()], + }], + ); + let item = StubItem { + path: Some("spec/engines/diesel.aadl".to_string()), + fqn: None, + }; + assert!(!keep_in_variant(&item, &context)); + } + + #[test] + fn empty_requires_is_satisfied_vacuously() { + // Per the contract: `requires: []` means "no feature + // requirement — equivalent to no binding at all". So a binding + // that matches but requires nothing keeps the item. + let context = ctx( + &[], + vec![Binding::Artifact { + artifact: "spec/x.aadl".to_string(), + requires: vec![], + }], + ); + let item = StubItem { + path: Some("spec/x.aadl".to_string()), + fqn: None, + }; + assert!(keep_in_variant(&item, &context)); + } +} diff --git a/crates/spar-variants/src/lib.rs b/crates/spar-variants/src/lib.rs new file mode 100644 index 0000000..d063338 --- /dev/null +++ b/crates/spar-variants/src/lib.rs @@ -0,0 +1,51 @@ +//! Consumer-side adapter for rivet's variant context blob, v1 contract. +//! +//! This crate is the spar side of the rivet ↔ spar variant binding +//! contract documented in +//! [`docs/contracts/rivet-spar-variant-v1.md`](../../../docs/contracts/rivet-spar-variant-v1.md). +//! +//! Rivet owns the entire product-line model — feature model, constraints, +//! variant definitions, bindings, SAT resolution. spar consumes a +//! resolved JSON context blob (emitted by `rivet resolve --variant +//! --format spar-context-json`) and uses it to filter HIR items down to +//! those that are valid in the chosen variant. spar does **not** parse +//! rivet artifacts and does **not** solve feature constraints. +//! +//! # Crate boundary +//! +//! This is the lowest layer of the consumer side. It depends on +//! [`spar-hir-def`] only for the eventual `HasBindingIdentity` adapter +//! impls — but those adapters do **not** live in this commit; they will +//! ship alongside the spar-cli wiring (Track B commit 2). For now the +//! crate exposes the trait and the `keep_in_variant` predicate, and +//! tests use a local stub type. There is no CLI integration in this +//! commit. +//! +//! # Public API +//! +//! - [`VariantContext`] — deserialize a rivet v1 blob with +//! [`VariantContext::from_json`]. +//! - [`Binding`] — file- or symbol-scoped binding entry. +//! - [`HasBindingIdentity`] — trait HIR items implement to participate +//! in variant filtering. +//! - [`keep_in_variant`] — apply intersection-semantics rules to decide +//! whether an item is kept for a given context. +//! +//! # Versioning +//! +//! v1 readers strictly accept `rivet_spar_context_version == "1"`. Any +//! other value (including a future `"2"`) is rejected with +//! [`ContextError::UnknownVersion`], per the contract's "Compatibility +//! and versioning" section: a v2 reader breaking-change is announced via +//! the version bump, and a v1 reader refusing v2 is the correct +//! behaviour. + +#![forbid(unsafe_code)] + +pub mod binding; +pub mod context; +pub mod filter; + +pub use binding::HasBindingIdentity; +pub use context::{Binding, ContextError, VariantContext}; +pub use filter::keep_in_variant; diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 566b750..a4191b9 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -152,6 +152,10 @@ criteria = "safe-to-deploy" version = "0.2.4" criteria = "safe-to-run" +[[exemptions.diff]] +version = "0.1.13" +criteria = "safe-to-run" + [[exemptions.dissimilar]] version = "1.0.11" criteria = "safe-to-run" @@ -392,6 +396,10 @@ criteria = "safe-to-deploy" version = "0.2.21" criteria = "safe-to-run" +[[exemptions.pretty_assertions]] +version = "1.4.1" +criteria = "safe-to-run" + [[exemptions.prettyplease]] version = "0.2.37" criteria = "safe-to-deploy" @@ -724,6 +732,11 @@ criteria = "safe-to-run" version = "0.245.1" criteria = "safe-to-deploy" +[[exemptions.yansi]] +version = "1.0.1" +criteria = "safe-to-run" + [[exemptions.zmij]] version = "1.0.21" criteria = "safe-to-deploy" +