query: add ents-query, the CommitQuery algebra and evaluator
commit c45d7df
query: add ents-query, the CommitQuery algebra and evaluator
Grammar and recursive-descent parser (three atoms closed under | & -,
left-associative at one precedence level, bare-glob compatibility),
with every write-time rejection effect.validation cites: unknown
atoms (the closed no-extensions set), refs/meta/* inside rev(),
effect-written namespaces inside meta(), and the reserved self
effect name.
Static footprint extraction from the syntax tree alone; incremental
entry sets computed from the transition frontier — a generation-
ordered paint-down walk between old and new tips for rev atoms, the
tested short-oid for results atoms, tips for meta atoms — then
membership-filtered against both transition sides, with reachability
pruned by lazily-memoized generation numbers and results resolved by
refname scan, never a history walk. work_set() substitutes self at
evaluation time (trigger entry minus any recorded status);
outstanding() is the boot-time reconciliation form.
Tested per surface: rstest tables for the enumerable grammar,
integration tests for the staged-pipeline/fan-in/exclusion idioms and
the object-read bound after a one-commit advance, and a proptest that
checks entry sets against an independent naive oracle’s
full(after) − full(before) over random histories of advances,
force-pushes, deletions, and result recordings.
crates/ents-query/src/ast.rs
@@ -1,0 +1,235 @@
+//! The `CommitQuery` syntax tree (`query.grammar`).
+
+use ents_model::Status;
+
+use crate::pattern::{Footprint, RefPattern};
+use crate::rev::RevExpr;
+
+/// The status argument of a `results()` atom: one of the closed
+/// taxonomy's three values, or `any` for any recorded status
+/// (`query.results`, `model.result-taxonomy`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum StatusFilter {
+ /// Only `pass` results.
+ Pass,
+ /// Only `fail` results.
+ Fail,
+ /// Only `error` results.
+ Error,
+ /// Any recorded status — the work-set subtraction side
+ /// (`query.workset`).
+ Any,
+}
+
+impl StatusFilter {
+ /// Whether a recorded status satisfies this filter.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_model::Status;
+ /// use ents_query::StatusFilter;
+ ///
+ /// assert!(StatusFilter::Any.admits(Status::Fail));
+ /// assert!(StatusFilter::Pass.admits(Status::Pass));
+ /// assert!(!StatusFilter::Pass.admits(Status::Error));
+ /// ```
+ #[must_use]
+ pub fn admits(&self, status: Status) -> bool {
+ match self {
+ Self::Any => true,
+ Self::Pass => status == Status::Pass,
+ Self::Fail => status == Status::Fail,
+ Self::Error => status == Status::Error,
+ }
+ }
+
+ pub(crate) fn parse(text: &str) -> Option<Self> {
+ match text {
+ "pass" => Some(Self::Pass),
+ "fail" => Some(Self::Fail),
+ "error" => Some(Self::Error),
+ "any" => Some(Self::Any),
+ _ => None,
+ }
+ }
+}
+
+impl std::fmt::Display for StatusFilter {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ Self::Pass => "pass",
+ Self::Fail => "fail",
+ Self::Error => "error",
+ Self::Any => "any",
+ })
+ }
+}
+
+/// A binary set operator (`query.set-ops`). All three share one
+/// precedence level and associate left (`query.grammar`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SetOp {
+ /// `|` — union.
+ Union,
+ /// `&` — intersection.
+ Intersect,
+ /// `-` — difference.
+ Difference,
+}
+
+impl std::fmt::Display for SetOp {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ Self::Union => "|",
+ Self::Intersect => "&",
+ Self::Difference => "-",
+ })
+ }
+}
+
+/// A parsed `CommitQuery`: three atoms closed under union,
+/// intersection, and difference (`query.grammar`), denoting a set of
+/// commits as a pure function of ref state.
+///
+/// The enum is deliberately exhaustive and public: `query.no-extensions`
+/// freezes the atom set (no content, time, or external-event terms), and
+/// `query.recursion` depends on downstream-of-an-effect being visible in
+/// this structure — see [`Query::results_dependencies`].
+///
+/// # Examples
+///
+/// ```
+/// use ents_query::{Query, SetOp};
+///
+/// // The staged-pipeline idiom.
+/// let query: Query = "rev(refs/heads/main) & results(unit, pass)".parse().expect("valid");
+/// let Query::Op { op: SetOp::Intersect, .. } = query else { panic!("an intersection") };
+/// ```
+// @relation(query.grammar, query.no-extensions, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Query {
+ /// `rev(expr)` — an ordinary Git revspec or ref glob over refs
+ /// outside `refs/meta/*` (`query.rev`).
+ Rev(RevExpr),
+ /// `results(effect, status)` — commits carrying a recorded result
+ /// (`query.results`).
+ Results {
+ /// The effect whose results namespace is scanned.
+ effect: String,
+ /// Which recorded statuses count.
+ status: StatusFilter,
+ },
+ /// `meta(glob)` — tip commits of matching author-written meta-refs
+ /// (`query.meta`).
+ Meta(RefPattern),
+ /// A binary set operation (`query.set-ops`).
+ Op {
+ /// The operator.
+ op: SetOp,
+ /// Left operand.
+ lhs: Box<Query>,
+ /// Right operand.
+ rhs: Box<Query>,
+ },
+}
+
+impl Query {
+ /// The refname patterns this query depends on, by static analysis
+ /// of the syntax tree alone (`query.footprint`): a `rev` term
+ /// contributes its own ref patterns, `results(effect, _)`
+ /// contributes the effect's results namespace, `meta(glob)`
+ /// contributes the glob itself.
+ // @relation(query.footprint, scope=function)
+ #[must_use]
+ pub fn footprint(&self) -> Footprint {
+ let mut patterns = Vec::new();
+ self.collect_patterns(&mut patterns);
+ Footprint::from_patterns(patterns)
+ }
+
+ fn collect_patterns(&self, out: &mut Vec<RefPattern>) {
+ match self {
+ Self::Rev(expr) => out.extend(expr.patterns()),
+ Self::Results { effect, .. } => {
+ if let Ok(pattern) = RefPattern::new(format!("refs/meta/results/{effect}/*")) {
+ out.push(pattern);
+ }
+ }
+ Self::Meta(glob) => out.push(glob.clone()),
+ Self::Op { lhs, rhs, .. } => {
+ lhs.collect_patterns(out);
+ rhs.collect_patterns(out);
+ }
+ }
+ }
+
+ /// The effects whose results this query reacts to, in syntactic
+ /// order — whether a query is downstream of an effect is determined
+ /// by inspecting whether `results(...)` appears in its text, never
+ /// by runtime behavior (`query.recursion`).
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_query::Query;
+ ///
+ /// let query: Query = "rev(main) & results(unit, pass) | results(integ, any)"
+ /// .parse().expect("valid");
+ /// assert_eq!(query.results_dependencies(), ["unit", "integ"]);
+ ///
+ /// let plain: Query = "rev(main)".parse().expect("valid");
+ /// assert!(plain.results_dependencies().is_empty());
+ /// ```
+ // @relation(query.recursion, scope=function)
+ #[must_use]
+ pub fn results_dependencies(&self) -> Vec<&str> {
+ let mut out = Vec::new();
+ self.collect_dependencies(&mut out);
+ out
+ }
+
+ fn collect_dependencies<'a>(&'a self, out: &mut Vec<&'a str>) {
+ match self {
+ Self::Rev(_) | Self::Meta(_) => {}
+ Self::Results { effect, .. } => {
+ if !out.contains(&effect.as_str()) {
+ out.push(effect);
+ }
+ }
+ Self::Op { lhs, rhs, .. } => {
+ lhs.collect_dependencies(out);
+ rhs.collect_dependencies(out);
+ }
+ }
+ }
+}
+
+impl std::fmt::Display for Query {
+ /// Canonical text: atoms as written, operators space-separated, a
+ /// parenthesized right operand wherever left-associativity would
+ /// otherwise regroup it. `parse(display(q)) == q` for every query.
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Rev(expr) => write!(f, "rev({})", expr.raw()),
+ Self::Results { effect, status } => write!(f, "results({effect}, {status})"),
+ Self::Meta(glob) => write!(f, "meta({glob})"),
+ Self::Op { op, lhs, rhs } => {
+ write!(f, "{lhs} {op} ")?;
+ if matches!(**rhs, Self::Op { .. }) {
+ write!(f, "({rhs})")
+ } else {
+ write!(f, "{rhs}")
+ }
+ }
+ }
+ }
+}
+
+impl std::str::FromStr for Query {
+ type Err = crate::error::ParseError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ crate::parse::parse(s)
+ }
+}
crates/ents-query/src/error.rs
@@ -1,0 +1,190 @@
+//! Parse-time and evaluation-time error types.
+//!
+//! [`ParseError`] is a *validation verdict on author input*: an effect
+//! definition carrying a trigger that fails to parse must be rejected
+//! before it is stored (`effect.validation`), so every variant explains
+//! what the author wrote wrong. [`EvalError`] is infrastructure — a
+//! store read failed mid-evaluation — and never a statement about the
+//! query's meaning.
+
+use gix_hash::ObjectId;
+
+/// Everything that makes a `CommitQuery` malformed (`query.grammar`,
+/// `query.rev`, `query.meta`).
+#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
+#[non_exhaustive]
+pub enum ParseError {
+ /// The input ended where a term was required.
+ #[error("unexpected end of query; expected a term")]
+ UnexpectedEnd,
+
+ /// An atom other than `rev`, `results`, or `meta` was named. The
+ /// grammar deliberately has no content, time, or external-event
+ /// atoms (`query.no-extensions`).
+ #[error(
+ "unknown atom {name:?}: the grammar has exactly rev(), results(), and meta() \
+ (query.no-extensions)"
+ )]
+ UnknownAtom {
+ /// The atom name as written.
+ name: String,
+ },
+
+ /// Structurally expected input was missing at `at` (byte offset).
+ #[error("expected {expected} at byte {at}")]
+ Expected {
+ /// What the parser needed.
+ expected: &'static str,
+ /// Byte offset into the query text.
+ at: usize,
+ },
+
+ /// A parenthesis never closed.
+ #[error("unbalanced parenthesis opened at byte {at}")]
+ Unbalanced {
+ /// Byte offset of the unmatched `(`.
+ at: usize,
+ },
+
+ /// The query parsed, but input remained.
+ #[error("trailing input after query: {rest:?}")]
+ Trailing {
+ /// The unconsumed tail.
+ rest: String,
+ },
+
+ /// `results()` was given a status outside the closed taxonomy.
+ #[error("results() status must be pass, fail, error, or any; got {got:?}")]
+ BadStatus {
+ /// The status as written.
+ got: String,
+ },
+
+ /// `results()` was given an effect name that is not a valid single
+ /// ref-path segment (`effect.definition`).
+ #[error("effect name {got:?} is not a valid ref-path segment")]
+ BadEffectName {
+ /// The effect name as written.
+ got: String,
+ },
+
+ /// `results(self, ...)` was written in a trigger. `self` is
+ /// notation substituted at evaluation time (`query.workset`), never
+ /// a keyword an author may write.
+ #[error(
+ "`self` is substituted at evaluation time (query.workset); it cannot be written \
+ in a trigger"
+ )]
+ SelfKeyword,
+
+ /// A `rev()` expression named a `refs/meta/*` pattern, which is
+ /// outside `rev()`'s domain by definition (`query.rev`).
+ #[error("rev() must not name a refs/meta/* pattern; got {pattern:?} (query.rev)")]
+ MetaInRev {
+ /// The offending pattern.
+ pattern: String,
+ },
+
+ /// A `meta()` glob does not start with `refs/meta/`, so it could
+ /// never match an author-written meta-ref.
+ #[error("meta() glob must start with refs/meta/; got {glob:?} (query.meta)")]
+ MetaGlobOutside {
+ /// The glob as written.
+ glob: String,
+ },
+
+ /// A `meta()` glob could match an effect-written namespace —
+ /// `refs/meta/results/*` or `refs/meta/index/*` — which must be
+ /// unreachable from `meta()` (`query.meta`, `query.recursion`).
+ #[error(
+ "meta() glob {glob:?} could match the effect-written namespace {namespace} \
+ (query.meta)"
+ )]
+ MetaGlobEffectWritten {
+ /// The glob as written.
+ glob: String,
+ /// The forbidden namespace prefix it could match.
+ namespace: &'static str,
+ },
+
+ /// `rev()` was given an empty expression.
+ #[error("empty rev() expression")]
+ EmptyRev,
+
+ /// `rev()` had only `^`-negated terms; at least one positive term
+ /// is required to denote a non-trivial set.
+ #[error("rev() needs at least one positive (non-negated) term")]
+ NoPositiveRev,
+
+ /// A revspec form this evaluator does not support yet. Unsupported
+ /// forms are an explicit error, never a silent empty set.
+ #[error(
+ "unsupported revspec form {token:?}: supported forms are refnames, refs/ globs, \
+ full hex object ids, ^negation, and A..B ranges"
+ )]
+ UnsupportedRev {
+ /// The unsupported token.
+ token: String,
+ },
+
+ /// A ref pattern contained bytes a refname can never contain.
+ #[error("invalid ref pattern {pattern:?}: {why}")]
+ BadPattern {
+ /// The pattern as written.
+ pattern: String,
+ /// What is wrong with it.
+ why: &'static str,
+ },
+}
+
+/// Everything that can prevent evaluation from completing — always
+/// infrastructure, never a property of the query.
+#[derive(Debug, thiserror::Error)]
+#[non_exhaustive]
+pub enum EvalError {
+ /// The ref store's read half failed.
+ #[error("ref store read failed: {0}")]
+ Refs(#[from] gix_ref_store::Error),
+
+ /// The object store failed while looking up `oid`.
+ #[error("object lookup failed for {oid}: {source}")]
+ Object {
+ /// The object being looked up.
+ oid: ObjectId,
+ /// The underlying object-store error.
+ #[source]
+ source: gix_object::find::Error,
+ },
+
+ /// `oid` was referenced (by a ref tip or a parent edge) but is not
+ /// in the object store.
+ #[error("object {oid} is missing from the object store")]
+ Missing {
+ /// The absent object.
+ oid: ObjectId,
+ },
+
+ /// `oid` exists but could not be decoded as a commit.
+ #[error("object {oid} could not be decoded: {detail}")]
+ Decode {
+ /// The undecodable object.
+ oid: ObjectId,
+ /// What failed, human-readable.
+ detail: String,
+ },
+
+ /// A results ref's tip tree did not deserialize as a recorded
+ /// [`ents_model::Status`]. Evaluation fails rather than guessing a
+ /// status (`model.result-taxonomy` is a closed taxonomy).
+ #[error("results ref {name} has an unreadable status tree: {source}")]
+ Status {
+ /// The results refname.
+ name: String,
+ /// The typed-tree deserialization error.
+ #[source]
+ source: facet_git_tree::Error,
+ },
+}
+
+/// The `Result` alias for evaluation operations.
+pub type EvalResult<T> = std::result::Result<T, EvalError>;
crates/ents-query/src/eval.rs
@@ -1,0 +1,806 @@
+//! Query evaluation: full sets, incremental entry sets, and work sets
+//! (`query.incremental`, `query.monotone`, `query.workset`).
+//!
+//! # Evaluation model
+//!
+//! The evaluator is pure over the read half of the ref store plus
+//! gitoxide's object-find seam. A [`Transition`] carries both sides of
+//! one ref's movement; every internal read goes through a state view
+//! that overrides that one ref, so the same evaluator answers "was this
+//! commit in the set before?" and "is it now?" against a single store.
+//!
+//! # Incrementality (`query.incremental`)
+//!
+//! An entry set is computed from the transition frontier: candidate
+//! commits come only from the symmetric difference of the affected
+//! atoms (a generation-bounded paint-down walk between the old and new
+//! tips for `rev`, the one tested commit for `results`, the two tips
+//! for `meta`), then each candidate is membership-tested against the
+//! new and old states — reachability tests pruned by generation
+//! numbers, results tests by refname scan (`query.results`), never a
+//! walk of full history. Generation numbers are computed lazily and
+//! memoized per evaluator; a persistent commit-graph file is a later
+//! optimization with the same bound.
+//!
+//! # Monotonicity (`query.monotone`)
+//!
+//! Entry sets are additions only. A shrinking ref (force-push, branch
+//! deletion) produces an empty or smaller entry set; nothing is ever
+//! retracted, because the only durable record — a written result —
+//! lives in immutable history, and the work set subtracts it by
+//! refname scan.
+
+use std::cell::RefCell;
+use std::collections::{BTreeSet, BinaryHeap, HashMap, HashSet};
+use std::rc::Rc;
+
+use ents_model::Status;
+use gix::refs::FullName;
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind};
+use gix_ref_store::RefStoreRead;
+
+use crate::ast::{Query, StatusFilter};
+use crate::error::{EvalError, EvalResult};
+use crate::rev::{RevExpr, RevTerm, dwim_candidates};
+
+/// One ref's movement, as `receive` observes it: the refname, the tip
+/// before, and the tip after (`None` on either side for creation and
+/// deletion).
+///
+/// # Examples
+///
+/// ```
+/// use ents_query::Transition;
+///
+/// let t = Transition {
+/// name: "refs/heads/main".try_into().expect("valid"),
+/// old: None,
+/// new: Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)),
+/// };
+/// assert!(t.old.is_none());
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Transition {
+ /// The ref that moved.
+ pub name: FullName,
+ /// Its tip before the transition (`None`: the ref did not exist).
+ pub old: Option<ObjectId>,
+ /// Its tip after the transition (`None`: the ref was deleted).
+ pub new: Option<ObjectId>,
+}
+
+/// Which side of a [`Transition`] a read observes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Side {
+ Old,
+ New,
+}
+
+/// Cached structure of one commit: parents and generation number
+/// (1 + the maximum parent generation; roots are generation 1).
+#[derive(Debug)]
+struct CommitInfo {
+ parents: Vec<ObjectId>,
+ generation: u64,
+}
+
+/// A `CommitQuery` evaluator over one ref store and one object store.
+///
+/// Caches commit structure (parents, generation numbers) and result
+/// statuses across calls, so reusing one evaluator across many
+/// transitions amortizes history walks — the shape a long-lived
+/// `receive` process has.
+///
+/// # Examples
+///
+/// ```
+/// use ents_query::{Evaluator, Query};
+/// use ents_testutil::{MemRefStore, ObjectStore, advance_ref};
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+/// let commits = advance_ref(&refs, &objects, "refs/heads/main", 2, 100);
+///
+/// let query: Query = "rev(refs/heads/main)".parse().expect("valid");
+/// let evaluator = Evaluator::new(&refs, &objects);
+/// let set = evaluator.eval(&query).expect("evaluates");
+/// assert_eq!(set.len(), 2);
+/// assert!(set.contains(&commits[0]) && set.contains(&commits[1]));
+/// ```
+pub struct Evaluator<'a> {
+ refs: &'a dyn RefStoreRead,
+ objects: &'a dyn Find,
+ info: RefCell<HashMap<ObjectId, Rc<CommitInfo>>>,
+ status: RefCell<HashMap<ObjectId, Status>>,
+}
+
+impl std::fmt::Debug for Evaluator<'_> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Evaluator")
+ .field("cached_commits", &self.info.borrow().len())
+ .finish_non_exhaustive()
+ }
+}
+
+impl<'a> Evaluator<'a> {
+ /// Build an evaluator over `refs` and `objects`.
+ pub fn new(refs: &'a dyn RefStoreRead, objects: &'a dyn Find) -> Self {
+ Self {
+ refs,
+ objects,
+ info: RefCell::new(HashMap::new()),
+ status: RefCell::new(HashMap::new()),
+ }
+ }
+
+ // -- public API ----------------------------------------------------
+
+ /// The full set `query` denotes against current ref state — the
+ /// reconciliation-grade evaluation (boot-time work-set scans);
+ /// steady-state consumers use [`Evaluator::entry_set`].
+ pub fn eval(&self, query: &Query) -> EvalResult<BTreeSet<ObjectId>> {
+ self.eval_side(query, None, Side::New)
+ }
+
+ /// Whether `oid` is in `query`'s set against current ref state.
+ ///
+ /// Reachability tests are pruned by generation numbers; results
+ /// tests are refname scans (`query.results`).
+ pub fn contains(&self, query: &Query, oid: ObjectId) -> EvalResult<bool> {
+ self.contains_side(query, oid, None, Side::New)
+ }
+
+ /// The commits that *enter* `query`'s set under `transition`,
+ /// computed incrementally from that frontier (`query.incremental`)
+ /// with entry-only semantics (`query.monotone`): an effect fires
+ /// once per commit in this set; commits leaving the set appear
+ /// nowhere and retract nothing.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_query::{Evaluator, Query, Transition};
+ /// use ents_testutil::{MemRefStore, ObjectStore, advance_ref};
+ ///
+ /// let refs = MemRefStore::default();
+ /// let objects = ObjectStore::default();
+ /// let first = advance_ref(&refs, &objects, "refs/heads/main", 1, 100);
+ /// let second = advance_ref(&refs, &objects, "refs/heads/main", 1, 200);
+ ///
+ /// let query: Query = "rev(refs/heads/main)".parse().expect("valid");
+ /// let evaluator = Evaluator::new(&refs, &objects);
+ /// let entered = evaluator.entry_set(&query, &Transition {
+ /// name: "refs/heads/main".try_into().expect("valid"),
+ /// old: Some(first[0]),
+ /// new: Some(second[0]),
+ /// }).expect("evaluates");
+ /// assert_eq!(entered.into_iter().collect::<Vec<_>>(), vec![second[0]]);
+ /// ```
+ // @relation(query.incremental, query.monotone, scope=function)
+ pub fn entry_set(
+ &self,
+ query: &Query,
+ transition: &Transition,
+ ) -> EvalResult<BTreeSet<ObjectId>> {
+ if !query.footprint().matches(transition.name.as_ref()) {
+ return Ok(BTreeSet::new());
+ }
+ let mut candidates = HashSet::new();
+ self.collect_candidates(query, transition, &mut candidates)?;
+ let mut entered = BTreeSet::new();
+ for oid in candidates {
+ if self.contains_side(query, oid, Some(transition), Side::New)?
+ && !self.contains_side(query, oid, Some(transition), Side::Old)?
+ {
+ entered.insert(oid);
+ }
+ }
+ Ok(entered)
+ }
+
+ /// The work set for `effect` under `transition`:
+ /// `trigger − results(self, any)` with `self` substituted here, at
+ /// evaluation time — the entry set of the trigger minus every
+ /// commit already carrying any recorded result for `effect`, by
+ /// refname scan of the effect's results namespace, never a walk of
+ /// history (`query.workset`).
+ ///
+ /// The effect's own results ref is the sole materialization marker:
+ /// there is no pipeline state anywhere else to consult.
+ // @relation(query.workset, scope=function)
+ pub fn work_set(
+ &self,
+ effect: &str,
+ trigger: &Query,
+ transition: &Transition,
+ ) -> EvalResult<BTreeSet<ObjectId>> {
+ let entered = self.entry_set(trigger, transition)?;
+ self.subtract_results(effect, entered, Some(transition))
+ }
+
+ /// The full outstanding set for `effect` against current ref
+ /// state: `eval(trigger) − results(self, any)` — the boot-time
+ /// reconciliation form of [`Evaluator::work_set`], from which the
+ /// obligation queue is reconstructible (`query.workset`).
+ // @relation(query.workset, scope=function)
+ pub fn outstanding(&self, effect: &str, trigger: &Query) -> EvalResult<BTreeSet<ObjectId>> {
+ let full = self.eval(trigger)?;
+ self.subtract_results(effect, full, None)
+ }
+
+ fn subtract_results(
+ &self,
+ effect: &str,
+ set: BTreeSet<ObjectId>,
+ transition: Option<&Transition>,
+ ) -> EvalResult<BTreeSet<ObjectId>> {
+ let recorded = self.results_index(effect, StatusFilter::Any, transition, Side::New)?;
+ Ok(set
+ .into_iter()
+ .filter(|oid| !prefix_matches(&recorded, *oid))
+ .collect())
+ }
+
+ // -- state views ---------------------------------------------------
+
+ /// Resolve `name` in the given state: the transition overrides its
+ /// own ref on both sides, so the underlying store may hold either
+ /// the pre- or post-transition value.
+ fn resolve(
+ &self,
+ name: &str,
+ transition: Option<&Transition>,
+ side: Side,
+ ) -> EvalResult<Option<ObjectId>> {
+ if let Some(t) = transition
+ && t.name.as_bstr() == name
+ {
+ return Ok(match side {
+ Side::Old => t.old,
+ Side::New => t.new,
+ });
+ }
+ let Ok(full) = FullName::try_from(name.to_owned()) else {
+ return Ok(None);
+ };
+ Ok(self.refs.get(full.as_ref())?)
+ }
+
+ /// All refs under `prefix` in the given state, transition applied.
+ fn iter_refs(
+ &self,
+ prefix: &str,
+ transition: Option<&Transition>,
+ side: Side,
+ ) -> EvalResult<Vec<(String, ObjectId)>> {
+ let mut out = Vec::new();
+ for entry in self.refs.iter_prefix(prefix)? {
+ let (name, oid) = entry?;
+ out.push((name.as_bstr().to_string(), oid));
+ }
+ if let Some(t) = transition {
+ let name = t.name.as_bstr().to_string();
+ if name.starts_with(prefix) {
+ out.retain(|(n, _)| *n != name);
+ let value = match side {
+ Side::Old => t.old,
+ Side::New => t.new,
+ };
+ if let Some(oid) = value {
+ out.push((name, oid));
+ }
+ }
+ }
+ Ok(out)
+ }
+
+ // -- atoms ---------------------------------------------------------
+
+ /// The positive and negative tip sets of a rev expression in one
+ /// state. Short names resolve through the gitrevisions lookup
+ /// order; an unresolved name contributes nothing (a trigger over a
+ /// not-yet-created branch denotes the empty set); globs expand over
+ /// `refs/*` minus `refs/meta/*`, which is outside `rev()`'s domain
+ /// by definition (`query.rev`).
+ // @relation(query.rev, scope=function)
+ fn rev_tips(
+ &self,
+ expr: &RevExpr,
+ transition: Option<&Transition>,
+ side: Side,
+ ) -> EvalResult<(Vec<ObjectId>, Vec<ObjectId>)> {
+ let resolve_terms = |terms: &[RevTerm]| -> EvalResult<Vec<ObjectId>> {
+ let mut tips = Vec::new();
+ for term in terms {
+ match term {
+ RevTerm::Oid(oid) => tips.push(*oid),
+ RevTerm::Name(name) => {
+ for candidate in dwim_candidates(name) {
+ if let Some(oid) = self.resolve(&candidate, transition, side)? {
+ tips.push(oid);
+ break;
+ }
+ }
+ }
+ RevTerm::Glob(pattern) => {
+ for (name, oid) in self.iter_refs("refs/", transition, side)? {
+ if !name.starts_with("refs/meta/") && pattern.matches_str(&name) {
+ tips.push(oid);
+ }
+ }
+ }
+ }
+ }
+ Ok(tips)
+ };
+ Ok((
+ resolve_terms(expr.include())?,
+ resolve_terms(expr.exclude())?,
+ ))
+ }
+
+ /// The recorded-result index for one effect in one state: the
+ /// short-oid refname segments (hex prefixes of tested commits)
+ /// whose recorded status satisfies `filter` — a scan of refname
+ /// patterns under the effect's results namespace, never a walk of
+ /// commit history (`query.results`).
+ // @relation(query.results, scope=function)
+ fn results_index(
+ &self,
+ effect: &str,
+ filter: StatusFilter,
+ transition: Option<&Transition>,
+ side: Side,
+ ) -> EvalResult<Vec<String>> {
+ let prefix = format!("refs/meta/results/{effect}/");
+ let mut shorts = Vec::new();
+ for (name, tip) in self.iter_refs(&prefix, transition, side)? {
+ let Some(short) = name.strip_prefix(&prefix) else {
+ continue;
+ };
+ if short.contains('/') || short.is_empty() {
+ continue;
+ }
+ if filter == StatusFilter::Any || filter.admits(self.result_status(&name, tip)?) {
+ shorts.push(short.to_ascii_lowercase());
+ }
+ }
+ Ok(shorts)
+ }
+
+ /// The recorded status behind one results ref tip, cached: the tip
+ /// commit's tree deserialized as the [`Status`] typed tree.
+ fn result_status(&self, name: &str, tip: ObjectId) -> EvalResult<Status> {
+ if let Some(status) = self.status.borrow().get(&tip) {
+ return Ok(*status);
+ }
+ let tree = self.commit_tree(tip)?;
+ let status: Status =
+ facet_git_tree::deserialize(&tree, self.objects).map_err(|source| {
+ EvalError::Status {
+ name: name.to_owned(),
+ source,
+ }
+ })?;
+ self.status.borrow_mut().insert(tip, status);
+ Ok(status)
+ }
+
+ /// The tip commits of every author-written meta-ref matching the
+ /// glob (`query.meta`); the parser already guarantees the glob
+ /// cannot match an effect-written namespace.
+ // @relation(query.meta, scope=function)
+ fn meta_tips(
+ &self,
+ pattern: &crate::pattern::RefPattern,
+ transition: Option<&Transition>,
+ side: Side,
+ ) -> EvalResult<Vec<ObjectId>> {
+ let mut tips = Vec::new();
+ for (name, oid) in self.iter_refs("refs/meta/", transition, side)? {
+ if pattern.matches_str(&name) {
+ tips.push(oid);
+ }
+ }
+ Ok(tips)
+ }
+
+ // -- full evaluation ------------------------------------------------
+
+ fn eval_side(
+ &self,
+ query: &Query,
+ transition: Option<&Transition>,
+ side: Side,
+ ) -> EvalResult<BTreeSet<ObjectId>> {
+ match query {
+ Query::Rev(expr) => {
+ let (include, exclude) = self.rev_tips(expr, transition, side)?;
+ let reached = self.reachable(&include)?;
+ let excluded = self.reachable(&exclude)?;
+ Ok(reached.difference(&excluded).copied().collect())
+ }
+ Query::Results { effect, status } => {
+ let shorts = self.results_index(effect, *status, transition, side)?;
+ let universe = self.universe(transition, side)?;
+ Ok(universe
+ .into_iter()
+ .filter(|oid| prefix_matches(&shorts, *oid))
+ .collect())
+ }
+ Query::Meta(pattern) => Ok(self
+ .meta_tips(pattern, transition, side)?
+ .into_iter()
+ .collect()),
+ // @relation(query.set-ops, scope=function)
+ Query::Op { op, lhs, rhs } => {
+ let l = self.eval_side(lhs, transition, side)?;
+ let r = self.eval_side(rhs, transition, side)?;
+ Ok(match op {
+ crate::ast::SetOp::Union => l.union(&r).copied().collect(),
+ crate::ast::SetOp::Intersect => l.intersection(&r).copied().collect(),
+ crate::ast::SetOp::Difference => l.difference(&r).copied().collect(),
+ })
+ }
+ }
+ }
+
+ /// Every commit reachable from any ref in the given state — the
+ /// resolution universe for standalone `results()` evaluation, where
+ /// the refname scan yields hex prefixes that must name real
+ /// commits. Only full (reconciliation-grade) evaluation pays this;
+ /// membership tests compare prefixes directly.
+ fn universe(
+ &self,
+ transition: Option<&Transition>,
+ side: Side,
+ ) -> EvalResult<HashSet<ObjectId>> {
+ let tips: Vec<ObjectId> = self
+ .iter_refs("refs/", transition, side)?
+ .into_iter()
+ .map(|(_, oid)| oid)
+ .collect();
+ self.reachable(&tips)
+ }
+
+ // -- membership -----------------------------------------------------
+
+ fn contains_side(
+ &self,
+ query: &Query,
+ oid: ObjectId,
+ transition: Option<&Transition>,
+ side: Side,
+ ) -> EvalResult<bool> {
+ match query {
+ Query::Rev(expr) => {
+ let (include, exclude) = self.rev_tips(expr, transition, side)?;
+ Ok(self.reaches(&include, oid)? && !self.reaches(&exclude, oid)?)
+ }
+ Query::Results { effect, status } => {
+ let shorts = self.results_index(effect, *status, transition, side)?;
+ Ok(prefix_matches(&shorts, oid))
+ }
+ Query::Meta(pattern) => Ok(self.meta_tips(pattern, transition, side)?.contains(&oid)),
+ Query::Op { op, lhs, rhs } => {
+ let l = self.contains_side(lhs, oid, transition, side)?;
+ let r = self.contains_side(rhs, oid, transition, side)?;
+ Ok(match op {
+ crate::ast::SetOp::Union => l || r,
+ crate::ast::SetOp::Intersect => l && r,
+ crate::ast::SetOp::Difference => l && !r,
+ })
+ }
+ }
+ }
+
+ // -- candidates -----------------------------------------------------
+
+ /// Commits whose membership in some atom of `query` can have
+ /// changed under `transition` — the frontier the entry set is
+ /// filtered from. Everything else provably kept its membership in
+ /// every atom, so it cannot have entered the composite.
+ fn collect_candidates(
+ &self,
+ query: &Query,
+ transition: &Transition,
+ out: &mut HashSet<ObjectId>,
+ ) -> EvalResult<()> {
+ let moved = transition.name.as_bstr().to_string();
+ match query {
+ Query::Rev(expr) => {
+ if expr
+ .patterns()
+ .iter()
+ .any(|pattern| pattern.matches_str(&moved))
+ {
+ let old: Vec<_> = transition.old.into_iter().collect();
+ let new: Vec<_> = transition.new.into_iter().collect();
+ out.extend(self.ahead_of(&new, &old)?);
+ out.extend(self.ahead_of(&old, &new)?);
+ }
+ }
+ Query::Results { effect, .. } => {
+ let prefix = format!("refs/meta/results/{effect}/");
+ if let Some(short) = moved.strip_prefix(&prefix)
+ && !short.contains('/')
+ && let Some(tested) = self.resolve_short(short, transition)?
+ {
+ out.insert(tested);
+ }
+ }
+ Query::Meta(pattern) => {
+ if pattern.matches_str(&moved) {
+ out.extend(transition.old);
+ out.extend(transition.new);
+ }
+ }
+ Query::Op { lhs, rhs, .. } => {
+ self.collect_candidates(lhs, transition, out)?;
+ self.collect_candidates(rhs, transition, out)?;
+ }
+ }
+ Ok(())
+ }
+
+ /// Resolve a results refname's short-oid segment to the full tested
+ /// commit id, searching commits reachable from the post-transition
+ /// ref state. A prefix that resolves to nothing reachable yields no
+ /// candidate: an unreachable commit is not an actionable entry.
+ fn resolve_short(&self, short: &str, transition: &Transition) -> EvalResult<Option<ObjectId>> {
+ let short = short.to_ascii_lowercase();
+ let universe = self.universe(Some(transition), Side::New)?;
+ Ok(universe
+ .into_iter()
+ .find(|oid| oid.to_string().starts_with(&short)))
+ }
+
+ // -- commit walks ----------------------------------------------------
+
+ /// Structure of `oid`, cached: parents and generation number,
+ /// resolved iteratively so a long first-parent chain cannot
+ /// overflow the stack.
+ fn commit_info(&self, oid: ObjectId) -> EvalResult<Rc<CommitInfo>> {
+ if let Some(info) = self.info.borrow().get(&oid) {
+ return Ok(Rc::clone(info));
+ }
+ let mut pending: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new();
+ let mut stack = vec![oid];
+ while let Some(&top) = stack.last() {
+ if self.info.borrow().contains_key(&top) {
+ stack.pop();
+ continue;
+ }
+ let parents = match pending.get(&top) {
+ Some(parents) => parents.clone(),
+ None => {
+ let parents = self.read_parents(top)?;
+ pending.insert(top, parents.clone());
+ parents
+ }
+ };
+ let unresolved: Vec<ObjectId> = {
+ let cache = self.info.borrow();
+ parents
+ .iter()
+ .filter(|p| !cache.contains_key(*p))
+ .copied()
+ .collect()
+ };
+ if unresolved.is_empty() {
+ let generation = {
+ let cache = self.info.borrow();
+ parents
+ .iter()
+ .filter_map(|p| cache.get(p).map(|i| i.generation))
+ .max()
+ .unwrap_or(0)
+ .saturating_add(1)
+ };
+ self.info.borrow_mut().insert(
+ top,
+ Rc::new(CommitInfo {
+ parents,
+ generation,
+ }),
+ );
+ stack.pop();
+ } else {
+ stack.extend(unresolved);
+ }
+ }
+ let cache = self.info.borrow();
+ cache
+ .get(&oid)
+ .map(Rc::clone)
+ .ok_or(EvalError::Missing { oid })
+ }
+
+ fn read_parents(&self, oid: ObjectId) -> EvalResult<Vec<ObjectId>> {
+ let mut buf = Vec::new();
+ let data = self
+ .objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| EvalError::Object { oid, source })?
+ .ok_or(EvalError::Missing { oid })?;
+ if data.kind != Kind::Commit {
+ return Err(EvalError::Decode {
+ oid,
+ detail: format!("expected a commit, found a {}", data.kind),
+ });
+ }
+ let commit =
+ CommitRef::from_bytes(data.data, oid.kind()).map_err(|e| EvalError::Decode {
+ oid,
+ detail: e.to_string(),
+ })?;
+ Ok(commit.parents().collect())
+ }
+
+ /// The tree of the commit at `oid`.
+ fn commit_tree(&self, oid: ObjectId) -> EvalResult<ObjectId> {
+ let mut buf = Vec::new();
+ let data = self
+ .objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| EvalError::Object { oid, source })?
+ .ok_or(EvalError::Missing { oid })?;
+ if data.kind != Kind::Commit {
+ return Err(EvalError::Decode {
+ oid,
+ detail: format!("expected a commit, found a {}", data.kind),
+ });
+ }
+ let commit =
+ CommitRef::from_bytes(data.data, oid.kind()).map_err(|e| EvalError::Decode {
+ oid,
+ detail: e.to_string(),
+ })?;
+ Ok(commit.tree())
+ }
+
+ /// Everything reachable from `tips` (inclusive) — full-walk
+ /// reachability, used by reconciliation-grade evaluation only.
+ fn reachable(&self, tips: &[ObjectId]) -> EvalResult<HashSet<ObjectId>> {
+ let mut seen = HashSet::new();
+ let mut queue: Vec<ObjectId> = tips.to_vec();
+ while let Some(oid) = queue.pop() {
+ if !seen.insert(oid) {
+ continue;
+ }
+ queue.extend(self.commit_info(oid)?.parents.iter().copied());
+ }
+ Ok(seen)
+ }
+
+ /// Whether any tip reaches `target` by parent edges, pruning every
+ /// path once its generation drops below `target`'s — the
+ /// generation-number bound of `query.incremental`.
+ fn reaches(&self, tips: &[ObjectId], target: ObjectId) -> EvalResult<bool> {
+ if tips.contains(&target) {
+ return Ok(true);
+ }
+ if tips.is_empty() {
+ return Ok(false);
+ }
+ let floor = self.commit_info(target)?.generation;
+ let mut heap = BinaryHeap::new();
+ let mut seen = HashSet::new();
+ for &tip in tips {
+ let info = self.commit_info(tip)?;
+ if info.generation >= floor && seen.insert(tip) {
+ heap.push((info.generation, tip));
+ }
+ }
+ while let Some((_, oid)) = heap.pop() {
+ if oid == target {
+ return Ok(true);
+ }
+ for parent in self.commit_info(oid)?.parents.iter().copied() {
+ if seen.insert(parent) {
+ let generation = self.commit_info(parent)?.generation;
+ if generation >= floor {
+ heap.push((generation, parent));
+ }
+ }
+ }
+ }
+ Ok(false)
+ }
+
+ /// Commits reachable from `new_tips` but not from `old_tips` — the
+ /// transition frontier, walked in descending generation order so
+ /// old-side paint stops at the frontier's own depth instead of
+ /// walking to the roots.
+ fn ahead_of(&self, new_tips: &[ObjectId], old_tips: &[ObjectId]) -> EvalResult<Vec<ObjectId>> {
+ const NEW: u8 = 1;
+ const OLD: u8 = 2;
+ if new_tips.is_empty() {
+ return Ok(Vec::new());
+ }
+ let mut flags: HashMap<ObjectId, u8> = HashMap::new();
+ let mut heap: BinaryHeap<(u64, ObjectId)> = BinaryHeap::new();
+ let mut queued: HashSet<ObjectId> = HashSet::new();
+ let mut new_only_queued = 0usize;
+
+ let push = |oid: ObjectId,
+ flag: u8,
+ flags: &mut HashMap<ObjectId, u8>,
+ heap: &mut BinaryHeap<(u64, ObjectId)>,
+ queued: &mut HashSet<ObjectId>,
+ new_only: &mut usize|
+ -> EvalResult<()> {
+ let entry = flags.entry(oid).or_insert(0);
+ let before = *entry;
+ *entry |= flag;
+ let after = *entry;
+ if queued.insert(oid) {
+ heap.push((self.commit_info(oid)?.generation, oid));
+ if after == NEW {
+ *new_only = new_only.saturating_add(1);
+ }
+ } else if before == NEW && after != NEW {
+ *new_only = new_only.saturating_sub(1);
+ }
+ Ok(())
+ };
+
+ for &tip in old_tips {
+ push(
+ tip,
+ OLD,
+ &mut flags,
+ &mut heap,
+ &mut queued,
+ &mut new_only_queued,
+ )?;
+ }
+ for &tip in new_tips {
+ push(
+ tip,
+ NEW,
+ &mut flags,
+ &mut heap,
+ &mut queued,
+ &mut new_only_queued,
+ )?;
+ }
+
+ let mut ahead = Vec::new();
+ while let Some((_, oid)) = heap.pop() {
+ // Descending generation order means every commit that could
+ // paint `oid` has already been processed, so its flag is
+ // final here.
+ let flag = flags.get(&oid).copied().unwrap_or(0);
+ queued.remove(&oid);
+ if flag == NEW {
+ new_only_queued = new_only_queued.saturating_sub(1);
+ ahead.push(oid);
+ }
+ for parent in self.commit_info(oid)?.parents.clone() {
+ push(
+ parent,
+ flag,
+ &mut flags,
+ &mut heap,
+ &mut queued,
+ &mut new_only_queued,
+ )?;
+ }
+ if new_only_queued == 0 {
+ // Nothing purely-new remains queued: everything deeper
+ // is reachable from the old tips too, so the frontier
+ // is complete — this is the generation bound.
+ break;
+ }
+ }
+ Ok(ahead)
+ }
+}
+
+/// Whether any short-oid hex prefix in `shorts` prefixes `oid`.
+fn prefix_matches(shorts: &[String], oid: ObjectId) -> bool {
+ let hex = oid.to_string();
+ shorts.iter().any(|short| hex.starts_with(short.as_str()))
+}
crates/ents-query/src/lib.rs
@@ -1,0 +1,89 @@
+//! The `CommitQuery` algebra (`docs/spec/query.sdoc`): three atoms —
+//! `rev()`, `results()`, `meta()` — closed under union, intersection,
+//! and difference, denoting a set of commits as a pure function of ref
+//! state. Every effect's trigger is one of these queries; composition
+//! happens by writing the query itself, never by a workflow language or
+//! a runtime scheduler.
+//!
+//! This crate owns the grammar and parser ([`Query`]), static
+//! ref-footprint extraction ([`Query::footprint`]), and evaluation
+//! ([`Evaluator`]): full reconciliation-grade sets, incremental entry
+//! sets bounded by generation numbers, and the work set
+//! `trigger − results(self, any)`. It is deliberately separate from
+//! executor and run-loop code (`arch.query-effect-split`): `receive`
+//! links this crate for footprint matching on every push and must never
+//! link executor code.
+//!
+//! # Spec coverage
+//!
+//! From `docs/spec/query.sdoc`:
+//!
+//! - `query.grammar`, `query.set-ops` — [`Query`], the parser, and
+//! [`SetOp`]; left-associative, one precedence level.
+//! - `query.rev` — [`RevExpr`]; `refs/meta/*` patterns are rejected at
+//! parse time, never silently evaluated. The supported revspec
+//! surface is refnames, `refs/` globs, full hex oids, `^negation`,
+//! and `A..B`; other revspec forms are an explicit
+//! [`ParseError::UnsupportedRev`], a deliberate subset deferred until
+//! a consumer needs it.
+//! - `query.results` — resolution is a refname scan of the effect's
+//! results namespace; membership tests compare hex prefixes and walk
+//! no history.
+//! - `query.meta` — the glob must stay under `refs/meta/*` and can
+//! never match `refs/meta/results/*` or `refs/meta/index/*`; the
+//! fanout index is not addressable by any atom.
+//! - `query.no-extensions` — the atom set is closed; `time(...)`,
+//! `content(...)`, or any other name is [`ParseError::UnknownAtom`].
+//! - `query.footprint` — [`Query::footprint`], from the syntax tree
+//! alone.
+//! - `query.incremental`, `query.monotone` — [`Evaluator::entry_set`].
+//! - `query.workset` — [`Evaluator::work_set`] (incremental) and
+//! [`Evaluator::outstanding`] (boot-time reconciliation); `self` is
+//! substituted at evaluation time and rejected in trigger text.
+//! - `query.recursion` — [`Query::results_dependencies`]; downstream-of
+//! is syntax, and `rev()`/`meta()` cannot name effect-written refs,
+//! so unwritten trigger cycles are unreachable by construction.
+//! - `query.rev-pattern-compat` — a bare ref glob parses as exactly
+//! `rev(<glob>)`.
+//!
+//! # Examples
+//!
+//! The staged-pipeline idiom, evaluated incrementally: integration only
+//! after unit tests pass.
+//!
+//! ```
+//! use ents_model::Status;
+//! use ents_query::{Evaluator, Query, Transition};
+//! use ents_testutil::{MemRefStore, ObjectStore, advance_ref, record_result};
+//!
+//! let refs = MemRefStore::default();
+//! let objects = ObjectStore::default();
+//! let commits = advance_ref(&refs, &objects, "refs/heads/main", 2, 100);
+//!
+//! let trigger: Query = "rev(refs/heads/main) & results(unit, pass)".parse().expect("valid");
+//! let evaluator = Evaluator::new(&refs, &objects);
+//!
+//! // A unit result lands for the first commit: exactly that commit
+//! // enters the staged trigger's set.
+//! let short = commits[0].to_string()[..12].to_owned();
+//! let result_tip = record_result(&refs, &objects, "unit", &short, Status::Pass, None, 300);
+//! let entered = evaluator.entry_set(&trigger, &Transition {
+//! name: format!("refs/meta/results/unit/{short}").as_str().try_into().expect("valid"),
+//! old: None,
+//! new: Some(result_tip),
+//! }).expect("evaluates");
+//! assert_eq!(entered.into_iter().collect::<Vec<_>>(), vec![commits[0]]);
+//! ```
+
+mod ast;
+mod error;
+mod eval;
+mod parse;
+mod pattern;
+mod rev;
+
+pub use ast::{Query, SetOp, StatusFilter};
+pub use error::{EvalError, EvalResult, ParseError};
+pub use eval::{Evaluator, Transition};
+pub use pattern::{Footprint, RefPattern};
+pub use rev::RevExpr;
crates/ents-query/src/parse.rs
@@ -1,0 +1,236 @@
+//! The `CommitQuery` parser (`query.grammar`), including the
+//! bare-glob compatibility rule (`query.rev-pattern-compat`) and every
+//! write-time rejection `effect.validation` cites: unknown atoms
+//! (`query.no-extensions`), `refs/meta/*` inside `rev()` (`query.rev`),
+//! and effect-written namespaces inside `meta()` (`query.meta`).
+
+use crate::ast::{Query, SetOp, StatusFilter};
+use crate::error::ParseError;
+use crate::pattern::RefPattern;
+use crate::rev::RevExpr;
+
+/// The namespaces `meta()` must never be able to match (`query.meta`):
+/// recorded results are reachable only through `results(...)`, and the
+/// fanout index is not addressable by any query atom at all.
+const EFFECT_WRITTEN: [&str; 2] = ["refs/meta/results/", "refs/meta/index/"];
+
+/// Parse a `CommitQuery`.
+///
+/// A bare ref glob is accepted wherever a query is expected, meaning
+/// exactly `rev(<glob>)` (`query.rev-pattern-compat`) — so a
+/// `RefPattern` predating `CommitQuery` keeps denoting the same set.
+// @relation(query.grammar, query.rev-pattern-compat, scope=function)
+pub(crate) fn parse(input: &str) -> Result<Query, ParseError> {
+ let mut parser = Parser { input, pos: 0 };
+ match parser.parse_query_complete() {
+ Ok(query) => Ok(query),
+ Err(err) => {
+ // The degenerate form: the whole (trimmed) input is one
+ // glob/refname token with none of the grammar's own
+ // structure in it. Its validation errors (a refs/meta/*
+ // glob, above all) are the ones worth surfacing.
+ let token = input.trim();
+ if token.is_empty()
+ || token
+ .bytes()
+ .any(|b| b.is_ascii_whitespace() || b"()|&,".contains(&b))
+ {
+ return Err(err);
+ }
+ RevExpr::parse(token).map(Query::Rev)
+ }
+ }
+}
+
+struct Parser<'a> {
+ input: &'a str,
+ pos: usize,
+}
+
+impl Parser<'_> {
+ fn parse_query_complete(&mut self) -> Result<Query, ParseError> {
+ let query = self.parse_query()?;
+ self.skip_ws();
+ if self.pos < self.input.len() {
+ return Err(ParseError::Trailing {
+ rest: self.rest().to_owned(),
+ });
+ }
+ Ok(query)
+ }
+
+ /// `query ::= term (("|" | "&" | "-") term)*` — left-associative,
+ /// one precedence level (`query.grammar`).
+ fn parse_query(&mut self) -> Result<Query, ParseError> {
+ let mut lhs = self.parse_term()?;
+ loop {
+ self.skip_ws();
+ let op = match self.peek() {
+ Some(b'|') => SetOp::Union,
+ Some(b'&') => SetOp::Intersect,
+ Some(b'-') => SetOp::Difference,
+ _ => return Ok(lhs),
+ };
+ self.pos = self.pos.saturating_add(1);
+ let rhs = self.parse_term()?;
+ lhs = Query::Op {
+ op,
+ lhs: Box::new(lhs),
+ rhs: Box::new(rhs),
+ };
+ }
+ }
+
+ fn parse_term(&mut self) -> Result<Query, ParseError> {
+ self.skip_ws();
+ match self.peek() {
+ None => Err(ParseError::UnexpectedEnd),
+ Some(b'(') => {
+ let opened_at = self.pos;
+ self.pos = self.pos.saturating_add(1);
+ let inner = self.parse_query()?;
+ self.skip_ws();
+ if self.peek() == Some(b')') {
+ self.pos = self.pos.saturating_add(1);
+ Ok(inner)
+ } else {
+ Err(ParseError::Unbalanced { at: opened_at })
+ }
+ }
+ Some(_) => self.parse_atom(),
+ }
+ }
+
+ fn parse_atom(&mut self) -> Result<Query, ParseError> {
+ let name = self.take_while(|b| b.is_ascii_alphanumeric() || b == b'_');
+ if name.is_empty() {
+ return Err(ParseError::Expected {
+ expected: "an atom (rev, results, or meta)",
+ at: self.pos,
+ });
+ }
+ let name = name.to_owned();
+ self.skip_ws();
+ if self.peek() != Some(b'(') {
+ return Err(ParseError::Expected {
+ expected: "'(' after the atom name",
+ at: self.pos,
+ });
+ }
+ let opened_at = self.pos;
+ self.pos = self.pos.saturating_add(1);
+ let args = self.take_balanced(opened_at)?.to_owned();
+ match name.as_str() {
+ "rev" => Ok(Query::Rev(RevExpr::parse(&args)?)),
+ "results" => parse_results(&args, self.pos),
+ "meta" => parse_meta(&args),
+ // The closed atom set: a content, time, or external-event
+ // term is an unknown atom, permanently
+ // (`query.no-extensions`).
+ _ => Err(ParseError::UnknownAtom { name }),
+ }
+ }
+
+ /// Consume up to the `)` matching the `(` at `opened_at` (exclusive)
+ /// and step past it, returning the enclosed text.
+ fn take_balanced(&mut self, opened_at: usize) -> Result<&str, ParseError> {
+ let start = self.pos;
+ let mut depth = 1usize;
+ while let Some(b) = self.peek() {
+ match b {
+ b'(' => depth = depth.saturating_add(1),
+ b')' => {
+ depth = depth.saturating_sub(1);
+ if depth == 0 {
+ let inner = self.input.get(start..self.pos).unwrap_or_default();
+ self.pos = self.pos.saturating_add(1);
+ return Ok(inner);
+ }
+ }
+ _ => {}
+ }
+ self.pos = self.pos.saturating_add(1);
+ }
+ Err(ParseError::Unbalanced { at: opened_at })
+ }
+
+ fn peek(&self) -> Option<u8> {
+ self.input.as_bytes().get(self.pos).copied()
+ }
+
+ fn skip_ws(&mut self) {
+ while self.peek().is_some_and(|b| b.is_ascii_whitespace()) {
+ self.pos = self.pos.saturating_add(1);
+ }
+ }
+
+ fn take_while(&mut self, keep: impl Fn(u8) -> bool) -> &str {
+ let start = self.pos;
+ while self.peek().is_some_and(&keep) {
+ self.pos = self.pos.saturating_add(1);
+ }
+ self.input.get(start..self.pos).unwrap_or_default()
+ }
+
+ fn rest(&self) -> &str {
+ self.input.get(self.pos..).unwrap_or_default()
+ }
+}
+
+/// `results(effect, status)` — two arguments, an effect name that is a
+/// valid single ref-path segment (`effect.definition`) and never the
+/// reserved `self` (`query.workset`), and a status from the closed
+/// taxonomy.
+fn parse_results(args: &str, at: usize) -> Result<Query, ParseError> {
+ let Some((effect, status)) = args.split_once(',') else {
+ return Err(ParseError::Expected {
+ expected: "results(effect, status)",
+ at,
+ });
+ };
+ let effect = effect.trim();
+ let status = status.trim();
+ if effect == "self" {
+ return Err(ParseError::SelfKeyword);
+ }
+ if effect.is_empty() || effect.contains('/') || !valid_ref_segment(effect) {
+ return Err(ParseError::BadEffectName {
+ got: effect.to_owned(),
+ });
+ }
+ let status = StatusFilter::parse(status).ok_or_else(|| ParseError::BadStatus {
+ got: status.to_owned(),
+ })?;
+ Ok(Query::Results {
+ effect: effect.to_owned(),
+ status,
+ })
+}
+
+/// A single segment is valid exactly when gitoxide accepts it inside a
+/// full refname — no parallel validation rules (`arch` sibling rule:
+/// gitoxide types are the primitives).
+fn valid_ref_segment(segment: &str) -> bool {
+ gix::refs::FullName::try_from(format!("refs/meta/results/{segment}/x")).is_ok()
+}
+
+/// `meta(glob)` — must stay under `refs/meta/*` and must not be able to
+/// match an effect-written namespace (`query.meta`).
+fn parse_meta(args: &str) -> Result<Query, ParseError> {
+ let glob = args.trim();
+ let pattern = RefPattern::new(glob)?;
+ if !glob.starts_with("refs/meta/") {
+ return Err(ParseError::MetaGlobOutside {
+ glob: glob.to_owned(),
+ });
+ }
+ for namespace in EFFECT_WRITTEN {
+ if pattern.may_match_with_prefix(namespace) {
+ return Err(ParseError::MetaGlobEffectWritten {
+ glob: glob.to_owned(),
+ namespace,
+ });
+ }
+ }
+ Ok(Query::Meta(pattern))
+}
crates/ents-query/src/pattern.rs
@@ -1,0 +1,243 @@
+//! Refname patterns and the static footprint (`query.footprint`).
+
+use gix::refs::FullNameRef;
+
+use crate::error::ParseError;
+
+/// A refname glob: literal bytes plus `*` wildcards, where each `*`
+/// matches any run of characters including `/` (the same shape git
+/// refspecs and the spec's own examples use — `refs/heads/*` matches
+/// `refs/heads/wip/x`).
+///
+/// # Examples
+///
+/// ```
+/// use ents_query::RefPattern;
+///
+/// let pattern = RefPattern::new("refs/heads/*").expect("valid");
+/// let name: gix::refs::FullName = "refs/heads/wip/x".try_into().expect("valid");
+/// assert!(pattern.matches(name.as_ref()));
+///
+/// let other: gix::refs::FullName = "refs/tags/v1".try_into().expect("valid");
+/// assert!(!pattern.matches(other.as_ref()));
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct RefPattern(String);
+
+impl RefPattern {
+ /// Validate and wrap a pattern.
+ ///
+ /// # Errors
+ ///
+ /// [`ParseError::BadPattern`] when the pattern is empty or contains
+ /// bytes no refname can carry (whitespace, `\`, control bytes, the
+ /// query grammar's own metacharacters, `..`, or `//`).
+ pub fn new(pattern: impl Into<String>) -> Result<Self, ParseError> {
+ let pattern = pattern.into();
+ let bad = |why: &'static str| ParseError::BadPattern {
+ pattern: pattern.clone(),
+ why,
+ };
+ if pattern.is_empty() {
+ return Err(bad("empty pattern"));
+ }
+ if pattern
+ .bytes()
+ .any(|b| b.is_ascii_whitespace() || b.is_ascii_control())
+ {
+ return Err(bad("whitespace or control byte"));
+ }
+ if pattern.bytes().any(|b| b"\\()|&,?[".contains(&b)) {
+ return Err(bad("byte a refname cannot carry"));
+ }
+ if pattern.contains("..") || pattern.contains("//") {
+ return Err(bad("empty or dot-dot path segment"));
+ }
+ if pattern.starts_with('/') || pattern.ends_with('/') {
+ return Err(bad("leading or trailing slash"));
+ }
+ Ok(Self(pattern))
+ }
+
+ /// The pattern text.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_query::RefPattern;
+ ///
+ /// assert_eq!(RefPattern::new("refs/tags/v*").expect("valid").as_str(), "refs/tags/v*");
+ /// ```
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+
+ /// Whether `name` matches this pattern.
+ #[must_use]
+ pub fn matches(&self, name: &FullNameRef) -> bool {
+ self.matches_str(&name.as_bstr().to_string())
+ }
+
+ /// [`RefPattern::matches`] over a plain string refname.
+ #[must_use]
+ pub(crate) fn matches_str(&self, name: &str) -> bool {
+ glob_match(self.0.as_bytes(), name.as_bytes())
+ }
+
+ /// The literal bytes before the first `*` (the whole pattern when
+ /// there is no wildcard).
+ pub(crate) fn literal_prefix(&self) -> &str {
+ self.0.split('*').next().unwrap_or(&self.0)
+ }
+
+ /// Whether some refname starting with `prefix` could match this
+ /// pattern — the conservative overlap test `query.meta` needs to
+ /// keep effect-written namespaces unreachable.
+ pub(crate) fn may_match_with_prefix(&self, prefix: &str) -> bool {
+ may_match(self.0.as_bytes(), prefix.as_bytes())
+ }
+}
+
+impl std::fmt::Display for RefPattern {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+/// Classic wildcard match: `*` matches any run (including `/`),
+/// everything else is literal. Iterative two-pointer with backtracking.
+fn glob_match(pattern: &[u8], text: &[u8]) -> bool {
+ let (mut p, mut t) = (0usize, 0usize);
+ let (mut star, mut mark) = (None::<usize>, 0usize);
+ while t < text.len() {
+ match pattern.get(p) {
+ Some(b'*') => {
+ star = Some(p);
+ mark = t;
+ p = p.saturating_add(1);
+ }
+ Some(&c) if text.get(t) == Some(&c) => {
+ p = p.saturating_add(1);
+ t = t.saturating_add(1);
+ }
+ _ => match star {
+ Some(s) => {
+ p = s.saturating_add(1);
+ mark = mark.saturating_add(1);
+ t = mark;
+ }
+ None => return false,
+ },
+ }
+ }
+ while pattern.get(p) == Some(&b'*') {
+ p = p.saturating_add(1);
+ }
+ p == pattern.len()
+}
+
+/// Whether the pattern could match some string that starts with
+/// `prefix`. `true` whenever the pattern can consume all of `prefix`
+/// (whatever pattern remains can always match its own literal tail).
+fn may_match(pattern: &[u8], prefix: &[u8]) -> bool {
+ let Some(rest) = prefix.split_first() else {
+ return true;
+ };
+ match pattern.split_first() {
+ None => false,
+ Some((b'*', tail)) => (0..=prefix.len()).any(|k| {
+ prefix
+ .get(k..)
+ .is_some_and(|suffix| may_match(tail, suffix))
+ }),
+ Some((&c, tail)) => c == *rest.0 && may_match(tail, rest.1),
+ }
+}
+
+/// The set of refname patterns a query depends on, extractable from its
+/// syntax tree alone (`query.footprint`) — what lets `receive` map one
+/// ref transition to the affected queries without re-scanning every
+/// effect on every push.
+///
+/// # Examples
+///
+/// ```
+/// use ents_query::Query;
+///
+/// let query: Query = "rev(refs/heads/main) & results(unit, pass)".parse().expect("valid");
+/// let footprint = query.footprint();
+///
+/// let main: gix::refs::FullName = "refs/heads/main".try_into().expect("valid");
+/// let result: gix::refs::FullName = "refs/meta/results/unit/abc".try_into().expect("valid");
+/// let other: gix::refs::FullName = "refs/heads/dev".try_into().expect("valid");
+/// assert!(footprint.matches(main.as_ref()));
+/// assert!(footprint.matches(result.as_ref()));
+/// assert!(!footprint.matches(other.as_ref()));
+/// ```
+// @relation(query.footprint, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Footprint(Vec<RefPattern>);
+
+impl Footprint {
+ pub(crate) fn from_patterns(mut patterns: Vec<RefPattern>) -> Self {
+ patterns.sort();
+ patterns.dedup();
+ Self(patterns)
+ }
+
+ /// The patterns, sorted and deduplicated.
+ #[must_use]
+ pub fn patterns(&self) -> &[RefPattern] {
+ &self.0
+ }
+
+ /// Whether a transition on `name` can affect the query's set.
+ #[must_use]
+ pub fn matches(&self, name: &FullNameRef) -> bool {
+ let text = name.as_bstr().to_string();
+ self.0.iter().any(|p| p.matches_str(&text))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![expect(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::exact("refs/heads/main", "refs/heads/main", true)]
+ #[case::star_crosses_slashes("refs/heads/*", "refs/heads/wip/x", true)]
+ #[case::mid_star("refs/tags/v*-rc", "refs/tags/v1.2-rc", true)]
+ #[case::two_stars("refs/*/unit/*", "refs/meta/results/unit/abc", true)]
+ #[case::suffix_must_still_match("refs/*/unit", "refs/meta/results/unit/abc", false)]
+ #[case::no_match("refs/heads/*", "refs/tags/v1", false)]
+ #[case::literal_shorter_than_text("refs/heads", "refs/heads/main", false)]
+ // @relation(query.footprint, scope=function, role=Verifies)
+ fn glob_matching_matches_git_style_star_runs(
+ #[case] pattern: &str,
+ #[case] name: &str,
+ #[case] expected: bool,
+ ) {
+ let pattern = RefPattern::new(pattern).expect("valid");
+ assert_eq!(pattern.matches_str(name), expected);
+ }
+
+ #[rstest]
+ #[case::wildcard_reaches_into_prefix("refs/meta/*", "refs/meta/results/", true)]
+ #[case::exact_inside_prefix("refs/meta/results/unit/abc", "refs/meta/results/", true)]
+ #[case::disjoint("refs/meta/issues/*", "refs/meta/results/", false)]
+ #[case::prefix_of_the_prefix("refs/meta/res*", "refs/meta/results/", true)]
+ // @relation(query.meta, scope=function, role=Verifies)
+ fn prefix_overlap_is_detected_conservatively(
+ #[case] pattern: &str,
+ #[case] prefix: &str,
+ #[case] expected: bool,
+ ) {
+ let pattern = RefPattern::new(pattern).expect("valid");
+ assert_eq!(pattern.may_match_with_prefix(prefix), expected);
+ }
+}
crates/ents-query/src/rev.rs
@@ -1,0 +1,181 @@
+//! `rev()` expressions over code refs (`query.rev`).
+
+use gix_hash::ObjectId;
+
+use crate::error::ParseError;
+use crate::pattern::RefPattern;
+
+/// One positive or negated term inside a `rev()` expression.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum RevTerm {
+ /// A refname, exact (`refs/heads/main`) or short (`main`, resolved
+ /// through the standard gitrevisions lookup order).
+ Name(String),
+ /// A ref glob (`refs/heads/*`). Globs must be written in full
+ /// `refs/...` form.
+ Glob(RefPattern),
+ /// A full hex object id.
+ Oid(ObjectId),
+}
+
+impl RevTerm {
+ /// The refname patterns this term can be affected by — the term's
+ /// contribution to the query footprint (`query.footprint`). A short
+ /// name contributes every candidate of the lookup order, since a
+ /// transition on any of them can change what the name resolves to.
+ pub(crate) fn patterns(&self) -> Vec<RefPattern> {
+ match self {
+ Self::Name(name) => dwim_candidates(name)
+ .iter()
+ .filter_map(|c| RefPattern::new(c.clone()).ok())
+ .collect(),
+ Self::Glob(pattern) => vec![pattern.clone()],
+ Self::Oid(_) => Vec::new(),
+ }
+ }
+}
+
+/// The gitrevisions lookup order for a short refname, restricted to the
+/// namespaces a ref store serves (`refs/*`). A full `refs/...` name is
+/// its own single candidate.
+pub(crate) fn dwim_candidates(name: &str) -> Vec<String> {
+ if name.starts_with("refs/") {
+ vec![name.to_owned()]
+ } else {
+ vec![
+ format!("refs/{name}"),
+ format!("refs/tags/{name}"),
+ format!("refs/heads/{name}"),
+ format!("refs/remotes/{name}"),
+ ]
+ }
+}
+
+/// A parsed `rev()` expression: whitespace-separated terms, `^`-negated
+/// terms subtracted, `A..B` sugar for `^A B` — the rev-list shape of
+/// `query.rev`'s "ordinary Git revspec or ref glob".
+///
+/// Unsupported revspec forms (`~n`/`^n` suffixes, `...`, `@{...}`,
+/// `^{...}`, abbreviated hex) are an explicit [`ParseError`], never a
+/// silent empty set; the supported surface is what the composition
+/// idioms and `query.rev`'s own examples use.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RevExpr {
+ include: Vec<RevTerm>,
+ exclude: Vec<RevTerm>,
+ raw: String,
+}
+
+impl RevExpr {
+ /// Parse the text between `rev(` and `)`.
+ pub(crate) fn parse(raw: &str) -> Result<Self, ParseError> {
+ let raw = raw.trim();
+ if raw.is_empty() {
+ return Err(ParseError::EmptyRev);
+ }
+ let mut include = Vec::new();
+ let mut exclude = Vec::new();
+ for token in raw.split_whitespace() {
+ if let Some(negated) = token.strip_prefix('^') {
+ exclude.push(parse_term(negated, token)?);
+ } else if token.contains("...") {
+ return Err(ParseError::UnsupportedRev {
+ token: token.to_owned(),
+ });
+ } else if let Some((base, tip)) = token.split_once("..") {
+ if base.is_empty() || tip.is_empty() {
+ return Err(ParseError::UnsupportedRev {
+ token: token.to_owned(),
+ });
+ }
+ exclude.push(parse_term(base, token)?);
+ include.push(parse_term(tip, token)?);
+ } else {
+ include.push(parse_term(token, token)?);
+ }
+ }
+ if include.is_empty() {
+ return Err(ParseError::NoPositiveRev);
+ }
+ Ok(Self {
+ include,
+ exclude,
+ raw: raw.to_owned(),
+ })
+ }
+
+ /// The expression text as written (trimmed), for display.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_query::Query;
+ ///
+ /// let query: Query = "rev(main ^release)".parse().expect("valid");
+ /// let Query::Rev(expr) = query else { panic!("a rev atom") };
+ /// assert_eq!(expr.raw(), "main ^release");
+ /// ```
+ #[must_use]
+ pub fn raw(&self) -> &str {
+ &self.raw
+ }
+
+ pub(crate) fn include(&self) -> &[RevTerm] {
+ &self.include
+ }
+
+ pub(crate) fn exclude(&self) -> &[RevTerm] {
+ &self.exclude
+ }
+
+ /// Every pattern of every term, positive and negated: a transition
+ /// on a negated ref changes the denoted set too.
+ pub(crate) fn patterns(&self) -> Vec<RefPattern> {
+ self.include
+ .iter()
+ .chain(&self.exclude)
+ .flat_map(RevTerm::patterns)
+ .collect()
+ }
+}
+
+/// Parse one term, rejecting `refs/meta/*` shapes (`query.rev`) and
+/// revspec operators this evaluator does not support.
+fn parse_term(term: &str, whole_token: &str) -> Result<RevTerm, ParseError> {
+ let unsupported = || ParseError::UnsupportedRev {
+ token: whole_token.to_owned(),
+ };
+ if term.is_empty() || term.contains(['~', ':', '@', '{', '}', '^']) {
+ return Err(unsupported());
+ }
+ if term.len() == 40 && term.bytes().all(|b| b.is_ascii_hexdigit()) {
+ let oid = ObjectId::from_hex(term.as_bytes()).map_err(|_e| unsupported())?;
+ return Ok(RevTerm::Oid(oid));
+ }
+ let meta = |pattern: &str| ParseError::MetaInRev {
+ pattern: pattern.to_owned(),
+ };
+ if term.contains('*') {
+ if !term.starts_with("refs/") {
+ return Err(unsupported());
+ }
+ let pattern = RefPattern::new(term).map_err(|_e| unsupported())?;
+ // Rejected only when the pattern *names* the meta namespace; a
+ // broad glob like `refs/*` is legal because `refs/meta/*` is
+ // outside rev()'s domain by definition and is excluded at
+ // evaluation, not silently matched (`query.rev`).
+ if pattern.literal_prefix().starts_with("refs/meta") {
+ return Err(meta(term));
+ }
+ return Ok(RevTerm::Glob(pattern));
+ }
+ // Charset sanity via the pattern validator (no wildcard present).
+ let _validated = RefPattern::new(term).map_err(|_e| unsupported())?;
+ if dwim_candidates(term)
+ .iter()
+ .any(|c| c.starts_with("refs/meta"))
+ {
+ return Err(meta(term));
+ }
+ Ok(RevTerm::Name(term.to_owned()))
+}
crates/ents-query/tests/eval.rs
@@ -1,0 +1,426 @@
+//! Evaluation integration tests on a synthetic repo: the
+//! staged-pipeline and fan-in composition idioms handled incrementally
+//! (the Phase 3 → 4 gate criterion), the exclusion idiom including a
+//! subtrahend shrink, the work set, and the generation-number read
+//! bound of `query.incremental`.
+
+#![expect(
+ clippy::expect_used,
+ clippy::indexing_slicing,
+ reason = "test code: fixture indexing panics are test failures"
+)]
+
+use std::collections::BTreeSet;
+
+use ents_model::Status;
+use ents_query::{Evaluator, Query, Transition};
+use ents_testutil::{
+ CountingFind, MemRefStore, ObjectStore, advance_ref, empty_tree, record_result,
+};
+use gix_hash::ObjectId;
+use gix_ref_store::RefStoreRead as _;
+use rstest::rstest;
+
+fn parse(input: &str) -> Query {
+ input.parse().expect("valid query in test")
+}
+
+fn short(oid: ObjectId) -> String {
+ oid.to_string().get(..12).expect("40 hex chars").to_owned()
+}
+
+fn set(oids: &[ObjectId]) -> BTreeSet<ObjectId> {
+ oids.iter().copied().collect()
+}
+
+/// The transition a fixture mutation just performed, reconstructed from
+/// the refname and its old/new tips.
+fn transition(name: &str, old: Option<ObjectId>, new: Option<ObjectId>) -> Transition {
+ Transition {
+ name: name.try_into().expect("valid refname"),
+ old,
+ new,
+ }
+}
+
+/// Record a result and return the transition that landed it.
+fn record(
+ refs: &MemRefStore,
+ objects: &ObjectStore,
+ effect: &str,
+ tested: ObjectId,
+ status: Status,
+ seconds: i64,
+) -> Transition {
+ let short = short(tested);
+ let tip = record_result(refs, objects, effect, &short, status, None, seconds);
+ transition(
+ &format!("refs/meta/results/{effect}/{short}"),
+ None,
+ Some(tip),
+ )
+}
+
+// ---------------------------------------------------------------------
+// The staged-pipeline idiom, both transition directions.
+// ---------------------------------------------------------------------
+
+#[rstest]
+// @relation(query.incremental, query.results, scope=function, role=Verifies)
+fn staged_pipeline_fires_when_the_result_lands() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 100);
+ let trigger = parse("rev(refs/heads/main) & results(unit, pass)");
+ let evaluator = Evaluator::new(&refs, &objects);
+
+ // Advancing main alone enters nothing: no unit results yet.
+ let advance = transition("refs/heads/main", None, Some(commits[2]));
+ assert!(
+ evaluator
+ .entry_set(&trigger, &advance)
+ .expect("evaluates")
+ .is_empty()
+ );
+
+ // A passing unit result for the middle commit: exactly it enters.
+ let landed = record(&refs, &objects, "unit", commits[1], Status::Pass, 300);
+ assert_eq!(
+ evaluator.entry_set(&trigger, &landed).expect("evaluates"),
+ set(&[commits[1]])
+ );
+
+ // A failing unit result for the tip enters nothing.
+ let failed = record(&refs, &objects, "unit", commits[2], Status::Fail, 310);
+ assert!(
+ evaluator
+ .entry_set(&trigger, &failed)
+ .expect("evaluates")
+ .is_empty()
+ );
+}
+
+#[rstest]
+// @relation(query.incremental, scope=function, role=Verifies)
+fn staged_pipeline_fires_when_the_rev_side_arrives_second() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ // The commit exists on a side branch, its result is already
+ // recorded, and only then does main advance to include it.
+ let commits = advance_ref(&refs, &objects, "refs/heads/dev", 2, 100);
+ record(&refs, &objects, "unit", commits[1], Status::Pass, 200);
+
+ let trigger = parse("rev(refs/heads/main) & results(unit, pass)");
+ let evaluator = Evaluator::new(&refs, &objects);
+
+ refs.set_str("refs/heads/main", commits[1]);
+ let advance = transition("refs/heads/main", None, Some(commits[1]));
+ // Both dev commits enter rev(main); only the tested one has a pass.
+ assert_eq!(
+ evaluator.entry_set(&trigger, &advance).expect("evaluates"),
+ set(&[commits[1]])
+ );
+}
+
+// ---------------------------------------------------------------------
+// The fan-in idiom: fires when the last prerequisite lands, in
+// whichever order the underlying refs moved.
+// ---------------------------------------------------------------------
+
+#[rstest]
+#[case::a_then_b(true)]
+#[case::b_then_a(false)]
+// @relation(query.incremental, query.results, scope=function, role=Verifies)
+fn fan_in_fires_once_when_the_last_prerequisite_lands(#[case] a_first: bool) {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let commits = advance_ref(&refs, &objects, "refs/heads/main", 1, 100);
+ let tested = commits[0];
+ let trigger = parse("results(unit, pass) & results(integ, pass)");
+ let evaluator = Evaluator::new(&refs, &objects);
+
+ let (first, second) = if a_first {
+ ("unit", "integ")
+ } else {
+ ("integ", "unit")
+ };
+
+ let first_landing = record(&refs, &objects, first, tested, Status::Pass, 200);
+ assert!(
+ evaluator
+ .entry_set(&trigger, &first_landing)
+ .expect("evaluates")
+ .is_empty(),
+ "one prerequisite alone must not fire"
+ );
+
+ let second_landing = record(&refs, &objects, second, tested, Status::Pass, 210);
+ assert_eq!(
+ evaluator
+ .entry_set(&trigger, &second_landing)
+ .expect("evaluates"),
+ set(&[tested]),
+ "the last prerequisite fires the fan-in"
+ );
+}
+
+// ---------------------------------------------------------------------
+// The exclusion idiom, including entry via a shrinking subtrahend.
+// ---------------------------------------------------------------------
+
+#[rstest]
+// @relation(query.set-ops, query.incremental, scope=function, role=Verifies)
+fn exclusion_skips_wip_branches_and_reenters_on_subtrahend_shrink() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let main = advance_ref(&refs, &objects, "refs/heads/main", 2, 100);
+ let query = parse("rev(refs/heads/*) - rev(refs/heads/wip/*)");
+ let evaluator = Evaluator::new(&refs, &objects);
+
+ // A wip branch on top of main: its new commit enters both sides at
+ // once, so it never enters the difference.
+ let wip = advance_ref(&refs, &objects, "refs/heads/wip/x", 1, 200);
+ let wip_advance = transition("refs/heads/wip/x", None, Some(wip[0]));
+ assert!(
+ evaluator
+ .entry_set(&query, &wip_advance)
+ .expect("evaluates")
+ .is_empty()
+ );
+
+ // But main's own commits are in the difference.
+ assert_eq!(
+ evaluator.eval(&query).expect("evaluates"),
+ set(&[main[0], main[1]])
+ );
+
+ // Deleting the wip branch shrinks the subtrahend: its commit is
+ // still reachable from... nothing. Re-point wip at main's tip
+ // first, so main's tip is temporarily excluded, then delete.
+ refs.set_str("refs/heads/wip/x", main[1]);
+ let repoint = transition("refs/heads/wip/x", Some(wip[0]), Some(main[1]));
+ assert!(
+ evaluator
+ .entry_set(&query, &repoint)
+ .expect("evaluates")
+ .is_empty(),
+ "pointing wip at existing commits only removes from the difference"
+ );
+ assert_eq!(evaluator.eval(&query).expect("evaluates"), BTreeSet::new());
+
+ let wip_name: gix::refs::FullName = "refs/heads/wip/x".try_into().expect("valid");
+ refs.remove(wip_name.as_ref());
+ let deletion = transition("refs/heads/wip/x", Some(main[1]), None);
+ // The subtrahend shrank: main's commits re-enter the difference.
+ assert_eq!(
+ evaluator.entry_set(&query, &deletion).expect("evaluates"),
+ set(&[main[0], main[1]])
+ );
+}
+
+// ---------------------------------------------------------------------
+// The work set: trigger − results(self, any).
+// ---------------------------------------------------------------------
+
+#[rstest]
+// @relation(query.workset, scope=function, role=Verifies)
+fn work_set_subtracts_any_recorded_status_by_refname_scan() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let first = advance_ref(&refs, &objects, "refs/heads/main", 1, 100);
+ let trigger = parse("rev(refs/heads/main)");
+ let evaluator = Evaluator::new(&refs, &objects);
+
+ // Advance by two; an *error* result for the first new commit is
+ // already recorded (a terminal infrastructure verdict counts as
+ // recorded — the obligation is discharged, `query.workset`).
+ let new = advance_ref(&refs, &objects, "refs/heads/main", 2, 200);
+ record(&refs, &objects, "unit", new[0], Status::Error, 250);
+
+ let advance = transition("refs/heads/main", Some(first[0]), Some(new[1]));
+ assert_eq!(
+ evaluator.entry_set(&trigger, &advance).expect("evaluates"),
+ set(&[new[0], new[1]]),
+ "the trigger itself knows nothing about results"
+ );
+ assert_eq!(
+ evaluator
+ .work_set("unit", &trigger, &advance)
+ .expect("evaluates"),
+ set(&[new[1]]),
+ "the effect's own results ref is the sole materialization marker"
+ );
+}
+
+#[rstest]
+// @relation(query.workset, query.monotone, scope=function, role=Verifies)
+fn outstanding_reconstructs_the_obligation_set_from_repository_state() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 100);
+ record(&refs, &objects, "unit", commits[0], Status::Pass, 200);
+ record(&refs, &objects, "unit", commits[2], Status::Fail, 210);
+
+ let trigger = parse("rev(refs/heads/main)");
+ let evaluator = Evaluator::new(&refs, &objects);
+ // No pipeline state anywhere: the outstanding set falls out of ref
+ // state alone — exactly what a boot-time reconciliation scan needs.
+ assert_eq!(
+ evaluator.outstanding("unit", &trigger).expect("evaluates"),
+ set(&[commits[1]])
+ );
+}
+
+// ---------------------------------------------------------------------
+// meta() entry, and monotone non-retraction.
+// ---------------------------------------------------------------------
+
+#[rstest]
+// @relation(query.meta, query.monotone, scope=function, role=Verifies)
+fn meta_tips_enter_and_old_tips_are_not_retracted() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let query = parse("meta(refs/meta/issues/*)");
+ let evaluator = Evaluator::new(&refs, &objects);
+
+ let tip1 = record_result(&refs, &objects, "unused", "seed", Status::Pass, None, 90);
+ let _ = tip1; // keep the results namespace non-empty and irrelevant
+
+ let tree = empty_tree(&objects);
+ let write = |parents: Vec<ObjectId>, seconds: i64| {
+ ents_testutil::write_commit(
+ &objects,
+ &ents_testutil::CommitSpec {
+ tree,
+ parents,
+ message: format!("issue mutation at {seconds}"),
+ seconds,
+ },
+ None,
+ )
+ };
+ let first = write(vec![], 100);
+ refs.set_str("refs/meta/issues/7", first);
+ let created = transition("refs/meta/issues/7", None, Some(first));
+ assert_eq!(
+ evaluator.entry_set(&query, &created).expect("evaluates"),
+ set(&[first])
+ );
+
+ let second = write(vec![first], 110);
+ refs.set_str("refs/meta/issues/7", second);
+ let advanced = transition("refs/meta/issues/7", Some(first), Some(second));
+ // Only the new tip enters; the old tip leaving the set retracts
+ // nothing — it simply stops being a tip.
+ assert_eq!(
+ evaluator.entry_set(&query, &advanced).expect("evaluates"),
+ set(&[second])
+ );
+}
+
+#[rstest]
+// @relation(query.monotone, scope=function, role=Verifies)
+fn a_force_push_shrinks_the_set_without_reentering_survivors() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 100);
+ let query = parse("rev(refs/heads/main)");
+ let evaluator = Evaluator::new(&refs, &objects);
+
+ // Force main back to its first commit: the set shrinks, nothing
+ // enters, nothing is retracted.
+ refs.set_str("refs/heads/main", commits[0]);
+ let force = transition("refs/heads/main", Some(commits[2]), Some(commits[0]));
+ assert!(
+ evaluator
+ .entry_set(&query, &force)
+ .expect("evaluates")
+ .is_empty()
+ );
+
+ // Re-advancing over the same commits re-enters them: whether they
+ // fire again is the work set's job, not the entry set's — results
+ // already written keep them discharged.
+ refs.set_str("refs/heads/main", commits[2]);
+ let restore = transition("refs/heads/main", Some(commits[0]), Some(commits[2]));
+ assert_eq!(
+ evaluator.entry_set(&query, &restore).expect("evaluates"),
+ set(&[commits[1], commits[2]])
+ );
+}
+
+// ---------------------------------------------------------------------
+// The generation-number bound (`query.incremental`).
+// ---------------------------------------------------------------------
+
+#[rstest]
+// @relation(query.incremental, scope=function, role=Verifies)
+fn entry_after_a_one_commit_advance_reads_a_bounded_frontier() {
+ const HISTORY: usize = 300;
+ const READ_BUDGET: usize = 25;
+
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let commits = advance_ref(&refs, &objects, "refs/heads/main", HISTORY, 1_000);
+ let tip = *commits.last().expect("non-empty");
+
+ let counting = CountingFind::new(&objects);
+ let evaluator = Evaluator::new(&refs, &counting);
+ let query = parse("rev(refs/heads/main) | results(unit, pass)");
+
+ // Warm the evaluator the way a long-lived receive process is warm:
+ // one reconciliation pass caches commit structure for the history.
+ let full = evaluator.eval(&query).expect("evaluates");
+ assert_eq!(full.len(), HISTORY);
+ let warm_reads = counting.reads();
+ assert!(warm_reads >= HISTORY, "the warm-up walk pays for history");
+
+ // One commit lands. The entry set must be computed from the
+ // frontier, not by re-walking three hundred commits.
+ let new = advance_ref(&refs, &objects, "refs/heads/main", 1, 2_000);
+ counting.reset();
+ let entered = evaluator
+ .entry_set(
+ &query,
+ &transition("refs/heads/main", Some(tip), Some(new[0])),
+ )
+ .expect("evaluates");
+ assert_eq!(entered, set(&[new[0]]));
+ assert!(
+ counting.reads() <= READ_BUDGET,
+ "a one-commit advance read {} objects; the frontier bound allows {}",
+ counting.reads(),
+ READ_BUDGET
+ );
+}
+
+#[rstest]
+// @relation(query.footprint, query.incremental, scope=function, role=Verifies)
+fn transitions_outside_the_footprint_are_free() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let commits = advance_ref(&refs, &objects, "refs/heads/main", 2, 100);
+ advance_ref(&refs, &objects, "refs/heads/dev", 2, 200);
+
+ let counting = CountingFind::new(&objects);
+ let evaluator = Evaluator::new(&refs, &counting);
+ let query = parse("rev(refs/heads/main)");
+
+ let dev_tip = refs
+ .get("refs/heads/dev".try_into().expect("valid"))
+ .ok()
+ .flatten();
+ let unrelated = transition("refs/heads/dev", None, dev_tip);
+ assert!(
+ evaluator
+ .entry_set(&query, &unrelated)
+ .expect("evaluates")
+ .is_empty()
+ );
+ assert_eq!(
+ counting.reads(),
+ 0,
+ "a non-matching footprint must short-circuit before any object read"
+ );
+ let _ = commits;
+}