diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..fc7a9725 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"cb60aa6b-b112-4d31-8e53-2ac3e19a3ff7","pid":73847,"procStart":"Fri May 29 16:53:25 2026","acquiredAt":1780208158852} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6af3ac..4c9d483f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ ### Fixed +- **Issue #349 — `required-backlink` rules now match the inverse-name + convention.** Schemas (e.g. `safety-case.yaml`) declare + `required-backlink: supported-by` — the *inverse* of the forward + `supports` link — and both `rivet validate` and `rivet coverage` + compared that name against the stored `Backlink.link_type`, which + holds the *forward* name. Result: GSN safety-case rules like + `goal-has-support` fired (and counted as uncovered) for every + artifact, even when the supporting solution was correctly linked. + Match now accepts either the forward or the inverse name, so + both conventions (`dev.yaml` uses forward, `safety-case.yaml` uses + inverse) validate consistently. Same fix path additionally evaluates + `alternate-backlinks` in both engines — previously a goal satisfied + only via an alternate (e.g. `decomposed-by` instead of + `supported-by`) was reported as missing. - **REQ-110 / REQ-111 — coverage "totals" no longer masquerade as artifact counts.** The dashboard overview rendered `{covered} / {total} artifacts covered` and the `coverage --format json` `overall` object exposed diff --git a/rivet-core/src/coverage.rs b/rivet-core/src/coverage.rs index a6f48a6d..9bd30464 100644 --- a/rivet-core/src/coverage.rs +++ b/rivet-core/src/coverage.rs @@ -191,22 +191,40 @@ pub fn compute_coverage(store: &Store, schema: &Schema, graph: &LinkGraph) -> Co .is_some_and(|a| target_types.contains(&a.artifact_type)) } }), - CoverageDirection::Backward => graph - .backlinks_to(id) - .iter() - // Same reasoning as forward: a backlink from the artifact - // to itself (self-referential link) cannot count as - // "satisfied by a different artifact." - .filter(|bl| bl.link_type == link_type && bl.source != *id) - .any(|bl| { - if target_types.is_empty() { - true - } else { - store - .get(&bl.source) - .is_some_and(|a| target_types.contains(&a.artifact_type)) - } - }), + CoverageDirection::Backward => { + // Schemas write the backlink name as either the forward + // link-type (e.g. `satisfies`) or the inverse + // (e.g. `supported-by`); accept either. `alternate-backlinks` + // adds further acceptable shapes (e.g. a safety-goal that + // is `supported-by` OR `decomposed-by` OR `has-sub-goal`). + let backlinks = graph.backlinks_to(id); + let backlink_matches = |link_name: &str, from_types: &[String]| { + backlinks + .iter() + // Same reasoning as forward: a backlink from the + // artifact to itself (self-referential link) cannot + // count as "satisfied by a different artifact." + .filter(|bl| bl.source != *id) + .filter(|bl| { + bl.link_type == link_name + || bl.inverse_type.as_deref() == Some(link_name) + }) + .any(|bl| { + if from_types.is_empty() { + true + } else { + store + .get(&bl.source) + .is_some_and(|a| from_types.contains(&a.artifact_type)) + } + }) + }; + backlink_matches(&link_type, &target_types) + || rule + .alternate_backlinks + .iter() + .any(|alt| backlink_matches(&alt.link_type, &alt.from_types)) + } }; if has_match { @@ -644,4 +662,140 @@ mod tests { ); assert_eq!(entry.total, 1); } + + /// Issue #349: schemas write `required-backlink` as either the forward + /// link-type name (`supports`) or its inverse name (`supported-by`). + /// safety-case.yaml uses the inverse-name convention. With the bug, + /// no artifact was ever counted as covered for such rules because the + /// stored `Backlink.link_type` is the forward name. This regression + /// test pins the fix: both conventions must produce identical coverage. + /// + /// rivet: fixes REQ-004 + #[test] + fn required_backlink_matches_inverse_link_type_name() { + use crate::schema::LinkTypeDef; + let mut file = minimal_schema("test"); + // Declare the link type with its inverse — mirrors safety-case.yaml. + file.link_types.push(LinkTypeDef { + name: "supports".into(), + inverse: Some("supported-by".into()), + description: "Solution supports goal".into(), + source_types: vec!["safety-solution".into()], + target_types: vec!["safety-goal".into()], + }); + file.traceability_rules = vec![TraceabilityRule { + name: "goal-has-support".into(), + description: "Every safety goal must be supported by evidence".into(), + source_type: "safety-goal".into(), + required_link: None, + // INVERSE name — the case that was silently broken. + required_backlink: Some("supported-by".into()), + target_types: vec![], + from_types: vec!["safety-solution".into()], + severity: Severity::Error, + alternate_backlinks: vec![], + }]; + let schema = Schema::merge(&[file]); + + let mut store = Store::new(); + store + .insert(minimal_artifact("SG-1", "safety-goal")) + .unwrap(); + // SOL-1 has the FORWARD link `supports → SG-1`. The auto-computed + // backlink to SG-1 stores `link_type = "supports"` and + // `inverse_type = Some("supported-by")`. + store + .insert(artifact_with_links( + "SOL-1", + "safety-solution", + &[("supports", "SG-1")], + )) + .unwrap(); + + let graph = LinkGraph::build(&store, &schema); + let report = compute_coverage(&store, &schema, &graph); + let entry = report + .entries + .iter() + .find(|e| e.rule_name == "goal-has-support") + .expect("rule should produce a coverage entry"); + + assert_eq!( + entry.covered, 1, + "SG-1 is supported-by SOL-1 (via the forward `supports` link); \ + coverage must count it even though the rule names the inverse" + ); + assert!(entry.uncovered_ids.is_empty(), "no goals are uncovered"); + } + + /// Issue #349 secondary: `alternate-backlinks` were never evaluated. + /// Safety-case schemas express "supported-by OR decomposed-by OR + /// has-sub-goal" via this field; an artifact satisfied only via an + /// alternate must still count as covered. + /// + /// rivet: fixes REQ-004 + #[test] + fn coverage_honours_alternate_backlinks() { + use crate::schema::{AlternateBacklink, LinkTypeDef}; + let mut file = minimal_schema("test"); + file.link_types.push(LinkTypeDef { + name: "supports".into(), + inverse: Some("supported-by".into()), + description: "Solution supports goal".into(), + source_types: vec!["safety-solution".into()], + target_types: vec!["safety-goal".into()], + }); + file.link_types.push(LinkTypeDef { + name: "decomposes".into(), + inverse: Some("decomposed-by".into()), + description: "Strategy decomposes a goal".into(), + source_types: vec!["safety-strategy".into()], + target_types: vec!["safety-goal".into()], + }); + file.traceability_rules = vec![TraceabilityRule { + name: "goal-has-support".into(), + description: "Every goal supported OR decomposed".into(), + source_type: "safety-goal".into(), + required_link: None, + required_backlink: Some("supported-by".into()), + target_types: vec![], + from_types: vec!["safety-solution".into()], + alternate_backlinks: vec![AlternateBacklink { + link_type: "decomposed-by".into(), + from_types: vec!["safety-strategy".into()], + }], + severity: Severity::Error, + }]; + let schema = Schema::merge(&[file]); + + let mut store = Store::new(); + // SG-A is decomposed (alternate) — no `supports` backlink. Must + // still count as covered. + store + .insert(minimal_artifact("SG-A", "safety-goal")) + .unwrap(); + store + .insert(artifact_with_links( + "STRAT-1", + "safety-strategy", + &[("decomposes", "SG-A")], + )) + .unwrap(); + // SG-B has neither — uncovered. + store + .insert(minimal_artifact("SG-B", "safety-goal")) + .unwrap(); + + let graph = LinkGraph::build(&store, &schema); + let report = compute_coverage(&store, &schema, &graph); + let entry = report + .entries + .iter() + .find(|e| e.rule_name == "goal-has-support") + .expect("rule should produce a coverage entry"); + + assert_eq!(entry.covered, 1, "SG-A covered via alternate backlink"); + assert_eq!(entry.uncovered_ids, vec!["SG-B"]); + assert_eq!(entry.total, 2); + } } diff --git a/rivet-core/src/validate.rs b/rivet-core/src/validate.rs index d3cee31a..c4434783 100644 --- a/rivet-core/src/validate.rs +++ b/rivet-core/src/validate.rs @@ -901,15 +901,32 @@ pub fn validate_structural_with_externals_and_variant( // Backlink check (coverage). Empty `from_types` means "match any" // — same convention as `coverage::compute_coverage`. + // + // Schemas write `required-backlink` as either the forward + // link-type name (e.g. `satisfies` in `dev.yaml`) or the + // inverse name (e.g. `supported-by` in `safety-case.yaml`). + // Accept either so both conventions validate correctly — + // matching only `bl.link_type` would miss the inverse-name case. + // `alternate-backlinks` provides additional acceptable shapes + // for the same rule (e.g. a safety-goal supported via + // `supported-by` OR decomposed via `decomposed-by`). if let Some(required_backlink) = &rule.required_backlink { - let has_backlink = graph.backlinks_to(id).iter().any(|bl| { - bl.link_type == *required_backlink - && (rule.from_types.is_empty() - || store - .get(&bl.source) - .is_some_and(|s| rule.from_types.contains(&s.artifact_type))) - }); - if !has_backlink { + let backlinks = graph.backlinks_to(id); + let matches = |link_name: &str, from_types: &[String]| { + backlinks.iter().any(|bl| { + (bl.link_type == link_name || bl.inverse_type.as_deref() == Some(link_name)) + && (from_types.is_empty() + || store + .get(&bl.source) + .is_some_and(|s| from_types.contains(&s.artifact_type))) + }) + }; + let primary = matches(required_backlink, &rule.from_types); + let alternate = rule + .alternate_backlinks + .iter() + .any(|alt| matches(&alt.link_type, &alt.from_types)); + if !primary && !alternate { diagnostics.push(Diagnostic { source_file: None, line: None, @@ -2440,6 +2457,134 @@ then: ); } + /// Issue #349: `required-backlink` written as the INVERSE link-type + /// name (e.g. `supported-by`, the convention used by + /// `schemas/safety-case.yaml`) was never matched against the stored + /// `Backlink.link_type` (the FORWARD name, e.g. `supports`). The + /// goal-has-support rule fired for every goal even when a solution + /// was correctly linked. Accept either spelling. + /// + /// rivet: fixes REQ-004 + #[test] + fn required_backlink_inverse_name_is_satisfied_by_forward_link() { + use crate::schema::LinkTypeDef; + let mut file = minimal_schema("test"); + file.link_types.push(LinkTypeDef { + name: "supports".into(), + inverse: Some("supported-by".into()), + description: "Solution supports goal".into(), + source_types: vec!["safety-solution".into()], + target_types: vec!["safety-goal".into()], + }); + file.traceability_rules = vec![TraceabilityRule { + name: "goal-has-support".into(), + description: "Every safety goal must be supported".into(), + source_type: "safety-goal".into(), + required_link: None, + // Inverse-name convention from safety-case.yaml. + required_backlink: Some("supported-by".into()), + target_types: vec![], + from_types: vec!["safety-solution".into()], + severity: Severity::Error, + alternate_backlinks: vec![], + }]; + let schema = Schema::merge(&[file]); + + let mut store = Store::new(); + let mut goal = minimal_artifact("SG-1", "safety-goal"); + goal.status = Some("approved".to_string()); + store.insert(goal).unwrap(); + let mut sol = minimal_artifact("SOL-1", "safety-solution"); + sol.status = Some("approved".to_string()); + sol.links = vec![Link { + link_type: "supports".to_string(), + target: "SG-1".to_string(), + external: None, + }]; + store.insert(sol).unwrap(); + + let graph = LinkGraph::build(&store, &schema); + let diags = validate_structural(&store, &schema, &graph); + let rule_diags: Vec<_> = diags + .iter() + .filter(|d| d.rule == "goal-has-support") + .collect(); + assert!( + rule_diags.is_empty(), + "SG-1 has a supported-by backlink (from SOL-1's forward `supports` link); \ + the rule must not fire. Got diagnostics: {:?}", + rule_diags.iter().map(|d| &d.message).collect::>() + ); + } + + /// Issue #349 secondary: `validate.rs` never evaluated + /// `rule.alternate_backlinks`. A safety-goal satisfied only via + /// an alternate (e.g. `decomposed-by` instead of `supported-by`) + /// still erroneously fired the rule. + /// + /// rivet: fixes REQ-004 + #[test] + fn validate_honours_alternate_backlinks() { + use crate::schema::{AlternateBacklink, LinkTypeDef}; + let mut file = minimal_schema("test"); + file.link_types.push(LinkTypeDef { + name: "supports".into(), + inverse: Some("supported-by".into()), + description: "Solution supports goal".into(), + source_types: vec!["safety-solution".into()], + target_types: vec!["safety-goal".into()], + }); + file.link_types.push(LinkTypeDef { + name: "decomposes".into(), + inverse: Some("decomposed-by".into()), + description: "Strategy decomposes a goal".into(), + source_types: vec!["safety-strategy".into()], + target_types: vec!["safety-goal".into()], + }); + file.traceability_rules = vec![TraceabilityRule { + name: "goal-supported-or-decomposed".into(), + description: "Every goal supported OR decomposed".into(), + source_type: "safety-goal".into(), + required_link: None, + required_backlink: Some("supported-by".into()), + target_types: vec![], + from_types: vec!["safety-solution".into()], + alternate_backlinks: vec![AlternateBacklink { + link_type: "decomposed-by".into(), + from_types: vec!["safety-strategy".into()], + }], + severity: Severity::Error, + }]; + let schema = Schema::merge(&[file]); + + let mut store = Store::new(); + let mut goal = minimal_artifact("SG-A", "safety-goal"); + goal.status = Some("approved".to_string()); + store.insert(goal).unwrap(); + // Strategy decomposes SG-A. No solution at all. + let mut strat = minimal_artifact("STRAT-1", "safety-strategy"); + strat.status = Some("approved".to_string()); + strat.links = vec![Link { + link_type: "decomposes".to_string(), + target: "SG-A".to_string(), + external: None, + }]; + store.insert(strat).unwrap(); + + let graph = LinkGraph::build(&store, &schema); + let diags = validate_structural(&store, &schema, &graph); + let rule_diags: Vec<_> = diags + .iter() + .filter(|d| d.rule == "goal-supported-or-decomposed") + .collect(); + assert!( + rule_diags.is_empty(), + "SG-A is satisfied via the alternate `decomposed-by` backlink; \ + the rule must not fire. Got: {:?}", + rule_diags.iter().map(|d| &d.message).collect::>() + ); + } + // ── Mutation-pinning tests for link cardinality ──────────────────── // // Each test pins one or more surviving mutants in