From 8dd0cf54baac649e58cfa5f16ae3b6661474f8f2 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 20 Oct 2026 18:29:02 +0530 Subject: [PATCH 1/2] test(parser): backport the parser vs grammar verifier to v2.x (#643) Port src/query/datalog/parser/verifier.rs and v3's grammar.pest (exact fact and retract fact, reserved list heads, keyword word boundaries) to main. INT-019 still mixes syntax and semantic parse errors on v2.x, so it counts as semantic, with a witness per semantic site; PRS-080, PRS-084 and PRS-107 do not exist here. KNOWN_DRIFT is v3's list unchanged: every entry matches at k = 3 and k = 4 on main. Add .github/workflows/grammar-verifier.yml: nightly k = 4 runs on main and v3, branch picked from the cron as soak.yml does, an issue opened or commented on failure. Refs #643 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019kY61Y11gcRD91Y6b7jbGT --- .github/workflows/grammar-verifier.yml | 99 +++ CHANGELOG.md | 4 + CLAUDE.md | 1 + docs/TEST_COVERAGE.md | 2 + src/query/datalog/parser.rs | 3 + src/query/datalog/parser/verifier.rs | 786 ++++++++++++++++++ tests/grammar/grammar.pest | 65 +- .../fact_too_few_elements.edn | 0 .../retract_wrong_arity.edn | 0 9 files changed, 941 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/grammar-verifier.yml create mode 100644 src/query/datalog/parser/verifier.rs rename tests/grammar/invalid/{semantic => syntax}/fact_too_few_elements.edn (100%) rename tests/grammar/invalid/{semantic => syntax}/retract_wrong_arity.edn (100%) diff --git a/.github/workflows/grammar-verifier.yml b/.github/workflows/grammar-verifier.yml new file mode 100644 index 000000000..4c5b8ba54 --- /dev/null +++ b/.github/workflows/grammar-verifier.yml @@ -0,0 +1,99 @@ +name: Grammar verifier (nightly) + +# Two branches, 12 hours apart: main at 05:30 UTC, v3 at 17:30 UTC. +# Scheduled runs always use this file from the default branch, so the branch +# under test is chosen from the cron string that fired (as in soak.yml). Keep +# the cron strings here and in VERIFIER_REF/run-name identical. +run-name: >- + Grammar verifier (${{ github.event_name == 'workflow_dispatch' && inputs.ref + || (github.event.schedule == '30 17 * * *' && 'v3' || 'main') }}) + +# #643: checks the parser against tests/grammar/grammar.pest on every input up +# to a bound (src/query/datalog/parser/verifier.rs). `cargo test` runs the +# structural level at k = 3; this job runs k = 4 (~22 CPU-minutes, ~6 minutes +# on 4 cores). + +on: + schedule: + - cron: "30 5 * * *" # main + - cron: "30 17 * * *" # v3 + workflow_dispatch: + inputs: + ref: + description: Branch to test + required: true + default: main + bound: + description: Structural bound k (MINIGRAF_GRAMMAR_BOUND) + required: true + default: "4" + +permissions: + contents: read + issues: write + +env: + VERIFIER_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || (github.event.schedule == '30 17 * * *' && 'v3' || 'main') }} + MINIGRAF_GRAMMAR_BOUND: ${{ github.event_name == 'workflow_dispatch' && inputs.bound || '4' }} + +jobs: + verifier: + name: Grammar verifier + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.VERIFIER_REF }} + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Build + run: cargo test --lib --no-run + + - name: Run the verifier + shell: bash + env: + RUST_BACKTRACE: 1 + run: | + set -o pipefail + cargo test --lib query::datalog::parser::verifier -- --nocapture 2>&1 | tee verifier-output.txt + + - name: Record verifier failure + if: ${{ failure() }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ISSUE_TITLE: "Nightly grammar verifier failure (${{ env.VERIFIER_REF }})" + run: | + report_file="verifier-failure.md" + { + echo "The nightly grammar verifier failed on ${{ env.VERIFIER_REF }}." + echo + echo "- Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo "- Commit: $(git rev-parse HEAD)" + echo "- Reproduce: \`MINIGRAF_GRAMMAR_BOUND=$MINIGRAF_GRAMMAR_BOUND cargo test --lib query::datalog::parser::verifier -- --nocapture\`" + echo + echo "## Test output" + echo + echo '```text' + tail -n 300 verifier-output.txt 2>/dev/null || echo "(no output: the run failed before the test started)" + echo '```' + } > "$report_file" + + existing_issue="$(gh issue list \ + --repo "$GITHUB_REPOSITORY" \ + --state open \ + --search "$ISSUE_TITLE in:title" \ + --json number,title \ + --limit 100 \ + --jq ".[] | select(.title == \"$ISSUE_TITLE\") | .number" \ + | head -n 1)" + + if [ -n "$existing_issue" ]; then + gh issue comment "$existing_issue" --repo "$GITHUB_REPOSITORY" --body-file "$report_file" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$ISSUE_TITLE" --body-file "$report_file" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a3a2d00..acbf8d475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A fact with more than four elements is rejected** (#649). `transact` read a fact's 4th element as its optional valid-time map and ignored everything after it, so `[[:alice :person/age 1 {} 99 :junk]]` stored `[:alice :person/age 1]` and dropped the rest without an error; two facts run together by a misplaced bracket lost one. A fact of more than four elements now fails with the new `PRS-108`, numbered as in v3.0.0, and the transaction writes nothing. A fact of five or more elements whose 4th is not a map now gets `PRS-108` instead of `PRS-047`. Present since v0.4.0. +### Tests + +- **Parser vs grammar verifier (#643), backported from v3.0.0.** `src/query/datalog/parser/verifier.rs` checks, for every generated input, that the parser rejects what `tests/grammar/grammar.pest` rejects, and accepts what it accepts unless the error code is semantic (a listed code with a witness pest accepts). It runs every string of 1 to 3 characters over 102 characters (ASCII, `\t \n \r`, four non-ASCII stand-ins) through `parse_edn`, and every bracket-balanced sequence of up to `MINIGRAF_GRAMMAR_BOUND` (default 3) of 32 tokens in 15 command templates through `parse_datalog_command`, also with no separators up to 3 tokens. Deterministic; about 40 s by default in a debug build. On v2.x `INT-019` still mixes syntax and semantic parse errors (v3.0.0 splits it into `PRS-083`–`PRS-107`), so it counts as semantic here. The drift it found is the same as on v3 and is listed in `KNOWN_DRIFT` by cause (comments and non-ASCII letters #650; empty sections, ignored keywords, rule and call heads, a lone `:` or `-`, unterminated strings #651; a fact of more than four elements, #649, is fixed above). `grammar.pest` now says what the parser means: a fact is exactly `[e a v]` plus an optional valid-time map and a retracted fact exactly `[e a v]`; `not`, `not-join`, `or`, `or-join` (and `and` inside an `or`) are never rule names; literal keywords end at a word boundary (`:find?x` is one keyword). A nightly workflow, `grammar-verifier.yml`, runs it at bound 4 on `main` and on `v3`, and opens or comments on an issue when it fails. + ## v2.0.8 — 2026-10-20 Patch release on the v2.x line under the [support policy](PHILOSOPHY.md#support-policy): seven data-integrity fixes for queries that silently returned too few or too many rows — `:db/valid-at` in `not-join` bodies (#615) and rule definitions (#617), `or` / `or-join` branches (#620, #625, #637), and rules that call or recurse through a rule with `not`, `not-join`, `or` or `or-join` (#628, #633). File format is unchanged (v7). No API changes. Stored data was never affected. Upgrading is recommended for every v2.x user who uses `or`, `not-join`, `:db/valid-at` or rules with negation or disjunction. diff --git a/CLAUDE.md b/CLAUDE.md index b4994f4ff..7f4ea68d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,7 @@ cargo run < demos/demo_negation.txt 3. **`src/query/datalog/`** — Datalog engine - `parser.rs`: EDN/Datalog parser — `transact`, `retract`, `query`, `rule`, `:as-of`, `:valid-at`, `not`, `not-join` + - `parser/verifier.rs`: test-only (not wasm32) bounded-exhaustive check of the parser against `tests/grammar/grammar.pest` (#643): every 1–3 character string (`parse_edn`) and every balanced token filler up to `MINIGRAF_GRAMMAR_BOUND` (default 3) in 15 command templates (`parse_datalog_command`); pest rejects ⇒ parser rejects, pest accepts ⇒ parser accepts or raises a `SEMANTIC` code (`INT-019` counts as semantic on v2.x); today's drift is `KNOWN_DRIFT`, one entry per cause, removed by its fix (#650, #651); `grammar-verifier.yml` runs bound 4 nightly - `executor.rs`: Query executor — temporal filter (tx-time → net-assert → valid-time), not/not-join post-filter - `matcher.rs`: Pattern matching with variable unification; `edn_to_value`, `edn_to_entity_id` - `evaluator.rs`: `RecursiveEvaluator` (semi-naive), `StratifiedEvaluator` (repeats a stratum whose rules recurse through a `not`/`or` rule until it derives nothing new, #633), `evaluate_not_join` diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 67c30761a..b50c4ea6c 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -22,6 +22,8 @@ For v2.0.5, torn-WAL-tail tests (#513) check that opening a WAL writer cuts off `tests/error_codes_prs_test.rs` checks that a fact of five or six elements, with or without a map 4th, fails with `PRS-108`, and that a transaction holding one stores none of its facts (#649). +Since v2.0.8, `src/query/datalog/parser/verifier.rs` (4 tests, backported from v3.0.0; #643) checks the parser against `tests/grammar/grammar.pest` on about 1.07M lexical strings and 555K structural inputs at the default bound (7.6M at `MINIGRAF_GRAMMAR_BOUND=4`, run nightly by `grammar-verifier.yml`): pest rejects ⇒ the parser rejects, pest accepts ⇒ the parser accepts or raises a `SEMANTIC` code with a witness (`INT-019` counts as semantic on v2.x). Known drift is listed by cause in `KNOWN_DRIFT` (#650, #651). `tests/grammar_conformance.rs` now classes a fact with too few elements and a retract of the wrong arity as syntax errors, since `grammar.pest`'s fact is exact. + Not counted above: the `BrowserDb` tests run only on wasm32 with `wasm-bindgen-test` in a browser. Two of them check that the IndexedDB flush returns every page a write dirtied, and that a dirty page that cannot be read is an error rather than left out. The v2.0.8 suite covers the public database API, the Datalog engine, storage and recovery paths, and the v2.0.0 locking and structured-error-code changes, plus v2.0.1's regression test for rebuilding indexes on open after several checkpoints (#370). Since v2.0.1, `tests/point_query_history_test.rs` and new storage unit tests check that attribute-narrowed bound-entity lookups return exactly what a full scan returns, and that `net_asserted_facts` matches its previous implementation on randomized histories (#323). Attribute-scan tests check that the AEVT range covers exactly the queried attribute, including non-ASCII names such as `:丿` and prefix siblings such as `:ab` for `:a` (#381). Incremental B+tree rebuild tests check that checkpoints which copy untouched index leaves produce exactly the entries and file checksum a full rebuild would, including over repeated checkpoints, that single-fact checkpoints do not fragment the index, and that a cyclic leaf chain or damaged slot directory is rejected rather than copied (#315). Directory-sync unit tests check that creating the `.graph` file or the WAL, and deleting the WAL, fsync the parent directory after the file operation, and that reopening an existing file does not (#389). Disjunction tests check that `or-join` returns an empty result, not INT-031, when no rows reach it: on an empty database, after a pattern that matches nothing, after a `not` that removes every row, and in a rule body (#405). A stratification unit test builds the same two-rule negative cycle 64 times and checks that the INT-054 message names the same predicate pair every time (#410). Magic-sets tests check that a recursive rule with a literal start that recurses through another rule returns the same results as without the literal (#297). API tests check that `execute()` and `WriteTransaction::execute()` reject `$slot` bind tokens in every position with API-010 (#407). The REPL `run()` test now runs in a child process with null stdin, so it no longer hangs when the test runner's stdin is an open non-TTY stream (#415). A fault-injection test crashes `save()` at each of its 47 page writes and syncs and checks that every reopen finds exactly the old or the new checkpointed facts (#374). For v2.0.3, storage unit tests and two `edge_cases_test.rs` tests check that more than 65,535 uncheckpointed facts each resolve to their own fact through entity lookups and attribute scans, in memory and file-backed, before and after a checkpoint, and that the widened `FactRef` encodes to the same bytes as before (#445). A WAL test reopens a database next to a WAL whose entries are all checkpointed, writes again, and checks that `:as-of` history is unchanged before and after another reopen; a unit test checks that restoring the transaction counter never lowers it (#447). Since v2.0.3, `tests/golden_corpus_test.rs` opens a copy of each committed golden file in `tests/golden/` (v7 files written by minigraf 2.0.3: one checkpoint, several checkpoints, indexes rebuilt on open, a crash-left WAL, an already-checkpointed WAL, same-transaction multi-values) and checks its manifest's CRC32, `tx_count` and query results after open, after a checkpoint and reopen, and after one more write; a second test checks that every golden file has a manifest (#391). A recursive-rules test runs rule queries on a database with more live facts than `max_results` and checks that they answer, and that the limit still stops a rule that derives more facts than it allows (#528). A WAL test restores a WAL whose entries are all checkpointed and checks that an explicit `checkpoint()` removes it, keeps the fact, and that the next write does not reuse tx 1; the golden-corpus test now requires `v7_stale_wal` to have no WAL after a checkpoint too (#457). A WAL test checks that dropping a handle opened with `wal_checkpoint_threshold(usize::MAX)` leaves the `.graph` file byte for byte unchanged under `Full` and `Normal` sync, keeps the WAL, and that a reopen replays it (#476). diff --git a/src/query/datalog/parser.rs b/src/query/datalog/parser.rs index f6cf8e419..4e64f3967 100644 --- a/src/query/datalog/parser.rs +++ b/src/query/datalog/parser.rs @@ -1901,6 +1901,9 @@ fn parse_rule(elements: &[EdnValue]) -> Result { })) } +#[cfg(all(test, not(target_arch = "wasm32")))] +mod verifier; + #[cfg(test)] mod tests { use super::*; diff --git a/src/query/datalog/parser/verifier.rs b/src/query/datalog/parser/verifier.rs new file mode 100644 index 000000000..4cebcb7c5 --- /dev/null +++ b/src/query/datalog/parser/verifier.rs @@ -0,0 +1,786 @@ +//! Bounded-exhaustive check of the parser against `tests/grammar/grammar.pest` (#643). +//! +//! For every generated input `s` (design: +//! `docs/superpowers/specs/2026-10-20-grammar-drift-verifier-design.md`): +//! +//! 1. pest rejects `s` ⇒ the parser rejects `s`; +//! 2. pest accepts `s` ⇒ the parser accepts `s`, or rejects it with a code in [`SEMANTIC`]. +//! +//! Two levels generate the inputs, both deterministically: +//! +//! - lexical: every string of 1 to 3 characters over [`alphabet`], pest's +//! `edn_value_only` against [`parse_edn`]; +//! - structural: every balanced sequence of up to `k` [`TOKENS`] in the hole of each of +//! [`TEMPLATES`], pest's `command` against [`parse_datalog_command`]. `k` is +//! `MINIGRAF_GRAMMAR_BOUND` (default [`DEFAULT_BOUND`]). +//! +//! Drift that exists today is listed in [`KNOWN_DRIFT`]. Each failing input is put down +//! to its first cause in text order ([`lexical_cause`], [`structural_cause`]), so it +//! matches at most one entry. A failure with no known cause fails the test, and so does +//! an entry that no failure matches once `k` reaches its `min_k`: a fix removes its entry. + +use super::{parse_datalog_command, parse_edn}; +use crate::error::MinigrafError; +use crate::query::datalog::types::EdnValue; +use pest::Parser as _; +use std::fmt::Write as _; + +#[derive(pest_derive::Parser)] +#[grammar = "tests/grammar/grammar.pest"] +struct G; + +const DEFAULT_BOUND: usize = 3; + +/// Fillers of up to this many tokens are also joined with no separators, including at +/// both template boundaries (`parse_edn` and pest each read `:find?e` as one keyword, +/// so the lexical level cannot see how glued tokens split). +const GLUE_BOUND: usize = 3; + +const LEXICAL_MAX_LEN: usize = 3; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Outcome { + Accept, + Reject(&'static str), +} + +fn outcome(r: anyhow::Result) -> Outcome { + match r { + Ok(_) => Outcome::Accept, + Err(e) => Outcome::Reject(MinigrafError::from(e).code()), + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Level { + Lexical, + Structural, +} + +// ── Inputs ──────────────────────────────────────────────────────────────── + +/// Printable ASCII, then `\t \n \r`, then non-ASCII stand-ins: a letter, a CJK +/// character, an Arabic-Indic digit and a no-break space. +fn alphabet() -> Vec { + let mut a: Vec = (0x20u8..=0x7E).map(char::from).collect(); + a.extend(['\t', '\n', '\r', 'é', '名', '٣', '\u{00A0}']); + a +} + +const TOKENS: [&str; 32] = [ + "(", + ")", + "[", + "]", + "{", + "}", + "query", + "transact", + "rule", + "not", + "not-join", + "or", + "and", + ":find", + ":where", + ":with", + ":as-of", + ":valid-at", + ":any-valid-time", + ":over", + ":order-by", + ":valid-from", + ":foo", + "?x", + "?y", + "foo", + "1", + "1.5", + "\"2024-01-01T00:00:00Z\"", + "<", + "$s", + "#uuid \"550e8400-e29b-41d4-a716-446655440000\"", +]; + +/// Bracket token → `(kind, opens)`. +fn bracket(t: u8) -> Option<(u8, bool)> { + (t < 6).then_some((t / 2, t.is_multiple_of(2))) +} + +/// `(prefix, suffix)`: the hole sits between them. Each puts the hole where a short +/// filler can complete a clause. +const TEMPLATES: [(&str, &str); 15] = [ + ("(query [", "])"), + ("(query [:find", ":where [?x :a ?y]])"), + ("(query [:find ?x :where [?x :a ?y]", "])"), + ("(query [:find ?x :where [?x :a ?y] (not", ")])"), + ("(query [:find ?x :where [?x :a ?y] (not-join [?x]", ")])"), + ("(query [:find ?x :where [?x :a ?y] (or", ")])"), + ("(query [:find ?x :where [?x :a ?y] (or-join [?x]", ")])"), + ("(query [:find ?x :where [?x :a ?y] (or (and", "))])"), + ("(query [:find ?x (sum ?y :over (", ")) :where [?x :a ?y]])"), + ("(transact [", "])"), + ("(transact [[:a :b 1", "]])"), + ("(transact {", "} [[:a :b 1]])"), + ("(retract [", "])"), + ("(rule [(", ") [?x :a 1]])"), + ("(rule [(foo ?x)", "])"), +]; + +/// Calls `f` on every balanced filler of exactly `len` tokens, in token-index order. +fn each_filler(len: usize, f: &mut impl FnMut(&[u8])) { + fn go(buf: &mut Vec, stack: &mut Vec, len: usize, f: &mut impl FnMut(&[u8])) { + let left = len - buf.len(); + if left == 0 { + if stack.is_empty() { + f(buf); + } + return; + } + for t in 0..TOKENS.len() as u8 { + match bracket(t) { + // Every open bracket needs a closer in the tokens left after this one. + Some((kind, true)) if stack.len() < left - 1 => { + stack.push(kind); + buf.push(t); + go(buf, stack, len, f); + buf.pop(); + stack.pop(); + } + Some((kind, false)) if stack.last() == Some(&kind) => { + stack.pop(); + buf.push(t); + go(buf, stack, len, f); + buf.pop(); + stack.push(kind); + } + None if stack.len() < left => { + buf.push(t); + go(buf, stack, len, f); + buf.pop(); + } + _ => {} + } + } + } + go(&mut Vec::new(), &mut Vec::new(), len, f); +} + +fn bound() -> usize { + std::env::var("MINIGRAF_GRAMMAR_BOUND") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_BOUND) +} + +// ── Semantic codes ──────────────────────────────────────────────────────── + +/// A code that an input `grammar.pest` accepts can raise, with such an input. Every +/// site that raises a listed code must be semantic; the comment above each entry +/// gives the sites (`parser.rs`) and why. +struct Semantic { + code: &'static str, + witness: &'static str, +} + +/// On v2.x, `INT-019` mixes syntax and semantic errors (v3.0.0 splits it into +/// `PRS-083`–`PRS-107`), so it counts as semantic here and clause 2 cannot catch a +/// syntax error coded `INT-019` that pest accepts (spec §8). +const SEMANTIC: &[Semantic] = &[ + // parse_query: `:with` without an aggregate in `:find` (the reference's Semantic + // Constraints). + Semantic { + code: "PRS-021", + witness: "(query [:find ?x :with ?y :where [?x :a ?y]])", + }, + // parse_query: a `:with` variable no `:where` clause binds. + Semantic { + code: "PRS-022", + witness: "(query [:find (count ?x) :with ?z :where [?x :a ?y]])", + }, + // parse_window: a window's `:over` without `:order-by` (`over_option*` allows none). + Semantic { + code: "INT-019", + witness: "(query [:find ?x (sum ?y :over ()) :where [?x :a ?y]])", + }, + // parse_rule: a rule body with no pattern or rule call (`rule_vector` allows + // `where_clause*`). + Semantic { + code: "INT-019", + witness: "(rule [(foo ?x)])", + }, +]; + +fn is_semantic(code: &str) -> bool { + SEMANTIC.iter().any(|s| s.code == code) +} + +// ── Known drift ─────────────────────────────────────────────────────────── + +/// The cause a failing input is put down to. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Cause { + // Lexical. + /// `;` starts a line comment in the parser; the grammar has no comments. + Comment, + /// A string with no closing quote reads to the end of the input. + UnterminatedString, + /// A non-ASCII letter or digit in a symbol or keyword (the grammar is ASCII). + UnicodeLetter, + /// `:` with no keyword character after it is the keyword `:`. + BareColon, + /// `-` with no word character after it is the symbol `-`. + LoneMinus, + // Structural. + /// `(query [])`. + EmptyQuery, + /// `:find` with no find spec before the next keyword or the end. + EmptyFind, + /// `:where` with no clause before the next keyword or the end. + EmptyWhere, + /// `:with` with no variable before the next keyword or the end. + EmptyWith, + /// A keyword the query vector does not know, with no value after it, is ignored. + UnknownKeyword, + /// A value after `:as-of`, `:valid-at`, `:any-valid-time` or `:max-*` and its + /// argument continues the section before that keyword. + ResumedSection, + /// A clause list whose head is a variable or an operator is read as a rule call. + CallHead, + /// A `:find` list whose head is a variable is read as an aggregate. + AggregateHead, + /// A rule head whose name is not a plain symbol or whose argument is not a + /// variable. + RuleHead, +} + +impl Cause { + fn level(self) -> Level { + match self { + Cause::Comment + | Cause::UnterminatedString + | Cause::UnicodeLetter + | Cause::BareColon + | Cause::LoneMinus => Level::Lexical, + _ => Level::Structural, + } + } +} + +struct Drift { + cause: Cause, + issue: u32, + /// The smallest structural bound whose inputs show it (unused at the lexical level). + min_k: usize, +} + +const KNOWN_DRIFT: &[Drift] = &[ + Drift { + cause: Cause::Comment, + issue: 650, + min_k: 0, + }, + Drift { + cause: Cause::UnterminatedString, + issue: 651, + min_k: 0, + }, + Drift { + cause: Cause::UnicodeLetter, + issue: 650, + min_k: 0, + }, + Drift { + cause: Cause::BareColon, + issue: 651, + min_k: 0, + }, + Drift { + cause: Cause::LoneMinus, + issue: 651, + min_k: 0, + }, + Drift { + cause: Cause::EmptyQuery, + issue: 651, + min_k: 0, + }, + Drift { + cause: Cause::EmptyFind, + issue: 651, + min_k: 0, + }, + Drift { + cause: Cause::EmptyWhere, + issue: 651, + min_k: 1, + }, + Drift { + cause: Cause::EmptyWith, + issue: 651, + min_k: 1, + }, + Drift { + cause: Cause::UnknownKeyword, + issue: 651, + min_k: 1, + }, + Drift { + cause: Cause::ResumedSection, + issue: 651, + min_k: 3, + }, + Drift { + cause: Cause::CallHead, + issue: 651, + min_k: 3, + }, + Drift { + cause: Cause::AggregateHead, + issue: 651, + min_k: 4, + }, + Drift { + cause: Cause::RuleHead, + issue: 651, + min_k: 1, + }, +]; + +/// Characters the parser reads as part of a symbol after its first. +fn symbol_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '?' | '_' | '-' | '/') +} + +/// The first cause, in text order, of a lexical failure. +fn lexical_cause(text: &str) -> Option { + let c: Vec = text.chars().collect(); + let mut i = 0; + while i < c.len() { + let next = c.get(i + 1).copied(); + match c[i] { + '"' => { + i += 1; + loop { + match c.get(i) { + None => return Some(Cause::UnterminatedString), + Some('\\') => i += 2, + Some('"') => break, + Some(_) => i += 1, + } + } + } + ';' => return Some(Cause::Comment), + ':' if !next.is_some_and(symbol_char) => return Some(Cause::BareColon), + '-' if !next.is_some_and(symbol_char) + && !(i > 0 && (symbol_char(c[i - 1]) || matches!(c[i - 1], ':' | '#' | '$'))) => + { + return Some(Cause::LoneMinus); + } + ch if !ch.is_ascii() && ch.is_alphanumeric() => return Some(Cause::UnicodeLetter), + _ => {} + } + i += 1; + } + None +} + +/// The first cause, in text order, of a structural failure, read from the parser's +/// EDN tree of the input (glued tokens split as the parser splits them). +fn structural_cause(text: &str) -> Option { + let EdnValue::List(cmd) = parse_edn(text).ok()? else { + return None; + }; + let arg = cmd.get(1); + match cmd.first()? { + EdnValue::Symbol(s) if s == "query" => query_cause(arg?.as_vector()?), + EdnValue::Symbol(s) if s == "rule" => rule_cause(arg?.as_vector()?), + _ => None, + } +} + +/// Walks the query vector's sections as `parse_query` does. +fn query_cause(items: &[EdnValue]) -> Option { + if items.is_empty() { + return Some(Cause::EmptyQuery); + } + let value_at = |j: usize| items.get(j).is_some_and(|v| v.as_keyword().is_none()); + // The section that values go to: `:find`, `:where`, or an unknown keyword. + let mut section: Option<&str> = None; + let mut i = 0; + while i < items.len() { + let Some(k) = items[i].as_keyword() else { + let cause = match section { + Some(":where") => clause_cause(&items[i]), + Some(":find") => find_cause(&items[i]), + _ => None, + }; + if cause.is_some() { + return cause; + } + i += 1; + continue; + }; + match k { + ":as-of" | ":valid-at" | ":max-derived-facts" | ":max-results" | ":any-valid-time" => { + i += if k == ":any-valid-time" { 1 } else { 2 }; + if section.is_some() && value_at(i) { + return Some(Cause::ResumedSection); + } + } + ":with" => { + if !value_at(i + 1) { + return Some(Cause::EmptyWith); + } + i += 1; + while value_at(i) { + i += 1; + } + } + _ if !value_at(i + 1) => { + return Some(match k { + ":find" => Cause::EmptyFind, + ":where" => Cause::EmptyWhere, + _ => Cause::UnknownKeyword, + }); + } + _ => { + section = Some(k); + i += 1; + } + } + } + None +} + +/// Whether pest's `plain_sym` reads all of `s`. +fn is_plain_symbol(s: &str) -> bool { + let word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '?' | '_' | '-' | '/'); + let mut chars = s.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => chars.all(word), + Some('-') => !chars.as_str().is_empty() && chars.all(word), + _ => false, + } +} + +/// Whether pest's `op_sym` reads all of `s`. +fn is_op_symbol(s: &str) -> bool { + matches!(s, "<=" | ">=" | "!=" | "<" | ">" | "=" | "+" | "*" | "/") +} + +/// The cause in a `:find` spec: an aggregate or window list must start with a name. +fn find_cause(spec: &EdnValue) -> Option { + match spec.as_list()?.first()? { + EdnValue::Symbol(f) if !is_plain_symbol(f) && !is_op_symbol(f) => { + Some(Cause::AggregateHead) + } + _ => None, + } +} + +/// The first cause in a `:where` clause, a negation or disjunction body, or a rule body. +fn clause_cause(clause: &EdnValue) -> Option { + let list = clause.as_list()?; + let EdnValue::Symbol(head) = list.first()? else { + return None; + }; + let rest = &list[1..]; + match head.as_str() { + "not" => rest.iter().find_map(clause_cause), + "not-join" => rest.get(1..)?.iter().find_map(clause_cause), + "or" => rest.iter().find_map(branch_cause), + "or-join" => rest.get(1..)?.iter().find_map(branch_cause), + h if !is_plain_symbol(h) => Some(Cause::CallHead), + _ => None, + } +} + +fn branch_cause(branch: &EdnValue) -> Option { + match branch.as_list().map(Vec::as_slice) { + Some([EdnValue::Symbol(and), body @ ..]) if and == "and" => { + body.iter().find_map(clause_cause) + } + _ => clause_cause(branch), + } +} + +fn rule_cause(rule: &[EdnValue]) -> Option { + let head = rule.first()?.as_list()?; + let name_ok = matches!(head.first(), Some(EdnValue::Symbol(s)) if is_plain_symbol(s)); + let args_ok = head + .iter() + .skip(1) + .all(|a| matches!(a, EdnValue::Symbol(s) if s.starts_with('?'))); + if !(name_ok && args_ok) { + return Some(Cause::RuleHead); + } + rule[1..].iter().find_map(clause_cause) +} + +// ── The check ───────────────────────────────────────────────────────────── + +/// Which clause of the property an input breaks, if any. +fn failure(pest_ok: bool, parser: Outcome) -> Option { + match (pest_ok, parser) { + (false, Outcome::Accept) => Some("pest rejects, parser accepts".to_string()), + (true, Outcome::Reject(code)) if !is_semantic(code) => Some(format!( + "pest accepts, parser rejects with syntax code {code}" + )), + _ => None, + } +} + +/// How many failures an entry matched, and the first. +#[derive(Default, Clone)] +struct Hits { + count: u64, + first: Option, +} + +impl Hits { + fn add(&mut self, shown: &str) { + self.count += 1; + if self.first.is_none() { + self.first = Some(shown.to_string()); + } + } + + fn merge(&mut self, other: Hits) { + self.count += other.count; + if self.first.is_none() { + self.first = other.first; + } + } +} + +const SHOWN: usize = 20; + +#[derive(Default)] +struct Report { + inputs: u64, + /// One per [`KNOWN_DRIFT`] entry. + known: Vec, + /// Failures with no known cause, by reason. + unknown: Vec<(String, Hits)>, + /// The first [`SHOWN`] failures with no known cause. + unknown_shown: Vec, +} + +impl Report { + fn new() -> Self { + Report { + known: vec![Hits::default(); KNOWN_DRIFT.len()], + ..Report::default() + } + } + + fn check(&mut self, level: Level, text: &str, pest_ok: bool, parser: Outcome) { + self.inputs += 1; + let Some(why) = failure(pest_ok, parser) else { + return; + }; + let cause = match level { + Level::Lexical => lexical_cause(text), + Level::Structural => structural_cause(text), + }; + let shown = format!("{text:?} ({why})"); + match KNOWN_DRIFT.iter().position(|d| Some(d.cause) == cause) { + Some(i) => self.known[i].add(&shown), + None => { + if self.unknown_shown.len() < SHOWN { + self.unknown_shown.push(shown.clone()); + } + match self.unknown.iter_mut().find(|(w, _)| *w == why) { + Some((_, hits)) => hits.add(&shown), + None => { + let mut hits = Hits::default(); + hits.add(&shown); + self.unknown.push((why, hits)); + } + } + } + } + } + + /// Adds `other`, whose inputs come after this report's. + fn merge(&mut self, other: Report) { + self.inputs += other.inputs; + for (h, o) in self.known.iter_mut().zip(other.known) { + h.merge(o); + } + for (why, o) in other.unknown { + match self.unknown.iter_mut().find(|(w, _)| *w == why) { + Some((_, hits)) => hits.merge(o), + None => self.unknown.push((why, o)), + } + } + let room = SHOWN - self.unknown_shown.len(); + self.unknown_shown + .extend(other.unknown_shown.into_iter().take(room)); + } + + /// Prints the entries' matches and fails on any failure with no known cause, or + /// on an entry of `level` that matched nothing although `k` reaches it. + fn finish(self, level: Level, k: usize) { + let mut out = String::new(); + let mut stale = false; + for (d, h) in KNOWN_DRIFT.iter().zip(&self.known) { + if d.cause.level() != level { + continue; + } + match &h.first { + Some(first) => { + let _ = writeln!( + out, + " known {:?} (#{}): {} inputs, first {first}", + d.cause, d.issue, h.count + ); + } + None if level == Level::Lexical || k >= d.min_k => { + stale = true; + let _ = writeln!( + out, + " STALE {:?} (#{}): matched no failing input; remove the entry", + d.cause, d.issue + ); + } + None => {} + } + } + let summary = format!("{level:?} level, k = {k}, {} inputs:\n{out}", self.inputs); + if self.unknown.is_empty() && !stale { + eprintln!("{summary}"); + return; + } + let mut msg = summary; + if !self.unknown.is_empty() { + msg.push_str("failures with no known cause, by reason:\n"); + for (why, h) in &self.unknown { + let first = h.first.as_deref().unwrap_or(""); + let _ = writeln!(msg, " {} x {why}; first {first}", h.count); + } + msg.push_str("the first of them:\n"); + for u in &self.unknown_shown { + let _ = writeln!(msg, " {u}"); + } + } + panic!("grammar drift:\n{msg}"); + } +} + +/// Checks every string of `len` characters whose first character is in `first`. +fn check_lexical(chars: &[char], len: usize, first: std::ops::Range) -> Report { + let mut report = Report::new(); + let mut digits = vec![0usize; len]; + digits[0] = first.start; + let mut text = String::new(); + loop { + text.clear(); + text.extend(digits.iter().map(|&d| chars[d])); + let pest_ok = G::parse(Rule::edn_value_only, &text).is_ok(); + report.check(Level::Lexical, &text, pest_ok, outcome(parse_edn(&text))); + // Odometer step: the last digit turns fastest. + let mut i = len - 1; + loop { + digits[i] += 1; + if digits[i] < if i == 0 { first.end } else { chars.len() } { + break; + } + if i == 0 { + return report; + } + digits[i] = 0; + i -= 1; + } + } +} + +fn check_template(template: usize, k: usize) -> Report { + let (prefix, suffix) = TEMPLATES[template]; + let mut report = Report::new(); + let mut text = String::new(); + for len in 0..=k { + each_filler(len, &mut |filler| { + for sep in [" ", ""] { + if sep.is_empty() && len > GLUE_BOUND { + continue; + } + text.clear(); + text.push_str(prefix); + for &t in filler { + text.push_str(sep); + text.push_str(TOKENS[usize::from(t)]); + } + text.push_str(sep); + text.push_str(suffix); + let pest_ok = G::parse(Rule::command, &text).is_ok(); + let parser = outcome(parse_datalog_command(&text)); + report.check(Level::Structural, &text, pest_ok, parser); + } + }); + } + report +} + +/// Runs `jobs` on scoped threads and merges their reports in job order. +fn run(jobs: Vec, f: impl Fn(J) -> Report + Sync) -> Report { + let f = &f; + std::thread::scope(|s| { + let handles: Vec<_> = jobs.into_iter().map(|j| s.spawn(move || f(j))).collect(); + let mut total = Report::new(); + for h in handles { + total.merge(h.join().expect("verifier worker panicked")); + } + total + }) +} + +#[test] +fn lexical_level() { + let chars = alphabet(); + assert_eq!(chars.len(), 102, "alphabet size"); + let n = chars.len(); + let parts = std::thread::available_parallelism().map_or(4, |p| p.get().min(n)); + // One job per (length, range of first characters), in input order. + let jobs: Vec<_> = (1..=LEXICAL_MAX_LEN) + .flat_map(|len| (0..parts).map(move |p| (len, p * n / parts..(p + 1) * n / parts))) + .collect(); + run(jobs, |(len, first)| check_lexical(&chars, len, first)).finish(Level::Lexical, 0); +} + +#[test] +fn structural_level() { + let k = bound(); + run((0..TEMPLATES.len()).collect(), |t| check_template(t, k)).finish(Level::Structural, k); +} + +#[test] +fn filler_counts_match_the_spec() { + let count = |k: usize| { + let mut n = 0u64; + for len in 0..=k { + each_filler(len, &mut |_| n += 1); + } + n + }; + assert_eq!(count(3), 18_516); + assert_eq!(count(4), 487_678); +} + +#[test] +fn semantic_witnesses_raise_their_code() { + for s in SEMANTIC { + assert!( + G::parse(Rule::command, s.witness).is_ok(), + "pest rejects the witness of {}", + s.code + ); + assert_eq!( + outcome(parse_datalog_command(s.witness)), + Outcome::Reject(s.code), + "witness of {}", + s.code + ); + } +} diff --git a/tests/grammar/grammar.pest b/tests/grammar/grammar.pest index f70f825fe..5fae33350 100644 --- a/tests/grammar/grammar.pest +++ b/tests/grammar/grammar.pest @@ -5,6 +5,28 @@ WHITESPACE = _{ " " | "\t" | "\n" | "\r" | "," } // Used in negative lookaheads to enforce keyword boundaries. word_cont = _{ ASCII_ALPHANUMERIC | "?" | "_" | "-" | "/" } +// Literal keywords: atomic, so that no whitespace is skipped before the +// boundary check (":find?x" is one keyword, as the parser reads it). +kw_find = @{ ":find" ~ !word_cont } +kw_where = @{ ":where" ~ !word_cont } +kw_with = @{ ":with" ~ !word_cont } +kw_as_of = @{ ":as-of" ~ !word_cont } +kw_valid_at = @{ ":valid-at" ~ !word_cont } +kw_any_valid_time = @{ ":any-valid-time" ~ !word_cont } +kw_max_derived_facts = @{ ":max-derived-facts" ~ !word_cont } +kw_max_results = @{ ":max-results" ~ !word_cont } +kw_over = @{ ":over" ~ !word_cont } +kw_partition_by = @{ ":partition-by" ~ !word_cont } +kw_order_by = @{ ":order-by" ~ !word_cont } +kw_desc = @{ ":desc" ~ !word_cont } +kw_asc = @{ ":asc" ~ !word_cont } +kw_valid_from = @{ ":valid-from" ~ !word_cont } +kw_valid_to = @{ ":valid-to" ~ !word_cont } + +// Reserved list heads: the parser reads these lists as clauses, never as rule calls. +reserved_head = @{ ("not-join" | "not" | "or-join" | "or") ~ !word_cont } +and_head = @{ "and" ~ !word_cont } + // ── EDN PRIMITIVES ──────────────────────────────────────────────────────── // boolean: "true" or "false" — only when NOT followed by a word character @@ -59,6 +81,9 @@ edn_list = { "(" ~ edn_value* ~ ")" } edn_vector = { "[" ~ edn_value* ~ "]" } edn_map = { "{" ~ (edn_value ~ edn_value)* ~ "}" } +// One EDN value and nothing else: the lexical oracle of the parser verifier (#643). +edn_value_only = { SOI ~ edn_value ~ EOI } + // ── TOP-LEVEL COMMAND ───────────────────────────────────────────────────── command = { SOI ~ (transact_cmd | retract_cmd | query_cmd | rule_cmd) ~ EOI } @@ -69,18 +94,20 @@ transact_cmd = { } // ── RETRACT ─────────────────────────────────────────────────────────────── -retract_cmd = { "(" ~ "retract" ~ fact_vector ~ ")" } +retract_cmd = { "(" ~ "retract" ~ "[" ~ retract_fact* ~ "]" ~ ")" } // Fact vector: a vector of individual fact vectors fact_vector = { "[" ~ fact* ~ "]" } -// A single fact: permissive (edn_value+) so arity violations are semantic, not syntax. -// The real parser enforces [E A V] or [E A V {map}]; pest only requires ≥1 element. -fact = { "[" ~ edn_value+ ~ "]" } +// A single fact: exactly [E A V] or [E A V {valid-time map}]. +fact = { "[" ~ edn_value ~ edn_value ~ edn_value ~ valid_time_map? ~ "]" } + +// A retracted fact: exactly [E A V]. +retract_fact = { "[" ~ edn_value ~ edn_value ~ edn_value ~ "]" } // Transaction-level valid-time options map valid_time_map = { "{" ~ valid_time_pair* ~ "}" } -valid_time_pair = { (":valid-from" | ":valid-to") ~ string_lit } +valid_time_pair = { (kw_valid_from | kw_valid_to) ~ string_lit } // ── QUERY ───────────────────────────────────────────────────────────────── query_cmd = { "(" ~ "query" ~ query_vector ~ ")" } @@ -95,7 +122,7 @@ query_section = { // ── FIND SECTION ────────────────────────────────────────────────────────── // :find accepts variables, aggregates, and window functions -find_section = { ":find" ~ find_spec+ } +find_section = { kw_find ~ find_spec+ } find_spec = { window_expr | aggregate_expr | variable } // Aggregate: (func-name ?var) — e.g. (count ?e), (sum ?salary) @@ -103,17 +130,17 @@ aggregate_expr = { "(" ~ (op_sym | plain_sym) ~ variable ~ ")" } // Window function: (func ?var :over (...)) or (func :over (...)) for rank/row-number window_expr = { - "(" ~ (op_sym | plain_sym) ~ (variable ~ ":over" | ":over") ~ over_clause ~ ")" + "(" ~ (op_sym | plain_sym) ~ (variable ~ kw_over | kw_over) ~ over_clause ~ ")" } over_clause = { "(" ~ over_option* ~ ")" } over_option = { - ":partition-by" ~ variable | - ":order-by" ~ variable | - ":desc" | ":asc" + kw_partition_by ~ variable | + kw_order_by ~ variable | + kw_desc | kw_asc } // ── WHERE SECTION ───────────────────────────────────────────────────────── -where_section = { ":where" ~ where_clause+ } +where_section = { kw_where ~ where_clause+ } // where_clause covers all clause forms where_clause = { @@ -136,11 +163,11 @@ not_join_clause = { "(" ~ "not-join" ~ join_vars ~ where_clause+ ~ ")" } or_clause = { "(" ~ "or" ~ or_branch+ ~ ")" } or_join_clause = { "(" ~ "or-join" ~ join_vars ~ or_branch+ ~ ")" } join_vars = { "[" ~ variable* ~ "]" } -or_branch = { and_branch | where_clause } +or_branch = { and_branch | !("(" ~ and_head) ~ where_clause } and_branch = { "(" ~ "and" ~ where_clause+ ~ ")" } // Rule invocation: (predicate-name args...) -rule_invocation = { "(" ~ plain_sym ~ edn_value* ~ ")" } +rule_invocation = { "(" ~ !reserved_head ~ plain_sym ~ edn_value* ~ ")" } // ── EXPRESSIONS ─────────────────────────────────────────────────────────── expr = { "(" ~ expr_body ~ ")" } @@ -158,12 +185,12 @@ expr_arg = { } // ── TEMPORAL / WITH SECTIONS ─────────────────────────────────────────────── -as_of_section = { ":as-of" ~ (integer_lit | string_lit | bind_slot) } -valid_at_section = { ":valid-at" ~ (string_lit | ":any-valid-time" | bind_slot) } -any_valid_time_section = { ":any-valid-time" } -with_section = { ":with" ~ variable+ } -max_derived_facts_section = { ":max-derived-facts" ~ integer_lit } -max_results_section = { ":max-results" ~ integer_lit } +as_of_section = { kw_as_of ~ (integer_lit | string_lit | bind_slot) } +valid_at_section = { kw_valid_at ~ (string_lit | kw_any_valid_time | bind_slot) } +any_valid_time_section = { kw_any_valid_time } +with_section = { kw_with ~ variable+ } +max_derived_facts_section = { kw_max_derived_facts ~ integer_lit } +max_results_section = { kw_max_results ~ integer_lit } // ── RULE ────────────────────────────────────────────────────────────────── // (rule [(head ?args) body-clause ...]) diff --git a/tests/grammar/invalid/semantic/fact_too_few_elements.edn b/tests/grammar/invalid/syntax/fact_too_few_elements.edn similarity index 100% rename from tests/grammar/invalid/semantic/fact_too_few_elements.edn rename to tests/grammar/invalid/syntax/fact_too_few_elements.edn diff --git a/tests/grammar/invalid/semantic/retract_wrong_arity.edn b/tests/grammar/invalid/syntax/retract_wrong_arity.edn similarity index 100% rename from tests/grammar/invalid/semantic/retract_wrong_arity.edn rename to tests/grammar/invalid/syntax/retract_wrong_arity.edn From 507fce6da3ffea32ee441d5e68a67a38f6d561d1 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 20 Oct 2026 18:30:01 +0530 Subject: [PATCH 2/2] docs: test counts and INT-019 caveat for the verifier backport (#643) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019kY61Y11gcRD91Y6b7jbGT --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- README.md | 2 +- docs/TEST_COVERAGE.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acbf8d475..017cad27e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Tests -- **Parser vs grammar verifier (#643), backported from v3.0.0.** `src/query/datalog/parser/verifier.rs` checks, for every generated input, that the parser rejects what `tests/grammar/grammar.pest` rejects, and accepts what it accepts unless the error code is semantic (a listed code with a witness pest accepts). It runs every string of 1 to 3 characters over 102 characters (ASCII, `\t \n \r`, four non-ASCII stand-ins) through `parse_edn`, and every bracket-balanced sequence of up to `MINIGRAF_GRAMMAR_BOUND` (default 3) of 32 tokens in 15 command templates through `parse_datalog_command`, also with no separators up to 3 tokens. Deterministic; about 40 s by default in a debug build. On v2.x `INT-019` still mixes syntax and semantic parse errors (v3.0.0 splits it into `PRS-083`–`PRS-107`), so it counts as semantic here. The drift it found is the same as on v3 and is listed in `KNOWN_DRIFT` by cause (comments and non-ASCII letters #650; empty sections, ignored keywords, rule and call heads, a lone `:` or `-`, unterminated strings #651; a fact of more than four elements, #649, is fixed above). `grammar.pest` now says what the parser means: a fact is exactly `[e a v]` plus an optional valid-time map and a retracted fact exactly `[e a v]`; `not`, `not-join`, `or`, `or-join` (and `and` inside an `or`) are never rule names; literal keywords end at a word boundary (`:find?x` is one keyword). A nightly workflow, `grammar-verifier.yml`, runs it at bound 4 on `main` and on `v3`, and opens or comments on an issue when it fails. +- **Parser vs grammar verifier (#643), backported from v3.0.0.** `src/query/datalog/parser/verifier.rs` checks, for every generated input, that the parser rejects what `tests/grammar/grammar.pest` rejects, and accepts what it accepts unless the error code is semantic (a listed code with a witness pest accepts). It runs every string of 1 to 3 characters over 102 characters (ASCII, `\t \n \r`, four non-ASCII stand-ins) through `parse_edn`, and every bracket-balanced sequence of up to `MINIGRAF_GRAMMAR_BOUND` (default 3) of 32 tokens in 15 command templates through `parse_datalog_command`, also with no separators up to 3 tokens. Deterministic; about 40 s by default in a debug build. On v2.x `INT-019` still mixes syntax and semantic parse errors (v3.0.0 splits it into `PRS-083`–`PRS-107`), so it counts as semantic here, and a syntax error coded `INT-019` that pest accepts is not caught (v3.0.0 checks that too). The drift it found is the same as on v3 and is listed in `KNOWN_DRIFT` by cause (comments and non-ASCII letters #650; empty sections, ignored keywords, rule and call heads, a lone `:` or `-`, unterminated strings #651; a fact of more than four elements, #649, is fixed above). `grammar.pest` now says what the parser means: a fact is exactly `[e a v]` plus an optional valid-time map and a retracted fact exactly `[e a v]`; `not`, `not-join`, `or`, `or-join` (and `and` inside an `or`) are never rule names; literal keywords end at a word boundary (`:find?x` is one keyword). A nightly workflow, `grammar-verifier.yml`, runs it at bound 4 on `main` and on `v3`, and opens or comments on an issue when it fails. ## v2.0.8 — 2026-10-20 diff --git a/CLAUDE.md b/CLAUDE.md index 7f4ea68d6..d3d53590a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ Auto-migrates v1/v2/v3/v4/v5/v6 → v7 on open/checkpoint. ## Test Coverage -**1313 tests** (1304 passing, 9 ignored; unit + integration + doc). +**1317 tests** (1308 passing, 9 ignored; unit + integration + doc). See `docs/TEST_COVERAGE.md` for the full per-file breakdown. **Testing conventions** — see the Testing Conventions section below before writing any tests. diff --git a/README.md b/README.md index 388ddb01c..4e7930ff5 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ let r2 = pq.execute(&[("tx", BindValue::TxCount(2)), ("entity", BindValue::Entit ```bash cargo run # interactive Datalog REPL -cargo test # run 1313 tests +cargo test # run 1317 tests cargo run < demos/demo_recursive.txt # recursive rules demo ``` diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index b50c4ea6c..1b3e18f85 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -4,7 +4,7 @@ **Verified**: 2026-10-20 with `cargo test` -**Result**: 1,304 passing tests and 9 ignored tests (1,313 total), including the golden-file corpus (#391) +**Result**: 1,308 passing tests and 9 ignored tests (1,317 total), including the golden-file corpus (#391) For v2.0.5, `tests/edge_cases_test.rs` checks that a file of 1 to 4,095 bytes is `STG-045`, unchanged and with no WAL, when it is a database cut below one page or foreign bytes, and opens as a new database that takes a write and reads it back after reopen when it is a prefix of the initial header or all zeros (#506). Rule-body tests in `tests/recursive_rules_test.rs` check that a `:db/valid-at` pattern in a rule definition, in its `not` and `not-join` bodies and in its `or` branches binds the query's valid time, on the semi-naive, stratified and magic-sets paths (#617).