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
42 changes: 40 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -769,14 +769,52 @@ rivet coverage --filter '(has-tag "safety")'
```

Available predicates: `=`, `!=`, `>`, `<`, `>=`, `<=`, `in`, `has-tag`, `has-field`,
`matches` (regex), `contains`, `linked-by`, `linked-from`, `linked-to`, `links-count`.
`matches` (regex), `contains`, `linked-by`, `linked-from`, `linked-to`, `linked-via`,
`links-count`.

Logical: `and`, `or`, `not`, `implies`, `excludes`.

Quantifiers: `forall`, `exists`, `count`.

Graph: `reachable-from`, `reachable-to`.

### Link predicates: which one do I want?

The `linked-*` family looks at four different shapes of question. Pick by what
you have on hand and what direction you care about:

| Form | Direction | Filter on | True iff |
|--------------------------------------|-----------|--------------------------|---------------------------------------------------------------------------|
| `(linked-via "T")` | outbound | link-type | the artifact has at least one outbound link of type `T` |
| `(linked-by "T" _)` | outbound | link-type | same as `linked-via "T"` — kept for backwards compatibility |
| `(linked-by "T" "DD-001")` | outbound | link-type + target id | the artifact has an outbound link of type `T` to `DD-001` |
| `(linked-to "DD-001")` | outbound | target id only | the artifact has any outbound link to `DD-001` (any link-type) |
| `(linked-from "T" _)` | inbound | link-type | the artifact has at least one inbound link of type `T` |
| `(linked-from "T" "REQ-004")` | inbound | link-type + source id | `REQ-004` has an outbound link of type `T` pointing at this artifact |
| `(links-count "T" > 2)` | outbound | link-type cardinality | the artifact has more than two outbound links of type `T` |

**Mnemonics.** `linked-via T` reads as "this artifact is linked **via** a `T`-link"
— the artifact is the source. `linked-from S` reads as "linked **from** S" —
the artifact is the target. `linked-to ID` reads as "linked **to** that id" —
the artifact is the source.

**Negation finds gaps.** The motivating case behind `linked-via` is gap-hunt:

```bash
# Attack-scenarios with no outbound `exploits` link
rivet list --filter '(and (= type "attack-scenario") (not (linked-via "exploits")))'

# Requirements with no inbound `verifies` link
rivet list --filter '(and (= type "requirement") (not (linked-from "verifies" _)))'

# Hazards with no inbound `prevents` link
rivet list --filter '(and (= type "hazard") (not (linked-from "prevents" _)))'
```

Both spellings work for outbound link-type membership: `(linked-via "T")` and
`(linked-by "T" _)` are equivalent. Reach for `linked-via` when you only care
about the link-type and `linked-by` when you also want to pin the target.

### Count comparisons

`(count <scope>)` as a standalone form matches artifacts that exist in
Expand Down Expand Up @@ -813,7 +851,7 @@ Only single-name field accessors are supported today. Dotted forms like
`links.satisfies.target` parse as a single symbol and currently resolve
to the empty string — they do not navigate nested structure. To filter
on links, use the purpose-built predicates (`linked-by`, `linked-from`,
`linked-to`, `links-count`) rather than field-path navigation.
`linked-to`, `linked-via`, `links-count`) rather than field-path navigation.

---

Expand Down
122 changes: 113 additions & 9 deletions rivet-core/src/sexpr_eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ fn classify_filter_error(source: &str, message: &str) -> Option<String> {
"linked-by",
"linked-from",
"linked-to",
"linked-via",
"links-count",
"reachable-from",
"reachable-to",
Expand Down Expand Up @@ -585,11 +586,18 @@ fn classify_filter_error(source: &str, message: &str) -> Option<String> {
// Case 3: unknown function / head symbol. The lowerer emits a
// message that typically mentions "unknown form" or "unexpected".
if message.contains("unknown") || message.contains("unexpected form") {
return Some(
"unknown head symbol; see docs/getting-started.md for the supported forms \
(and/or/not/implies/excludes/=/!=/>/</has-tag/has-field/in/matches/contains/linked-*)"
.to_string(),
);
// Build the operator list from the same source of truth as the
// lowerer (`HEADS`) so adding a new operator never leaves this hint
// stale. Group the linked-* family explicitly — issue #190 documents
// a real user who could not discover `linked-via` from the previous
// `linked-*` wildcard.
let mut heads: Vec<&str> = HEADS.to_vec();
heads.sort_unstable();
return Some(format!(
"unknown head symbol; see docs/getting-started.md \
for the supported forms ({})",
heads.join("/")
));
}

None
Expand Down Expand Up @@ -921,6 +929,24 @@ fn lower_list(node: &crate::sexpr::SyntaxNode, errors: &mut Vec<LowerError>) ->
let val = extract_value(&args[0])?;
Some(Expr::LinkedTo(val))
}
// `linked-via` is the explicit-direction alias for outbound link-type
// membership: `(linked-via "T")` is true iff the artifact has at least
// one outbound link of type T. Equivalent to `(linked-by "T" _)`; the
// separate name is offered because issue #190 documents that authors
// reach for `via`/`out-link`/`has-link` when they want this and
// misread `linked-by` as inbound. Lowers to the existing AST node so
// the evaluator and `links-count` complement remain a single code path.
"linked-via" => {
if args.len() != 1 {
errors.push(LowerError {
offset,
message: "'linked-via' requires exactly 1 argument (the link type)".into(),
});
return None;
}
let lt = extract_value(&args[0])?;
Some(Expr::LinkedBy(lt, Value::Wildcard))
}
"links-count" => {
if args.len() != 3 {
errors.push(LowerError {
Expand Down Expand Up @@ -1353,6 +1379,81 @@ mod tests {
assert!(run(&expr, &test_artifact()));
}

// Issue #190: `linked-via "T"` means "has at least one outbound link of
// type T". The motivating use case is gap-hunt: every attack-scenario
// missing an outbound `exploits` link. Three checks:
// 1) artifact with the link-type → present
// 2) artifact without the link-type → absent
// 3) `(not (linked-via "X"))` flips, so it actually finds gaps
#[test]
#[cfg_attr(miri, ignore)]
fn filter_linked_via_outbound_present() {
let expr = parse_filter(r#"(linked-via "satisfies")"#).unwrap();
assert!(run(&expr, &test_artifact()));
}

#[test]
#[cfg_attr(miri, ignore)]
fn filter_linked_via_outbound_absent() {
let expr = parse_filter(r#"(linked-via "exploits")"#).unwrap();
assert!(!run(&expr, &test_artifact()));
}

#[test]
#[cfg_attr(miri, ignore)]
fn filter_not_linked_via_finds_gap() {
let expr = parse_filter(r#"(not (linked-via "exploits"))"#).unwrap();
assert!(run(&expr, &test_artifact()));
let expr = parse_filter(r#"(not (linked-via "satisfies"))"#).unwrap();
assert!(!run(&expr, &test_artifact()));
}

#[test]
#[cfg_attr(miri, ignore)]
fn filter_linked_via_arity() {
// 0 args → error, 2 args → error, 1 arg → ok.
assert!(parse_filter(r#"(linked-via)"#).is_err());
assert!(parse_filter(r#"(linked-via "satisfies" "DD-001")"#).is_err());
assert!(parse_filter(r#"(linked-via "satisfies")"#).is_ok());
}

#[test]
#[cfg_attr(miri, ignore)]
fn filter_linked_via_equivalent_to_linked_by_wildcard() {
// `(linked-via "T")` and `(linked-by "T" _)` lower differently in
// surface syntax but must produce identical match outcomes.
let via = parse_filter(r#"(linked-via "satisfies")"#).unwrap();
let by_wild = parse_filter(r#"(linked-by "satisfies" _)"#).unwrap();
let art = test_artifact();
assert_eq!(run(&via, &art), run(&by_wild, &art));
let via2 = parse_filter(r#"(linked-via "exploits")"#).unwrap();
let by_wild2 = parse_filter(r#"(linked-by "exploits" _)"#).unwrap();
assert_eq!(run(&via2, &art), run(&by_wild2, &art));
}

#[test]
#[cfg_attr(miri, ignore)]
fn unknown_head_hint_lists_linked_via() {
// Regression: the parse-error hint must enumerate `linked-via` so a
// user who tried `(linked-vai "X")` (typo) discovers the right name.
let result = parse_filter(r#"(out-link "satisfies")"#);
assert!(result.is_err());
let errs = result.err().unwrap();
let hints: Vec<String> = errs.iter().filter_map(|e| e.note.clone()).collect();
let combined = hints.join(" | ");
assert!(
combined.contains("linked-via"),
"hint should enumerate linked-via; got: {combined}"
);
// And the rest of the linked-* family while we're at it.
for op in ["linked-by", "linked-from", "linked-to"] {
assert!(
combined.contains(op),
"hint should enumerate {op}; got: {combined}"
);
}
}

#[test]
#[cfg_attr(miri, ignore)]
fn filter_links_count() {
Expand Down Expand Up @@ -1685,10 +1786,13 @@ mod tests {
.note
.as_ref()
.expect("expected a note on unknown head symbol");
assert!(
note.contains("unknown head symbol") && note.contains("and/or/not"),
"note should list supported forms. got: {note}"
);
// The hint is now generated from the HEADS array (sorted) rather
// than a hand-maintained string, so check for the load-bearing
// anchor + a representative selection of operators.
assert!(note.contains("unknown head symbol"), "got: {note}");
for op in ["and", "or", "not", "has-tag", "linked-via"] {
assert!(note.contains(op), "note should list `{op}`; got: {note}");
}
}

/// Valid s-expression input must not carry a note — classification
Expand Down
32 changes: 32 additions & 0 deletions rivet-core/tests/sexpr_doc_examples.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,37 @@ fn docs_example_linked_by_wildcard() {
);
}

// docs/getting-started.md gap-hunt example added with `linked-via`:
// `rivet list --filter '(and (= type "attack-scenario") (not (linked-via "exploits")))'`
//
// This fixture has no attack-scenarios, so adapt: in the requirement
// world, "find requirements that don't satisfy anything" is the same
// shape, exercised below.
#[test]
fn docs_example_linked_via_outbound_membership() {
// `(linked-via "T")` is the explicit-direction sibling of
// `(linked-by "T" _)` and selects the same set of artifacts.
let (store, graph) = fixture();
let via = count_matches(r#"(linked-via "satisfies")"#, &store, &graph);
let by_wild = count_matches(r#"(linked-by "satisfies" _)"#, &store, &graph);
assert_eq!(via, by_wild, "linked-via and linked-by _ must agree");
assert_eq!(via, 3); // REQ-001, REQ-002, REQ-003
}

#[test]
fn docs_example_not_linked_via_finds_gap() {
// The motivating use-case from issue #190: find artifacts of a given
// type with NO outbound link of the named type. Here: requirements
// that satisfy nothing — REQ-004 is the only such requirement.
let (store, graph) = fixture();
let n = count_matches(
r#"(and (= type "requirement") (not (linked-via "satisfies")))"#,
&store,
&graph,
);
assert_eq!(n, 1); // REQ-004
}

#[test]
fn docs_example_links_count_gt_two() {
// `rivet list --filter '(links-count "satisfies" > 2)'`
Expand Down Expand Up @@ -221,6 +252,7 @@ fn docs_listed_predicates_all_parse_as_forms() {
r#"(linked-by "satisfies")"#,
r#"(linked-from "satisfies")"#,
r#"(linked-to "REQ-001")"#,
r#"(linked-via "satisfies")"#,
r#"(links-count "satisfies" > 1)"#,
r#"(and true false)"#,
r#"(or true false)"#,
Expand Down
Loading