From d7b7e61ec6f3991430f5c6d405dbec45e32c1625 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 22 May 2026 06:19:10 +0200 Subject: [PATCH 1/7] docs(artifacts): file REQ-083 (feature-model composition) + REQ-084 (Verus CI silent-failure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REQ-083 — multi-file feature models: each model file stays a standalone, independently-solvable unit; a feature-model-binding file mounts sub-models at parent features with explicit mapping + prefix namespacing (the externals: prefix:ID model). The v0.12.0 headline. REQ-084 — the Verus Proofs CI job's Nix install fails on the no-sudo self-hosted runner, so `Verify Verus specs` is skipped while the continue-on-error job stays green: a proof job verifying nothing. Proven on PR #311 run 26246893086. Refs: FEAT-135 --- artifacts/feature-model-composition.yaml | 93 ++++++++++++++++++++++++ artifacts/requirements.yaml | 50 +++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 artifacts/feature-model-composition.yaml diff --git a/artifacts/feature-model-composition.yaml b/artifacts/feature-model-composition.yaml new file mode 100644 index 00000000..cafdf95e --- /dev/null +++ b/artifacts/feature-model-composition.yaml @@ -0,0 +1,93 @@ +# Feature-model composition — multi-file feature models (2026-05-21) +# +# User-requested topic (2026-05-21): author a product line's feature +# tree across multiple files — the top-level model in one file, each +# sub-level (powertrain, ECU, ...) in its own file — and have a binding +# file relate them into one resolved tree. The motivating example: +# "build a car or a motorcycle; the powertrain runs on 4 wheels or 2; +# the ECU further down controls this." +# +# Rivet today: a feature model is strictly one file. `FeatureModel` +# (rivet-core/src/feature_model.rs:58) parses exactly `root`, `features`, +# `constraints`, `attribute-schema` — no `include`, `import`, `extends`. +# +# Design settled with the requester: each file stays a first-class, +# standalone feature model (already solvable per-file today); a new +# `feature-model-binding` file mounts sub-models at parent features with +# explicit mapping + prefix namespacing — the same by-reference model as +# `externals: prefix:ID`. See docs/pure-variants-comparison.md §6 (PV's +# Feature-Model / Family-Model split — the binding is the gluing layer). +# +# Targeted v0.12.0 (new capability — minor version, not a v0.11.1 patch). + +artifacts: + + - id: REQ-083 + type: requirement + title: "Feature models must compose across files — sub-models mounted at parent features via an explicit, prefixed binding" + status: draft + description: | + Rivet feature models are single-file: `FeatureModel` + (rivet-core/src/feature_model.rs:58) parses only `root`, + `features`, `constraints`, `attribute-schema` — there is no + `include`, `import`, or `extends`. A product line therefore + cannot be authored as a top-level tree plus per-level sub-trees + owned by different teams (e.g. vehicle -> powertrain -> ECU). + + Design (settled with the requester, 2026-05-21): + + - Each feature-model file remains a first-class, standalone + model. `rivet variant solve ` already resolves a + complete per-file model with its own root and constraints, + with no parent. The "run each subfeature on its own" property + is preserved, not newly built — sub-models need not always + break down from a top. + - A new `feature-model-binding` file (`kind: + feature-model-binding`) declares composition: which sub-model + file mounts at which parent feature. The mapping is EXPLICIT + (parent feature name -> sub-model file) and the mounted + sub-model's features are PREFIX-namespaced — mirroring the + `externals: prefix:ID` by-reference model — so one sub-model + is reusable under several parents without name collisions. + - On load, Rivet splices each sub-model's tree in at its mount + point and unions the constraint sets into ONE resolved tree + for the solver. The merged tree is what `solve` / `check` / + `explain` operate on; each file still validates independently. + - Parent constraints may reference prefixed child features, + e.g. `(implies car powertrain:four-wheel)`. + + This is the Feature-Model / Family-Model layering that + docs/pure-variants-comparison.md §6 describes: the binding layer + is the solution-space glue. Per the F2 silent-failure class, a + broken mount must fail loudly — never a silent skip. + + Acceptance: + - `rivet variant solve powertrain.feature.yaml` resolves the + sub-model standalone (own root + constraints, no parent), + exit 0. + - Given a `feature-model-binding` mounting + `powertrain.feature.yaml` at parent feature `powertrain` + with prefix `powertrain`, `rivet variant solve ` + yields a single merged tree and `rivet variant list + ` shows the child features namespaced + (`powertrain:four-wheel`). + - A parent constraint `(implies car powertrain:four-wheel)` is + enforced by the solver and surfaced by `rivet variant + explain`. + - The same sub-model mounted under two parents gets two + distinct prefixes; features do not collide. + - A mount whose target file is missing, or whose declared + prefix collides with another mount, is a hard ERROR with + non-zero exit — never a silent skip. + - Regression tests in rivet-core (`feature_model.rs`) and + rivet-cli (variant CLI integration). + tags: [variant, ple, feature-model, composition, multi-file] + fields: + priority: should + category: functional + baseline: v0.12.0-track + links: + - type: derives-from + target: REQ-042 + - type: traces-to + target: REQ-044 diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index d21ad605..fa0f72bd 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -1455,3 +1455,53 @@ artifacts: created-by: ai-assisted model: claude-opus-4-7 timestamp: 2026-04-22T20:29:48Z + + - id: REQ-084 + type: requirement + title: "Verus proof CI must fail loudly when its Nix install fails — a continue-on-error proof job that verifies nothing is a silent failure" + status: draft + description: | + The Verus Proofs CI job (.github/workflows/ci.yml) installs Nix + via DeterminateSystems/nix-installer-action, then runs + `bazel test //verus:rivet_specs_verify`. The job is + `continue-on-error: true` — intentionally soft-gated, because + Verus has an open SMT proof gap (ci.yml lines 555-558). + + On the self-hosted runner (`[self-hosted, linux, x64, lean-mem]`, + NoNewPrivileges=true, no sudo) the `nix-installer` binary + escalates via `sudo` and fails; the `Install Nix` step errors and + `Verify Verus specs` is then SKIPPED. Because the job is + `continue-on-error`, the workflow stays green. Net effect: the + Verus proof job has been verifying nothing while reporting no + blocking failure — the F2 silent-failure class. A proof job that + is not proving anything is worse than a red one. + + Confirmed 2026-05-21 on PR #311 run 26246893086: the Verus job + step sequence was `failure Install Nix` -> `skipped Verify Verus + specs`. + + Pinning the action + `determinate: false` (shipped in v0.11.1) + fixed the Rocq job on ubuntu-latest but cannot fix Verus: no + version of `nix-installer-action` installs Nix without root. The + durable fix is to run the Verus (and Rocq) Bazel/Nix jobs in a + rootless `nixos/nix` container — Nix pre-installed in the image, + no installer, no daemon, no sudo, no unpinned-action drift + surface. + + Acceptance: + - The Verus CI job's `Verify Verus specs` step actually + executes — assert it is not `skipped` in the job step list. + - Environment breakage (Nix/toolchain unavailable) makes the + job FAIL visibly, distinct from a soft-gated proof result: + `continue-on-error` must not be able to mask a job that + never ran the verifier. + - A completed Verus job's `verus-test-log` artifact contains a + real Verus solver result. + tags: [ci, verus, silent-failure, f2-family, nix] + fields: + priority: should + category: non-functional + baseline: v0.12.0-track + links: + - type: derives-from + target: FEAT-135 From a3a7dd6dc11dc6afb546da4955693df0561bf1d9 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 22 May 2026 06:43:20 +0200 Subject: [PATCH 2/7] feat(variant): multi-file feature model composition core (REQ-083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature models can now be composed across files. A new feature-model-binding file (`kind: feature-model-binding`) mounts standalone sub-model files at parent features under an explicit, unique prefix. `FeatureModel::load_composed` splices each sub-model into its parent — prefixing feature names, child refs, the root, and bare feature tokens in constraint strings — unions the constraint sets, and runs the normal construction + tree validation once over the merged result. Each model file remains independently solvable via `from_yaml` (refactored to share `from_yaml_struct`). A broken mount fails loudly: missing file, absent or `leaf` mount point, duplicate prefix, or cyclic composition each return an error rather than silently skipping (F2 silent-failure class). `is_symbol_cont` in the s-expr lexer now accepts `:`, so a namespaced feature reference (`prefix:feature`) lexes as a single symbol — required for cross-prefix constraints like `(implies car pwt:four-wheel)`. CLI wiring (`rivet variant` commands accepting a binding file) is a follow-up. Implements: REQ-083 --- rivet-core/src/feature_model.rs | 697 ++++++++++++++++++++++++++++++++ rivet-core/src/sexpr.rs | 6 +- 2 files changed, 701 insertions(+), 2 deletions(-) diff --git a/rivet-core/src/feature_model.rs b/rivet-core/src/feature_model.rs index bb081d5f..95ddd391 100644 --- a/rivet-core/src/feature_model.rs +++ b/rivet-core/src/feature_model.rs @@ -325,6 +325,192 @@ fn default_group() -> GroupType { GroupType::Leaf } +// ── Feature-model composition (REQ-083) ──────────────────────────────── + +/// On-disk YAML for a `feature-model-binding` file: declares how +/// standalone feature-model files compose into one resolved tree. +/// +/// Each `compose` entry names a parent model file and the sub-models +/// mounted onto its features. A sub-model may itself appear as another +/// entry's `parent`, giving arbitrary-depth composition. +#[derive(Debug, Deserialize)] +struct FeatureModelBindingYaml { + #[allow(dead_code)] + kind: Option, + #[serde(default)] + compose: Vec, +} + +/// One parent model plus the sub-models mounted onto its features. +#[derive(Debug, Deserialize)] +struct ComposeEntryYaml { + /// Path to the parent feature-model file, relative to the binding file. + parent: String, + /// Maps a feature name in the parent model (the mount point) to the + /// sub-model mounted there. + #[serde(default)] + mount: BTreeMap, +} + +/// A single mount: which sub-model file, under which prefix. +#[derive(Debug, Deserialize)] +struct MountYaml { + /// Path to the sub-model feature-model file, relative to the binding file. + model: String, + /// Prefix applied to every feature of the sub-model: the sub-model's + /// feature `f` becomes `prefix:f` in the composed tree, so one + /// sub-model can be mounted under several parents without collision. + prefix: String, +} + +/// Load `model_path` (relative to `base_dir`) as raw YAML, then recursively +/// splice in every sub-model the binding mounts onto it. `path` tracks the +/// current recursion chain so a cyclic composition fails loudly instead of +/// recursing forever. +fn load_and_splice( + model_path: &str, + base_dir: &std::path::Path, + entries: &BTreeMap<&str, &ComposeEntryYaml>, + path: &mut BTreeSet, +) -> Result { + if !path.insert(model_path.to_string()) { + return Err(Error::Schema(format!( + "feature-model-binding: composition cycle through model `{model_path}`" + ))); + } + let full = base_dir.join(model_path); + let src = std::fs::read_to_string(&full) + .map_err(|e| Error::Schema(format!("feature-model file `{}`: {e}", full.display())))?; + let mut raw: FeatureModelYaml = serde_yaml::from_str(&src) + .map_err(|e| Error::Schema(format!("feature-model file `{}`: {e}", full.display())))?; + + if let Some(entry) = entries.get(model_path) { + for (mount_point, mount) in &entry.mount { + let sub = load_and_splice(&mount.model, base_dir, entries, path)?; + let prefixed = prefix_model_yaml(sub, &mount.prefix); + splice_into(&mut raw, mount_point, prefixed, model_path)?; + } + } + path.remove(model_path); + Ok(raw) +} + +/// Splice a fully-composed, already-prefixed sub-model into `parent` at +/// the mount-point feature `mount_point`. +fn splice_into( + parent: &mut FeatureModelYaml, + mount_point: &str, + sub: FeatureModelYaml, + parent_path: &str, +) -> Result<(), Error> { + let mp = parent.features.get_mut(mount_point).ok_or_else(|| { + Error::Schema(format!( + "feature-model-binding: mount point `{mount_point}` is not a feature \ + in parent model `{parent_path}`" + )) + })?; + if mp.group == GroupType::Leaf { + return Err(Error::Schema(format!( + "feature-model-binding: mount point `{mount_point}` in `{parent_path}` is \ + `group: leaf`; declare it `group: mandatory` (or optional/alternative/or) \ + so the sub-model can attach" + ))); + } + mp.children.push(sub.root.clone()); + + for (name, feat) in sub.features { + if parent.features.contains_key(&name) { + return Err(Error::Schema(format!( + "feature-model-binding: composed feature `{name}` collides with an \ + existing feature — a mount prefix is not unique enough" + ))); + } + parent.features.insert(name, feat); + } + parent.constraints.extend(sub.constraints); + for (key, decl) in sub.attribute_schema { + if parent.attribute_schema.contains_key(&key) { + return Err(Error::Schema(format!( + "feature-model-binding: attribute-schema key `{key}` is declared by \ + more than one composed model" + ))); + } + parent.attribute_schema.insert(key, decl); + } + Ok(()) +} + +/// Prefix every feature of a sub-model with `prefix:` — feature-map keys, +/// child references, the root, and bare feature-name tokens inside +/// constraint strings. Attribute-schema keys are attribute names, not +/// feature names, and are left untouched. +fn prefix_model_yaml(raw: FeatureModelYaml, prefix: &str) -> FeatureModelYaml { + // Only names defined by THIS model are rewritten inside constraints. + let mut names: BTreeSet = raw.features.keys().cloned().collect(); + names.insert(raw.root.clone()); + let pfx = |n: &str| format!("{prefix}:{n}"); + + let features = raw + .features + .into_iter() + .map(|(name, mut fy)| { + fy.children = fy.children.iter().map(|c| pfx(c)).collect(); + (pfx(&name), fy) + }) + .collect(); + + let constraints = raw + .constraints + .iter() + .map(|c| prefix_constraint(c, prefix, &names)) + .collect(); + + FeatureModelYaml { + kind: raw.kind, + root: pfx(&raw.root), + features, + constraints, + attribute_schema: raw.attribute_schema, + } +} + +/// Rewrite every bare feature-name token in a constraint string to its +/// prefixed form. Tokens are maximal runs of non-whitespace, non-paren +/// characters; only tokens that exactly match a feature name of the +/// sub-model being prefixed are rewritten, so operators (`implies`, +/// `and`, ...) and parentheses pass through untouched. +fn prefix_constraint(src: &str, prefix: &str, names: &BTreeSet) -> String { + let mut out = String::new(); + let mut tok = String::new(); + for ch in src.chars() { + if ch.is_whitespace() || ch == '(' || ch == ')' { + flush_constraint_token(&mut tok, prefix, names, &mut out); + out.push(ch); + } else { + tok.push(ch); + } + } + flush_constraint_token(&mut tok, prefix, names, &mut out); + out +} + +fn flush_constraint_token( + tok: &mut String, + prefix: &str, + names: &BTreeSet, + out: &mut String, +) { + if tok.is_empty() { + return; + } + if names.contains(tok.as_str()) { + out.push_str(prefix); + out.push(':'); + } + out.push_str(tok); + tok.clear(); +} + /// Build an `AttributeTypeDecl` from the YAML shape, applying narrow /// validation. Errors include the attribute key and the offending field /// for downstream debuggability. @@ -590,7 +776,16 @@ impl FeatureModel { pub fn from_yaml(yaml: &str) -> Result { let raw: FeatureModelYaml = serde_yaml::from_str(yaml).map_err(|e| Error::Schema(format!("feature model: {e}")))?; + Self::from_yaml_struct(raw) + } + /// Build a `FeatureModel` from an already-parsed `FeatureModelYaml`. + /// + /// Shared by `from_yaml` (single file) and `load_composed` (multi-file + /// composition, REQ-083). Composition is performed on the raw YAML + /// structs, so this construction + tree-validation logic runs exactly + /// once over the merged result. + fn from_yaml_struct(raw: FeatureModelYaml) -> Result { let mut features = BTreeMap::new(); // First pass: create features without parent links. @@ -702,6 +897,95 @@ impl FeatureModel { Ok(model) } + /// Load a feature model composed from multiple files via a + /// `feature-model-binding` file (REQ-083). + /// + /// Every model file the binding references is itself a valid + /// standalone feature model — `load_composed` is purely additive over + /// `from_yaml`. Composition splices each mounted sub-model into its + /// parent under an explicit, unique prefix (`prefix:feature`), then + /// runs the normal construction + tree validation once over the + /// merged result. A broken mount (missing file, absent or `leaf` + /// mount point, duplicate prefix, cyclic composition) is a hard + /// error, never a silent skip. + pub fn load_composed(binding_path: &std::path::Path) -> Result { + let binding_src = std::fs::read_to_string(binding_path).map_err(|e| { + Error::Schema(format!( + "feature-model-binding `{}`: {e}", + binding_path.display() + )) + })?; + let binding: FeatureModelBindingYaml = serde_yaml::from_str(&binding_src).map_err(|e| { + Error::Schema(format!( + "feature-model-binding `{}`: {e}", + binding_path.display() + )) + })?; + if binding.compose.is_empty() { + return Err(Error::Schema( + "feature-model-binding: `compose:` is empty — nothing to compose".to_string(), + )); + } + let base_dir = binding_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + + // Index compose entries by parent path; reject a parent listed twice. + let mut entries: BTreeMap<&str, &ComposeEntryYaml> = BTreeMap::new(); + for entry in &binding.compose { + if entries.insert(entry.parent.as_str(), entry).is_some() { + return Err(Error::Schema(format!( + "feature-model-binding: model `{}` is listed as `parent:` more than once", + entry.parent + ))); + } + } + + // Every mount prefix must be unique across the whole composition. + let mut prefixes: BTreeSet<&str> = BTreeSet::new(); + let mut mounted: BTreeSet<&str> = BTreeSet::new(); + for entry in &binding.compose { + for mount in entry.mount.values() { + if !prefixes.insert(mount.prefix.as_str()) { + return Err(Error::Schema(format!( + "feature-model-binding: prefix `{}` is used by more than one mount \ + — prefixes must be unique", + mount.prefix + ))); + } + mounted.insert(mount.model.as_str()); + } + } + + // The root model is the one `parent:` never itself mounted. + let roots: Vec<&str> = entries + .keys() + .copied() + .filter(|p| !mounted.contains(p)) + .collect(); + let root_model = match roots.as_slice() { + [one] => *one, + [] => { + return Err(Error::Schema( + "feature-model-binding: every `parent:` is also mounted — \ + no root model (composition is cyclic)" + .to_string(), + )); + } + many => { + return Err(Error::Schema(format!( + "feature-model-binding: {} candidate root models are never mounted ({}) \ + — exactly one root expected", + many.len(), + many.join(", ") + ))); + } + }; + + let merged = load_and_splice(root_model, base_dir, &entries, &mut BTreeSet::new())?; + Self::from_yaml_struct(merged) + } + /// Validate the feature tree: no cycles, all children referenced exist, /// group types consistent with child counts. fn validate_tree(&self) -> Result<(), Error> { @@ -2057,4 +2341,417 @@ bindings: let msg = format!("{err}"); assert!(msg.contains("parsing variant config"), "got: {msg}"); } + + // ── Feature-model composition (REQ-083) ──────────────────────────── + + const SUB_POWERTRAIN: &str = r#" +kind: feature-model +root: powertrain +features: + powertrain: + group: alternative + children: [four-wheel, two-wheel] + four-wheel: { group: leaf } + two-wheel: { group: leaf } +"#; + + /// rivet: verifies REQ-083 + #[test] + fn compose_two_files_namespaces_submodel() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("powertrain.yaml"), SUB_POWERTRAIN).unwrap(); + std::fs::write( + dir.join("vehicle.yaml"), + r#" +kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [powertrain] + powertrain: + group: mandatory +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#" +kind: feature-model-binding +compose: + - parent: vehicle.yaml + mount: + powertrain: + model: powertrain.yaml + prefix: pwt +"#, + ) + .unwrap(); + + let model = FeatureModel::load_composed(&bpath).unwrap(); + assert_eq!(model.root, "vehicle"); + // Sub-model features are namespaced under the mount prefix. + assert!(model.features.contains_key("pwt:powertrain")); + assert!(model.features.contains_key("pwt:four-wheel")); + assert!(model.features.contains_key("pwt:two-wheel")); + // The mount-point feature gained the sub-model root as a child. + assert_eq!( + model.features["powertrain"].children, + vec!["pwt:powertrain".to_string()] + ); + // The sub-model file is also a valid standalone model. + let standalone = FeatureModel::from_yaml(SUB_POWERTRAIN).unwrap(); + assert_eq!(standalone.root, "powertrain"); + assert!(standalone.features.contains_key("four-wheel")); + } + + /// rivet: verifies REQ-083 + #[test] + fn compose_three_files_recursive() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write( + dir.join("ecu.yaml"), + r#" +kind: feature-model +root: ecu-control +features: + ecu-control: + group: or + children: [torque-vectoring, abs] + torque-vectoring: { group: leaf } + abs: { group: leaf } +"#, + ) + .unwrap(); + std::fs::write( + dir.join("powertrain.yaml"), + r#" +kind: feature-model +root: powertrain +features: + powertrain: + group: mandatory + children: [wheels, ecu-control] + wheels: { group: leaf } + ecu-control: + group: mandatory +"#, + ) + .unwrap(); + std::fs::write( + dir.join("vehicle.yaml"), + r#" +kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [powertrain] + powertrain: + group: mandatory +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#" +kind: feature-model-binding +compose: + - parent: vehicle.yaml + mount: + powertrain: + model: powertrain.yaml + prefix: pwt + - parent: powertrain.yaml + mount: + ecu-control: + model: ecu.yaml + prefix: ecu +"#, + ) + .unwrap(); + + let model = FeatureModel::load_composed(&bpath).unwrap(); + assert_eq!(model.root, "vehicle"); + assert!(model.features.contains_key("pwt:powertrain")); + assert!(model.features.contains_key("pwt:wheels")); + // The deepest sub-model is mounted into powertrain, which is + // itself mounted — so its features carry both prefixes, namespaced + // by the full mount path. + assert!(model.features.contains_key("pwt:ecu:ecu-control")); + assert!(model.features.contains_key("pwt:ecu:torque-vectoring")); + assert!(model.features.contains_key("pwt:ecu:abs")); + } + + /// rivet: verifies REQ-083 + #[test] + fn compose_parent_constraint_references_prefixed_child() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("sub.yaml"), SUB_POWERTRAIN).unwrap(); + std::fs::write( + dir.join("top.yaml"), + r#" +kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [body, powertrain] + body: + group: alternative + children: [car, motorcycle] + car: { group: leaf } + motorcycle: { group: leaf } + powertrain: + group: mandatory +constraints: + - (implies car pwt:four-wheel) +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#" +kind: feature-model-binding +compose: + - parent: top.yaml + mount: + powertrain: + model: sub.yaml + prefix: pwt +"#, + ) + .unwrap(); + + // The top model's constraint references a prefixed child feature; + // it must parse against the composed feature set. + let model = FeatureModel::load_composed(&bpath).unwrap(); + assert_eq!(model.constraints.len(), 1); + assert!(model.features.contains_key("pwt:four-wheel")); + } + + /// rivet: verifies REQ-083 + #[test] + fn compose_same_submodel_two_prefixes_no_collision() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write( + dir.join("axle.yaml"), + r#" +kind: feature-model +root: axle +features: + axle: + group: alternative + children: [driven, free] + driven: { group: leaf } + free: { group: leaf } +"#, + ) + .unwrap(); + std::fs::write( + dir.join("car.yaml"), + r#" +kind: feature-model +root: car +features: + car: + group: mandatory + children: [front-axle, rear-axle] + front-axle: + group: mandatory + rear-axle: + group: mandatory +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#" +kind: feature-model-binding +compose: + - parent: car.yaml + mount: + front-axle: + model: axle.yaml + prefix: front + rear-axle: + model: axle.yaml + prefix: rear +"#, + ) + .unwrap(); + + let model = FeatureModel::load_composed(&bpath).unwrap(); + assert!(model.features.contains_key("front:axle")); + assert!(model.features.contains_key("front:driven")); + assert!(model.features.contains_key("rear:axle")); + assert!(model.features.contains_key("rear:driven")); + } + + /// rivet: verifies REQ-083 + #[test] + fn compose_missing_model_file_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write( + dir.join("top.yaml"), + r#" +kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [powertrain] + powertrain: + group: mandatory +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#" +kind: feature-model-binding +compose: + - parent: top.yaml + mount: + powertrain: + model: nonexistent.yaml + prefix: pwt +"#, + ) + .unwrap(); + let err = FeatureModel::load_composed(&bpath).unwrap_err(); + assert!(format!("{err}").contains("nonexistent.yaml"), "got: {err}"); + } + + /// rivet: verifies REQ-083 + #[test] + fn compose_duplicate_prefix_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("sub.yaml"), SUB_POWERTRAIN).unwrap(); + std::fs::write( + dir.join("car.yaml"), + r#" +kind: feature-model +root: car +features: + car: + group: mandatory + children: [a, b] + a: + group: mandatory + b: + group: mandatory +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#" +kind: feature-model-binding +compose: + - parent: car.yaml + mount: + a: + model: sub.yaml + prefix: dup + b: + model: sub.yaml + prefix: dup +"#, + ) + .unwrap(); + let err = FeatureModel::load_composed(&bpath).unwrap_err(); + assert!(format!("{err}").contains("prefix"), "got: {err}"); + } + + /// rivet: verifies REQ-083 + #[test] + fn compose_unknown_mount_point_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("sub.yaml"), SUB_POWERTRAIN).unwrap(); + std::fs::write( + dir.join("top.yaml"), + r#" +kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [chassis] + chassis: + group: mandatory +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#" +kind: feature-model-binding +compose: + - parent: top.yaml + mount: + no-such-feature: + model: sub.yaml + prefix: pwt +"#, + ) + .unwrap(); + let err = FeatureModel::load_composed(&bpath).unwrap_err(); + assert!( + format!("{err}").contains("mount point") + && format!("{err}").contains("no-such-feature"), + "got: {err}" + ); + } + + /// rivet: verifies REQ-083 + #[test] + fn compose_leaf_mount_point_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("sub.yaml"), SUB_POWERTRAIN).unwrap(); + std::fs::write( + dir.join("top.yaml"), + r#" +kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [powertrain] + powertrain: { group: leaf } +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#" +kind: feature-model-binding +compose: + - parent: top.yaml + mount: + powertrain: + model: sub.yaml + prefix: pwt +"#, + ) + .unwrap(); + let err = FeatureModel::load_composed(&bpath).unwrap_err(); + assert!(format!("{err}").contains("leaf"), "got: {err}"); + } } diff --git a/rivet-core/src/sexpr.rs b/rivet-core/src/sexpr.rs index d15bfe07..8a5f7979 100644 --- a/rivet-core/src/sexpr.rs +++ b/rivet-core/src/sexpr.rs @@ -13,7 +13,9 @@ //! float = [+-]? [0-9]+ '.' [0-9]* //! bool = 'true' | 'false' //! wildcard = '_' -//! symbol = [a-zA-Z_!?] [a-zA-Z0-9_\-!?.*]* +//! symbol = [a-zA-Z_!?><=] [a-zA-Z0-9_\-!?.*><=:]* +//! (`:` lets a namespaced feature reference such as +//! `prefix:feature` lex as a single symbol — REQ-083) //! comment = ';' ... newline // SAFETY-REVIEW (SCRC Phase 1, DD-058): File-scope blanket allow for @@ -277,7 +279,7 @@ fn is_symbol_cont(b: u8) -> bool { b.is_ascii_alphanumeric() || matches!( b, - b'_' | b'-' | b'!' | b'?' | b'.' | b'*' | b'>' | b'<' | b'=' + b'_' | b'-' | b'!' | b'?' | b'.' | b'*' | b'>' | b'<' | b'=' | b':' ) } From 0f1b843c0dd8dee0cac1f9cc152144efc5d82585 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 22 May 2026 19:08:52 +0200 Subject: [PATCH 3/7] feat(variant): rivet variant accepts feature-model-binding files (REQ-083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes REQ-083: the CLI surface for multi-file feature model composition. `FeatureModel::load(path)` dispatches on the file's `kind:` — a `feature-model-binding` file is composed via `load_composed`, any other file is parsed as a single model via `from_yaml`. The nine `FeatureModel::from_yaml` call sites across the `rivet variant` and `rivet validate --model` paths now route through `load`, so every command transparently accepts a binding file wherever it accepted a plain model. Adds `rivet-cli/tests/variant_compose.rs` — integration tests driving the real binary: `variant list` / `solve` over a composition, and a broken mount failing loudly with a non-zero exit. Documents the `feature-model-binding` compose format in docs/feature-model-schema.md. Implements: REQ-083 --- docs/feature-model-schema.md | 46 +++++++ rivet-cli/src/main.rs | 36 ++---- rivet-cli/tests/variant_compose.rs | 195 +++++++++++++++++++++++++++++ rivet-core/src/feature_model.rs | 23 ++++ 4 files changed, 273 insertions(+), 27 deletions(-) create mode 100644 rivet-cli/tests/variant_compose.rs diff --git a/docs/feature-model-schema.md b/docs/feature-model-schema.md index 1610cfec..940162d7 100644 --- a/docs/feature-model-schema.md +++ b/docs/feature-model-schema.md @@ -159,6 +159,52 @@ and `allowed but unbound` features. Maps features to the artifacts and source files that implement them. See [feature-model-bindings.md](feature-model-bindings.md). +## 4. Composing models across files + +A large product line can be split across files: a top-level model in one +file, each sub-level (powertrain, ECU, …) in its own file owned by +whichever team owns that level. Every model file remains a valid, +independently-solvable feature model on its own — `rivet variant list +--model powertrain.yaml` works with no parent. + +A **`feature-model-binding`** file (REQ-083) declares how the files +compose: + +```yaml +kind: feature-model-binding +compose: + - parent: vehicle.yaml # path, relative to this binding file + mount: + powertrain: # a feature in the parent = the mount point + model: powertrain.yaml # the sub-model file + prefix: pwt # prefix for the sub-model's features + - parent: powertrain.yaml # a sub-model may itself be a parent + mount: + ecu-control: + model: ecu.yaml + prefix: ecu +``` + +On load, each mounted sub-model is spliced into its parent: the +sub-model's features are namespaced under the mount `prefix` +(`pwt:four-wheel`), its root becomes a child of the mount-point feature, +and the constraint sets are unioned into one resolved tree that +`solve` / `check` / `explain` / `list` all operate on. + +Rules: + +- The **mount point** must be an explicit feature in the parent model + with a non-`leaf` group (`mandatory`, `optional`, `alternative`, `or`) + so the sub-model can attach. +- Every mount **`prefix`** must be unique across the whole composition. +- A parent constraint may reference a prefixed child feature, e.g. + `(implies car pwt:four-wheel)`. +- A broken mount — missing file, unknown or `leaf` mount point, duplicate + prefix, cyclic composition — is a hard error, never a silent skip. + +Any `rivet variant` command accepts a binding file wherever it accepts a +plain model: `rivet variant list --model binding.yaml`. + ## CLI reference ```sh diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 34991c90..9b0a8331 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -4663,9 +4663,7 @@ fn cmd_validate( // nothing : ordinary project validation. let (store, graph, variant_scope_name) = match (model_path, variant_path, binding_path) { (Some(mp), Some(vp), Some(bp)) => { - let model_yaml = std::fs::read_to_string(mp) - .with_context(|| format!("reading feature model {}", mp.display()))?; - let fm = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml) + let fm = rivet_core::feature_model::FeatureModel::load(mp) .map_err(|e| anyhow::anyhow!("{e}"))?; let variant_yaml = std::fs::read_to_string(vp) @@ -4710,9 +4708,7 @@ fn cmd_validate( // Model + binding, no variant: validate model/binding consistency // without resolving a specific variant. Unknown feature names in // the binding file are reported as errors. - let model_yaml = std::fs::read_to_string(mp) - .with_context(|| format!("reading feature model {}", mp.display()))?; - let fm = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml) + let fm = rivet_core::feature_model::FeatureModel::load(mp) .map_err(|e| anyhow::anyhow!("{e}"))?; let binding_yaml = std::fs::read_to_string(bp) @@ -11415,9 +11411,7 @@ fn cmd_variant_check( ) -> Result { validate_format(format, &["text", "json"])?; - let model_yaml = std::fs::read_to_string(model_path) - .with_context(|| format!("reading {}", model_path.display()))?; - let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml) + let model = rivet_core::feature_model::FeatureModel::load(model_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let variant_yaml = std::fs::read_to_string(variant_path) @@ -11479,9 +11473,7 @@ fn cmd_variant_check_all( ) -> Result { validate_format(format, &["text", "json"])?; - let model_yaml = std::fs::read_to_string(model_path) - .with_context(|| format!("reading {}", model_path.display()))?; - let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml) + let model = rivet_core::feature_model::FeatureModel::load(model_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let binding_yaml = std::fs::read_to_string(binding_path) @@ -11557,9 +11549,7 @@ fn cmd_variant_check_all( fn cmd_variant_list(model_path: &std::path::Path, format: &str) -> Result { validate_format(format, &["text", "json"])?; - let model_yaml = std::fs::read_to_string(model_path) - .with_context(|| format!("reading {}", model_path.display()))?; - let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml) + let model = rivet_core::feature_model::FeatureModel::load(model_path) .map_err(|e| anyhow::anyhow!("{e}"))?; if format == "json" { @@ -11623,9 +11613,7 @@ fn cmd_variant_solve( ) -> Result { validate_format(format, &["text", "json"])?; - let model_yaml = std::fs::read_to_string(model_path) - .with_context(|| format!("reading {}", model_path.display()))?; - let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml) + let model = rivet_core::feature_model::FeatureModel::load(model_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let variant_yaml = std::fs::read_to_string(variant_path) @@ -11758,9 +11746,7 @@ fn load_and_solve_variant( rivet_core::feature_model::FeatureModel, rivet_core::feature_model::ResolvedVariant, )> { - let model_yaml = std::fs::read_to_string(model_path) - .with_context(|| format!("reading {}", model_path.display()))?; - let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml) + let model = rivet_core::feature_model::FeatureModel::load(model_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let variant_yaml = std::fs::read_to_string(variant_path) .with_context(|| format!("reading {}", variant_path.display()))?; @@ -12086,9 +12072,7 @@ fn cmd_variant_manifest( ) -> Result { validate_format(format, &["text", "json"])?; - let model_yaml = std::fs::read_to_string(model_path) - .with_context(|| format!("reading {}", model_path.display()))?; - let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml) + let model = rivet_core::feature_model::FeatureModel::load(model_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let variant_yaml = std::fs::read_to_string(variant_path) @@ -12172,9 +12156,7 @@ fn cmd_variant_matrix( other => anyhow::bail!("unknown --wrap `{other}`: expected `fragment` or `job`"), }; - let model_yaml = std::fs::read_to_string(model_path) - .with_context(|| format!("reading {}", model_path.display()))?; - let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml) + let model = rivet_core::feature_model::FeatureModel::load(model_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let binding_yaml = std::fs::read_to_string(binding_path) diff --git a/rivet-cli/tests/variant_compose.rs b/rivet-cli/tests/variant_compose.rs new file mode 100644 index 00000000..bf4c41ea --- /dev/null +++ b/rivet-cli/tests/variant_compose.rs @@ -0,0 +1,195 @@ +// SAFETY-REVIEW (SCRC Phase 1, DD-058): Integration test / bench code. +// Tests legitimately use unwrap/expect/panic/assert-indexing patterns +// because a test failure should panic with a clear stack. Blanket-allow +// the Phase 1 restriction lints at crate scope; real risk analysis for +// these lints is carried by production code in rivet-core/src and +// rivet-cli/src, not by the test harnesses. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::wildcard_enum_match_arm, + clippy::match_wildcard_for_single_variants, + clippy::panic, + clippy::todo, + clippy::unimplemented, + clippy::dbg_macro, + clippy::print_stdout, + clippy::print_stderr +)] + +//! Integration tests for REQ-083: `rivet variant` commands accept a +//! `feature-model-binding` file in place of a plain feature model and +//! transparently compose the multi-file tree. +//! +//! rivet: verifies REQ-083 + +use std::process::Command; + +fn rivet_bin() -> std::path::PathBuf { + if let Ok(bin) = std::env::var("CARGO_BIN_EXE_rivet") { + return std::path::PathBuf::from(bin); + } + let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest.parent().expect("workspace root"); + workspace_root.join("target").join("debug").join("rivet") +} + +/// Write a two-file composition (vehicle + powertrain sub-model) and a +/// binding that mounts powertrain under the `pwt` prefix. Returns the +/// temp dir (kept alive by the caller) and the binding-file path. +fn write_composition() -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_path_buf(); + + std::fs::write( + dir.join("powertrain.yaml"), + r#"kind: feature-model +root: powertrain +features: + powertrain: + group: alternative + children: [four-wheel, two-wheel] + four-wheel: { group: leaf } + two-wheel: { group: leaf } +"#, + ) + .unwrap(); + std::fs::write( + dir.join("vehicle.yaml"), + r#"kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [powertrain] + powertrain: + group: mandatory +"#, + ) + .unwrap(); + let binding = dir.join("binding.yaml"); + std::fs::write( + &binding, + r#"kind: feature-model-binding +compose: + - parent: vehicle.yaml + mount: + powertrain: + model: powertrain.yaml + prefix: pwt +"#, + ) + .unwrap(); + (tmp, binding) +} + +/// `rivet variant list --model ` composes the multi-file model +/// and lists the sub-model's features under their mount prefix. +#[test] +fn variant_list_composes_multi_file_model() { + let (_keep, binding) = write_composition(); + + let out = Command::new(rivet_bin()) + .args(["variant", "list", "--model", binding.to_str().unwrap()]) + .output() + .expect("rivet variant list"); + + assert!( + out.status.success(), + "list of a composition binding must succeed. stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("root: vehicle"), "stdout:\n{stdout}"); + // Sub-model features appear under the mount prefix. + assert!(stdout.contains("pwt:powertrain"), "stdout:\n{stdout}"); + assert!(stdout.contains("pwt:four-wheel"), "stdout:\n{stdout}"); + assert!(stdout.contains("pwt:two-wheel"), "stdout:\n{stdout}"); +} + +/// `rivet variant solve` against a composition resolves a variant that +/// selects a prefixed sub-model feature. +#[test] +fn variant_solve_resolves_prefixed_selection() { + let (_keep, binding) = write_composition(); + let dir = binding.parent().unwrap(); + let variant = dir.join("variant.yaml"); + std::fs::write(&variant, "name: four-wheeler\nselects: [pwt:four-wheel]\n").unwrap(); + + let out = Command::new(rivet_bin()) + .args([ + "variant", + "solve", + "--model", + binding.to_str().unwrap(), + "--variant", + variant.to_str().unwrap(), + ]) + .output() + .expect("rivet variant solve"); + + assert!( + out.status.success(), + "solve of a composition must succeed. stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("pwt:four-wheel"), + "the prefixed selection must appear in the effective set. stdout:\n{stdout}" + ); +} + +/// A binding with a missing sub-model file fails loudly (non-zero exit, +/// the missing path named) — never a silent skip. +#[test] +fn variant_list_broken_mount_fails_loudly() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write( + dir.join("vehicle.yaml"), + r#"kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [powertrain] + powertrain: + group: mandatory +"#, + ) + .unwrap(); + let binding = dir.join("binding.yaml"); + std::fs::write( + &binding, + r#"kind: feature-model-binding +compose: + - parent: vehicle.yaml + mount: + powertrain: + model: nonexistent.yaml + prefix: pwt +"#, + ) + .unwrap(); + + let out = Command::new(rivet_bin()) + .args(["variant", "list", "--model", binding.to_str().unwrap()]) + .output() + .expect("rivet variant list"); + + assert!( + !out.status.success(), + "a binding with a missing sub-model file must fail" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("nonexistent.yaml"), + "the error must name the missing file. stderr:\n{stderr}" + ); +} diff --git a/rivet-core/src/feature_model.rs b/rivet-core/src/feature_model.rs index 95ddd391..75212819 100644 --- a/rivet-core/src/feature_model.rs +++ b/rivet-core/src/feature_model.rs @@ -986,6 +986,29 @@ impl FeatureModel { Self::from_yaml_struct(merged) } + /// Load a feature model from a file, dispatching on its `kind:`. + /// + /// A file declaring `kind: feature-model-binding` is composed via + /// [`load_composed`](Self::load_composed); any other file is parsed as + /// a single feature model via [`from_yaml`](Self::from_yaml). This is + /// the entry point CLI commands use, so a `--model` argument + /// transparently accepts either a plain model or a composition. + pub fn load(path: &std::path::Path) -> Result { + let src = std::fs::read_to_string(path) + .map_err(|e| Error::Schema(format!("feature model `{}`: {e}", path.display())))?; + #[derive(Deserialize)] + struct KindProbe { + kind: Option, + } + let probe: KindProbe = serde_yaml::from_str(&src) + .map_err(|e| Error::Schema(format!("feature model `{}`: {e}", path.display())))?; + if probe.kind.as_deref() == Some("feature-model-binding") { + Self::load_composed(path) + } else { + Self::from_yaml(&src) + } + } + /// Validate the feature tree: no cycles, all children referenced exist, /// group types consistent with child counts. fn validate_tree(&self) -> Result<(), Error> { From cebc9c5638622a8a49146c1318896b2c5592dfa4 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 22 May 2026 19:09:00 +0200 Subject: [PATCH 4/7] =?UTF-8?q?release(v0.12.0):=20multi-file=20feature=20?= =?UTF-8?q?models=20=E2=80=94=20REQ-083?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump workspace + VS Code extension to 0.12.0 and add the CHANGELOG section. v0.12.0 ships multi-file feature model composition: a feature-model-binding file mounts standalone sub-model files onto parent features under explicit, unique prefixes, resolving to one tree. Minor version — new file kind and new rivet variant input behaviour, no breaking schema or CLI removal. Refs: REQ-083 --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ Cargo.lock | 6 +++--- Cargo.toml | 2 +- vscode-rivet/package.json | 2 +- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6533a023..0002586c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ ## [Unreleased] +## [0.12.0] — 2026-05-22 + +Theme: **multi-file feature models**. A product line can now be authored +as a top-level feature model plus per-level sub-models in their own +files — vehicle → powertrain → ECU — each file a valid, independently +solvable model on its own. Minor version: new file kind and new +`rivet variant` input behaviour, no breaking schema or CLI removal. + +### Added + +- **`feature-model-binding` files** (REQ-083) — a `kind: + feature-model-binding` file with a `compose:` list mounts standalone + sub-model files onto parent features. Each mount declares an explicit, + unique `prefix:` and the sub-model's features are namespaced under it + (`pwt:four-wheel`), mirroring the `externals: prefix:ID` model. + Composition is recursive (a sub-model may itself be a parent) and + resolves to one tree that `solve` / `check` / `explain` / `list` + operate on. `FeatureModel::load_composed` / `FeatureModel::load`. +- Every `rivet variant` command accepts a binding file wherever it + accepts a plain model (`rivet variant list --model binding.yaml`) — + the file's `kind:` selects composition vs. single-file parsing. + +### Changed + +- The s-expression lexer accepts `:` inside a symbol, so a namespaced + feature reference (`prefix:feature`) lexes as one token — required for + cross-prefix constraints like `(implies car pwt:four-wheel)`. + +### Notes + +- Composition resolves sub-model files by path **relative to the binding + file, within one repository**; pulling a sub-model from another git + repo is not yet supported (tracked separately). A broken mount — + missing file, unknown or `leaf` mount point, duplicate prefix, cyclic + composition — is a hard error, never a silent skip. + ## [0.11.1] — 2026-05-21 Theme: **the Mythos silent-failure hunt**. A `scripts/mythos/` diff --git a/Cargo.lock b/Cargo.lock index 9c65e11c..e1944b64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -973,7 +973,7 @@ dependencies = [ [[package]] name = "etch" -version = "0.11.1" +version = "0.12.0" dependencies = [ "petgraph 0.7.1", ] @@ -2709,7 +2709,7 @@ dependencies = [ [[package]] name = "rivet-cli" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", "axum", @@ -2737,7 +2737,7 @@ dependencies = [ [[package]] name = "rivet-core" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 8a7c1753..99820ac0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.11.1" +version = "0.12.0" authors = ["PulseEngine "] edition = "2024" license = "Apache-2.0" diff --git a/vscode-rivet/package.json b/vscode-rivet/package.json index 0b0586d6..2c1a0dc3 100644 --- a/vscode-rivet/package.json +++ b/vscode-rivet/package.json @@ -3,7 +3,7 @@ "displayName": "Rivet SDLC", "description": "SDLC artifact traceability with live validation, hover info, and embedded dashboard", "publisher": "pulseengine", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "repository": { "type": "git", From 98c899bb0f85ed6d75713b7fcefb9266ecf9b2f0 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 22 May 2026 19:45:12 +0200 Subject: [PATCH 5/7] ci: ignore RUSTSEC-2026-0149 (wasmtime-wasi) in the security audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RUSTSEC-2026-0149 is a high-severity advisory in wasmtime-wasi 43, a transitive dependency behind the optional, non-default `wasm` feature — the same class as the 13 wasmtime advisories the audit step already ignores. The 43.x line has no fixed release; clearing it for real needs a wasmtime 43->45 major bump, which is out of scope for this release. Add it to the existing `--ignore` list so the audit reflects a triaged state rather than an untriaged failure. --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb3915c7..808a4c9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,7 +201,10 @@ jobs: - name: Install cargo-audit run: cargo install cargo-audit --locked - name: Run cargo audit - # wasmtime advisories ignored — behind optional wasm feature gate. + # wasmtime advisories ignored — behind optional wasm feature gate + # (not built by default). RUSTSEC-2026-0149 (wasmtime-wasi 43, + # high) has no fix in the 43.x line; clearing it needs a wasmtime + # 43->45 major bump, tracked separately. run: >- cargo audit --ignore RUSTSEC-2026-0085 @@ -217,6 +220,7 @@ jobs: --ignore RUSTSEC-2026-0096 --ignore RUSTSEC-2026-0103 --ignore RUSTSEC-2026-0104 + --ignore RUSTSEC-2026-0149 deny: # Renamed: skipping advisories until smithy ships an upgraded From f6999b93e2cc5f03aa2b34568005b0d25f46c0a7 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 22 May 2026 20:03:31 +0200 Subject: [PATCH 6/7] test(variant): cover REQ-083 broken-mount error branches Add five error-path tests for feature-model composition: empty `compose:`, a parent listed twice, multiple candidate roots, a cyclic binding (every parent mounted), and a composition cycle deeper than the root caught by the recursion-path guard. These exercise REQ-083's "a broken mount is a hard error, never a silent skip" Acceptance criterion across every detection branch. Verifies: REQ-083 --- rivet-core/src/feature_model.rs | 135 ++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/rivet-core/src/feature_model.rs b/rivet-core/src/feature_model.rs index 75212819..60ca1180 100644 --- a/rivet-core/src/feature_model.rs +++ b/rivet-core/src/feature_model.rs @@ -2777,4 +2777,139 @@ compose: let err = FeatureModel::load_composed(&bpath).unwrap_err(); assert!(format!("{err}").contains("leaf"), "got: {err}"); } + + /// rivet: verifies REQ-083 + #[test] + fn compose_empty_compose_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let bpath = tmp.path().join("binding.yaml"); + std::fs::write(&bpath, "kind: feature-model-binding\ncompose: []\n").unwrap(); + let err = FeatureModel::load_composed(&bpath).unwrap_err(); + assert!(format!("{err}").contains("empty"), "got: {err}"); + } + + /// rivet: verifies REQ-083 + #[test] + fn compose_parent_listed_twice_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let bpath = tmp.path().join("binding.yaml"); + std::fs::write( + &bpath, + r#"kind: feature-model-binding +compose: + - parent: vehicle.yaml + mount: + a: + model: sub.yaml + prefix: p1 + - parent: vehicle.yaml + mount: + b: + model: sub.yaml + prefix: p2 +"#, + ) + .unwrap(); + let err = FeatureModel::load_composed(&bpath).unwrap_err(); + assert!(format!("{err}").contains("more than once"), "got: {err}"); + } + + /// rivet: verifies REQ-083 + #[test] + fn compose_multiple_roots_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let bpath = tmp.path().join("binding.yaml"); + // Two parents, neither mounted by the other — no single root. + std::fs::write( + &bpath, + r#"kind: feature-model-binding +compose: + - parent: a.yaml + mount: + f: + model: x.yaml + prefix: p1 + - parent: b.yaml + mount: + g: + model: y.yaml + prefix: p2 +"#, + ) + .unwrap(); + let err = FeatureModel::load_composed(&bpath).unwrap_err(); + assert!(format!("{err}").contains("root"), "got: {err}"); + } + + /// rivet: verifies REQ-083 + #[test] + fn compose_cyclic_binding_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let bpath = tmp.path().join("binding.yaml"); + // a mounts b, b mounts a — every parent is also mounted, no root. + std::fs::write( + &bpath, + r#"kind: feature-model-binding +compose: + - parent: a.yaml + mount: + fa: + model: b.yaml + prefix: pb + - parent: b.yaml + mount: + fb: + model: a.yaml + prefix: pa +"#, + ) + .unwrap(); + let err = FeatureModel::load_composed(&bpath).unwrap_err(); + assert!(format!("{err}").contains("cyclic"), "got: {err}"); + } + + /// A composition cycle deeper than the root (root -> a -> b -> a) is + /// caught by the recursion-path guard, not root detection. + /// + /// rivet: verifies REQ-083 + #[test] + fn compose_deep_cycle_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + let model = |root: &str, child: &str| { + format!( + "kind: feature-model\nroot: {root}\nfeatures:\n {root}:\n \ + group: mandatory\n children: [{child}]\n {child}:\n \ + group: mandatory\n" + ) + }; + std::fs::write(dir.join("root.yaml"), model("r", "ma")).unwrap(); + std::fs::write(dir.join("a.yaml"), model("a", "mb")).unwrap(); + std::fs::write(dir.join("b.yaml"), model("b", "ma2")).unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#"kind: feature-model-binding +compose: + - parent: root.yaml + mount: + ma: + model: a.yaml + prefix: pa + - parent: a.yaml + mount: + mb: + model: b.yaml + prefix: pb + - parent: b.yaml + mount: + ma2: + model: a.yaml + prefix: pa2 +"#, + ) + .unwrap(); + let err = FeatureModel::load_composed(&bpath).unwrap_err(); + assert!(format!("{err}").contains("cycle"), "got: {err}"); + } } From 3169bb98c84f4b75e247ea58caae19f8f71faf35 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 22 May 2026 21:52:13 +0200 Subject: [PATCH 7/7] test(variant): cover constraint-prefixing and the load dispatcher (REQ-083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An llvm-cov pass surfaced a real gap: prefix_constraint / flush_constraint_token — the tokeniser that rewrites bare feature names inside a sub-model's own constraint strings — had zero coverage, because no test composed a sub-model that declared constraints. Add five tests: a sub-model whose constraint is prefixed and proven to still fire under the solver after composition; sub-model attribute-schema merge; attribute-schema key collision; the FeatureModel::load kind-dispatcher; and an unreadable/malformed binding file. feature_model.rs line coverage 84.6% -> 87.4%; the composition functions (load_composed, load_and_splice, splice_into) now sit at 89-100% with no CRAP-flagged anti-patterns. Verifies: REQ-083 --- rivet-core/src/feature_model.rs | 260 ++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) diff --git a/rivet-core/src/feature_model.rs b/rivet-core/src/feature_model.rs index 60ca1180..8659edad 100644 --- a/rivet-core/src/feature_model.rs +++ b/rivet-core/src/feature_model.rs @@ -2912,4 +2912,264 @@ compose: let err = FeatureModel::load_composed(&bpath).unwrap_err(); assert!(format!("{err}").contains("cycle"), "got: {err}"); } + + /// A sub-model's own constraints are prefixed during composition so + /// they keep referring to the right (now namespaced) features. Proven + /// end-to-end: the constraint must still fire under the solver. + /// + /// rivet: verifies REQ-083 + #[test] + fn compose_prefixes_submodel_constraints() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write( + dir.join("powertrain.yaml"), + r#"kind: feature-model +root: powertrain +features: + powertrain: + group: mandatory + children: [drive, extras] + drive: + group: alternative + children: [four-wheel, two-wheel] + four-wheel: { group: leaf } + two-wheel: { group: leaf } + extras: + group: optional + children: [traction-control] + traction-control: { group: leaf } +constraints: + - (implies four-wheel traction-control) +"#, + ) + .unwrap(); + std::fs::write( + dir.join("vehicle.yaml"), + r#"kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [powertrain] + powertrain: + group: mandatory +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#"kind: feature-model-binding +compose: + - parent: vehicle.yaml + mount: + powertrain: + model: powertrain.yaml + prefix: pwt +"#, + ) + .unwrap(); + + let model = FeatureModel::load_composed(&bpath).unwrap(); + assert_eq!(model.constraints.len(), 1); + + // If `four-wheel` in the sub-model's constraint was not rewritten + // to `pwt:four-wheel`, the implication would not fire for the + // prefixed selection. Solving proves the prefixing happened. + let vc = VariantConfig { + name: "t".to_string(), + selects: vec!["pwt:four-wheel".to_string()], + }; + let resolved = solve(&model, &vc).unwrap(); + assert!( + resolved.effective_features.contains("pwt:traction-control"), + "the sub-model constraint must be prefixed and still fire: {:?}", + resolved.effective_features + ); + } + + /// A sub-model's `attribute-schema` is carried into the composed model. + /// + /// rivet: verifies REQ-083 + #[test] + fn compose_merges_submodel_attribute_schema() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write( + dir.join("powertrain.yaml"), + r#"kind: feature-model +root: powertrain +attribute-schema: + power-kw: + type: int +features: + powertrain: + group: alternative + children: [four-wheel, two-wheel] + four-wheel: + group: leaf + attributes: + power-kw: 150 + two-wheel: + group: leaf + attributes: + power-kw: 60 +"#, + ) + .unwrap(); + std::fs::write( + dir.join("vehicle.yaml"), + r#"kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [powertrain] + powertrain: + group: mandatory +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#"kind: feature-model-binding +compose: + - parent: vehicle.yaml + mount: + powertrain: + model: powertrain.yaml + prefix: pwt +"#, + ) + .unwrap(); + + let model = FeatureModel::load_composed(&bpath).unwrap(); + assert!( + model.attribute_schema.contains_key("power-kw"), + "the sub-model's attribute-schema must be carried into the \ + composed model" + ); + } + + /// The same attribute-schema key declared by two composed models is a + /// hard error — composition must not silently pick one. + /// + /// rivet: verifies REQ-083 + #[test] + fn compose_attribute_schema_collision_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write( + dir.join("powertrain.yaml"), + r#"kind: feature-model +root: powertrain +attribute-schema: + power-kw: + type: int +features: + powertrain: { group: leaf } +"#, + ) + .unwrap(); + std::fs::write( + dir.join("vehicle.yaml"), + r#"kind: feature-model +root: vehicle +attribute-schema: + power-kw: + type: string +features: + vehicle: + group: mandatory + children: [powertrain] + powertrain: + group: mandatory +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#"kind: feature-model-binding +compose: + - parent: vehicle.yaml + mount: + powertrain: + model: powertrain.yaml + prefix: pwt +"#, + ) + .unwrap(); + + let err = FeatureModel::load_composed(&bpath).unwrap_err(); + assert!(format!("{err}").contains("attribute-schema"), "got: {err}"); + } + + /// `FeatureModel::load` dispatches on `kind:` — a plain model file is + /// parsed directly, a `feature-model-binding` file is composed. + /// + /// rivet: verifies REQ-083 + #[test] + fn load_dispatches_on_kind() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + + let plain = dir.join("plain.yaml"); + std::fs::write(&plain, SUB_POWERTRAIN).unwrap(); + let m = FeatureModel::load(&plain).unwrap(); + assert_eq!(m.root, "powertrain"); + + std::fs::write(dir.join("powertrain.yaml"), SUB_POWERTRAIN).unwrap(); + std::fs::write( + dir.join("vehicle.yaml"), + r#"kind: feature-model +root: vehicle +features: + vehicle: + group: mandatory + children: [powertrain] + powertrain: + group: mandatory +"#, + ) + .unwrap(); + let bpath = dir.join("binding.yaml"); + std::fs::write( + &bpath, + r#"kind: feature-model-binding +compose: + - parent: vehicle.yaml + mount: + powertrain: + model: powertrain.yaml + prefix: pwt +"#, + ) + .unwrap(); + let composed = FeatureModel::load(&bpath).unwrap(); + assert_eq!(composed.root, "vehicle"); + assert!(composed.features.contains_key("pwt:four-wheel")); + } + + /// An unreadable or malformed binding file fails loudly, naming the + /// file — never a silent skip. + /// + /// rivet: verifies REQ-083 + #[test] + fn compose_unreadable_binding_is_error() { + let tmp = tempfile::tempdir().unwrap(); + // Nonexistent binding path. + let missing = tmp.path().join("nope.yaml"); + assert!(FeatureModel::load_composed(&missing).is_err()); + // Malformed YAML. + let bad = tmp.path().join("bad.yaml"); + std::fs::write(&bad, "kind: feature-model-binding\ncompose: [: : :\n").unwrap(); + let err = FeatureModel::load_composed(&bad).unwrap_err(); + assert!( + format!("{err}").contains("feature-model-binding"), + "got: {err}" + ); + } }