From fb891c19c3c50870fcb85a421304254dfaf96069 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Mon, 13 Apr 2026 07:29:09 -0500 Subject: [PATCH] =?UTF-8?q?feat(cli):=20wire=20variant=20subcommand=20?= =?UTF-8?q?=E2=80=94=20check,=20list,=20solve=20with=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New CLI commands for product line variant management: rivet variant list --model feature-model.yaml rivet variant check --model feature-model.yaml --variant eu-adas-c.yaml rivet variant solve --model fm.yaml --variant v.yaml --binding bindings.yaml Features: - Feature tree visualization with group labels - Constraint propagation with feature name preprocessing - Binding resolution showing bound artifact IDs - Project scope validation (which bound artifacts exist) - JSON output format for all commands - Example files in examples/variant/ Implements: REQ-042, REQ-043, REQ-044, REQ-046 Refs: FEAT-110, FEAT-111, FEAT-112, FEAT-113 Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/variant/bindings.yaml | 14 ++ examples/variant/eu-adas-c.yaml | 5 + examples/variant/feature-model.yaml | 65 +++++++ rivet-cli/src/main.rs | 282 ++++++++++++++++++++++++++++ rivet-core/src/feature_model.rs | 59 +++++- 5 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 examples/variant/bindings.yaml create mode 100644 examples/variant/eu-adas-c.yaml create mode 100644 examples/variant/feature-model.yaml diff --git a/examples/variant/bindings.yaml b/examples/variant/bindings.yaml new file mode 100644 index 0000000..3762d4b --- /dev/null +++ b/examples/variant/bindings.yaml @@ -0,0 +1,14 @@ +bindings: + pedestrian-detection: + artifacts: [REQ-042, REQ-043] + source: ["src/perception/pedestrian/**"] + lane-keeping: + artifacts: [REQ-050] + source: ["src/control/lane_keep/**"] + adaptive-cruise: + artifacts: [REQ-051] + source: ["src/control/cruise/**"] + eu: + artifacts: [REQ-200] + asil-c: + artifacts: [REQ-101] diff --git a/examples/variant/eu-adas-c.yaml b/examples/variant/eu-adas-c.yaml new file mode 100644 index 0000000..1395554 --- /dev/null +++ b/examples/variant/eu-adas-c.yaml @@ -0,0 +1,5 @@ +name: eu-adas-c +selects: + - eu + - adas + - asil-c diff --git a/examples/variant/feature-model.yaml b/examples/variant/feature-model.yaml new file mode 100644 index 0000000..37cba93 --- /dev/null +++ b/examples/variant/feature-model.yaml @@ -0,0 +1,65 @@ +kind: feature-model +root: vehicle-platform + +features: + vehicle-platform: + group: mandatory + children: [market, safety-level, feature-set] + + market: + group: alternative + children: [eu, us, cn] + + eu: + group: leaf + us: + group: leaf + cn: + group: leaf + + safety-level: + group: alternative + children: [qm, asil-a, asil-b, asil-c, asil-d] + + qm: + group: leaf + asil-a: + group: leaf + asil-b: + group: leaf + asil-c: + group: leaf + asil-d: + group: leaf + + feature-set: + group: or + children: [base, adas, autonomous] + + base: + group: leaf + + adas: + group: mandatory + children: [lane-keeping, adaptive-cruise, pedestrian-detection] + + lane-keeping: + group: leaf + adaptive-cruise: + group: leaf + pedestrian-detection: + group: leaf + + autonomous: + group: mandatory + children: [path-planning, sensor-fusion] + + path-planning: + group: leaf + sensor-fusion: + group: leaf + +constraints: + - (implies eu pedestrian-detection) + - (implies autonomous (and adas asil-d)) + - (implies adas (or asil-b asil-c asil-d)) diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 1cf4e1d..0f117b6 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -496,6 +496,12 @@ enum Command { action: SnapshotAction, }, + /// Product line variant management (feature model + constraint solver) + Variant { + #[command(subcommand)] + action: VariantAction, + }, + /// Import artifacts using a custom WASM adapter component #[cfg(feature = "wasm")] Import { @@ -759,6 +765,52 @@ enum SnapshotAction { List, } +#[derive(Subcommand)] +enum VariantAction { + /// Check a variant configuration against a feature model + Check { + /// Path to feature model YAML file + #[arg(long)] + model: PathBuf, + + /// Path to variant configuration YAML file + #[arg(long)] + variant: PathBuf, + + /// Output format: "text" (default) or "json" + #[arg(short, long, default_value = "text")] + format: String, + }, + /// List features in a feature model + List { + /// Path to feature model YAML file + #[arg(long)] + model: PathBuf, + + /// Output format: "text" (default) or "json" + #[arg(short, long, default_value = "text")] + format: String, + }, + /// Solve: propagate a variant selection and show effective features + Solve { + /// Path to feature model YAML file + #[arg(long)] + model: PathBuf, + + /// Path to variant configuration YAML file + #[arg(long)] + variant: PathBuf, + + /// Path to binding model YAML file (optional) + #[arg(long)] + binding: Option, + + /// Output format: "text" (default) or "json" + #[arg(short, long, default_value = "text")] + format: String, + }, +} + fn main() -> ExitCode { let cli = Cli::parse(); @@ -975,6 +1027,20 @@ fn run(cli: Cli) -> Result { } SnapshotAction::List => cmd_snapshot_list(&cli), }, + Command::Variant { action } => match action { + VariantAction::Check { + model, + variant, + format, + } => cmd_variant_check(model, variant, format), + VariantAction::List { model, format } => cmd_variant_list(model, format), + VariantAction::Solve { + model, + variant, + binding, + format, + } => cmd_variant_solve(&cli, model, variant, binding.as_deref(), format), + }, #[cfg(feature = "wasm")] Command::Import { adapter, @@ -6277,6 +6343,222 @@ fn cmd_snapshot_list(cli: &Cli) -> Result { Ok(true) } +// ── Variant commands ──────────────────────────────────────────────────── + +/// Check a variant configuration against a feature model. +fn cmd_variant_check( + model_path: &std::path::Path, + variant_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) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let variant_yaml = std::fs::read_to_string(variant_path) + .with_context(|| format!("reading {}", variant_path.display()))?; + let variant: rivet_core::feature_model::VariantConfig = + serde_yaml::from_str(&variant_yaml).context("parsing variant config")?; + + match rivet_core::feature_model::solve(&model, &variant) { + Ok(resolved) => { + if format == "json" { + let output = serde_json::json!({ + "result": "PASS", + "variant": resolved.name, + "effective_features": resolved.effective_features, + "feature_count": resolved.effective_features.len(), + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("Variant '{}': PASS", resolved.name); + println!( + "Effective features ({}):", + resolved.effective_features.len() + ); + for f in &resolved.effective_features { + println!(" {f}"); + } + } + Ok(true) + } + Err(errors) => { + if format == "json" { + let errs: Vec = errors.iter().map(|e| format!("{e:?}")).collect(); + let output = serde_json::json!({ + "result": "FAIL", + "variant": variant.name, + "errors": errs, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + eprintln!("Variant '{}': FAIL", variant.name); + for err in &errors { + eprintln!(" {err:?}"); + } + } + Ok(false) + } + } +} + +/// List features in a feature model. +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) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + if format == "json" { + let features: Vec = model + .features + .values() + .map(|f| { + serde_json::json!({ + "name": f.name, + "group": format!("{:?}", f.group).to_lowercase(), + "children": f.children, + "parent": f.parent, + }) + }) + .collect(); + let output = serde_json::json!({ + "root": model.root, + "feature_count": model.features.len(), + "constraint_count": model.constraints.len(), + "features": features, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("Feature model (root: {})", model.root); + println!( + "{} features, {} constraints\n", + model.features.len(), + model.constraints.len() + ); + print_feature_tree(&model, &model.root, 0); + } + + Ok(true) +} + +fn print_feature_tree(model: &rivet_core::feature_model::FeatureModel, name: &str, depth: usize) { + use rivet_core::feature_model::GroupType; + let indent = " ".repeat(depth); + if let Some(f) = model.features.get(name) { + let group_label = match f.group { + GroupType::Mandatory => " [mandatory]", + GroupType::Optional => " [optional]", + GroupType::Alternative => " [alternative]", + GroupType::Or => " [or]", + GroupType::Leaf => "", + }; + println!("{indent}{name}{group_label}"); + for child in &f.children { + print_feature_tree(model, child, depth + 1); + } + } +} + +/// Solve a variant and optionally show bound artifacts. +fn cmd_variant_solve( + cli: &Cli, + model_path: &std::path::Path, + variant_path: &std::path::Path, + binding_path: Option<&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) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let variant_yaml = std::fs::read_to_string(variant_path) + .with_context(|| format!("reading {}", variant_path.display()))?; + let variant: rivet_core::feature_model::VariantConfig = + serde_yaml::from_str(&variant_yaml).context("parsing variant config")?; + + let resolved = rivet_core::feature_model::solve(&model, &variant).map_err(|errs| { + let msgs: Vec = errs.iter().map(|e| format!("{e:?}")).collect(); + anyhow::anyhow!("variant check failed:\n {}", msgs.join("\n ")) + })?; + + let binding = if let Some(bp) = binding_path { + let yaml = + std::fs::read_to_string(bp).with_context(|| format!("reading {}", bp.display()))?; + let b: rivet_core::feature_model::FeatureBinding = + serde_yaml::from_str(&yaml).context("parsing binding")?; + Some(b) + } else { + None + }; + + let bound_artifacts: Vec = if let Some(ref b) = binding { + resolved + .effective_features + .iter() + .flat_map(|f| { + b.bindings + .get(f) + .map(|bind| bind.artifacts.clone()) + .unwrap_or_default() + }) + .collect() + } else { + Vec::new() + }; + + if format == "json" { + let output = serde_json::json!({ + "variant": resolved.name, + "effective_features": resolved.effective_features, + "feature_count": resolved.effective_features.len(), + "bound_artifacts": bound_artifacts, + "bound_artifact_count": bound_artifacts.len(), + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("Variant '{}': PASS", resolved.name); + let features_list: Vec<&str> = resolved + .effective_features + .iter() + .map(|s| s.as_str()) + .collect(); + println!( + "Effective features ({}): {}", + features_list.len(), + features_list.join(", ") + ); + + if !bound_artifacts.is_empty() { + println!("\nBound artifacts ({}):", bound_artifacts.len()); + for id in &bound_artifacts { + println!(" {id}"); + } + + if let Ok(ctx) = ProjectContext::load(cli) { + let found = bound_artifacts + .iter() + .filter(|id| ctx.store.get(id).is_some()) + .count(); + println!( + "\nVariant scope: {found}/{} artifacts resolved in project", + bound_artifacts.len() + ); + } + } + } + + Ok(true) +} + fn find_latest_snapshot(snap_dir: &std::path::Path) -> Result { if !snap_dir.exists() { anyhow::bail!("no snapshots directory found — run `rivet snapshot capture` first"); diff --git a/rivet-core/src/feature_model.rs b/rivet-core/src/feature_model.rs index eb9e61f..e742601 100644 --- a/rivet-core/src/feature_model.rs +++ b/rivet-core/src/feature_model.rs @@ -106,6 +106,58 @@ fn default_group() -> GroupType { GroupType::Leaf } +/// Preprocess a feature constraint string: replace bare feature names +/// with `(has-tag "name")` so the s-expression parser accepts them. +/// The solver later interprets HasTag as "feature is selected". +fn preprocess_feature_constraint(src: &str, features: &BTreeMap) -> String { + let tokens = crate::sexpr::lex(src); + let mut result = String::new(); + for token in &tokens { + if token.kind == crate::sexpr::SyntaxKind::Symbol { + let name = token.text; + // Known forms pass through unchanged. + if matches!( + name, + "and" + | "or" + | "not" + | "implies" + | "excludes" + | "forall" + | "exists" + | "=" + | "!=" + | ">" + | "<" + | ">=" + | "<=" + | "has-tag" + | "has-field" + | "in" + | "linked-by" + | "linked-from" + | "linked-to" + | "links-count" + | "matches" + | "contains" + | "reachable-from" + | "reachable-to" + | "count" + ) { + result.push_str(name); + } else if features.contains_key(name) { + // Bare feature name → (has-tag "name") + result.push_str(&format!("(has-tag \"{name}\")")); + } else { + result.push_str(name); + } + } else { + result.push_str(token.text); + } + } + result +} + impl FeatureModel { /// Parse a feature model from a YAML string. pub fn from_yaml(yaml: &str) -> Result { @@ -153,9 +205,13 @@ impl FeatureModel { } // Parse constraint s-expressions. + // Feature model constraints use bare symbols as feature names. + // Wrap them in (has-tag "name") so the parser accepts them — + // the solver interprets HasTag as "feature is selected". let mut constraints = Vec::new(); for src in &raw.constraints { - let expr = sexpr_eval::parse_filter(src) + let preprocessed = preprocess_feature_constraint(src, &features); + let expr = sexpr_eval::parse_filter(&preprocessed) .map_err(|errs| Error::Schema(format!("constraint `{src}`: {errs:?}")))?; constraints.push(expr); } @@ -458,6 +514,7 @@ fn extract_feature_name(expr: &Expr) -> Option { Some(val.clone()) } Expr::HasField(sexpr_eval::Value::Str(name)) => Some(name.clone()), + Expr::HasTag(sexpr_eval::Value::Str(name)) => Some(name.clone()), _ => None, } }