diff --git a/docs/getting-started.md b/docs/getting-started.md index f55ce00b..b610e04e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -769,7 +769,8 @@ 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`. @@ -777,6 +778,43 @@ 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 )` as a standalone form matches artifacts that exist in @@ -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. --- diff --git a/rivet-core/src/sexpr_eval.rs b/rivet-core/src/sexpr_eval.rs index 0769d405..38a6af5e 100644 --- a/rivet-core/src/sexpr_eval.rs +++ b/rivet-core/src/sexpr_eval.rs @@ -531,6 +531,7 @@ fn classify_filter_error(source: &str, message: &str) -> Option { "linked-by", "linked-from", "linked-to", + "linked-via", "links-count", "reachable-from", "reachable-to", @@ -585,11 +586,18 @@ fn classify_filter_error(source: &str, message: &str) -> Option { // 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/=/!=/>/ = HEADS.to_vec(); + heads.sort_unstable(); + return Some(format!( + "unknown head symbol; see docs/getting-started.md \ + for the supported forms ({})", + heads.join("/") + )); } None @@ -921,6 +929,24 @@ fn lower_list(node: &crate::sexpr::SyntaxNode, errors: &mut Vec) -> 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 { @@ -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 = 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() { @@ -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 diff --git a/rivet-core/tests/sexpr_doc_examples.rs b/rivet-core/tests/sexpr_doc_examples.rs index 48682a14..07c56fd7 100644 --- a/rivet-core/tests/sexpr_doc_examples.rs +++ b/rivet-core/tests/sexpr_doc_examples.rs @@ -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)'` @@ -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)"#,