Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,37 @@
"Skill(commit-commands:commit)",
"Bash(cat)",
"Read(//tmp/**)",
"Skill(commit-commands:commit-push-pr)"
"Skill(commit-commands:commit-push-pr)",
"Bash(git tag -a v0.4.0 9a46e86 -m 'Release v0.4.0 *)",
"Bash(gh release *)",
"Bash(git tag *)",
"Bash(./target/release/rivet validate *)",
"Bash(./target/release/rivet stats *)",
"Bash(./target/release/rivet coverage *)",
"Bash(./target/release/rivet commits *)",
"Bash(awk -F'|' '{printf \"%-10s %-60s %s\\\\n\", substr\\($1,1,8\\), substr\\($2,1,60\\), substr\\($3,1,80\\)}')",
"Bash(./target/release/rivet list *)",
"Bash(awk '{print $6, $7, $8, $NF}')",
"Bash(awk 'BEGIN{job=\"\"} /:$/{if\\($0!~/error/\\){job=$0}} /continue-on-error: true/{print job}')",
"Bash(cargo mutants *)",
"Bash(curl -s \"https://raw.githubusercontent.com/pulseengine/rules_verus/e2c1600a8cca4c0deb78c5fcb4a33f1da2273d29/verus/BUILD.bazel\")",
"Bash(curl -s \"https://raw.githubusercontent.com/pulseengine/rules_verus/e2c1600a8cca4c0deb78c5fcb4a33f1da2273d29/verus/extensions.bzl\")",
"Bash(git ls-remote *)",
"Bash(awk -F'\\\\t' '{print $1,$4}')",
"Bash(awk -F'\\\\t' '{print $2}')",
"WebFetch(domain:arxiv.org)",
"Bash(pdftotext /Users/r/.claude/projects/-Users-r-git-pulseengine-rivet/b8aa1c86-f679-4617-b1b6-9173ce3de7fc/tool-results/webfetch-1776711947091-wbamv0.pdf /tmp/paper.txt)",
"Bash(pip install *)",
"Bash(/opt/homebrew/bin/python3.11 -c ' *)",
"Bash(awk -F'\\\\t' '{printf \"%-30s %s\\\\n\",$1,$2}')",
"Bash(awk -F'\\\\t' '{printf \"%-35s %s\\\\n\",$1,$2}')",
"Bash(awk -F'\\\\t' '$2==\"fail\"{print $1}')",
"Bash(awk -F'\\\\t' '$2==\"fail\"{print $1,$4}')",
"Bash(awk -F'\\\\t' '{printf \"%-35s %-5s %s\\\\n\",$1,$2,$4}')",
"Bash(cargo tree *)",
"Bash(git restore *)",
"Bash(awk -F'\\\\t' '{print $4}')",
"Bash(awk -F'\\\\t' '{print $1, $2}')"
]
}
}
98 changes: 96 additions & 2 deletions rivet-core/src/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ pub fn compute_coverage(store: &Store, schema: &Schema, graph: &LinkGraph) -> Co
CoverageDirection::Forward => graph
.links_from(id)
.iter()
.filter(|l| l.link_type == link_type)
// Self-satisfying links (DD-001 → DD-001) must not count:
// an author could otherwise close the loop on their own
// artifact and pass coverage with zero upstream trace.
.filter(|l| l.link_type == link_type && l.target != *id)
.any(|l| {
if target_types.is_empty() {
true
Expand All @@ -119,7 +122,10 @@ pub fn compute_coverage(store: &Store, schema: &Schema, graph: &LinkGraph) -> Co
CoverageDirection::Backward => graph
.backlinks_to(id)
.iter()
.filter(|bl| bl.link_type == link_type)
// 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
Expand Down Expand Up @@ -287,4 +293,92 @@ mod tests {
assert!(json.contains("req-coverage"));
assert!(json.contains("dd-justification"));
}

/// Self-satisfying links (`source == target`, e.g. `DD-001 → DD-001`)
/// must not count as satisfying a traceability rule. Otherwise an
/// author can close the loop on their own artifact and pass CI without
/// any real upstream trace.
///
/// rivet: fixes REQ-004
#[test]
fn self_link_does_not_satisfy_forward_rule() {
// Rule: every DD must satisfy *any* artifact (target_types empty).
// Without the fix, a DD that points to itself would count.
let mut file = minimal_schema("test");
file.traceability_rules = vec![TraceabilityRule {
name: "dd-needs-upstream".into(),
description: "Every DD must satisfy something upstream".into(),
source_type: "design-decision".into(),
required_link: Some("satisfies".into()),
required_backlink: None,
target_types: vec![], // match any — makes the self-link trap reachable
from_types: vec![],
severity: Severity::Error,
}];
let schema = Schema::merge(&[file]);

let mut store = Store::new();
// DD-001 "satisfies" itself.
store
.insert(artifact_with_links(
"DD-001",
"design-decision",
&[("satisfies", "DD-001")],
))
.unwrap();

let graph = LinkGraph::build(&store, &schema);
let report = compute_coverage(&store, &schema, &graph);
let entry = &report.entries[0];
assert_eq!(entry.rule_name, "dd-needs-upstream");
assert_eq!(
entry.covered, 0,
"DD-001 self-satisfying link must not count as covered"
);
assert_eq!(entry.total, 1);
assert_eq!(entry.uncovered_ids, vec!["DD-001"]);
}

/// Backlink direction of the same bug: a DD that claims its own
/// requirement (e.g. REQ-X backlinked by REQ-X via some self-link)
/// must not count.
///
/// rivet: fixes REQ-004
#[test]
fn self_link_does_not_satisfy_backlink_rule() {
let mut file = minimal_schema("test");
file.traceability_rules = vec![TraceabilityRule {
name: "req-needs-downstream".into(),
description: "Every req must be satisfied by something".into(),
source_type: "requirement".into(),
required_link: None,
required_backlink: Some("satisfies".into()),
target_types: vec![],
from_types: vec![], // match any
severity: Severity::Warning,
}];
let schema = Schema::merge(&[file]);

let mut store = Store::new();
// REQ-001 has a self-satisfies link (i.e. REQ-001 → REQ-001).
// The backlink REQ-001 ← REQ-001 must not count as "satisfied by
// a downstream artifact."
store
.insert(artifact_with_links(
"REQ-001",
"requirement",
&[("satisfies", "REQ-001")],
))
.unwrap();

let graph = LinkGraph::build(&store, &schema);
let report = compute_coverage(&store, &schema, &graph);
let entry = &report.entries[0];
assert_eq!(entry.rule_name, "req-needs-downstream");
assert_eq!(
entry.covered, 0,
"self-backlink must not count REQ-001 as covered"
);
assert_eq!(entry.total, 1);
}
}
63 changes: 63 additions & 0 deletions rivet-core/src/formats/generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ impl Adapter for GenericYamlAdapter {
}
}

// `deny_unknown_fields` is deliberate: without it, typos like `artifact:`
// (singular) or `Artifacts:` (wrong case) silently deserialize to an empty
// `GenericFile`, and the offending top-level block becomes invisible to the
// trace graph. The YAML footgun fuzzer confirmed this class of bug.
#[derive(Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct GenericFile {
artifacts: Vec<GenericArtifact>,
}
Expand Down Expand Up @@ -225,4 +230,62 @@ mod tests {
"expected size-limit error, got: {msg}"
);
}

/// When a file has both a correct `artifacts:` key AND a typo-ed
/// companion key, `serde_yaml` without `deny_unknown_fields` would
/// silently drop the typo-ed key — losing any artifacts the user
/// accidentally placed there. Discovered by the YAML footgun fuzzer.
///
/// rivet: fixes REQ-004 verifies REQ-010
#[test]
fn typo_companion_key_produces_parse_error() {
// Both keys at top level: `artifacts:` is valid but `artifact:`
// (singular typo) contains artifacts the user meant to include.
// Without deny_unknown_fields, this parses Ok and the typo'd
// block is invisible.
let yaml = "\
artifacts:
- id: REQ-001
type: requirement
title: Valid entry
artifact:
- id: REQ-002
type: requirement
title: Typo'd entry
";
let result = parse_generic_yaml(yaml, None);
assert!(
result.is_err(),
"parse must fail on unknown top-level key 'artifact'; got Ok({:?})",
result.as_ref().ok()
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("artifact") || msg.contains("unknown field"),
"error message should mention the offending key, got: {msg}"
);
}

/// A file with `Artifacts:` (wrong case) alongside `artifacts:`.
///
/// rivet: fixes REQ-004 verifies REQ-010
#[test]
fn capitalized_artifacts_companion_key_produces_parse_error() {
let yaml = "\
artifacts:
- id: REQ-001
type: requirement
title: Valid entry
Artifacts:
- id: REQ-002
type: requirement
title: Wrong-case typo
";
let result = parse_generic_yaml(yaml, None);
assert!(
result.is_err(),
"parse must fail on wrong-case top-level key 'Artifacts'; got Ok({:?})",
result.as_ref().ok()
);
}
}
Loading
Loading