diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b7728a..0185195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,96 @@ ## [Unreleased] +## [0.4.3] — 2026-04-23 + +### `rivet variant` — build-system query surface and solve debugger + +Three new subcommands complete the variant-scoped CLI surface +(`REQ-046`). Feature models can now carry typed `attributes:` per +feature, round-tripped through `solve()` and emitted into seven +different build systems — the same one variant YAML can configure +Cargo, CMake, Bazel, a C/C++ header, Make, shell env, or structured +JSON without divergent hand-written shims. + +- `rivet variant features --format {json,env,cargo,cmake,cpp-header,bazel,make}` + emits every effective feature plus its `attributes:` entries with long, + namespaced identifiers (`RIVET_FEATURE_*`, `RIVET_ATTR_*`). Every format + is **loud on failure** — a variant that violates a constraint exits + non-zero with the violation list, never a partial emission. + Non-scalar attribute values (lists/maps) only serialise through + `--format json`; build-system formatters return `Error::Schema` rather + than invent a silent flattening convention. + +- `rivet variant value FEATURE` — shell-friendly single-feature probe with + exit codes `0` (selected), `1` (unselected), `2` (unknown feature or + variant fails to solve). Designed for `if rivet variant value … ; then …`. + +- `rivet variant attr FEATURE KEY` — print one attribute value. Scalars + print bare; list/map values print as JSON so shells can parse + structurally. + +- `rivet variant explain [FEATURE]` — dev/debug UX for "why did my + variant pick/skip feature X?". Full audit mode prints every effective + feature with its origin (`selected` / `mandatory` / `implied by ` / + `allowed`), plus the unselected set and the full constraint list. + Single-feature focus mode zooms on one feature and lists every + constraint that mentions it. + +Feature models gained an `attributes:` key per feature, parsed as +`BTreeMap`. The shipped +`examples/variant/feature-model.yaml` now carries realistic metadata +(`asil-numeric`, `compliance`, `locale`) so the worked examples in +`docs/getting-started.md` run against the fixture and produce the +documented output. + +Test coverage: 11 unit tests in `rivet_core::variant_emit::tests` for +per-format rendering, 15 integration tests in +`rivet-cli/tests/variant_emit.rs` for CLI end-to-end, exit-code +contract, loud-on-failure path, and the realistic-example smoke across +all seven formats. + +### S-expression follow-ups + +- `(> (count ) N)` now lowers to a new `CountCompare` expr + variant that evaluates the count against the store once and compares + to an integer threshold. Previously the audit documented `(count …)` + as "meant for numeric comparisons" but no lowering existed — you + could only use it as a standalone predicate. Every comparison operator + (`>`, `<`, `>=`, `<=`, `=`, `!=`) now accepts a `(count …)` LHS with + an integer RHS. + +- `(matches "")` validates the regex at lower time + instead of silently returning `false` at runtime on malformed + patterns. Closes the "mysterious empty result" footgun — typing + `(matches id "[")` used to match nothing and cost debug time; now it + produces a parse error with the compiler's message. Non-literal + patterns (rare; from field interpolation) still use the runtime-lenient + path. + +- `docs/getting-started.md` gains dedicated sections for count + comparisons and regex validation, plus a note that dotted accessors + like `links.satisfies.target` are not supported — use the purpose-built + `linked-by` / `linked-from` / `linked-to` / `links-count` predicates. + +### Rivet Delta CI action — SVG render for email/mobile + +`rivet-delta.yml` workflow now pre-renders the summary Mermaid diagram +to SVG and pushes it to an orphan `rivet-delta-renders` branch, so email +notifications and the GitHub mobile app show the diagram inline instead +of a `` text block that nothing except the web UI can render. +Classification-priority ordering in `scripts/diff-to-markdown.mjs` is +also fixed so multi-label changes (`breaking` + `additive` → `breaking`) +pick the most severe. + +### Stamp command + +- `rivet stamp all --missing-provenance` filter now correctly checks the + first-class `provenance:` struct field (previously it looked for a + `provenance` entry in generic `fields:` and was therefore a no-op). +- `set_provenance` no longer aborts the whole batch on a single + CST-invisible artifact; it warns and skips that one artifact and + continues. + ### Safety-Critical Rust Consortium (SCRC) clippy escalation — Phase 1 Follow-up to the v0.4.2 commitment recorded in `DD-058`. The full diff --git a/Cargo.lock b/Cargo.lock index 721ce30..cae78fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -972,7 +972,7 @@ dependencies = [ [[package]] name = "etch" -version = "0.4.2" +version = "0.4.3" dependencies = [ "petgraph 0.7.1", ] @@ -2693,7 +2693,7 @@ dependencies = [ [[package]] name = "rivet-cli" -version = "0.4.2" +version = "0.4.3" dependencies = [ "anyhow", "axum", @@ -2720,7 +2720,7 @@ dependencies = [ [[package]] name = "rivet-core" -version = "0.4.2" +version = "0.4.3" dependencies = [ "anyhow", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 4c12f15..6a4d716 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.4.2" +version = "0.4.3" authors = ["PulseEngine "] edition = "2024" license = "Apache-2.0" diff --git a/artifacts/v043-artifacts.yaml b/artifacts/v043-artifacts.yaml index a2a976d..1593f07 100644 --- a/artifacts/v043-artifacts.yaml +++ b/artifacts/v043-artifacts.yaml @@ -198,3 +198,265 @@ artifacts: created-by: ai-assisted model: claude-opus-4-7 timestamp: 2026-04-22T21:30:00Z + + # ── Variant query surface (v0.4.3 headline feature) ───────────────── + + - id: DD-061 + type: design-decision + title: Build-system emitter formats are namespaced and loud-on-failure + status: approved + description: > + `rivet variant features` emits long, namespaced identifiers + (`RIVET_FEATURE_`, `RIVET_ATTR__`) in every + format so a downstream project can embed several rivet models + without collision. Every formatter is loud on two conditions: + (a) a variant that violates a constraint exits non-zero with the + violation list, never a partial emission; (b) a non-scalar + attribute value (list or map) is only preserved by `--format json` + — the build-system formatters return `Error::Schema` rather than + invent a silent flattening convention. Both are explicit + rejections of the "silent accept" antipattern this project + tracks in `REQ-004`. + tags: [variant, cli, loud, design-decision] + links: + - type: satisfies + target: REQ-046 + - type: depends-on + target: DD-050 + fields: + rationale: > + The user's v0.4.3 direction was explicit: "1 Rust Cargo Bazel + cmake c cpp and generics, 2 both [boolean+string attributes], + 3 loud, 4 long [namespace]". Namespace-prefixed names win on + coexistence; loud-on-failure wins on debuggability; loud-on- + non-scalar wins on user control (they decide the flattening, + the tool does not guess). The alternative — silent flattening + by comma-join or last-write-wins — was rejected because the + attribute YAML is the user's source of truth, and making the + tool pick a convention invisibly would have been the same + footgun as the parse-time validations we added in #196. + alternatives-considered: > + (1) Short identifiers (FEATURE_ASIL_C) — rejected because it + conflicts with hand-rolled feature flags many projects already + have in their build system. (2) Silent flattening of + non-scalars — rejected, see rationale. (3) One format per + subcommand (`rivet variant cargo`) — rejected, single flag + keeps the CLI surface narrow and predictable. + provenance: + created-by: ai-assisted + model: claude-opus-4-7 + timestamp: 2026-04-23T05:25:00Z + + - id: FEAT-130 + type: feature + title: rivet variant features/value/attr — build-system query surface + status: approved + description: > + Three new subcommands on the variant-scoped CLI surface. `rivet + variant features --format {json,env,cargo,cmake,cpp-header, + bazel,make}` emits the resolved feature set plus per-feature + `attributes:` entries in seven build-system-specific formats. + `rivet variant value FEATURE` is a shell-friendly probe with + exit codes 0/1/2 (on/off/unknown). `rivet variant attr FEATURE + KEY` prints a single attribute. Feature models gained an + `attributes:` map per feature, parsed as `BTreeMap` and round-tripped through `solve()`. + tags: [variant, cli, build-system, v043] + links: + - type: implements + target: REQ-046 + - type: satisfies + target: DD-061 + fields: + source-ref: > + rivet-core/src/variant_emit.rs (formatters + unit tests) + rivet-core/src/feature_model.rs (attributes field) + rivet-cli/src/main.rs (cmd_variant_features/value/attr) + rivet-cli/tests/variant_emit.rs (CLI integration tests) + examples/variant/feature-model.yaml (enriched with realistic + asil-numeric, compliance, locale attributes) + verification: > + 11 unit tests in rivet_core::variant_emit::tests for per-format + rendering (slug rules, sh-quoting, loud-error path, JSON + structure preservation). + 15 integration tests in rivet-cli/tests/variant_emit.rs + covering CLI end-to-end, exit-code contract, loud-on-failure + path, and a smoke run against the shipped eu-adas-c example. + Full cargo test --workspace green. + provenance: + created-by: ai-assisted + model: claude-opus-4-7 + timestamp: 2026-04-23T05:25:00Z + + - id: FEAT-131 + type: feature + title: rivet variant explain — debug why the solver picked what it picked + status: approved + description: > + `rivet variant explain [FEATURE]` answers the "why did my + variant pick/skip feature X?" question. Full audit mode (no + FEATURE) prints every effective feature with its origin + (`selected` / `mandatory` / `implied by ` / `allowed`), + plus the unselected set and the constraint list. Single-feature + focus mode prints one feature's state, origin, attribute values, + and every constraint that mentions it. `--format json` emits + the same structured audit for scripts. + tags: [variant, cli, dev-ux, debugging, v043] + links: + - type: implements + target: REQ-046 + - type: satisfies + target: DD-050 + fields: + rationale: > + The user called this out explicitly: "we should do in parallel + that we are really good on developing and debugging this + stuff". The solver already tracks FeatureOrigin per effective + feature; the missing piece was surfacing it on the CLI. This + lands that surface. + source-ref: > + rivet-cli/src/main.rs (cmd_variant_explain, VariantAction::Explain). + 3 integration tests covering text+JSON modes and full-variant + audit. + provenance: + created-by: ai-assisted + model: claude-opus-4-7 + timestamp: 2026-04-23T05:25:00Z + + # ── S-expression hardening ────────────────────────────────────────── + + - id: DD-062 + type: design-decision + title: matches regex and count-compare validated at lower time, not runtime + status: approved + description: > + Regex patterns in `(matches "")` and the integer + RHS of a `(count …)` comparison are validated when the filter + expression is lowered from s-expression to `Expr`, not lazily at + evaluation time. A malformed regex or a non-integer count + threshold produces a parse error with the compiler's message, + not a silent empty result set. + tags: [sexpr, validation, loud, design-decision] + links: + - type: satisfies + target: REQ-004 + - type: depends-on + target: DD-058 + fields: + rationale: > + The sexpr audit (PR #194) documented `(count …)` as "meant + for numeric comparisons" but no lowering existed. Users + typing `(> (count …) 10)` saw silent no-match. Separately, + `(matches id "[")` compiled but never matched — the regex + crate failed at runtime and the evaluator swallowed the + error. Both are classic silent-accept footguns. Validating + at lower time converts the footgun into a build-time error + and matches the broader "loud" direction the project has + adopted across v0.4.2 and v0.4.3. + provenance: + created-by: ai-assisted + model: claude-opus-4-7 + timestamp: 2026-04-23T05:25:00Z + + - id: FEAT-132 + type: feature + title: count-compare lowering + matches parse-time regex validation + status: approved + description: > + New `Expr::CountCompare(Box, CompOp, i64)` variant. The + lowering pass detects a `(count )` on the LHS of any of + the six comparison operators (>, <, >=, <=, =, !=) with an + integer literal on the RHS and produces CountCompare, which the + evaluator realises by counting matching artifacts once and + comparing. Separately, `(matches "")` + now calls `regex::Regex::new` at lower time; a parse error + surfaces with the compiler's message. Non-literal patterns (rare; + field interpolation) still use the runtime-lenient path. + tags: [sexpr, lowering, validation, v043] + links: + - type: implements + target: REQ-004 + - type: satisfies + target: DD-062 + fields: + source-ref: > + rivet-core/src/sexpr_eval.rs — CountCompare variant, + count-extract helpers, regex parse-time validation. + rivet-core/tests/sexpr_predicate_matrix.rs — renamed test + pins new strict behavior. + rivet-core/tests/sexpr_fuzz.rs — expr_to_sexpr handles the + new variant so fuzz roundtrip stays equivalent. + verification: > + 5 new regression tests: count_compare_gt_threshold, + count_compare_requires_integer_rhs, + count_compare_all_six_operators_lower, + matches_rejects_invalid_regex_at_lower_time, + matches_accepts_valid_regex. + provenance: + created-by: ai-assisted + model: claude-opus-4-7 + timestamp: 2026-04-23T05:25:00Z + + # ── CI / release pipeline ─────────────────────────────────────────── + + - id: FEAT-133 + type: feature + title: Rivet Delta — pre-rendered Mermaid SVG for email/mobile viewers + status: approved + description: > + The rivet-delta GitHub Action now pre-renders the summary + Mermaid diagram to SVG via `@mermaid-js/mermaid-cli` and pushes + the rendered asset to an orphan `rivet-delta-renders` branch at + `pr-/run-/diagram.svg`. The PR comment embeds both the + Mermaid source (web UI) and an `` pointing at the + SVG so email notifications and the GitHub mobile app show the + diagram inline. Classification-priority ordering in + `scripts/diff-to-markdown.mjs` is also fixed so multi-label + changes (breaking+additive → breaking) pick the most severe. + tags: [ci, rivet-delta, ux, v043] + links: + - type: traces-to + target: FEAT-010 + fields: + source-ref: > + .github/workflows/rivet-delta.yml — SVG render step + orphan + branch push. + scripts/diff-to-markdown.mjs — --mmd-out, --svg-url flags, + classification priority. + verification: > + Playwright e2e test renders a fixture delta and asserts both + the Mermaid block and the SVG anchor appear in the comment + body. ts-transpile smoke test runs against the spec file. + provenance: + created-by: ai-assisted + model: claude-opus-4-7 + timestamp: 2026-04-23T05:25:00Z + + - id: FEAT-134 + type: feature + title: rivet stamp — correct --missing-provenance filter + warn-skip + status: approved + description: > + `rivet stamp all --missing-provenance` previously checked for a + `provenance` entry in generic `fields:`, which was always + absent because provenance is a first-class struct field on + Artifact. The filter now correctly checks `a.provenance.is_none()`. + Separately, `set_provenance` no longer aborts the whole batch on + a single CST-invisible artifact; it warns and skips that item + and continues stamping the rest. + tags: [stamp, cli, fix, v043] + links: + - type: verifies + target: REQ-004 + fields: + source-ref: > + rivet-cli/src/main.rs — cmd_stamp_all filter predicate. + rivet-core/src/store.rs — set_provenance warn-skip path. + verification: > + stamp integration tests cover both the filter (an artifact + with no provenance is stamped) and the warn-skip path + (mixing CST-visible + CST-invisible items completes). + provenance: + created-by: ai-assisted + model: claude-opus-4-7 + timestamp: 2026-04-23T05:25:00Z diff --git a/docs/getting-started.md b/docs/getting-started.md index c847964..f55ce00 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -996,6 +996,34 @@ Exit codes for `value` / `attr`: - `1` — feature not selected (defined but absent from this variant) - `2` — unknown feature, missing attribute key, or variant fails to solve +### Debugging: `rivet variant explain` + +When a variant picks up features you didn't expect (or skips ones you +did), `rivet variant explain` answers the "why?". Two modes: + +```bash +# Full audit: every effective feature + origin, unselected features, +# and the constraint list (debugger's eye view of one solve) +rivet variant explain --model fm.yaml --variant prod.yaml + +# Single-feature focus: origin, attribute values, and every +# constraint that mentions the feature +rivet variant explain --model fm.yaml --variant prod.yaml asil-c +``` + +Each effective feature carries an **origin** explaining how it got +into the set: + +| Origin | Meaning | +|--------------------|---------| +| `selected` | You listed it under `selects:` in the variant config | +| `mandatory` | Parent group is `mandatory`, or this is the root | +| `implied by ` | A constraint forced it in once `` was selected | +| `allowed` | Present in the model, solver did not prove it mandatory | + +Add `--format json` for machine-readable output (the dashboard variant +scope uses the same shape). + ### Variants in the dashboard `rivet serve` auto-discovers variant configuration when these files exist diff --git a/examples/variant/feature-model.yaml b/examples/variant/feature-model.yaml index 37cba93..603aa95 100644 --- a/examples/variant/feature-model.yaml +++ b/examples/variant/feature-model.yaml @@ -10,27 +10,55 @@ features: group: alternative children: [eu, us, cn] + # Market-specific compliance metadata eu: group: leaf + attributes: + compliance: "unece-r157" + locale: "en_EU" us: group: leaf + attributes: + compliance: "fmvss-127" + locale: "en_US" cn: group: leaf + attributes: + compliance: "gb-7258" + locale: "zh_CN" safety-level: group: alternative children: [qm, asil-a, asil-b, asil-c, asil-d] + # Each safety level carries an ISO 26262 numeric rating plus the + # minimum required analysis techniques. These show up in emitted + # build config as RIVET_ATTR_ASIL_C_ASIL_NUMERIC=3 etc. qm: group: leaf + attributes: + asil-numeric: 0 + reqs: "none" asil-a: group: leaf + attributes: + asil-numeric: 1 + reqs: "fmea" asil-b: group: leaf + attributes: + asil-numeric: 2 + reqs: "fmea" asil-c: group: leaf + attributes: + asil-numeric: 3 + reqs: "fmea-dfa" asil-d: group: leaf + attributes: + asil-numeric: 4 + reqs: "fmea-dfa-fta" feature-set: group: or diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index b025f3d..909894a 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -1036,6 +1036,31 @@ enum VariantAction { /// Attribute key key: String, }, + /// Explain a variant: why each feature is (or is not) selected, what + /// the solver did, and which constraints fired. Great for debugging + /// "why did my variant pick/skip feature X?". + /// + /// Without `[feature]`: prints the full audit (every effective + /// feature + origin, attributes, listed unselected features, and + /// the constraint list). With `[feature]`: prints just that + /// feature's state, origin, attribute values, and whether any + /// constraint mentions it. + Explain { + /// Path to feature model YAML file + #[arg(long)] + model: PathBuf, + + /// Path to variant configuration YAML file + #[arg(long)] + variant: PathBuf, + + /// Single feature to explain. Omit for a full-variant audit. + feature: Option, + + /// Output format: "text" (default) or "json" + #[arg(short, long, default_value = "text")] + format: String, + }, } fn main() -> ExitCode { @@ -1306,6 +1331,12 @@ fn run(cli: Cli) -> Result { feature, key, } => cmd_variant_attr(model, variant, feature, key), + VariantAction::Explain { + model, + variant, + feature, + format, + } => cmd_variant_explain(model, variant, feature.as_deref(), format), }, #[cfg(feature = "wasm")] Command::Import { @@ -8041,6 +8072,219 @@ fn cmd_variant_attr( } } +fn cmd_variant_explain( + model_path: &std::path::Path, + variant_path: &std::path::Path, + focus: Option<&str>, + format: &str, +) -> Result { + validate_format(format, &["text", "json"])?; + let (model, resolved) = load_and_solve_variant(model_path, variant_path)?; + use rivet_core::feature_model::FeatureOrigin; + + if let Some(name) = focus { + // Single-feature focus + let exists = model.features.contains_key(name); + let selected = resolved.effective_features.contains(name); + let origin = resolved.origins.get(name); + let attrs = model.features.get(name).map(|f| &f.attributes); + let mentioning_constraints: Vec = model + .constraints + .iter() + .filter_map(|c| { + let rendered = format!("{c:?}"); + if rendered.contains(&format!("\"{name}\"")) || rendered.contains(name) { + Some(rendered) + } else { + None + } + }) + .collect(); + + if format == "json" { + let origin_v = origin.map(|o| match o { + FeatureOrigin::UserSelected => serde_json::json!({ "kind": "selected" }), + FeatureOrigin::Mandatory => serde_json::json!({ "kind": "mandatory" }), + FeatureOrigin::ImpliedBy(c) => serde_json::json!({ "kind": "implied", "by": c }), + FeatureOrigin::AllowedButUnbound => serde_json::json!({ "kind": "allowed" }), + }); + let attrs_v: serde_json::Value = attrs + .map(|m| { + let o: serde_json::Map<_, _> = m + .iter() + .map(|(k, v)| (k.clone(), rivet_core_yaml_to_json(v))) + .collect(); + serde_json::Value::Object(o) + }) + .unwrap_or(serde_json::Value::Null); + let out = serde_json::json!({ + "feature": name, + "declared_in_model": exists, + "selected": selected, + "origin": origin_v, + "attributes": attrs_v, + "mentioning_constraints": mentioning_constraints, + "variant": resolved.name, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + return Ok(selected); + } + + println!("Feature: {name}"); + println!(" declared in model: {}", if exists { "yes" } else { "no" }); + println!(" selected in variant `{}`: {}", resolved.name, selected); + if let Some(o) = origin { + let label = match o { + FeatureOrigin::UserSelected => "user-selected via `selects:`".to_string(), + FeatureOrigin::Mandatory => "auto-selected (mandatory group or root)".to_string(), + FeatureOrigin::ImpliedBy(cause) => format!("implied by `{cause}`"), + FeatureOrigin::AllowedButUnbound => "allowed but unbound".to_string(), + }; + println!(" origin: {label}"); + } else if selected { + println!(" origin: (no origin recorded — legacy path?)"); + } else { + println!(" origin: (not selected — feature absent from effective set)"); + } + if let Some(a) = attrs { + if a.is_empty() { + println!(" attributes: (none)"); + } else { + println!(" attributes:"); + for (k, v) in a { + let rendered = match v { + serde_yaml::Value::Null => "null".into(), + serde_yaml::Value::Bool(b) => b.to_string(), + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::String(s) => format!("\"{s}\""), + _ => serde_json::to_string(&rivet_core_yaml_to_json(v)) + .unwrap_or_else(|_| "".into()), + }; + println!(" {k} = {rendered}"); + } + } + } + if !mentioning_constraints.is_empty() { + println!(" constraints mentioning `{name}`:"); + for c in &mentioning_constraints { + println!(" - {c}"); + } + } + return Ok(selected); + } + + // Full-variant audit. + if format == "json" { + let origins: serde_json::Map = resolved + .origins + .iter() + .map(|(n, o)| { + let v = match o { + FeatureOrigin::UserSelected => serde_json::json!({ "kind": "selected" }), + FeatureOrigin::Mandatory => serde_json::json!({ "kind": "mandatory" }), + FeatureOrigin::ImpliedBy(c) => serde_json::json!({ "kind": "implied", "by": c }), + FeatureOrigin::AllowedButUnbound => serde_json::json!({ "kind": "allowed" }), + }; + (n.clone(), v) + }) + .collect(); + let unselected: Vec = model + .features + .keys() + .filter(|k| !resolved.effective_features.contains(*k)) + .cloned() + .collect(); + let constraints: Vec = + model.constraints.iter().map(|c| format!("{c:?}")).collect(); + let attrs: serde_json::Map = resolved + .effective_features + .iter() + .filter_map(|n| { + let f = model.features.get(n)?; + if f.attributes.is_empty() { + None + } else { + let inner: serde_json::Map = f + .attributes + .iter() + .map(|(k, v)| (k.clone(), rivet_core_yaml_to_json(v))) + .collect(); + Some((n.clone(), serde_json::Value::Object(inner))) + } + }) + .collect(); + let out = serde_json::json!({ + "variant": resolved.name, + "effective_features": resolved.effective_features, + "origins": origins, + "unselected_features": unselected, + "attributes": attrs, + "constraints": constraints, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + return Ok(true); + } + + println!("Variant audit: `{}`", resolved.name); + println!(); + println!( + "Effective features ({} of {}):", + resolved.effective_features.len(), + model.features.len() + ); + let width = resolved + .effective_features + .iter() + .map(|s| s.len()) + .max() + .unwrap_or(0); + for name in &resolved.effective_features { + let o = resolved.origins.get(name); + let label = match o { + Some(FeatureOrigin::UserSelected) => "selected".to_string(), + Some(FeatureOrigin::Mandatory) => "mandatory".to_string(), + Some(FeatureOrigin::ImpliedBy(cause)) => format!("implied by {cause}"), + Some(FeatureOrigin::AllowedButUnbound) => "allowed".to_string(), + None => "(no origin)".to_string(), + }; + let attr_note = model + .features + .get(name) + .map(|f| { + if f.attributes.is_empty() { + String::new() + } else { + format!(" [{} attr]", f.attributes.len()) + } + }) + .unwrap_or_default(); + println!(" + {name: = model + .features + .keys() + .filter(|k| !resolved.effective_features.contains(*k)) + .collect(); + if !unselected.is_empty() { + println!(); + println!("Unselected features ({}):", unselected.len()); + for n in &unselected { + println!(" - {n}"); + } + } + + if !model.constraints.is_empty() { + println!(); + println!("Constraints ({}):", model.constraints.len()); + for c in &model.constraints { + println!(" {c:?}"); + } + } + + Ok(true) +} + /// YAML→JSON conversion for non-scalar attribute values printed by /// `rivet variant attr`. Mirrors the internal helper in `variant_emit` /// but is small enough to keep here rather than expose publicly. diff --git a/rivet-cli/tests/variant_emit.rs b/rivet-cli/tests/variant_emit.rs index 83289eb..b4b339c 100644 --- a/rivet-cli/tests/variant_emit.rs +++ b/rivet-cli/tests/variant_emit.rs @@ -250,6 +250,133 @@ fn value_unknown_feature_exits_two() { assert_eq!(out.status.code(), Some(2)); } +#[test] +fn explain_single_feature_shows_origin_and_attrs() { + let tmp = tempfile::tempdir().unwrap(); + let (m, v) = write_fixture(tmp.path()); + let out = Command::new(rivet_bin()) + .args([ + "variant", "explain", + "--model", m.to_str().unwrap(), + "--variant", v.to_str().unwrap(), + "asil-c", + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("Feature: asil-c")); + assert!(stdout.contains("selected in variant `prod`: true")); + assert!(stdout.contains("user-selected via `selects:`")); + assert!(stdout.contains("asil-numeric = 3")); + assert!(stdout.contains("reqs = \"fmea-dfa\"")); +} + +#[test] +fn explain_single_feature_json_mode() { + let tmp = tempfile::tempdir().unwrap(); + let (m, v) = write_fixture(tmp.path()); + let out = Command::new(rivet_bin()) + .args([ + "variant", "explain", + "--model", m.to_str().unwrap(), + "--variant", v.to_str().unwrap(), + "--format", "json", + "asil-c", + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); + assert_eq!(v["feature"], "asil-c"); + assert_eq!(v["selected"], true); + assert_eq!(v["origin"]["kind"], "selected"); + assert_eq!(v["attributes"]["asil-numeric"], 3); +} + +#[test] +fn explain_full_variant_audit_lists_origins_and_unselected() { + let tmp = tempfile::tempdir().unwrap(); + // Model with an optional feature so we get both selected and unselected + let model = tmp.path().join("feature-model.yaml"); + fs::write( + &model, + r#" +root: rt +features: + rt: { group: optional, children: [a, b, c] } + a: { group: leaf } + b: { group: leaf } + c: { group: leaf } +"#, + ) + .unwrap(); + let variant = tmp.path().join("v.yaml"); + fs::write(&variant, "name: v\nselects:\n - a\n").unwrap(); + + let out = Command::new(rivet_bin()) + .args([ + "variant", "explain", + "--model", model.to_str().unwrap(), + "--variant", variant.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("Variant audit: `v`")); + assert!(stdout.contains("Effective features")); + assert!(stdout.contains("+ a")); + assert!(stdout.contains("Unselected features")); + assert!(stdout.contains("- b")); + assert!(stdout.contains("- c")); +} + +/// Smoke every formatter against the shipped examples/variant/ fixture. +/// Catches regressions where a format change works on a toy model but +/// breaks on a realistic one (constraint-driven inclusion, multiple +/// attribute types per feature, non-trivial tree depth). +#[test] +fn every_format_renders_realistic_example() { + let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest.parent().expect("workspace root"); + let model = workspace_root.join("examples/variant/feature-model.yaml"); + let variant = workspace_root.join("examples/variant/eu-adas-c.yaml"); + if !model.exists() || !variant.exists() { + // Keep this test silent if the examples dir is stripped from + // a release tarball — real users run it against the repo. + return; + } + for fmt in ["json", "env", "cargo", "cmake", "cpp-header", "bazel", "make"] { + let out = Command::new(rivet_bin()) + .args([ + "variant", "features", + "--model", model.to_str().unwrap(), + "--variant", variant.to_str().unwrap(), + "--format", fmt, + ]) + .output() + .unwrap_or_else(|e| panic!("rivet variant features --format {fmt}: {e}")); + assert!( + out.status.success(), + "--format {fmt} failed: stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + // Every format should mention the variant name and ASIL-C + // (the headline feature from the example). + assert!( + stdout.contains("eu-adas-c"), + "--format {fmt}: variant name missing in output:\n{stdout}" + ); + let feature_markers = ["ASIL_C", "asil-c"]; + assert!( + feature_markers.iter().any(|m| stdout.contains(m)), + "--format {fmt}: no asil-c marker in output:\n{stdout}" + ); + } +} + #[test] fn attr_prints_scalar_and_errors_on_missing_key() { let tmp = tempfile::tempdir().unwrap();