git-ents.gitmain
⌘K
foforge
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.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -400,6 +400,22 @@ "thiserror", ] +[[package]] +name = "ents-query" +version = "0.0.0" +dependencies = [ + "ents-model", + "ents-testutil", + "facet-git-tree", + "gix", + "gix-hash", + "gix-object", + "gix-ref-store", + "proptest", + "rstest", + "thiserror", +] + [[package]] name = "ents-testutil" version = "0.0.0"
Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/ents-gate", "crates/ents-model", + "crates/ents-query", "crates/ents-testutil", "crates/gix-ref-store", ] @@ -19,6 +20,7 @@ [workspace.dependencies] ents-gate = { path = "crates/ents-gate" } ents-model = { path = "crates/ents-model" } +ents-query = { path = "crates/ents-query" } ents-testutil = { path = "crates/ents-testutil" } gix-ref-store = { path = "crates/gix-ref-store" } arborium = { version = "2.18", default-features = false, features = [
crates/ents-query/Cargo.toml @@ -1,0 +1,23 @@ +[package] +name = "ents-query" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +ents-model = { workspace = true } +facet-git-tree = { workspace = true } +gix = { workspace = true } +gix-hash = { workspace = true } +gix-object = { workspace = true } +gix-ref-store = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +ents-testutil = { workspace = true } +proptest = { workspace = true } +rstest = { workspace = true } + +[lints] +workspace = true
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; +}
crates/ents-query/tests/grammar.rs @@ -1,0 +1,295 @@ +//! Grammar, validation, footprint, and compatibility tests — rstest +//! tables, because the spec enumerates the cases. + +#![expect(clippy::expect_used, clippy::panic, reason = "test code")] + +use ents_query::{ParseError, Query, SetOp}; +use rstest::rstest; + +fn parse(input: &str) -> Query { + input + .parse() + .unwrap_or_else(|e| panic!("{input:?} must parse: {e}")) +} + +fn parse_err(input: &str) -> ParseError { + input + .parse::<Query>() + .err() + .unwrap_or_else(|| panic!("{input:?} must be rejected")) +} + +// --------------------------------------------------------------------- +// query.grammar / query.set-ops +// --------------------------------------------------------------------- + +#[rstest] +#[case::rev_atom("rev(refs/heads/main)")] +#[case::rev_range("rev(main ^release)")] +#[case::rev_dotdot("rev(release..main)")] +#[case::results_atom("results(unit, pass)")] +#[case::meta_atom("meta(refs/meta/issues/*)")] +#[case::union("rev(a) | rev(b)")] +#[case::intersection("rev(refs/heads/main) & results(unit, pass)")] +#[case::difference("rev(refs/heads/*) - rev(refs/heads/wip/*)")] +#[case::parenthesized("rev(a) & (rev(b) | rev(c))")] +#[case::whitespace_tolerant(" rev( main ) &results(unit,any) ")] +// @relation(query.grammar, scope=function, role=Verifies) +fn well_formed_queries_parse(#[case] input: &str) { + let _query = parse(input); +} + +#[rstest] +// @relation(query.grammar, query.set-ops, scope=function, role=Verifies) +fn operators_are_left_associative_at_one_precedence_level() { + // a | b & c parses as (a | b) & c — never as a | (b & c). + let query = parse("rev(a) | rev(b) & rev(c)"); + let Query::Op { + op: SetOp::Intersect, + lhs, + rhs, + } = query + else { + panic!("top-level operator must be the rightmost one"); + }; + assert!(matches!( + *lhs, + Query::Op { + op: SetOp::Union, + .. + } + )); + assert!(matches!(*rhs, Query::Rev(_))); +} + +#[rstest] +// @relation(query.set-ops, scope=function, role=Verifies) +fn parentheses_regroup_evaluation_order() { + let grouped = parse("rev(a) | (rev(b) & rev(c))"); + let Query::Op { + op: SetOp::Union, + rhs, + .. + } = grouped + else { + panic!("parentheses must override left-to-right order"); + }; + assert!(matches!( + *rhs, + Query::Op { + op: SetOp::Intersect, + .. + } + )); +} + +#[rstest] +#[case::atom("rev(refs/heads/main)")] +#[case::rev_with_negation("rev(main ^release)")] +#[case::results("results(unit, pass)")] +#[case::meta("meta(refs/meta/issues/*)")] +#[case::left_chain("rev(a) | rev(b) & rev(c) - rev(d)")] +#[case::grouped("rev(a) - (rev(b) | rev(c))")] +#[case::bare_glob("refs/heads/*")] +// @relation(query.grammar, scope=function, role=Verifies) +fn display_round_trips_through_the_parser(#[case] input: &str) { + let query = parse(input); + let rendered = query.to_string(); + assert_eq!(parse(&rendered), query, "display: {rendered:?}"); +} + +#[rstest] +#[case::empty("")] +#[case::bare_operator("| rev(a)")] +#[case::dangling_operator("rev(a) |")] +#[case::unbalanced_paren("(rev(a)")] +#[case::unbalanced_atom("rev(main")] +#[case::trailing_garbage("rev(a) rev(b)")] +#[case::missing_status("results(unit)")] +#[case::empty_rev("rev()")] +#[case::only_negations("rev(^main)")] +// @relation(query.grammar, scope=function, role=Verifies) +fn malformed_queries_are_rejected(#[case] input: &str) { + let _err = parse_err(input); +} + +// --------------------------------------------------------------------- +// query.no-extensions — the atom set is closed. +// --------------------------------------------------------------------- + +#[rstest] +#[case::time_atom("time(5m)")] +#[case::cron_atom("cron(hourly) & rev(main)")] +#[case::content_atom("content(Cargo.toml)")] +#[case::path_atom("rev(main) & path(src/*)")] +#[case::webhook_atom("webhook(deploy)")] +// @relation(query.no-extensions, scope=function, role=Verifies) +fn content_time_and_external_event_atoms_do_not_exist(#[case] input: &str) { + assert!(matches!(parse_err(input), ParseError::UnknownAtom { .. })); +} + +// --------------------------------------------------------------------- +// query.rev — refs/meta/* is outside rev()'s domain. +// --------------------------------------------------------------------- + +#[rstest] +#[case::exact_meta_ref("rev(refs/meta/config)")] +#[case::meta_glob("rev(refs/meta/issues/*)")] +#[case::meta_short_name("rev(meta/issues)")] +#[case::negated_meta("rev(main ^refs/meta/config)")] +// @relation(query.rev, scope=function, role=Verifies) +fn rev_naming_a_meta_pattern_is_malformed_not_empty(#[case] input: &str) { + assert!(matches!(parse_err(input), ParseError::MetaInRev { .. })); +} + +#[rstest] +#[case::triple_dot("rev(a...b)")] +#[case::tilde_suffix("rev(main~3)")] +#[case::caret_suffix("rev(main^2)")] +#[case::reflog("rev(main@{1})")] +#[case::peel("rev(v1.0^{commit})")] +// @relation(query.rev, scope=function, role=Verifies) +fn unsupported_revspec_forms_error_explicitly(#[case] input: &str) { + assert!(matches!( + parse_err(input), + ParseError::UnsupportedRev { .. } + )); +} + +// --------------------------------------------------------------------- +// query.meta — effect-written namespaces are unreachable. +// --------------------------------------------------------------------- + +#[rstest] +#[case::results_glob("meta(refs/meta/results/*)")] +#[case::results_exact("meta(refs/meta/results/unit/abc)")] +#[case::index_glob("meta(refs/meta/index/*)")] +#[case::broad_meta_glob("meta(refs/meta/*)")] +#[case::sneaky_prefix("meta(refs/meta/res*)")] +// @relation(query.meta, query.recursion, scope=function, role=Verifies) +fn meta_cannot_match_effect_written_namespaces(#[case] input: &str) { + assert!(matches!( + parse_err(input), + ParseError::MetaGlobEffectWritten { .. } + )); +} + +#[rstest] +#[case::heads_glob("meta(refs/heads/*)")] +#[case::short_glob("meta(issues/*)")] +// @relation(query.meta, scope=function, role=Verifies) +fn meta_glob_must_stay_under_refs_meta(#[case] input: &str) { + assert!(matches!( + parse_err(input), + ParseError::MetaGlobOutside { .. } + )); +} + +// --------------------------------------------------------------------- +// query.workset — `self` is notation, not a keyword. +// --------------------------------------------------------------------- + +#[rstest] +// @relation(query.workset, scope=function, role=Verifies) +fn results_self_cannot_be_written_in_a_trigger() { + assert_eq!(parse_err("results(self, any)"), ParseError::SelfKeyword); +} + +#[rstest] +#[case::bad_status("results(unit, maybe)")] +#[case::empty_effect("results(, pass)")] +#[case::effect_with_slash("results(a/b, pass)")] +#[case::effect_with_star("results(a*, pass)")] +// @relation(query.results, scope=function, role=Verifies) +fn results_arguments_are_validated(#[case] input: &str) { + let err = parse_err(input); + assert!( + matches!( + err, + ParseError::BadStatus { .. } | ParseError::BadEffectName { .. } + ), + "got {err:?}" + ); +} + +// --------------------------------------------------------------------- +// query.rev-pattern-compat — bare glob is the degenerate rev() query. +// --------------------------------------------------------------------- + +#[rstest] +#[case::heads_glob("refs/heads/*")] +#[case::tags_glob("refs/tags/v*")] +#[case::exact_ref("refs/heads/main")] +#[case::short_name("main")] +// @relation(query.rev-pattern-compat, scope=function, role=Verifies) +fn a_bare_ref_glob_means_exactly_rev_of_that_glob(#[case] glob: &str) { + assert_eq!(parse(glob), parse(&format!("rev({glob})"))); +} + +#[rstest] +// @relation(query.rev-pattern-compat, query.rev, scope=function, role=Verifies) +fn a_bare_meta_glob_is_still_rejected() { + assert!(matches!( + parse_err("refs/meta/*"), + ParseError::MetaInRev { .. } | ParseError::UnexpectedEnd + )); +} + +// --------------------------------------------------------------------- +// query.footprint — static extraction from the syntax tree alone. +// --------------------------------------------------------------------- + +#[rstest] +#[case::exact_rev("rev(refs/heads/main)", &["refs/heads/main"])] +#[case::results("results(unit, pass)", &["refs/meta/results/unit/*"])] +#[case::meta("meta(refs/meta/issues/*)", &["refs/meta/issues/*"])] +#[case::rev_negation_contributes( + "rev(refs/heads/main ^refs/heads/release)", + &["refs/heads/main", "refs/heads/release"] +)] +#[case::composite( + "rev(refs/heads/main) & results(unit, pass)", + &["refs/heads/main", "refs/meta/results/unit/*"] +)] +#[case::short_name_contributes_the_lookup_order( + "rev(main)", + &["refs/main", "refs/tags/main", "refs/heads/main", "refs/remotes/main"] +)] +// @relation(query.footprint, scope=function, role=Verifies) +fn footprints_come_from_the_syntax_tree_alone(#[case] input: &str, #[case] expected: &[&str]) { + let footprint = parse(input).footprint(); + let mut got: Vec<&str> = footprint.patterns().iter().map(|p| p.as_str()).collect(); + let mut expected: Vec<&str> = expected.to_vec(); + got.sort_unstable(); + expected.sort_unstable(); + assert_eq!(got, expected); +} + +#[rstest] +#[case::affected("refs/meta/results/unit/abc123", true)] +#[case::other_effect("refs/meta/results/integ/abc123", false)] +#[case::trigger_ref("refs/heads/main", true)] +#[case::unrelated("refs/heads/dev", false)] +// @relation(query.footprint, scope=function, role=Verifies) +fn footprint_maps_a_ref_transition_to_affected_queries( + #[case] refname: &str, + #[case] affected: bool, +) { + let query = parse("rev(refs/heads/main) & results(unit, pass)"); + let name: gix::refs::FullName = refname.try_into().expect("valid"); + assert_eq!(query.footprint().matches(name.as_ref()), affected); +} + +// --------------------------------------------------------------------- +// query.recursion — downstream-of is syntax. +// --------------------------------------------------------------------- + +#[rstest] +#[case::not_downstream("rev(refs/heads/main)", &[])] +#[case::single("rev(main) & results(unit, pass)", &["unit"])] +#[case::fan_in("results(unit, pass) & results(integ, pass)", &["unit", "integ"])] +#[case::deduplicated("results(unit, pass) | results(unit, fail)", &["unit"])] +// @relation(query.recursion, scope=function, role=Verifies) +fn downstream_of_an_effect_is_visible_in_the_text(#[case] input: &str, #[case] expected: &[&str]) { + assert_eq!(parse(input).results_dependencies(), expected); +}
crates/ents-query/tests/monotone.rs @@ -1,0 +1,309 @@ +//! Property test for monotone, entry-only semantics (`query.monotone`) +//! and incremental-equals-full evaluation (`query.incremental`): random +//! synthetic ref histories — advances, force-pushes, deletions, result +//! recordings — checked after every transition against an independent +//! naive oracle. The evaluator's incremental entry set must equal the +//! oracle's `full(after) − full(before)` for every query, every time; +//! the work set must equal the entry set minus recorded prefixes. + +#![expect( + clippy::expect_used, + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + clippy::unreachable, + reason = "test code: fixture indexing panics are test failures" +)] + +use std::collections::{HashMap, HashSet}; + +use ents_model::Status; +use ents_query::{Evaluator, Query, Transition}; +use ents_testutil::{MemRefStore, ObjectStore, advance_ref, record_result}; +use gix_hash::ObjectId; +use gix_object::{CommitRef, Find as _}; +use gix_ref_store::RefStoreRead as _; +use proptest::prelude::*; + +const BRANCHES: [&str; 3] = ["refs/heads/main", "refs/heads/dev", "refs/heads/wip/x"]; +const EFFECTS: [&str; 2] = ["unit", "integ"]; +const STATUSES: [Status; 3] = [Status::Pass, Status::Fail, Status::Error]; + +/// One randomized history operation. +#[derive(Debug, Clone)] +enum Op { + Advance { + branch: usize, + count: usize, + }, + ForceTo { + branch: usize, + commit: usize, + }, + Delete { + branch: usize, + }, + Record { + effect: usize, + commit: usize, + status: usize, + short_len: usize, + }, +} + +fn op_strategy() -> impl Strategy<Value = Op> { + prop_oneof![ + (0..3usize, 1..3usize).prop_map(|(branch, count)| Op::Advance { branch, count }), + (0..3usize, 0..64usize).prop_map(|(branch, commit)| Op::ForceTo { branch, commit }), + (0..3usize).prop_map(|branch| Op::Delete { branch }), + (0..2usize, 0..64usize, 0..3usize, 7..12usize).prop_map( + |(effect, commit, status, short_len)| Op::Record { + effect, + commit, + status, + short_len + } + ), + ] +} + +// --------------------------------------------------------------------- +// The naive oracle: full evaluation from first principles. +// --------------------------------------------------------------------- + +type Refs = HashMap<String, ObjectId>; + +fn snapshot(refs: &MemRefStore) -> Refs { + refs.iter_prefix("refs/") + .expect("iterable") + .map(|entry| { + let (name, oid) = entry.expect("readable"); + (name.as_bstr().to_string(), oid) + }) + .collect() +} + +fn parents(objects: &ObjectStore, oid: ObjectId) -> Vec<ObjectId> { + let mut buf = Vec::new(); + let data = objects + .try_find(&oid, &mut buf) + .expect("readable") + .expect("present"); + CommitRef::from_bytes(data.data, oid.kind()) + .expect("a commit") + .parents() + .collect() +} + +fn reach(objects: &ObjectStore, tips: impl IntoIterator<Item = ObjectId>) -> HashSet<ObjectId> { + let mut seen = HashSet::new(); + let mut queue: Vec<ObjectId> = tips.into_iter().collect(); + while let Some(oid) = queue.pop() { + if seen.insert(oid) { + queue.extend(parents(objects, oid)); + } + } + seen +} + +fn reach_glob(objects: &ObjectStore, refs: &Refs, prefix: &str) -> HashSet<ObjectId> { + reach( + objects, + refs.iter() + .filter(|(name, _)| name.starts_with(prefix) && !name.starts_with("refs/meta/")) + .map(|(_, oid)| *oid), + ) +} + +/// Recorded shorts for one effect whose status satisfies `want` +/// (`None` = any), read back through facet like the evaluator does. +fn recorded(objects: &ObjectStore, refs: &Refs, effect: &str, want: Option<Status>) -> Vec<String> { + let prefix = format!("refs/meta/results/{effect}/"); + refs.iter() + .filter_map(|(name, tip)| { + let short = name.strip_prefix(&prefix)?; + let mut buf = Vec::new(); + let data = objects.try_find(tip, &mut buf).expect("readable")?; + let tree = CommitRef::from_bytes(data.data, tip.kind()) + .expect("commit") + .tree(); + let status: Status = facet_git_tree::deserialize(&tree, objects).expect("status tree"); + (want.is_none() || want == Some(status)).then(|| short.to_owned()) + }) + .collect() +} + +fn universe(objects: &ObjectStore, refs: &Refs) -> HashSet<ObjectId> { + reach(objects, refs.values().copied()) +} + +fn by_prefix(universe: &HashSet<ObjectId>, shorts: &[String]) -> HashSet<ObjectId> { + universe + .iter() + .copied() + .filter(|oid| { + let hex = oid.to_string(); + shorts.iter().any(|s| hex.starts_with(s.as_str())) + }) + .collect() +} + +/// The five checked queries and their independent full evaluations. +fn oracle(objects: &ObjectStore, refs: &Refs, query_index: usize) -> HashSet<ObjectId> { + let main = reach(objects, refs.get("refs/heads/main").copied()); + match query_index { + 0 => main, + 1 => { + let heads = reach_glob(objects, refs, "refs/heads/"); + let wip = reach_glob(objects, refs, "refs/heads/wip/"); + heads.difference(&wip).copied().collect() + } + 2 => { + let u = universe(objects, refs); + let pass = by_prefix(&u, &recorded(objects, refs, "unit", Some(Status::Pass))); + main.intersection(&pass).copied().collect() + } + 3 => { + let u = universe(objects, refs); + let unit = by_prefix(&u, &recorded(objects, refs, "unit", Some(Status::Pass))); + let integ = by_prefix(&u, &recorded(objects, refs, "integ", Some(Status::Pass))); + unit.intersection(&integ).copied().collect() + } + 4 => { + let dev = reach(objects, refs.get("refs/heads/dev").copied()); + main.union(&dev).copied().collect() + } + _ => unreachable!("five queries"), + } +} + +const QUERIES: [&str; 5] = [ + "rev(refs/heads/main)", + "rev(refs/heads/*) - rev(refs/heads/wip/*)", + "rev(refs/heads/main) & results(unit, pass)", + "results(unit, pass) & results(integ, pass)", + "rev(refs/heads/main) | rev(refs/heads/dev)", +]; + +// --------------------------------------------------------------------- +// The property. +// --------------------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + + // @relation(query.monotone, query.incremental, query.set-ops, query.workset, scope=function, role=Verifies) + #[test] + fn entry_sets_equal_the_oracle_diff_over_random_histories( + ops in proptest::collection::vec(op_strategy(), 1..14) + ) { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let queries: Vec<Query> = + QUERIES.iter().map(|q| q.parse().expect("valid")).collect(); + let evaluator = Evaluator::new(&refs, &objects); + + let mut commits: Vec<ObjectId> = Vec::new(); + let mut seconds = 1_000i64; + + for op in ops { + let before = snapshot(&refs); + + // Apply the operation; `moved` is the transitioned refname. + let moved: Option<String> = match op { + Op::Advance { branch, count } => { + let name = BRANCHES[branch]; + seconds += 100; + commits.extend(advance_ref(&refs, &objects, name, count, seconds)); + Some(name.to_owned()) + } + Op::ForceTo { branch, commit } => { + if commits.is_empty() { + continue; + } + let name = BRANCHES[branch]; + let target = commits[commit % commits.len()]; + refs.set_str(name, target); + Some(name.to_owned()) + } + Op::Delete { branch } => { + let name = BRANCHES[branch]; + let full: gix::refs::FullName = name.try_into().expect("valid"); + refs.remove(full.as_ref()); + Some(name.to_owned()) + } + Op::Record { effect, commit, status, short_len } => { + if commits.is_empty() { + continue; + } + let tested = commits[commit % commits.len()]; + let short = tested.to_string() + .get(..short_len) + .expect("40 hex chars") + .to_owned(); + seconds += 100; + record_result( + &refs, &objects, EFFECTS[effect], &short, + STATUSES[status], None, seconds, + ); + Some(format!("refs/meta/results/{}/{short}", EFFECTS[effect])) + } + }; + + let after = snapshot(&refs); + let Some(moved) = moved else { continue }; + let (old, new) = (before.get(&moved).copied(), after.get(&moved).copied()); + if old == new { + continue; // a no-op transition denotes no frontier + } + let transition = Transition { + name: moved.as_str().try_into().expect("valid"), + old, + new, + }; + + for (index, query) in queries.iter().enumerate() { + let full_before = oracle(&objects, &before, index); + let full_after = oracle(&objects, &after, index); + + // Incremental entry == full(after) − full(before): + // entry-only, no retraction, no full re-evaluation. + let expected: std::collections::BTreeSet<ObjectId> = + full_after.difference(&full_before).copied().collect(); + let entered = evaluator + .entry_set(query, &transition) + .expect("evaluates"); + prop_assert_eq!( + &entered, &expected, + "entry mismatch for {} under {:?}", QUERIES[index], transition + ); + + // Full evaluation agrees with the oracle outright. + let full = evaluator.eval(query).expect("evaluates"); + let oracle_after: std::collections::BTreeSet<ObjectId> = + full_after.iter().copied().collect(); + prop_assert_eq!( + &full, &oracle_after, + "full-eval mismatch for {}", QUERIES[index] + ); + } + + // The work set: trigger − results(self, any), on the plain + // rev trigger. + let trigger = &queries[0]; + let entered = evaluator.entry_set(trigger, &transition).expect("evaluates"); + let unit_any = recorded(&objects, &after, "unit", None); + let expected_work: std::collections::BTreeSet<ObjectId> = entered + .iter() + .copied() + .filter(|oid| { + let hex = oid.to_string(); + !unit_any.iter().any(|s| hex.starts_with(s.as_str())) + }) + .collect(); + let work = evaluator + .work_set("unit", trigger, &transition) + .expect("evaluates"); + prop_assert_eq!(&work, &expected_work, "work-set mismatch"); + } + } +}