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
8 changes: 5 additions & 3 deletions rivet-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3318,7 +3318,9 @@ fn cmd_list(
anyhow::anyhow!("invalid filter: {}", msgs.join("; "))
})?;
let graph = rivet_core::links::LinkGraph::build(&store, &ctx.schema);
results.retain(|a| rivet_core::sexpr_eval::matches_filter(&expr, a, &graph));
results.retain(|a| {
rivet_core::sexpr_eval::matches_filter_with_store(&expr, a, &graph, &store)
});
}

if format == "json" {
Expand Down Expand Up @@ -3379,7 +3381,7 @@ fn cmd_stats(
})?;
let mut filtered = rivet_core::store::Store::default();
for a in store.iter() {
if rivet_core::sexpr_eval::matches_filter(&expr, a, &graph) {
if rivet_core::sexpr_eval::matches_filter_with_store(&expr, a, &graph, &store) {
filtered.upsert(a.clone());
}
}
Expand Down Expand Up @@ -3480,7 +3482,7 @@ fn cmd_coverage(
})?;
let mut filtered = rivet_core::store::Store::default();
for a in store.iter() {
if rivet_core::sexpr_eval::matches_filter(&expr, a, &graph) {
if rivet_core::sexpr_eval::matches_filter_with_store(&expr, a, &graph, &store) {
filtered.upsert(a.clone());
}
}
Expand Down
7 changes: 6 additions & 1 deletion rivet-cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,12 @@ fn tool_query(proj: &McpProject, params: &QueryParams) -> Result<Value> {
let mut results: Vec<Value> = Vec::new();

for artifact in proj.store.iter() {
if !rivet_core::sexpr_eval::matches_filter(&expr, artifact, &proj.graph) {
if !rivet_core::sexpr_eval::matches_filter_with_store(
&expr,
artifact,
&proj.graph,
&proj.store,
) {
continue;
}
let links_json: Vec<Value> = artifact
Expand Down
7 changes: 6 additions & 1 deletion rivet-cli/src/serve/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,12 @@ pub(crate) async fn artifacts(
continue;
}
if let Some(ref expr) = sexpr_filter {
if !rivet_core::sexpr_eval::matches_filter(expr, artifact, &guard.graph) {
if !rivet_core::sexpr_eval::matches_filter_with_store(
expr,
artifact,
&guard.graph,
&guard.store,
) {
continue;
}
}
Expand Down
2 changes: 1 addition & 1 deletion rivet-core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ pub fn filter_artifact_ids(
let graph = build_link_graph(db, source_set, schema_set);
store
.iter()
.filter(|a| crate::sexpr_eval::matches_filter(&expr, a, &graph))
.filter(|a| crate::sexpr_eval::matches_filter_with_store(&expr, a, &graph, &store))
.map(|a| a.id.clone())
.collect()
}
Expand Down
171 changes: 170 additions & 1 deletion rivet-core/src/sexpr_eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use crate::links::LinkGraph;
use crate::model::Artifact;
use crate::store::Store;

// ── Typed AST ───────────────────────────────────────────────────────────

Expand Down Expand Up @@ -63,6 +64,23 @@ pub enum Expr {
/// `(links-count "satisfies" > 2)` — cardinality check.
LinksCount(Value, CompOp, Value),

// ── Quantifiers (require Store access) ────────────────────────
/// `(forall <scope> <predicate>)` — all artifacts in scope satisfy predicate.
/// Scope is a filter expression; predicate is checked per matching artifact.
Forall(Box<Expr>, Box<Expr>),
/// `(exists <scope> <predicate>)` — at least one artifact in scope satisfies predicate.
Exists(Box<Expr>, Box<Expr>),
/// `(count <scope>)` — number of artifacts matching scope (compared via parent).
Count(Box<Expr>),

// ── Graph traversal ─────────────────────────────────────────────
/// `(reachable-from "REQ-001" "satisfies")` — true if current artifact is
/// reachable from the given start via the given link type.
ReachableFrom(Value, Value),
/// `(reachable-to "TEST-090" "verifies")` — true if the given target is
/// reachable from the current artifact via the given link type.
ReachableTo(Value, Value),

// ── Literal ─────────────────────────────────────────────────────
/// Constant boolean (useful after constant folding).
BoolLit(bool),
Expand Down Expand Up @@ -101,9 +119,14 @@ pub enum CompOp {
// ── Evaluation context ──────────────────────────────────────────────────

/// Context needed to check a predicate against one artifact.
///
/// For quantifier expressions (forall, exists, count), the `store` field
/// must be set. Single-artifact predicates work without it.
pub struct EvalContext<'a> {
pub artifact: &'a Artifact,
pub graph: &'a LinkGraph,
/// Required for quantifier expressions. None = quantifiers return false.
pub store: Option<&'a Store>,
}

// ── Predicate checker ───────────────────────────────────────────────────
Expand Down Expand Up @@ -192,6 +215,67 @@ pub fn check(expr: &Expr, ctx: &EvalContext) -> bool {
}
}

// Quantifiers
Expr::Forall(scope, predicate) => {
let Some(store) = ctx.store else {
return false;
};
store.iter().all(|a| {
let scope_ctx = EvalContext {
artifact: a,
graph: ctx.graph,
store: ctx.store,
};
// If artifact doesn't match scope, it's vacuously true
if !check(scope, &scope_ctx) {
return true;
}
check(predicate, &scope_ctx)
})
}
Expr::Exists(scope, predicate) => {
let Some(store) = ctx.store else {
return false;
};
store.iter().any(|a| {
let scope_ctx = EvalContext {
artifact: a,
graph: ctx.graph,
store: ctx.store,
};
check(scope, &scope_ctx) && check(predicate, &scope_ctx)
})
}
Expr::Count(_scope) => {
// Count is not a boolean predicate on its own — it's used
// inside comparison expressions. Return true if count > 0.
let Some(store) = ctx.store else {
return false;
};
store.iter().any(|a| {
let scope_ctx = EvalContext {
artifact: a,
graph: ctx.graph,
store: ctx.store,
};
check(_scope, &scope_ctx)
})
}

// Graph traversal
Expr::ReachableFrom(start_id, link_type) => {
let start = value_to_str(start_id);
let lt = value_to_str(link_type);
let reachable = ctx.graph.reachable(&start, &lt);
reachable.contains(&ctx.artifact.id)
}
Expr::ReachableTo(target_id, link_type) => {
let target = value_to_str(target_id);
let lt = value_to_str(link_type);
let reachable = ctx.graph.reachable(&ctx.artifact.id, &lt);
reachable.contains(&target)
}

Expr::BoolLit(b) => *b,
}
}
Expand Down Expand Up @@ -341,7 +425,26 @@ pub fn parse_filter(source: &str) -> Result<Expr, Vec<FilterError>> {

/// Convenience: parse a filter and check it against one artifact.
pub fn matches_filter(expr: &Expr, artifact: &Artifact, graph: &LinkGraph) -> bool {
let ctx = EvalContext { artifact, graph };
let ctx = EvalContext {
artifact,
graph,
store: None,
};
check(expr, &ctx)
}

/// Check a filter with full store access (needed for quantifiers).
pub fn matches_filter_with_store(
expr: &Expr,
artifact: &Artifact,
graph: &LinkGraph,
store: &Store,
) -> bool {
let ctx = EvalContext {
artifact,
graph,
store: Some(store),
};
check(expr, &ctx)
}

Expand Down Expand Up @@ -594,6 +697,71 @@ fn lower_list(node: &crate::sexpr::SyntaxNode, errors: &mut Vec<LowerError>) ->
Some(Expr::LinksCount(lt, op, val))
}

// Quantifiers
"forall" => {
if args.len() != 2 {
errors.push(LowerError {
offset,
message: "'forall' requires exactly 2 arguments (scope predicate)".into(),
});
return None;
}
let scope = lower_child(&args[0], errors)?;
let pred = lower_child(&args[1], errors)?;
Some(Expr::Forall(Box::new(scope), Box::new(pred)))
}
"exists" => {
if args.len() != 2 {
errors.push(LowerError {
offset,
message: "'exists' requires exactly 2 arguments (scope predicate)".into(),
});
return None;
}
let scope = lower_child(&args[0], errors)?;
let pred = lower_child(&args[1], errors)?;
Some(Expr::Exists(Box::new(scope), Box::new(pred)))
}
"count" => {
if args.len() != 1 {
errors.push(LowerError {
offset,
message: "'count' requires exactly 1 argument (scope)".into(),
});
return None;
}
let scope = lower_child(&args[0], errors)?;
Some(Expr::Count(Box::new(scope)))
}

// Graph traversal
"reachable-from" => {
if args.len() != 2 {
errors.push(LowerError {
offset,
message: "'reachable-from' requires exactly 2 arguments (start-id link-type)"
.into(),
});
return None;
}
let start = extract_value(&args[0])?;
let lt = extract_value(&args[1])?;
Some(Expr::ReachableFrom(start, lt))
}
"reachable-to" => {
if args.len() != 2 {
errors.push(LowerError {
offset,
message: "'reachable-to' requires exactly 2 arguments (target-id link-type)"
.into(),
});
return None;
}
let target = extract_value(&args[0])?;
let lt = extract_value(&args[1])?;
Some(Expr::ReachableTo(target, lt))
}

unknown => {
errors.push(LowerError {
offset,
Expand Down Expand Up @@ -741,6 +909,7 @@ mod tests {
let ctx = EvalContext {
artifact,
graph: &graph,
store: None,
};
check(expr, &ctx)
}
Expand Down
1 change: 1 addition & 0 deletions rivet-core/tests/proptest_sexpr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ fn run_check(expr: &Expr, artifact: &Artifact) -> bool {
let ctx = EvalContext {
artifact,
graph: &graph,
store: None,
};
sexpr_eval::check(expr, &ctx)
}
Expand Down
Loading