From 1e12534feacf527219b864bff3d221740bb08d6c Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 17:57:01 -0500 Subject: [PATCH 1/7] feat(workspace): stand up the pinax workspace and implement lexis Four-crate workspace per Decision 14 (lexis -> hypomnema -> phylaxis -> pinax), root Cargo.toml carrying workspace.package/dependencies/lints, and the standard root files (deny.toml copied from heurema, clippy.toml, minimal rustfmt.toml, .config/nextest.toml). hypomnema, phylaxis, and pinax are conforming empty library crates that reserve their position in the locked dependency graph; only lexis is implemented. lexis implements Decision 5's strict six-type system: SqlType (the fixed INTEGER/REAL/TEXT/BLOB/BOOLEAN/DATETIME set, no NUMERIC/DECIMAL/DATE/ TIME/JSON), Value (the runtime value enum, NULL as a first-class variant for SQL three-valued logic), validated newtypes (RealValue rejects NaN at the insert-bind boundary, TextMaxLen and identifiers validate via TryFrom), schema DDL vocabulary (ColumnDef/TableDef enforcing type + nullability + length at construction, Nullability with no Default so omitting NULL/NOT NULL cannot compile silently), and query-expression AST vocabulary (Expr + operators, scoped to expressions per Decision 6's explicit deferral of the full statement grammar to Phase 4). Every newtype is repr(transparent) with a private field and a TryFrom constructor; no infallible From exists where an invariant could fail. CRATE-INDEX.toml declares the layer_order and per-crate dependency edges using each crate's own path-pinned Cargo.toml dependency, not workspace.dependencies, matching kanon's own convention and letting ARCHITECTURE/crate-index-conformance actually cross-check the graph. Every crate carries CRATE-SHAPE.toml with shape = unclassified per CRATE-SHAPE.md's registry contract, since a durable shape is a review decision this PR does not invent. --- .config/nextest.toml | 17 ++ CRATE-INDEX.toml | 37 +++ Cargo.toml | 74 +++++ clippy.toml | 16 ++ crates/hypomnema/CRATE-SHAPE.toml | 9 + crates/hypomnema/Cargo.toml | 30 +++ crates/hypomnema/src/lib.rs | 8 + crates/lexis/CRATE-SHAPE.toml | 9 + crates/lexis/Cargo.toml | 35 +++ crates/lexis/src/ast.rs | 166 ++++++++++++ crates/lexis/src/error.rs | 143 ++++++++++ crates/lexis/src/identifier.rs | 163 +++++++++++ crates/lexis/src/lib.rs | 39 +++ crates/lexis/src/schema.rs | 433 ++++++++++++++++++++++++++++++ crates/lexis/src/types.rs | 170 ++++++++++++ crates/lexis/src/value.rs | 293 ++++++++++++++++++++ crates/phylaxis/CRATE-SHAPE.toml | 9 + crates/phylaxis/Cargo.toml | 31 +++ crates/phylaxis/src/lib.rs | 9 + crates/pinax/CRATE-SHAPE.toml | 9 + crates/pinax/Cargo.toml | 32 +++ crates/pinax/src/lib.rs | 11 + deny.toml | 64 +++++ rustfmt.toml | 1 + 24 files changed, 1808 insertions(+) create mode 100644 .config/nextest.toml create mode 100644 CRATE-INDEX.toml create mode 100644 Cargo.toml create mode 100644 clippy.toml create mode 100644 crates/hypomnema/CRATE-SHAPE.toml create mode 100644 crates/hypomnema/Cargo.toml create mode 100644 crates/hypomnema/src/lib.rs create mode 100644 crates/lexis/CRATE-SHAPE.toml create mode 100644 crates/lexis/Cargo.toml create mode 100644 crates/lexis/src/ast.rs create mode 100644 crates/lexis/src/error.rs create mode 100644 crates/lexis/src/identifier.rs create mode 100644 crates/lexis/src/lib.rs create mode 100644 crates/lexis/src/schema.rs create mode 100644 crates/lexis/src/types.rs create mode 100644 crates/lexis/src/value.rs create mode 100644 crates/phylaxis/CRATE-SHAPE.toml create mode 100644 crates/phylaxis/Cargo.toml create mode 100644 crates/phylaxis/src/lib.rs create mode 100644 crates/pinax/CRATE-SHAPE.toml create mode 100644 crates/pinax/Cargo.toml create mode 100644 crates/pinax/src/lib.rs create mode 100644 deny.toml create mode 100644 rustfmt.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..1f66f61 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,17 @@ +# Nextest configuration for pinax. +# See: https://nexte.st/book/configuration.html + +[profile.default] +slow-timeout = { period = "60s", terminate-after = 5 } + +[profile.ci] +# WHY no retries in the profile the required gate runs: a retry makes the +# check non-deterministic in the PASS direction — a test that fails once and +# passes on retry reports green, so the Gate-Passed trailer attests a result +# the same commit may not reproduce, and the flake itself is never seen. +# Flakes are defects to fix, not results to average away. +retries = 0 +slow-timeout = { period = "60s", terminate-after = 2 } + +[profile.ci.junit] +path = "target/nextest/ci/junit.xml" diff --git a/CRATE-INDEX.toml b/CRATE-INDEX.toml new file mode 100644 index 0000000..5033cec --- /dev/null +++ b/CRATE-INDEX.toml @@ -0,0 +1,37 @@ +# Layers rank the workspace from foundation upward. A crate may depend on +# its own layer or any layer below it; ARCHITECTURE/crate-index-conformance +# rejects an edge that points up. Mirrors Decision 14's locked dependency +# graph exactly: lexis -> hypomnema -> phylaxis -> pinax. +layer_order = ["vocabulary", "record", "guard", "facade"] + +[crates.lexis] +layer = "vocabulary" +purpose = "Language of data: strict six-type value system, schema DDL, and query-expression AST vocabulary. Leaf crate — no fleet dependencies (Decision 5, Decision 14)." +path = "crates/lexis" +depends_on = [] +used_by = ["hypomnema", "phylaxis", "pinax"] +dev_depends_on = [] + +[crates.hypomnema] +layer = "record" +purpose = "Written record: WAL, virtual-WAL trait, and cr-sqlite-shaped causal changelog feeding antigraphos (Decision 3, Decision 8, Decision 14)." +path = "crates/hypomnema" +depends_on = ["lexis"] +used_by = ["phylaxis", "pinax"] +dev_depends_on = [] + +[crates.phylaxis] +layer = "guard" +purpose = "Guarding: MVCC snapshot isolation, per-page AEAD encryption, and the heurēma index adapter (Decision 4, Decision 9, Decision 14)." +path = "crates/phylaxis" +depends_on = ["lexis", "hypomnema"] +used_by = ["pinax"] +dev_depends_on = [] + +[crates.pinax] +layer = "facade" +purpose = "Facade: pager, buffer pool, B-tree, page format, SQL surface (parser/planner/executor), async API, migration runner, CLI (Decision 1, Decision 2, Decision 6, Decision 7, Decision 10, Decision 12, Decision 14)." +path = "crates/pinax" +depends_on = ["lexis", "hypomnema", "phylaxis"] +used_by = [] +dev_depends_on = [] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a3dc3da --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,74 @@ +[workspace] +resolver = "2" +members = [ + "crates/hypomnema", + "crates/lexis", + "crates/phylaxis", + "crates/pinax", +] + +[workspace.package] +version = "0.0.2" +edition = "2024" +rust-version = "1.94" +license = "LicenseRef-PolyForm-Shield-1.0.0" +homepage = "https://github.com/forkwright/pinax" +repository = "https://github.com/forkwright/pinax" +authors = ["forkwright"] + +[workspace.dependencies] +# WHY: fleet-preferred small-string type for validated identifier newtypes +# (kanon RUST.md § Validation constructors canonical example). +compact_str = "0.9" +# WHY: property-based testing for the type system's validated constructors +# (kanon RUST.md § Testing). +proptest = "1" +# WHY: snafu is the fleet-wide error library per kanon RUST.md; pinned once +# here so every crate inherits via `workspace = true`. Internal cross-crate +# edges (lexis, hypomnema, phylaxis) are NOT listed here — they use direct +# `{ path = "...", version = "..." }` pins in the consuming crate's own +# manifest, matching kanon's own workspace convention, because that is what +# lets ARCHITECTURE/crate-index-conformance read real `path =` edges out of +# each crate's Cargo.toml and cross-check them against CRATE-INDEX.toml. +snafu = "0.8" + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +# WHY: warn not deny — tests legitimately use these; each library crate +# denies both at the crate root (`#![deny(clippy::unwrap_used, ...)]`) with +# a `#![cfg_attr(test, allow(...))]` escape, so tests keep the ergonomics +# and non-test library code stays at zero tolerance. +unwrap_used = "warn" +expect_used = "warn" +dbg_macro = "deny" +todo = "deny" +unimplemented = "deny" +await_holding_lock = "deny" +# WHY: pedantic overrides — fleet baseline (kanon, heurema). Noise reduction, +# not a relaxation of the invariants the task conformance bar names: no `as` +# casts and no unwrap/expect are enforced by hand and by the crate-level +# deny above, not by these specific pedantic lints. +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +cast_lossless = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +doc_markdown = "allow" +similar_names = "allow" +must_use_unit = "allow" + +[workspace.lints.rust] +unsafe_code = "deny" + +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 2 + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..456017e --- /dev/null +++ b/clippy.toml @@ -0,0 +1,16 @@ +# Workspace-wide clippy configuration for pinax. + +too-many-arguments-threshold = 8 + +# WHY: acronyms that appear unquoted in rustdoc. Extend as later phases +# (WAL, MVCC, AEAD, HNSW, BM25, ...) land real code. +doc-valid-idents = [ + "DDL", + "AST", + "SQL", + "UTC", +] + +[[disallowed-methods]] +path = "std::process::exit" +reason = "library crate — exit() bypasses destructors and consumer error handling" diff --git a/crates/hypomnema/CRATE-SHAPE.toml b/crates/hypomnema/CRATE-SHAPE.toml new file mode 100644 index 0000000..68a5269 --- /dev/null +++ b/crates/hypomnema/CRATE-SHAPE.toml @@ -0,0 +1,9 @@ +# Shape declaration per ARCHITECTURE.md § Composition + D-036 Phase 1. +# WHY: per-crate shape is the architectural commitment a future basanos rule +# (ARCHITECTURE/crate-shape-mismatch — D-036 Phase 2) checks against. Left +# unclassified: a durable shape is assigned by review, not invented per-crate +# (CRATE-SHAPE.md § Registry Contract). +shape = "unclassified" +top_level = "layer" +within_crate = "feature" +purpose = "Written record: WAL, virtual-WAL trait, and cr-sqlite-shaped causal changelog feeding antigraphos (Decision 14). Not yet implemented — reserves the workspace position." diff --git a/crates/hypomnema/Cargo.toml b/crates/hypomnema/Cargo.toml new file mode 100644 index 0000000..5105982 --- /dev/null +++ b/crates/hypomnema/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "hypomnema" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +description = "Written-record primitives for pinax: WAL, virtual-WAL trait, and cr-sqlite-shaped causal changelog (not yet implemented)." +readme = "../../README.md" +keywords = ["sql", "wal", "changelog"] +categories = ["data-structures"] + +[dependencies] +lexis = { path = "../lexis", version = "0.0.2" } + +[lints] +workspace = true + +# WHY: maturity flag is read by the kanon substrate registry so consumers can +# tell at a glance which API surfaces are pre-stabilization. This crate is an +# empty scaffold reserving its position in the Decision 14 dependency graph; +# the WAL + virtual-WAL trait + changelog land in Phase 02. +[package.metadata.kanon] +maturity = "scaffold" +since = "2026-08-15" +phase = "2" +phase-description = "empty crate reserving the workspace position and dependency edge; no implementation yet" +exit-criteria = "Phase 2 WAL + virtual-WAL trait + causal changelog land; see kanon/projects/pinax/ROADMAP.md Phase 02" diff --git a/crates/hypomnema/src/lib.rs b/crates/hypomnema/src/lib.rs new file mode 100644 index 0000000..6837689 --- /dev/null +++ b/crates/hypomnema/src/lib.rs @@ -0,0 +1,8 @@ +//! Written record: WAL, virtual-WAL trait, and cr-sqlite-shaped causal +//! changelog feeding antigraphos (Decision 3, Decision 8, Decision 14). +//! +//! Empty scaffold reserving this crate's position in the locked dependency +//! graph (`lexis -> hypomnema -> phylaxis -> pinax`). Implementation lands +//! in Phase 02 — see `kanon/projects/pinax/ROADMAP.md`. + +#![deny(missing_docs)] diff --git a/crates/lexis/CRATE-SHAPE.toml b/crates/lexis/CRATE-SHAPE.toml new file mode 100644 index 0000000..af27b28 --- /dev/null +++ b/crates/lexis/CRATE-SHAPE.toml @@ -0,0 +1,9 @@ +# Shape declaration per ARCHITECTURE.md § Composition + D-036 Phase 1. +# WHY: per-crate shape is the architectural commitment a future basanos rule +# (ARCHITECTURE/crate-shape-mismatch — D-036 Phase 2) checks against. Left +# unclassified: a durable shape is assigned by review, not invented per-crate +# (CRATE-SHAPE.md § Registry Contract). +shape = "unclassified" +top_level = "leaf" +within_crate = "feature" +purpose = "Language of data: strict six-type value system, schema DDL, and query-expression AST vocabulary (Decision 5, Decision 14). Leaf crate — no fleet dependencies." diff --git a/crates/lexis/Cargo.toml b/crates/lexis/Cargo.toml new file mode 100644 index 0000000..e0cd05f --- /dev/null +++ b/crates/lexis/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "lexis" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +description = "Strict typed language of data for pinax: the six-type value system, schema DDL, and query-expression AST vocabulary." +readme = "../../README.md" +keywords = ["sql", "types", "schema", "ast"] +categories = ["data-structures"] + +[dependencies] +compact_str = { workspace = true } +snafu = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } + +[lints] +workspace = true + +# WHY: maturity flag is read by the kanon substrate registry so consumers can +# tell at a glance which API surfaces are pre-stabilization. Phase 0 lands +# the six-type value system, schema DDL, and expression-AST vocabulary +# (Decision 5, Decision 14); the full statement grammar (SELECT / INSERT / +# UPDATE / DELETE with joins, subqueries, window functions) is Phase 4. +[package.metadata.kanon] +maturity = "alpha" +since = "2026-08-15" +phase = "0" +phase-description = "six-type value system, schema DDL, and expression-AST vocabulary per Decision 5 and Decision 14" +exit-criteria = "Phase 1 pager adopts lexis::Value for on-disk row encoding; Phase 4 parser adopts lexis::ast for full statement grammar; see kanon/projects/pinax/ROADMAP.md" diff --git a/crates/lexis/src/ast.rs b/crates/lexis/src/ast.rs new file mode 100644 index 0000000..0d4e687 --- /dev/null +++ b/crates/lexis/src/ast.rs @@ -0,0 +1,166 @@ +//! Query-expression AST vocabulary (Decision 6, Decision 14). +//! +//! WHY scoped to expressions only: Decision 14 assigns lexis "query AST, +//! SQL vocabulary" — the node types an expression tree is built from. +//! Decision 6 locks the parser *strategy* (hand-rolled recursive descent) +//! but explicitly defers the full statement grammar: "SELECT alone has +//! precedence tables, correlated subqueries, lateral joins, window +//! functions" is named there as the reason a combinator parser does not +//! scale, and ROADMAP.md assigns that grammar to Phase 04. This module +//! defines the expression vocabulary those statements will be built from — +//! `Expr` and its operators — not the statement types (`SELECT` / `INSERT` +//! / `UPDATE` / `DELETE` / `CREATE TABLE`) themselves, and it implements no +//! evaluator: null-propagation, comparison magnitude, and arithmetic +//! overflow behavior are executor concerns for the pinax facade. + +use crate::identifier::ColumnName; +use crate::types::SqlType; +use crate::value::Value; + +/// A query expression node. +/// +/// WHY `#[non_exhaustive]`: the statement-level grammar landing in Phase 04 +/// will need to extend this vocabulary (function calls, subqueries, +/// `CASE`); existing exhaustive matches outside this crate must not become +/// a breaking change when it does. +/// +/// This type carries no evaluation semantics. In particular: +/// - Null propagation ("any operator on NULL returns NULL", Decision 5) is +/// not implemented here — evaluating an `Expr` is executor behavior. +/// - `BinaryOperator` variants are not type-checked against their operands +/// by this type; [`SqlType::check_comparable`] is the type-level rule a +/// future executor consults before evaluating a comparison. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Expr { + /// A literal value. + Literal(Value), + /// A reference to a column by name. + Column(ColumnName), + /// A unary operator applied to one operand. + UnaryOp { + /// The operator. + op: UnaryOperator, + /// The operand. + operand: Box, + }, + /// A binary operator applied to two operands. + BinaryOp { + /// The operator. + op: BinaryOperator, + /// The left-hand operand. + left: Box, + /// The right-hand operand. + right: Box, + }, + /// An explicit `CAST(operand AS target)`. + /// + /// WHY explicit CAST is its own node rather than an implicit + /// conversion inside `BinaryOp`: Decision 5 requires "cross-type + /// operators require explicit `CAST(x AS TYPE)`" — the AST vocabulary + /// must have a place to put that explicitness, or the parser would + /// have nowhere to record it. + Cast { + /// The operand being cast. + operand: Box, + /// The target type. + target: SqlType, + }, + /// `operand IS NULL`. + IsNull(Box), + /// `operand IS NOT NULL`. + IsNotNull(Box), +} + +/// A unary query operator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum UnaryOperator { + /// Logical negation. + Not, + /// Arithmetic negation. + Neg, +} + +/// A binary query operator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum BinaryOperator { + /// `=` + Eq, + /// `<>` / `!=` + Ne, + /// `<` + Lt, + /// `<=` + Le, + /// `>` + Gt, + /// `>=` + Ge, + /// `AND` + And, + /// `OR` + Or, + /// `+` + Add, + /// `-` + Sub, + /// `*` + Mul, + /// `/` + Div, + /// `%` + Mod, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn literal_wraps_a_value() { + let expr = Expr::Literal(Value::Integer(1)); + assert_eq!(expr, Expr::Literal(Value::Integer(1))); + } + + #[test] + fn binary_op_nests_boxed_operands() { + let expr = Expr::BinaryOp { + op: BinaryOperator::Eq, + left: Box::new(Expr::Column( + ColumnName::try_from("id").expect("valid identifier"), + )), + right: Box::new(Expr::Literal(Value::Integer(1))), + }; + let Expr::BinaryOp { op, .. } = expr else { + panic!("constructed a BinaryOp"); + }; + assert_eq!(op, BinaryOperator::Eq); + } + + #[test] + fn cast_carries_target_type() { + let expr = Expr::Cast { + operand: Box::new(Expr::Column( + ColumnName::try_from("id").expect("valid identifier"), + )), + target: SqlType::Text, + }; + let Expr::Cast { target, .. } = expr else { + panic!("constructed a Cast"); + }; + assert_eq!(target, SqlType::Text); + } + + #[test] + fn is_null_and_is_not_null_wrap_one_operand() { + let column = || { + Box::new(Expr::Column( + ColumnName::try_from("id").expect("valid identifier"), + )) + }; + assert_eq!(Expr::IsNull(column()), Expr::IsNull(column())); + assert_ne!(Expr::IsNull(column()), Expr::IsNotNull(column())); + } +} diff --git a/crates/lexis/src/error.rs b/crates/lexis/src/error.rs new file mode 100644 index 0000000..8f790c5 --- /dev/null +++ b/crates/lexis/src/error.rs @@ -0,0 +1,143 @@ +//! Error types for lexis. +//! +//! WHY: one error enum per crate, per kanon RUST.md § Error handling — +//! validation failures across the type system, identifiers, and schema +//! vocabulary all surface through `LexisError` rather than a per-type enum, +//! so a caller matches one surface regardless of which validated +//! constructor rejected the input. + +use crate::types::SqlType; + +/// Errors raised by lexis's validated constructors and type-checking rules. +/// +/// WHY `#[non_exhaustive]`: adding a new validation rule (a new constructor, +/// a new cross-field check) must not be a breaking change for callers that +/// already match on this enum. +#[derive(Debug, snafu::Snafu)] +#[snafu(visibility(pub(crate)))] +#[non_exhaustive] +pub enum LexisError { + /// A column or table identifier was empty. + #[snafu(display("{kind} name must not be empty"))] + EmptyIdentifier { + /// The identifier class that was empty (`"column"` or `"table"`). + kind: &'static str, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A `REAL` value was NaN at the insert-bind boundary. + /// + /// WHY: Decision 5 permits NaN only as a transient computed + /// intermediate; it is rejected the moment a value is bound for + /// storage. `RealValue::try_from` is that boundary. + #[snafu(display("REAL value must be finite and non-NaN, got {value}"))] + NanReal { + /// The rejected value. + value: f64, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A `TEXT` column's declared maximum length was zero. + #[snafu(display("TEXT max length must be greater than zero"))] + TextMaxLenZero { + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A maximum length was declared on a non-`TEXT` column. + #[snafu(display("max length is only valid for TEXT columns, got {sql_type}"))] + TextMaxLenOnNonText { + /// The column's actual declared type. + sql_type: SqlType, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Two `SqlType`s cannot be compared without an explicit `CAST`. + /// + /// WHY: Decision 5 permits INTEGER↔REAL comparison (numerics unify) and + /// same-type comparison; every other pairing is a type error, not a + /// silent coercion. + #[snafu(display("cannot compare {left} with {right}: explicit CAST required"))] + IncomparableTypes { + /// The left-hand operand's type. + left: SqlType, + /// The right-hand operand's type. + right: SqlType, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A table was declared with zero columns. + #[snafu(display("table `{table}` must declare at least one column"))] + EmptyColumnList { + /// The table's name. + table: String, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A table declared the same column name more than once. + #[snafu(display("table `{table}` declares column `{column}` more than once"))] + DuplicateColumn { + /// The table's name. + table: String, + /// The column name that repeated. + column: String, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// `Value::Null` was bound to a `NOT NULL` column. + #[snafu(display("column `{column}` is NOT NULL"))] + NullNotAllowed { + /// The column's name. + column: String, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A value's runtime type did not match its column's declared type. + /// + /// WHY: Decision 5's entire point — no type affinity, no implicit + /// conversion. `expected` and `actual` are always unequal when this + /// variant is constructed. + #[snafu(display("column `{column}` expects {expected}, got {actual}"))] + TypeMismatch { + /// The column's name. + column: String, + /// The column's declared type. + expected: SqlType, + /// The value's actual type. + actual: SqlType, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A `TEXT` value exceeded its column's declared maximum length. + #[snafu(display( + "column `{column}` exceeds max length {max_len} (got {actual_len} characters)" + ))] + TextTooLong { + /// The column's name. + column: String, + /// The column's declared maximum length. + max_len: u32, + /// The value's actual character length. + actual_len: usize, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, +} diff --git a/crates/lexis/src/identifier.rs b/crates/lexis/src/identifier.rs new file mode 100644 index 0000000..373efde --- /dev/null +++ b/crates/lexis/src/identifier.rs @@ -0,0 +1,163 @@ +//! Validated schema identifiers. +//! +//! WHY newtypes: `ColumnName` and `TableName` wrap the same underlying +//! representation but must never be interchangeable at a call site — a +//! function that takes a table name must not silently accept a column +//! name. Two distinct types give the compiler that guarantee for free +//! (kanon RUST.md § Type system, Newtypes for domain concepts). +//! +//! WHY only non-emptiness is validated: Decision 5 and Decision 6 fix the +//! six-type system and the parser strategy but do not specify identifier +//! grammar (allowed character set, maximum length, quoting, reserved-word +//! handling). Phase 4 (parser) owns full SQL identifier syntax; inventing a +//! charset or length cap here would be a decision this crate has no +//! authority to make. Non-emptiness is the one invariant that holds +//! regardless of what that grammar turns out to be. + +use std::fmt; + +use compact_str::CompactString; +use snafu::ensure; + +use crate::error::{EmptyIdentifierSnafu, LexisError}; + +/// A validated, non-empty column identifier. +/// +/// WHY `#[repr(transparent)]`: single-field tuple newtype wrapping a +/// `CompactString`; the representation is guaranteed identical to the +/// wrapped type. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct ColumnName(CompactString); + +impl ColumnName { + /// Borrow the validated identifier as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom<&str> for ColumnName { + type Error = LexisError; + + /// Validate and construct a [`ColumnName`]. + /// + /// # Errors + /// + /// Returns [`LexisError::EmptyIdentifier`] if `value` is empty. + fn try_from(value: &str) -> Result { + ensure!(!value.is_empty(), EmptyIdentifierSnafu { kind: "column" }); + Ok(Self(value.into())) + } +} + +impl fmt::Display for ColumnName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for ColumnName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// A validated, non-empty table identifier. +/// +/// WHY `#[repr(transparent)]`: single-field tuple newtype wrapping a +/// `CompactString`; the representation is guaranteed identical to the +/// wrapped type. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct TableName(CompactString); + +impl TableName { + /// Borrow the validated identifier as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom<&str> for TableName { + type Error = LexisError; + + /// Validate and construct a [`TableName`]. + /// + /// # Errors + /// + /// Returns [`LexisError::EmptyIdentifier`] if `value` is empty. + fn try_from(value: &str) -> Result { + ensure!(!value.is_empty(), EmptyIdentifierSnafu { kind: "table" }); + Ok(Self(value.into())) + } +} + +impl fmt::Display for TableName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for TableName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn column_name_accepts_non_empty() { + let name = ColumnName::try_from("id").expect("non-empty identifier is valid"); + assert_eq!(name.as_str(), "id"); + } + + #[test] + fn column_name_rejects_empty() { + let err = ColumnName::try_from("").expect_err("empty identifier must be rejected"); + assert!(matches!( + err, + LexisError::EmptyIdentifier { kind: "column", .. } + )); + } + + #[test] + fn table_name_accepts_non_empty() { + let name = TableName::try_from("media_items").expect("non-empty identifier is valid"); + assert_eq!(name.as_str(), "media_items"); + } + + #[test] + fn table_name_rejects_empty() { + let err = TableName::try_from("").expect_err("empty identifier must be rejected"); + assert!(matches!( + err, + LexisError::EmptyIdentifier { kind: "table", .. } + )); + } + + #[test] + fn display_matches_as_str() { + let column = ColumnName::try_from("added_at").expect("valid identifier"); + assert_eq!(column.to_string(), "added_at"); + let table = TableName::try_from("media_items").expect("valid identifier"); + assert_eq!(table.to_string(), "media_items"); + } + + #[test] + fn column_name_and_table_name_are_distinct_types() { + // WHY: this is a compile-time property, not a runtime assertion — + // if `ColumnName` and `TableName` ever became interchangeable this + // test would fail to compile, not fail to pass. + fn takes_table_name(_: &TableName) {} + let column = ColumnName::try_from("id").expect("valid identifier"); + let table = TableName::try_from("id").expect("valid identifier"); + takes_table_name(&table); + assert_eq!(column.as_str(), table.as_str()); + } +} diff --git a/crates/lexis/src/lib.rs b/crates/lexis/src/lib.rs new file mode 100644 index 0000000..664d0b7 --- /dev/null +++ b/crates/lexis/src/lib.rs @@ -0,0 +1,39 @@ +//! # lexis +//! +//! Language of data for pinax (Decision 5, Decision 14): the strict +//! six-type value system, schema DDL vocabulary, and query-expression AST +//! vocabulary. Leaf crate — no dependency on any other fleet crate. +//! +//! Type discipline is this crate's one job: every newtype validates at +//! construction (`TryFrom`, never an infallible `From` where an invariant +//! exists), fields stay private, and there is no path that produces a +//! value the type system calls valid but the domain calls wrong. No type +//! affinity, no silent coercion, strict mode only — SQLite's type-affinity +//! lookup table is the failure mode this crate exists to rule out. +//! +//! What this crate does NOT do: parse SQL text (Phase 4, hand-rolled +//! recursive descent per Decision 6), plan or execute a query (Phase 4/5), +//! or define the full statement grammar (`SELECT` / `INSERT` / `UPDATE` / +//! `DELETE` / `CREATE TABLE` with joins, subqueries, window functions — +//! Phase 4). This crate defines the vocabulary those phases build with: +//! [`Value`] and [`SqlType`] for data, [`ColumnDef`] and [`TableDef`] for +//! schema DDL, [`Expr`] and its operators for query expressions. + +#![deny(missing_docs)] +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used)] +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] + +mod ast; +mod error; +mod identifier; +mod schema; +mod types; +mod value; + +pub use ast::{BinaryOperator, Expr, UnaryOperator}; +pub use error::LexisError; +pub use identifier::{ColumnName, TableName}; +pub use schema::{ColumnDef, Nullability, TableDef, TextMaxLen}; +pub use types::SqlType; +pub use value::{DateTimeValue, RealValue, Value}; diff --git a/crates/lexis/src/schema.rs b/crates/lexis/src/schema.rs new file mode 100644 index 0000000..0ee9c0c --- /dev/null +++ b/crates/lexis/src/schema.rs @@ -0,0 +1,433 @@ +//! Schema DDL vocabulary: columns and tables (Decision 5, Decision 14). + +use std::collections::HashSet; +use std::num::NonZeroU32; + +use snafu::{OptionExt, ensure}; + +use crate::error::{ + DuplicateColumnSnafu, EmptyColumnListSnafu, LexisError, NullNotAllowedSnafu, + TextMaxLenOnNonTextSnafu, TextMaxLenZeroSnafu, TextTooLongSnafu, TypeMismatchSnafu, +}; +use crate::identifier::{ColumnName, TableName}; +use crate::types::SqlType; +use crate::value::Value; + +/// Whether a column accepts `NULL`. +/// +/// WHY no [`Default`] impl: Decision 5 requires "explicit `NULL` / `NOT +/// NULL` declaration ... omitting the annotation is a parse error." A +/// `Default` would give silent-nullable-by-omission exactly the semantics +/// the decision rejects — every caller must name one of the two variants. +/// +/// ```compile_fail +/// // Nullability deliberately has no Default: omitting the annotation +/// // must be a parse error, not a silent NULLABLE. +/// let _n: lexis::Nullability = Default::default(); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Nullability { + /// Column accepts `NULL`. + Nullable, + /// Column rejects `NULL`. + NotNull, +} + +/// A validated, non-zero `TEXT` column length bound (`TEXT(n)`). +/// +/// WHY `#[repr(transparent)]`: single-field tuple newtype wrapping a +/// [`NonZeroU32`]; the representation is guaranteed identical to the +/// wrapped type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct TextMaxLen(NonZeroU32); + +impl TextMaxLen { + /// Read the validated, non-zero length bound. + #[must_use] + pub fn get(self) -> u32 { + self.0.get() + } +} + +impl TryFrom for TextMaxLen { + type Error = LexisError; + + /// Validate and construct a [`TextMaxLen`]. + /// + /// # Errors + /// + /// Returns [`LexisError::TextMaxLenZero`] if `value` is zero. + fn try_from(value: u32) -> Result { + NonZeroU32::new(value) + .map(Self) + .context(TextMaxLenZeroSnafu) + } +} + +/// A single column's schema declaration (Decision 5). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ColumnDef { + name: ColumnName, + sql_type: SqlType, + nullability: Nullability, + text_max_len: Option, +} + +impl ColumnDef { + /// Validate and construct a [`ColumnDef`]. + /// + /// # Errors + /// + /// Returns [`LexisError::TextMaxLenOnNonText`] if `text_max_len` is + /// `Some` and `sql_type` is not [`SqlType::Text`]. + #[must_use] + pub fn new( + name: ColumnName, + sql_type: SqlType, + nullability: Nullability, + text_max_len: Option, + ) -> Result { + ensure!( + text_max_len.is_none() || sql_type == SqlType::Text, + TextMaxLenOnNonTextSnafu { sql_type } + ); + Ok(Self { + name, + sql_type, + nullability, + text_max_len, + }) + } + + /// The column's validated name. + #[must_use] + pub fn name(&self) -> &ColumnName { + &self.name + } + + /// The column's declared type. + #[must_use] + pub fn sql_type(&self) -> SqlType { + self.sql_type + } + + /// The column's nullability. + #[must_use] + pub fn nullability(&self) -> Nullability { + self.nullability + } + + /// The column's declared `TEXT` length bound, if any. + #[must_use] + pub fn text_max_len(&self) -> Option { + self.text_max_len + } + + /// Check a value against this column's declared type and nullability. + /// + /// WHY this is lexis's job: Decision 5's entire point is "values + /// inserted into a column must be of that type or NULL (if nullable). + /// Implicit conversions are rejected with a type error" — this method + /// is that rule, expressed once so every future caller (the pager's + /// row encoder, the executor's INSERT/UPDATE path) enforces it + /// identically instead of re-implementing it. + /// + /// # Errors + /// + /// Returns [`LexisError::NullNotAllowed`] if `value` is `NULL` and + /// this column is [`Nullability::NotNull`]. + /// + /// Returns [`LexisError::TypeMismatch`] if `value`'s type does not + /// exactly match this column's declared type. + /// + /// Returns [`LexisError::TextTooLong`] if `value` is `TEXT`, this + /// column declares a `text_max_len`, and the value's character count + /// exceeds it. + #[must_use] + pub fn check_value(&self, value: &Value) -> Result<(), LexisError> { + let actual_type = match value { + Value::Null => { + ensure!( + self.nullability == Nullability::Nullable, + NullNotAllowedSnafu { + column: self.name.as_str(), + } + ); + return Ok(()); + } + Value::Integer(_) => SqlType::Integer, + Value::Real(_) => SqlType::Real, + Value::Text(_) => SqlType::Text, + Value::Blob(_) => SqlType::Blob, + Value::Boolean(_) => SqlType::Boolean, + Value::Datetime(_) => SqlType::Datetime, + }; + + ensure!( + actual_type == self.sql_type, + TypeMismatchSnafu { + column: self.name.as_str(), + expected: self.sql_type, + actual: actual_type, + } + ); + + // WHY no `as` cast: `u32::try_from(len)` is itself the bound check + // for a `usize` too large for `u32` — a failed conversion means + // `len` cannot possibly fit under `max_len`, so `is_ok_and` folds + // "too large to convert" and "converts but exceeds the bound" into + // one comparison without ever discarding precision silently. + if let (Value::Text(text), Some(max_len)) = (value, self.text_max_len) { + let len = text.chars().count(); + let within_bound = u32::try_from(len).is_ok_and(|actual| actual <= max_len.get()); + ensure!( + within_bound, + TextTooLongSnafu { + column: self.name.as_str(), + max_len: max_len.get(), + actual_len: len, + } + ); + } + + Ok(()) + } +} + +/// A table's schema: name plus an ordered, duplicate-free column list. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TableDef { + name: TableName, + columns: Vec, +} + +impl TableDef { + /// Validate and construct a [`TableDef`]. + /// + /// WHY these two invariants beyond Decision 5's text: a table with + /// zero columns, or two columns sharing a name, cannot exist in any + /// relational model — this is relational-model correctness, not an + /// invented pinax-specific rule. + /// + /// # Errors + /// + /// Returns [`LexisError::EmptyColumnList`] if `columns` is empty. + /// + /// Returns [`LexisError::DuplicateColumn`] if any two columns share a + /// name. + #[must_use] + pub fn new(name: TableName, columns: Vec) -> Result { + ensure!( + !columns.is_empty(), + EmptyColumnListSnafu { + table: name.as_str(), + } + ); + + let mut seen: HashSet<&str> = HashSet::new(); + for column in &columns { + ensure!( + seen.insert(column.name().as_str()), + DuplicateColumnSnafu { + table: name.as_str(), + column: column.name().as_str(), + } + ); + } + + Ok(Self { name, columns }) + } + + /// The table's validated name. + #[must_use] + pub fn name(&self) -> &TableName { + &self.name + } + + /// The table's columns, in declaration order. + #[must_use] + pub fn columns(&self) -> &[ColumnDef] { + &self.columns + } + + /// Look up a column by name. + #[must_use] + pub fn column(&self, name: &str) -> Option<&ColumnDef> { + self.columns + .iter() + .find(|column| column.name().as_str() == name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id_column() -> ColumnDef { + ColumnDef::new( + ColumnName::try_from("id").expect("valid identifier"), + SqlType::Integer, + Nullability::NotNull, + None, + ) + .expect("INTEGER NOT NULL with no length bound is always valid") + } + + #[test] + fn text_max_len_accepts_positive() { + let bound = TextMaxLen::try_from(255).expect("positive length is valid"); + assert_eq!(bound.get(), 255); + } + + #[test] + fn text_max_len_rejects_zero() { + let err = TextMaxLen::try_from(0).expect_err("zero length must be rejected"); + assert!(matches!(err, LexisError::TextMaxLenZero { .. })); + } + + #[test] + fn column_def_rejects_max_len_on_non_text() { + let bound = TextMaxLen::try_from(10).expect("valid bound"); + let err = ColumnDef::new( + ColumnName::try_from("id").expect("valid identifier"), + SqlType::Integer, + Nullability::NotNull, + Some(bound), + ) + .expect_err("max length on INTEGER must be rejected"); + assert!(matches!(err, LexisError::TextMaxLenOnNonText { .. })); + } + + #[test] + fn column_def_accepts_max_len_on_text() { + let bound = TextMaxLen::try_from(255).expect("valid bound"); + let column = ColumnDef::new( + ColumnName::try_from("title").expect("valid identifier"), + SqlType::Text, + Nullability::Nullable, + Some(bound), + ) + .expect("max length on TEXT is valid"); + assert_eq!(column.text_max_len(), Some(bound)); + } + + #[test] + fn check_value_rejects_null_on_not_null_column() { + let column = id_column(); + let err = column + .check_value(&Value::Null) + .expect_err("NULL on NOT NULL column must be rejected"); + assert!(matches!(err, LexisError::NullNotAllowed { .. })); + } + + #[test] + fn check_value_accepts_null_on_nullable_column() { + let column = ColumnDef::new( + ColumnName::try_from("note").expect("valid identifier"), + SqlType::Text, + Nullability::Nullable, + None, + ) + .expect("valid column"); + assert!(column.check_value(&Value::Null).is_ok()); + } + + #[test] + fn check_value_rejects_type_mismatch() { + let column = id_column(); + let err = column + .check_value(&Value::Text(String::from("nope"))) + .expect_err("TEXT into INTEGER column must be rejected"); + assert!(matches!( + err, + LexisError::TypeMismatch { + expected: SqlType::Integer, + actual: SqlType::Text, + .. + } + )); + } + + #[test] + fn check_value_accepts_matching_type() { + let column = id_column(); + assert!(column.check_value(&Value::Integer(42)).is_ok()); + } + + #[test] + fn check_value_rejects_text_over_max_len() { + let bound = TextMaxLen::try_from(3).expect("valid bound"); + let column = ColumnDef::new( + ColumnName::try_from("code").expect("valid identifier"), + SqlType::Text, + Nullability::NotNull, + Some(bound), + ) + .expect("valid column"); + let err = column + .check_value(&Value::Text(String::from("abcd"))) + .expect_err("4 characters must exceed a bound of 3"); + assert!(matches!(err, LexisError::TextTooLong { .. })); + } + + #[test] + fn check_value_counts_characters_not_bytes() { + // WHY: multi-byte UTF-8 must not be penalized for byte length when + // the declared bound is a character count. + let bound = TextMaxLen::try_from(2).expect("valid bound"); + let column = ColumnDef::new( + ColumnName::try_from("code").expect("valid identifier"), + SqlType::Text, + Nullability::NotNull, + Some(bound), + ) + .expect("valid column"); + // "\u{1F600}\u{1F600}" is two grapheme-adjacent scalars, 8 bytes. + assert!( + column + .check_value(&Value::Text(String::from("\u{1F600}\u{1F600}"))) + .is_ok() + ); + } + + #[test] + fn table_def_rejects_empty_columns() { + let err = TableDef::new(TableName::try_from("t").expect("valid identifier"), vec![]) + .expect_err("zero columns must be rejected"); + assert!(matches!(err, LexisError::EmptyColumnList { .. })); + } + + #[test] + fn table_def_rejects_duplicate_column_names() { + let err = TableDef::new( + TableName::try_from("t").expect("valid identifier"), + vec![id_column(), id_column()], + ) + .expect_err("duplicate column name must be rejected"); + assert!(matches!(err, LexisError::DuplicateColumn { .. })); + } + + #[test] + fn table_def_accepts_distinct_columns_and_looks_up_by_name() { + let title = ColumnDef::new( + ColumnName::try_from("title").expect("valid identifier"), + SqlType::Text, + Nullability::Nullable, + None, + ) + .expect("valid column"); + let table = TableDef::new( + TableName::try_from("t").expect("valid identifier"), + vec![id_column(), title], + ) + .expect("distinct columns are valid"); + assert_eq!(table.columns().len(), 2); + assert_eq!( + table.column("title").map(ColumnDef::sql_type), + Some(SqlType::Text) + ); + assert!(table.column("missing").is_none()); + } +} diff --git a/crates/lexis/src/types.rs b/crates/lexis/src/types.rs new file mode 100644 index 0000000..43f618f --- /dev/null +++ b/crates/lexis/src/types.rs @@ -0,0 +1,170 @@ +//! The strict six-type system (Decision 5). +//! +//! WHY: SQLite's type affinity silently coerces between declared and +//! bound types (`SQLITE_AFF_NUMERIC` accepting TEXT, for example). Pinax +//! rejects that entirely — a column declares exactly one of these six +//! types, values must match it or be `NULL` in a nullable column, and +//! every implicit conversion is a type error, not a coercion. `NUMERIC`, +//! `DECIMAL`, `DATE`, `TIME`, and `JSON` are explicitly rejected as column +//! types by Decision 5 and must never be added here. + +use std::fmt; + +use snafu::ensure; + +use crate::error::{IncomparableTypesSnafu, LexisError}; + +/// The six fixed SQL types a column may declare (Decision 5). +/// +/// WHY `#[non_exhaustive]`: this set is locked by Decision 5, but the enum +/// still carries the fleet-wide public-enum convention so a hypothetical +/// future addition is not a breaking change for exhaustive-matching callers +/// outside this crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum SqlType { + /// 64-bit signed integer. No unsigned variant. + Integer, + /// IEEE-754 double. + Real, + /// UTF-8 text, optionally length-bounded by the declaring column. + Text, + /// Opaque bytes, no encoding implied. + Blob, + /// `true` / `false`, distinct from `Integer`. + Boolean, + /// 64-bit nanosecond UTC epoch. + Datetime, +} + +impl SqlType { + /// Check whether two types may be compared without an explicit `CAST`. + /// + /// WHY: Decision 5 permits exactly one cross-type pairing — + /// `Integer`↔`Real`, because "numerics unify to REAL for the compare". + /// Every other cross-type pairing (INTEGER vs TEXT, and so on) is a + /// type error. Same-type pairs are always comparable. + /// + /// This checks type compatibility only; it does not perform the + /// comparison itself. Runtime value comparison — including the + /// mantissa-exactness rule for `INTEGER`↔`REAL` and three-valued `NULL` + /// propagation — is executor behavior assigned to the pinax facade + /// (Phase 4/5 planner and executor), not this crate's vocabulary. + /// + /// # Errors + /// + /// Returns [`LexisError::IncomparableTypes`] if `self` and `other` are + /// different types other than the `Integer`/`Real` pairing. + #[must_use] + pub fn check_comparable(self, other: Self) -> Result<(), LexisError> { + let compatible = self == other + || matches!( + (self, other), + (Self::Integer, Self::Real) | (Self::Real, Self::Integer) + ); + ensure!( + compatible, + IncomparableTypesSnafu { + left: self, + right: other, + } + ); + Ok(()) + } +} + +impl fmt::Display for SqlType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Integer => "INTEGER", + Self::Real => "REAL", + Self::Text => "TEXT", + Self::Blob => "BLOB", + Self::Boolean => "BOOLEAN", + Self::Datetime => "DATETIME", + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_comparable_accepts_identical_types() { + assert!(SqlType::Text.check_comparable(SqlType::Text).is_ok()); + } + + #[test] + fn check_comparable_accepts_integer_real_either_direction() { + assert!(SqlType::Integer.check_comparable(SqlType::Real).is_ok()); + assert!(SqlType::Real.check_comparable(SqlType::Integer).is_ok()); + } + + #[test] + fn check_comparable_rejects_integer_text() { + let err = SqlType::Integer + .check_comparable(SqlType::Text) + .expect_err("INTEGER vs TEXT must be rejected"); + assert!(matches!(err, LexisError::IncomparableTypes { .. })); + } + + #[test] + fn check_comparable_rejects_boolean_integer() { + // WHY: SQLite conflates 0/1 with true/false; Decision 5 explicitly + // keeps BOOLEAN distinct from INTEGER. + let err = SqlType::Boolean + .check_comparable(SqlType::Integer) + .expect_err("BOOLEAN vs INTEGER must be rejected"); + assert!(matches!(err, LexisError::IncomparableTypes { .. })); + } + + #[test] + fn display_matches_sql_keyword() { + assert_eq!(SqlType::Integer.to_string(), "INTEGER"); + assert_eq!(SqlType::Real.to_string(), "REAL"); + assert_eq!(SqlType::Text.to_string(), "TEXT"); + assert_eq!(SqlType::Blob.to_string(), "BLOB"); + assert_eq!(SqlType::Boolean.to_string(), "BOOLEAN"); + assert_eq!(SqlType::Datetime.to_string(), "DATETIME"); + } + + /// WHY a hand-written strategy rather than a derive: `SqlType` has no + /// `Arbitrary` impl (adding one would pull `proptest-derive` into a + /// library dependency for a six-variant enum); enumerating the fixed + /// set directly is both simpler and, per Decision 5, exhaustive by + /// construction — no seventh variant can appear. + fn any_sql_type() -> impl proptest::strategy::Strategy { + proptest::prop_oneof![ + proptest::strategy::Just(SqlType::Integer), + proptest::strategy::Just(SqlType::Real), + proptest::strategy::Just(SqlType::Text), + proptest::strategy::Just(SqlType::Blob), + proptest::strategy::Just(SqlType::Boolean), + proptest::strategy::Just(SqlType::Datetime), + ] + } + + proptest::proptest! { + // WHY: Decision 5's comparison rule is inherently symmetric ("a + // compares with b" cannot be true in one direction and false in + // the other for a same-type-or-numeric-unify rule) — this + // property must hold across all 36 ordered pairs, not just the + // ones a hand-picked example set happens to cover. + #[test] + fn check_comparable_is_symmetric( + left in any_sql_type(), + right in any_sql_type(), + ) { + proptest::prop_assert_eq!( + left.check_comparable(right).is_ok(), + right.check_comparable(left).is_ok(), + ); + } + + #[test] + fn check_comparable_always_accepts_identical_types(sql_type in any_sql_type()) { + proptest::prop_assert!(sql_type.check_comparable(sql_type).is_ok()); + } + } +} diff --git a/crates/lexis/src/value.rs b/crates/lexis/src/value.rs new file mode 100644 index 0000000..32a959c --- /dev/null +++ b/crates/lexis/src/value.rs @@ -0,0 +1,293 @@ +//! Runtime values for the six-type system (Decision 5). + +use std::fmt; + +use snafu::ensure; + +use crate::error::{LexisError, NanRealSnafu}; +use crate::types::SqlType; + +/// A validated `REAL` value: an [`f64`] that is never NaN. +/// +/// WHY: Decision 5 permits NaN only as a transient computed intermediate — +/// "operators should fail loudly, not propagate NaN silently" — and rejects +/// it "at insert-bind". This newtype IS that insert-bind boundary: a raw +/// `f64` computed mid-expression may be NaN, but the only way to obtain a +/// [`RealValue`] is through [`TryFrom::try_from`], which refuses it. +/// +/// WHY `#[repr(transparent)]`: single-field tuple newtype wrapping an +/// `f64`; the representation is guaranteed identical to the wrapped type. +#[derive(Debug, Clone, Copy, PartialEq)] +#[repr(transparent)] +pub struct RealValue(f64); + +impl RealValue { + /// Read the validated, non-NaN floating-point value. + #[must_use] + pub fn get(self) -> f64 { + self.0 + } +} + +impl TryFrom for RealValue { + type Error = LexisError; + + /// Validate and construct a [`RealValue`]. + /// + /// # Errors + /// + /// Returns [`LexisError::NanReal`] if `value` is NaN. + fn try_from(value: f64) -> Result { + ensure!(!value.is_nan(), NanRealSnafu { value }); + Ok(Self(value)) + } +} + +impl fmt::Display for RealValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +/// A `DATETIME` value: nanoseconds since the UTC epoch (Decision 5). +/// +/// WHY a newtype despite having no rejectable invariant (any `i64` is a +/// valid nanosecond offset): compile-time parameter-swap safety against +/// every other bare `i64` in the domain (row counts, lamport clocks, byte +/// lengths). Because every `i64` is valid, the conversion is honestly +/// infallible — `From`, not `TryFrom` (kanon RUST.md § Validation +/// constructors: "Do not implement `From` ... when [invariants exist]", +/// which implies the converse for the case where none do). +/// +/// WHY `#[repr(transparent)]`: single-field tuple newtype wrapping an +/// `i64`; the representation is guaranteed identical to the wrapped type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(transparent)] +pub struct DateTimeValue(i64); + +impl DateTimeValue { + /// Read the nanosecond UTC epoch offset. + #[must_use] + pub fn get(self) -> i64 { + self.0 + } +} + +impl From for DateTimeValue { + fn from(value: i64) -> Self { + Self(value) + } +} + +impl From for i64 { + fn from(value: DateTimeValue) -> Self { + value.0 + } +} + +impl fmt::Display for DateTimeValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +/// A runtime value inhabiting one of the six types, or `NULL`. +/// +/// WHY `NULL` is a variant rather than `Option` wrapping the rest: +/// SQL's three-valued logic needs `NULL` to flow through the same value +/// channel as typed data (it can be compared, matched by `IS NULL`, and +/// bound to any nullable column regardless of that column's declared +/// type) — `Value` already models "no fixed type" for `Null` via +/// [`Value::sql_type`] returning `None`, so wrapping in `Option` would +/// duplicate that channel rather than clarify it. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Value { + /// SQL `NULL`. Has no fixed type; comparable against any nullable + /// column regardless of that column's declared type. + Null, + /// `INTEGER`: 64-bit signed. + Integer(i64), + /// `REAL`: validated non-NaN IEEE-754 double. + Real(RealValue), + /// `TEXT`: UTF-8 string. + Text(String), + /// `BLOB`: opaque bytes. + Blob(Vec), + /// `BOOLEAN`: `true` / `false`. + Boolean(bool), + /// `DATETIME`: nanosecond UTC epoch. + Datetime(DateTimeValue), +} + +impl Value { + /// The [`SqlType`] this value inhabits, or `None` for `NULL`. + /// + /// WHY `None` for `Null`: SQL `NULL` has no fixed type of its own — it + /// is valid in any nullable column regardless of that column's + /// declared type (Decision 5). + #[must_use] + pub fn sql_type(&self) -> Option { + match self { + Self::Null => None, + Self::Integer(_) => Some(SqlType::Integer), + Self::Real(_) => Some(SqlType::Real), + Self::Text(_) => Some(SqlType::Text), + Self::Blob(_) => Some(SqlType::Blob), + Self::Boolean(_) => Some(SqlType::Boolean), + Self::Datetime(_) => Some(SqlType::Datetime), + } + } + + /// Whether this value is `NULL`. + #[must_use] + pub fn is_null(&self) -> bool { + matches!(self, Self::Null) + } +} + +impl TryFrom for Value { + type Error = LexisError; + + /// Validate and construct a [`Value::Real`]. + /// + /// # Errors + /// + /// Returns [`LexisError::NanReal`] if `value` is NaN. + fn try_from(value: f64) -> Result { + Ok(Self::Real(RealValue::try_from(value)?)) + } +} + +impl From for Value { + fn from(value: i64) -> Self { + Self::Integer(value) + } +} + +impl From for Value { + fn from(value: bool) -> Self { + Self::Boolean(value) + } +} + +impl From for Value { + fn from(value: String) -> Self { + Self::Text(value) + } +} + +impl From> for Value { + fn from(value: Vec) -> Self { + Self::Blob(value) + } +} + +impl From for Value { + fn from(value: DateTimeValue) -> Self { + Self::Datetime(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + // WHY explicit: `.prop_filter(...)` below is a `Strategy` trait method + // called via dot-syntax, which requires the trait in scope even though + // every other proptest item here is called through a fully-qualified + // path. + use proptest::strategy::Strategy as _; + + #[test] + fn real_value_accepts_finite() { + let real = RealValue::try_from(3.5).expect("finite value is valid"); + assert!((real.get() - 3.5).abs() < f64::EPSILON); + } + + #[test] + fn real_value_accepts_infinity() { + // WHY: Decision 5 rejects NaN specifically, not non-finite values + // in general — infinity is a legitimate computed result. + assert!(RealValue::try_from(f64::INFINITY).is_ok()); + } + + #[test] + fn real_value_rejects_nan() { + let err = RealValue::try_from(f64::NAN).expect_err("NaN must be rejected"); + assert!(matches!(err, LexisError::NanReal { .. })); + } + + #[test] + fn value_try_from_f64_rejects_nan() { + let err = Value::try_from(f64::NAN).expect_err("NaN must be rejected"); + assert!(matches!(err, LexisError::NanReal { .. })); + } + + #[test] + fn datetime_value_round_trips_through_i64() { + let dt = DateTimeValue::from(1_700_000_000_000_000_000_i64); + assert_eq!(i64::from(dt), 1_700_000_000_000_000_000_i64); + } + + #[test] + fn sql_type_maps_each_variant() { + assert_eq!(Value::Integer(1).sql_type(), Some(SqlType::Integer)); + assert_eq!( + Value::Real(RealValue::try_from(1.0).expect("valid")).sql_type(), + Some(SqlType::Real) + ); + assert_eq!( + Value::Text(String::from("x")).sql_type(), + Some(SqlType::Text) + ); + assert_eq!(Value::Blob(vec![1]).sql_type(), Some(SqlType::Blob)); + assert_eq!(Value::Boolean(true).sql_type(), Some(SqlType::Boolean)); + assert_eq!( + Value::Datetime(DateTimeValue::from(0)).sql_type(), + Some(SqlType::Datetime) + ); + } + + #[test] + fn null_has_no_sql_type() { + assert_eq!(Value::Null.sql_type(), None); + assert!(Value::Null.is_null()); + } + + #[test] + fn non_null_values_report_is_null_false() { + assert!(!Value::Integer(0).is_null()); + } + + proptest::proptest! { + // WHY: `RealValue::try_from` is lexis's one true validated-real + // boundary — every finite or infinite `f64` must pass, and NaN + // (in any bit pattern; `is_nan()` is pattern-agnostic) must + // always be rejected. A fixed set of example values cannot cover + // this; the property must hold for the whole `f64` domain. + #[test] + fn real_value_accepts_iff_not_nan(raw in proptest::num::f64::ANY) { + let result = RealValue::try_from(raw); + proptest::prop_assert_eq!(result.is_ok(), !raw.is_nan()); + } + + #[test] + fn real_value_round_trips_non_nan(raw in proptest::num::f64::ANY.prop_filter( + "exclude NaN — RealValue::try_from rejects it by construction", + |value| !value.is_nan(), + )) { + let real = RealValue::try_from(raw).expect("non-NaN value is always valid"); + // WHY bit-pattern equality, not `==`: `f64::NAN != f64::NAN` + // makes `==` unsuitable for a round-trip property, and this + // path is already filtered to non-NaN, but `-0.0 == 0.0` would + // also hide a sign-bit round-trip defect that `to_bits` does + // not. + proptest::prop_assert_eq!(real.get().to_bits(), raw.to_bits()); + } + + #[test] + fn datetime_value_round_trips_any_i64(raw in proptest::num::i64::ANY) { + proptest::prop_assert_eq!(i64::from(DateTimeValue::from(raw)), raw); + } + } +} diff --git a/crates/phylaxis/CRATE-SHAPE.toml b/crates/phylaxis/CRATE-SHAPE.toml new file mode 100644 index 0000000..0590821 --- /dev/null +++ b/crates/phylaxis/CRATE-SHAPE.toml @@ -0,0 +1,9 @@ +# Shape declaration per ARCHITECTURE.md § Composition + D-036 Phase 1. +# WHY: per-crate shape is the architectural commitment a future basanos rule +# (ARCHITECTURE/crate-shape-mismatch — D-036 Phase 2) checks against. Left +# unclassified: a durable shape is assigned by review, not invented per-crate +# (CRATE-SHAPE.md § Registry Contract). +shape = "unclassified" +top_level = "layer" +within_crate = "feature" +purpose = "Guarding: MVCC snapshot isolation, per-page AEAD encryption, and the heurēma index adapter (Decision 14). Not yet implemented — reserves the workspace position." diff --git a/crates/phylaxis/Cargo.toml b/crates/phylaxis/Cargo.toml new file mode 100644 index 0000000..051deca --- /dev/null +++ b/crates/phylaxis/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "phylaxis" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +description = "Integrity and access guards for pinax: MVCC snapshot isolation, per-page AEAD encryption, and the heurēma index adapter (not yet implemented)." +readme = "../../README.md" +keywords = ["sql", "mvcc", "encryption"] +categories = ["data-structures"] + +[dependencies] +hypomnema = { path = "../hypomnema", version = "0.0.2" } +lexis = { path = "../lexis", version = "0.0.2" } + +[lints] +workspace = true + +# WHY: maturity flag is read by the kanon substrate registry so consumers can +# tell at a glance which API surfaces are pre-stabilization. This crate is an +# empty scaffold reserving its position in the Decision 14 dependency graph; +# MVCC lands Phase 03, encryption lands Phase 06. +[package.metadata.kanon] +maturity = "scaffold" +since = "2026-08-15" +phase = "3" +phase-description = "empty crate reserving the workspace position and dependency edges; no implementation yet" +exit-criteria = "Phase 3 MVCC + Phase 6 encryption land; see kanon/projects/pinax/ROADMAP.md Phase 03/06" diff --git a/crates/phylaxis/src/lib.rs b/crates/phylaxis/src/lib.rs new file mode 100644 index 0000000..5c201b4 --- /dev/null +++ b/crates/phylaxis/src/lib.rs @@ -0,0 +1,9 @@ +//! Guarding: MVCC snapshot isolation, per-page AEAD encryption, and the +//! heurēma index adapter (Decision 4, Decision 9, Decision 14). +//! +//! Empty scaffold reserving this crate's position in the locked dependency +//! graph (`lexis -> hypomnema -> phylaxis -> pinax`). Implementation lands +//! in Phase 03 (MVCC) and Phase 06 (encryption) — see +//! `kanon/projects/pinax/ROADMAP.md`. + +#![deny(missing_docs)] diff --git a/crates/pinax/CRATE-SHAPE.toml b/crates/pinax/CRATE-SHAPE.toml new file mode 100644 index 0000000..3e57e08 --- /dev/null +++ b/crates/pinax/CRATE-SHAPE.toml @@ -0,0 +1,9 @@ +# Shape declaration per ARCHITECTURE.md § Composition + D-036 Phase 1. +# WHY: per-crate shape is the architectural commitment a future basanos rule +# (ARCHITECTURE/crate-shape-mismatch — D-036 Phase 2) checks against. Left +# unclassified: a durable shape is assigned by review, not invented per-crate +# (CRATE-SHAPE.md § Registry Contract). +shape = "unclassified" +top_level = "layer" +within_crate = "feature" +purpose = "Facade: pager, buffer pool, B-tree, page format, SQL surface, async API, migration runner, CLI (Decision 14). Not yet implemented — reserves the workspace position." diff --git a/crates/pinax/Cargo.toml b/crates/pinax/Cargo.toml new file mode 100644 index 0000000..6c9e67e --- /dev/null +++ b/crates/pinax/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "pinax" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +description = "Sovereign relational storage facade for the forkwright fleet: pager, buffer pool, B-tree, SQL surface, async API, migrations, and CLI (not yet implemented)." +readme = "../../README.md" +keywords = ["sql", "database", "storage"] +categories = ["database"] + +[dependencies] +hypomnema = { path = "../hypomnema", version = "0.0.2" } +lexis = { path = "../lexis", version = "0.0.2" } +phylaxis = { path = "../phylaxis", version = "0.0.2" } + +[lints] +workspace = true + +# WHY: maturity flag is read by the kanon substrate registry so consumers can +# tell at a glance which API surfaces are pre-stabilization. This crate is an +# empty scaffold reserving the facade's position in the Decision 14 +# dependency graph; the pager + buffer pool + B-tree land Phase 01. +[package.metadata.kanon] +maturity = "scaffold" +since = "2026-08-15" +phase = "1" +phase-description = "empty crate reserving the workspace position and dependency edges; no implementation yet" +exit-criteria = "Phase 1 pager + buffer pool + B-tree land; see kanon/projects/pinax/ROADMAP.md Phase 01" diff --git a/crates/pinax/src/lib.rs b/crates/pinax/src/lib.rs new file mode 100644 index 0000000..7c87bde --- /dev/null +++ b/crates/pinax/src/lib.rs @@ -0,0 +1,11 @@ +//! Facade: pager, buffer pool, B-tree, page format, SQL surface +//! (parser/planner/executor), async API, migration runner, CLI +//! (Decision 1, Decision 2, Decision 6, Decision 7, Decision 10, +//! Decision 12, Decision 14). +//! +//! Empty scaffold reserving this crate's position in the locked dependency +//! graph (`lexis -> hypomnema -> phylaxis -> pinax`). Implementation begins +//! in Phase 01 (pager + buffer pool + B-tree) — see +//! `kanon/projects/pinax/ROADMAP.md`. + +#![deny(missing_docs)] diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..9545e07 --- /dev/null +++ b/deny.toml @@ -0,0 +1,64 @@ +# cargo-deny configuration for pinax. +# +# Template copied from forkwright/heurema; the advisory + license + ban + +# sources policy mirrors the fleet baseline (kanon RUST.md § Dependencies). + +[graph] +targets = [] +all-features = true + +[advisories] +yanked = "deny" +ignore = [] + +[licenses] +allow = [ + "MPL-2.0", + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Unicode-DFS-2016", + "CC0-1.0", + "Zlib", +] +confidence-threshold = 0.92 + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[[bans.deny]] +crate = "openssl-sys" +reason = "rustls is the fleet TLS choice; openssl-sys pulls system OpenSSL and complicates supply chain" + +# WHY: kanon RUST.md bans `thiserror` (replaced by snafu), `async-trait` +# (native async fn in trait), `lazy_static` / `once_cell` (std::sync::LazyLock), +# and `failure` (unmaintained). Applies fleet-wide. +[[bans.deny]] +crate = "thiserror" +reason = "fleet errors use snafu (kanon RUST.md)" + +[[bans.deny]] +crate = "async-trait" +reason = "native async fn in trait since Rust 1.75 (kanon RUST.md)" + +[[bans.deny]] +crate = "lazy_static" +reason = "std::sync::LazyLock stabilized in 1.80 (kanon RUST.md)" + +[[bans.deny]] +crate = "once_cell" +reason = "std::sync::LazyLock stabilized in 1.80 (kanon RUST.md)" + +[[bans.deny]] +crate = "failure" +reason = "abandoned since 2019; use snafu (kanon RUST.md)" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f216078 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" From 1b0ee1c6511ef77fcd80f4529948e6f24fc4e6d8 Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 18:00:58 -0500 Subject: [PATCH 2/7] chore(workspace): commit the generated Cargo.lock Resolved by kanon gate --tier full while validating this branch. Committing the lockfile matches the fleet convention (heurema commits its own) and pins the dependency graph deterministically for CI. --- Cargo.lock | 443 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 Cargo.lock diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7b47fe4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,443 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hypomnema" +version = "0.0.2" +dependencies = [ + "lexis", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lexis" +version = "0.0.2" +dependencies = [ + "compact_str", + "proptest", + "snafu", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "phylaxis" +version = "0.0.2" +dependencies = [ + "hypomnema", + "lexis", +] + +[[package]] +name = "pinax" +version = "0.0.2" +dependencies = [ + "hypomnema", + "lexis", + "phylaxis", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] From 2c0114cb52b617e7853329eb2329c12898cfa131 Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 19:20:06 -0500 Subject: [PATCH 3/7] feat(ci): add build CI and security scanning now that code has landed Pinax carried only gate-attestation.yml (trailer-only, no build fallback) and release-please.yml -- the sole path to landing non-docs code was a locally-produced Gate-Passed trailer. That gap was fine while pinax was docs-only; it became a real problem the moment #10 landed a four-crate workspace with no build CI to catch it. Adds ci.yml (fmt/check/clippy/nextest matrix, modelled on sphragis's job shape and heurema's --workspace/nextest split, since pinax is a workspace like heurema) and security.yml + osv-scanner.toml (cargo-deny/cargo-audit/osv-scanner, modelled on sphragis's pinned-binary cargo-audit install rather than heurema's source build, which couples the scanner's MSRV to the crate's). Both are informational until gate-attestation.yml migrates to the hybrid-gate pattern sphragis and heurema already run. Also lands the two drift items #10's own PR body flagged and deliberately left: .kanon-ci.toml now runs the real Rust gate stages its own comment named (cargo fmt/check/clippy/nextest, concurrency- capped per basanos CI.md) ahead of the existing kanon-lint stages, and drops the now-inapplicable disabled [verifier] override now that a real workspace exists to probe. release-please-config.json gains extra-files entries so a release bump updates the workspace version and every internal path-dependency pin that hardcodes it -- without them a release would bump lexis to a new patch while every crate that depends on it via path+version stayed pinned to the old one, breaking the build. --- .github/workflows/ci.yml | 87 ++++++++++++++++++++++++++++++++++ .github/workflows/security.yml | 86 +++++++++++++++++++++++++++++++++ .kanon-ci.toml | 48 +++++++++++-------- osv-scanner.toml | 5 ++ release-please-config.json | 29 +++++++++++- 5 files changed, 235 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/security.yml create mode 100644 osv-scanner.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f0aebeb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +# WHY: gate-attestation.yml here is still the org-default trailer-only +# reusable (no full-build fallback) -- unlike sphragis and heurema, which +# moved to forkwright/.github's hybrid-gate.yml (kanon#2522) and run this +# same fmt/check/clippy/nextest matrix as the trailer-less fallback build. +# Until pinax makes that same move, this workflow is the only build signal +# it runs on every PR; it is informational (not a required check) and does +# not substitute for the Gate-Passed trailer branch protection requires. +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: cargo fmt + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: rustfmt + - name: cargo fmt check + run: cargo fmt --all -- --check + + check: + name: cargo check + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: cargo check workspace + run: cargo check --workspace --all-targets + + clippy: + name: cargo clippy + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: clippy + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: clippy workspace + run: cargo clippy --workspace --all-targets -- -D warnings + + test: + name: cargo test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2 + with: + tool: nextest + - name: cargo nextest run workspace + run: cargo nextest run --workspace + - name: cargo test doc + # NOTE: nextest does not execute doctests. + run: cargo test --workspace --doc diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..35e0dcd --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,86 @@ +# WHY: Block merges on known vulnerabilities and license/source/ban +# violations. Runs `cargo audit` (RustSec DB), `cargo deny check` +# (licenses, sources, bans, advisories), and OSV-Scanner on every PR and +# daily against main so newly-published CVEs against existing deps are caught +# even when no PR is open. +name: Security + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + # NOTE: Daily at 05:23 CST (11:23 UTC). Off-peak minute per global + # rule about avoiding :00/:30 cron schedules. + - cron: "23 11 * * *" + workflow_dispatch: + +# PROJECT: explicit top-level default deny; job-level permissions grant only what's needed +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + cargo-deny: + # WHY: checks licenses, banned crates, source registries, and the + # RustSec advisory DB using deny.toml ignore list. Any finding fails + # the job -- suppressions require a deny.toml entry with a WHY comment, + # not silent --ignore flags. + name: cargo deny + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false # PROJECT: security hardening -- never expose token to steps + - uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20 + with: + # WHY: run every check explicitly so a schema regression in one + # section can't silently disable the others. cargo-deny expects + # the check subcommand followed by space-separated check names; + # `arguments:` passes post-subcommand flags only. + command: check advisories licenses bans sources + arguments: --all-features + + cargo-audit: + # WHY: cargo-deny advisories check overlaps but uses a different code + # path; running cargo-audit independently protects against bugs or + # config drift disabling one of the two. + name: cargo audit + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + # WHY: install a prebuilt cargo-audit binary rather than source-building + # with this crate's pinned toolchain -- that decouples the scanner's + # MSRV (cargo-audit 0.22.2 requires Rust >=1.88) from the audited + # crate's MSRV. The exact scanner version is pinned and deliberately + # reviewed, never floated. + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: cargo-audit@0.22.2 + - name: cargo audit + # WHY: -D unmaintained,unsound,yanked escalates those categories + # to errors (default is warning only). Suppressions go through + # deny.toml advisories.ignore entries -- no silent --ignore flags here. + run: cargo audit --deny unmaintained --deny unsound --deny yanked + + osv-scanner: + name: osv scanner + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 + permissions: + actions: read + contents: read + security-events: write + with: + scan-args: '--config=osv-scanner.toml --lockfile=Cargo.lock' diff --git a/.kanon-ci.toml b/.kanon-ci.toml index 252def1..247f52a 100644 --- a/.kanon-ci.toml +++ b/.kanon-ci.toml @@ -2,18 +2,9 @@ # forge-only: false # spdx: LicenseRef-PolyForm-Shield-1.0.0 -# Pinax CI pipeline. -# -# Pinax is docs-only today (design phase) - no Cargo.toml, no -# crates, nothing for the default Rust gate to exercise. The docs-phase -# gate keeps CI honest with real checks that do not require a Rust -# workspace: the canonical whole-repository lint, plus the README -# writing check on top of it. -# -# The workspace lands at Phase 01 (pager / buffer pool / B-tree). -# When code lands, replace `pipeline.stages` with the full Rust gate: -# -# stages = ["cargo fmt", "cargo check", "cargo clippy", "cargo nextest", "kanon lint"] +# NOTE: Pinax CI pipeline -- full Rust gate (fmt / check / clippy / nextest) +# over the four-crate workspace, plus the repo-wide and README-prose kanon +# lint passes. # WHY(#1) `kanon lint .` and not the two scoped commands it replaces: the # pipeline previously ran `kanon lint --workflow .kanon-ci.toml` and @@ -23,8 +14,29 @@ # least likely to have drifted and excluded the ones that had. The # whole-repo lint subsumes the workflow check; the README writing check stays # because `--writing` applies prose rules the default pass does not. +# +# WHY --jobs 8 / --build-jobs 8 --test-threads 8: kanon-ci concurrency caps +# (basanos CI.md) -- the fleet budget for one CI run is ~25GB RSS; uncapped +# cargo/nextest use host CPU count and can exceed it under parallel rustc +# plus test workers. [pipeline] -stages = ["repo-lint", "readme-lint"] +stages = ["cargo fmt", "cargo check", "cargo clippy", "cargo nextest", "repo-lint", "readme-lint"] + +[stages."cargo fmt"] +cmd = "cargo fmt --all -- --check" +timeout_secs = 300 + +[stages."cargo check"] +cmd = "cargo check --workspace --all-targets --jobs 8" +timeout_secs = 600 + +[stages."cargo clippy"] +cmd = "cargo clippy --workspace --all-targets --jobs 8 -- -D warnings" +timeout_secs = 600 + +[stages."cargo nextest"] +cmd = "cargo nextest run --workspace --build-jobs 8 --test-threads 8" +timeout_secs = 600 [stages.repo-lint] cmd = "kanon lint ." @@ -34,9 +46,7 @@ timeout_secs = 120 cmd = "kanon lint --writing README.md" timeout_secs = 30 -# WHY disabled: the independent verifier's default build probe -# (`cargo check --all-features --tests`) fails structurally against a -# repo with no Cargo workspace and reports a false divergence. -# Re-enable when the pipeline grows real Rust stages to reproduce. -[verifier] -enabled = false +# NOTE: [verifier] omitted -- the workspace now exists, so the independent +# verifier's default build probe reproduces cleanly; the prior explicit +# `enabled = false` (docs-only repo, no Cargo workspace to probe) no longer +# applies. Absent section defaults to enabled per basanos VerifierConfig. diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 0000000..59aa130 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,5 @@ +# WHY: This file is DERIVED from deny.toml [[advisories.ignore]]. +# Do not edit the ignore list here; edit deny.toml and run +# `kanon audit derive-ignores --apply`. See standards/SUPPLY-CHAIN.md. + +IgnoredVulns = [] diff --git a/release-please-config.json b/release-please-config.json index d8687f0..cacbd4e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -17,7 +17,34 @@ ], "packages": { ".": { - "changelog-path": "CHANGELOG.md" + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "toml", + "path": "Cargo.toml", + "jsonpath": "$.workspace.package.version" + }, + { + "type": "toml", + "path": "crates/hypomnema/Cargo.toml", + "jsonpath": "$.dependencies[?(@.path)].version" + }, + { + "type": "toml", + "path": "crates/phylaxis/Cargo.toml", + "jsonpath": "$.dependencies[?(@.path)].version" + }, + { + "type": "toml", + "path": "crates/pinax/Cargo.toml", + "jsonpath": "$.dependencies[?(@.path)].version" + }, + { + "type": "toml", + "path": "Cargo.lock", + "jsonpath": "$.package[?(!@.source)].version" + } + ] } } } From 238159a241051a4b97da72500cb692bbd3e90f56 Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 19:58:30 -0500 Subject: [PATCH 4/7] fix(lexis): drop redundant #[must_use] on Result-returning functions clippy::double_must_use fired on ColumnDef::new, ColumnDef::check_value, TableDef::new, and SqlType::check_comparable: each carried an explicit #[must_use] on top of a Result<_, LexisError> return, and Result is already #[must_use] at the type level under -D warnings. RUST.md's "must_use on every public Result-returning fn" rule is what produced these -- tracked as kanon#3473, unresolved. Until it lands, drop the redundant attribute; Result's type-level must_use already carries the guarantee the rule was reaching for. #[must_use] stays on builder methods and pure non-Result functions, which are correct and were never flagged. --- crates/lexis/src/schema.rs | 3 --- crates/lexis/src/types.rs | 1 - 2 files changed, 4 deletions(-) diff --git a/crates/lexis/src/schema.rs b/crates/lexis/src/schema.rs index 0ee9c0c..afe9c07 100644 --- a/crates/lexis/src/schema.rs +++ b/crates/lexis/src/schema.rs @@ -82,7 +82,6 @@ impl ColumnDef { /// /// Returns [`LexisError::TextMaxLenOnNonText`] if `text_max_len` is /// `Some` and `sql_type` is not [`SqlType::Text`]. - #[must_use] pub fn new( name: ColumnName, sql_type: SqlType, @@ -145,7 +144,6 @@ impl ColumnDef { /// Returns [`LexisError::TextTooLong`] if `value` is `TEXT`, this /// column declares a `text_max_len`, and the value's character count /// exceeds it. - #[must_use] pub fn check_value(&self, value: &Value) -> Result<(), LexisError> { let actual_type = match value { Value::Null => { @@ -217,7 +215,6 @@ impl TableDef { /// /// Returns [`LexisError::DuplicateColumn`] if any two columns share a /// name. - #[must_use] pub fn new(name: TableName, columns: Vec) -> Result { ensure!( !columns.is_empty(), diff --git a/crates/lexis/src/types.rs b/crates/lexis/src/types.rs index 43f618f..d498713 100644 --- a/crates/lexis/src/types.rs +++ b/crates/lexis/src/types.rs @@ -55,7 +55,6 @@ impl SqlType { /// /// Returns [`LexisError::IncomparableTypes`] if `self` and `other` are /// different types other than the `Integer`/`Real` pairing. - #[must_use] pub fn check_comparable(self, other: Self) -> Result<(), LexisError> { let compatible = self == other || matches!( From 6cc5810f2723e47e6ce63eca6425d54d54a3cef7 Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 19:58:46 -0500 Subject: [PATCH 5/7] fix(deny): allow pinax's own licence, scope the once_cell ban to tempfile deny.toml was copied from heurema (MPL-2.0) without adapting two entries to pinax's own posture: - licenses.allow was missing LicenseRef-PolyForm-Shield-1.0.0, pinax's own declared workspace licence (Cargo.toml). cargo-deny rejected all four crates. kanon's own deny.toml already carries this entry. - once_cell v1.21.4 is on the ban list (LazyLock stabilized in 1.80) but arrives transitively via lexis's dev-dependency chain proptest -> tempfile -> once_cell. The ban exists to keep our own code off once_cell, not to forbid every transitive dependency's choice, and tempfile's dependency graph isn't ours to control. `wrappers` scopes the exception to exactly that chain -- once_cell stays denied everywhere else -- rather than dropping the ban or dropping proptest, which the fleet testing standard requires for property coverage here. --- deny.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/deny.toml b/deny.toml index 9545e07..181b516 100644 --- a/deny.toml +++ b/deny.toml @@ -24,6 +24,11 @@ allow = [ "Unicode-DFS-2016", "CC0-1.0", "Zlib", + # WHY: pinax's own declared workspace licence (Cargo.toml + # `license = "LicenseRef-PolyForm-Shield-1.0.0"`). heurema is MPL-2.0 + # and never needed this entry; kanon's deny.toml carries the same + # entry for the same reason and is the correct reference here. + "LicenseRef-PolyForm-Shield-1.0.0", ] confidence-threshold = 0.92 @@ -52,7 +57,8 @@ reason = "std::sync::LazyLock stabilized in 1.80 (kanon RUST.md)" [[bans.deny]] crate = "once_cell" -reason = "std::sync::LazyLock stabilized in 1.80 (kanon RUST.md)" +reason = "std::sync::LazyLock stabilized in 1.80 (kanon RUST.md); this bans OUR code from depending on once_cell directly, not every transitive dependency's choice. once_cell reaches the workspace solely as a direct dependency of tempfile, via lexis's dev-dependency chain proptest -> tempfile -> once_cell; we do not control tempfile's dependency choices. `wrappers` scopes the exception to exactly that chain (once_cell is still denied everywhere else). Drop this exception once tempfile migrates off once_cell upstream, or if proptest's tempfile dependency is ever removed." +wrappers = ["tempfile"] [[bans.deny]] crate = "failure" From ad7d46d54ecf80e677d0f228c58e52ffe259303c Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 19:59:59 -0500 Subject: [PATCH 6/7] fix(lint): clear kanon-lint warnings blocking the gate kanon lint reported 15 warnings on this branch; the gate treats any warning as red. Fixes the 13 this PR introduced and 2 that predate it: - MANIFEST/maturity-description-mismatch x3: hypomnema, phylaxis, and pinax declare maturity = "scaffold" but carried production-tone descriptions. Prefixed each with "(scaffold)" per MANIFEST.md; also dropped "Sovereign" from pinax's description (WRITING/identity-fluff, kanon#2984 -- an identity claim, not a description of what the crate does). - TESTING/no-tests x4: hypomnema, phylaxis, and pinax are empty scaffolds by design at this phase (doc-comment-only lib.rs, zero behavior) -- suppressed via .kanon-lint-ignore, citing each crate's own exit-criteria as the drop condition, rather than writing a tautological test with nothing real to assert. lexis's lib.rs is a different case: it is NOT empty (41 tests), the rule only inspects lib.rs itself and a sibling tests/ dir and cannot see the schema.rs and types.rs submodule tests -- same false-positive shape as kanon's own existing TESTING/no-tests:crates/mnemosyne/src/lib.rs entry. - STORAGE/no-migration-checksum x2: verified against the rule's own implementation (has_ddl bare-matches CREATE/ALTER/DROP TABLE with no requirement that it sit near an execution call, unlike has_migration_runner's identifier-shape requirement). Both lexis files are pure type-vocabulary doc comments naming "CREATE TABLE" as a statement type the crate explicitly does not implement. Genuine false positive -- evidence posted to kanon#2975 rather than filing a duplicate; suppressed via .kanon-lint-ignore referencing that issue (not inline: the violation reports at line 1, which is the doc comment itself, so an inline marker would leak into rendered rustdoc). - TESTING/tautological-test x2: ast.rs's assert_eq! compared two textually-identical Expr::IsNull(column()) calls; named the two independently-constructed instances so the derived-PartialEq check they exercise isn't confused for a self-comparison. types.rs's prop_assert! wrapped a bare bool with no recognized verification macro in the body per the rule's heuristic; switched to prop_assert_eq!, matching the sibling property test one function up. - RUST/import-order x1: value.rs's test module put a crate-local use super::* before the external proptest import; std/external/crate is the required order. - WRITING/identity-fluff x1: covered above (pinax's description). - SHELL/unpinned-action x2 (pre-existing): gate-attestation.yml and release-please.yml called forkwright/.github's reusable workflows at the main branch ref, a mutable ref. Same defect class already fixed in hamma#91 -- pinned both to the current main commit SHA with a WHY and the refresh command. kanon lint . --all: 0 warnings, 0 errors (6 suppressed, all documented above). --- .github/workflows/gate-attestation.yml | 7 +++- .github/workflows/release-please.yml | 7 +++- .kanon-lint-ignore | 50 ++++++++++++++++++++++++++ crates/hypomnema/Cargo.toml | 2 +- crates/lexis/src/ast.rs | 7 +++- crates/lexis/src/types.rs | 2 +- crates/lexis/src/value.rs | 3 +- crates/phylaxis/Cargo.toml | 2 +- crates/pinax/Cargo.toml | 2 +- 9 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 .kanon-lint-ignore diff --git a/.github/workflows/gate-attestation.yml b/.github/workflows/gate-attestation.yml index bb69d22..d4c1415 100644 --- a/.github/workflows/gate-attestation.yml +++ b/.github/workflows/gate-attestation.yml @@ -18,4 +18,9 @@ concurrency: jobs: call: - uses: forkwright/.github/.github/workflows/gate-attestation.yml@main + # WHY pinned to a commit SHA, not @main: a mutable branch ref lets the + # remote workflow's behavior change under an already-merged pinax + # commit. Refresh via: + # gh api repos/forkwright/.github/commits/main --jq '.sha' + # and review the diff at forkwright/.github before bumping. + uses: forkwright/.github/.github/workflows/gate-attestation.yml@df92942bcc41cc7ffd0339b75b2f01e52269d0ef # main diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index ee0525f..0cd3c68 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -23,4 +23,9 @@ permissions: jobs: call: - uses: forkwright/.github/.github/workflows/release-please.yml@main + # WHY pinned to a commit SHA, not @main: a mutable branch ref lets the + # remote workflow's behavior change under an already-merged pinax + # commit. Refresh via: + # gh api repos/forkwright/.github/commits/main --jq '.sha' + # and review the diff at forkwright/.github before bumping. + uses: forkwright/.github/.github/workflows/release-please.yml@df92942bcc41cc7ffd0339b75b2f01e52269d0ef # main diff --git a/.kanon-lint-ignore b/.kanon-lint-ignore new file mode 100644 index 0000000..c5bc92e --- /dev/null +++ b/.kanon-lint-ignore @@ -0,0 +1,50 @@ +# Rules to skip for specific paths +# Format: RULE/name:path/glob + +# ============================================================================= +# crates/lexis/ — false positives, not real defects +# ============================================================================= + +# WHY: lexis's lib.rs is a module-declaration + public re-export file with no +# behavior of its own; its 41 tests live in the schema.rs and types.rs +# submodules (#[cfg(test)] mod tests, plus proptest properties). The +# TESTING/no-tests heuristic only inspects lib.rs itself and a sibling +# tests/ directory, so it cannot see submodule test coverage. Same +# false-positive shape and same fix as the existing +# TESTING/no-tests:crates/mnemosyne/src/lib.rs entry in kanon's own +# .kanon-lint-ignore. +TESTING/no-tests:crates/lexis/src/lib.rs + +# WHY(forkwright/kanon#2975): STORAGE/no-migration-checksum's has_ddl check +# is a bare substring match for CREATE/ALTER/DROP TABLE with no requirement +# that the keyword be co-located with an execution call or migration +# runner — unlike has_migration_runner, which requires an actual +# function/module identifier (kanon#1040 B7 narrowed that path specifically +# to avoid firing on bare-word mentions in doc-comment prose, but has_ddl +# never received the same treatment). Both lexis files here are pure +# type-vocabulary code (#![forbid(unsafe_code)], no DB connection, no SQL +# execution) whose module doc comments explicitly name "CREATE TABLE" as +# one of the future statement types the crate does NOT implement. Evidence +# posted to kanon#2975; drop this entry once that rule fix lands. +STORAGE/no-migration-checksum:crates/lexis/src/ast.rs +STORAGE/no-migration-checksum:crates/lexis/src/lib.rs + +# ============================================================================= +# crates/{hypomnema,phylaxis,pinax}/ — genuinely empty scaffolds +# ============================================================================= + +# WHY: these three crates are empty by design at this phase — lib.rs is +# module-doc-comment-only, zero functions, zero types, zero logic (see each +# crate's [package.metadata.kanon] maturity = "scaffold" in Cargo.toml). A +# #[cfg(test)] module here would have no real behavior to assert against; +# writing one anyway would itself be a tautological/vacuous test (the +# TESTING/tautological-test class this same lint run flags elsewhere), which +# is worse than an honest, exit-criteria-bound suppression. Each crate's own +# Cargo.toml exit-criteria field names the concrete Phase that ends this: +# hypomnema Phase 02 (WAL + virtual-WAL trait + causal changelog), phylaxis +# Phase 03/06 (MVCC + encryption), pinax Phase 01 (pager + buffer pool + +# B-tree) — see kanon/projects/pinax/ROADMAP.md. Drop each entry the moment +# its crate gets real behavior and a real test. +TESTING/no-tests:crates/hypomnema/src/lib.rs +TESTING/no-tests:crates/phylaxis/src/lib.rs +TESTING/no-tests:crates/pinax/src/lib.rs diff --git a/crates/hypomnema/Cargo.toml b/crates/hypomnema/Cargo.toml index 5105982..52e8f44 100644 --- a/crates/hypomnema/Cargo.toml +++ b/crates/hypomnema/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true homepage.workspace = true repository.workspace = true authors.workspace = true -description = "Written-record primitives for pinax: WAL, virtual-WAL trait, and cr-sqlite-shaped causal changelog (not yet implemented)." +description = "(scaffold) Written-record primitives for pinax: WAL, virtual-WAL trait, and cr-sqlite-shaped causal changelog." readme = "../../README.md" keywords = ["sql", "wal", "changelog"] categories = ["data-structures"] diff --git a/crates/lexis/src/ast.rs b/crates/lexis/src/ast.rs index 0d4e687..3bd1a19 100644 --- a/crates/lexis/src/ast.rs +++ b/crates/lexis/src/ast.rs @@ -160,7 +160,12 @@ mod tests { ColumnName::try_from("id").expect("valid identifier"), )) }; - assert_eq!(Expr::IsNull(column()), Expr::IsNull(column())); + // WHY two named bindings: verifies two independently-constructed + // `IsNull` values over equal operands compare equal by structure + // (derived `PartialEq`), not by pointer identity. + let left = Expr::IsNull(column()); + let right = Expr::IsNull(column()); + assert_eq!(left, right); assert_ne!(Expr::IsNull(column()), Expr::IsNotNull(column())); } } diff --git a/crates/lexis/src/types.rs b/crates/lexis/src/types.rs index d498713..572fadd 100644 --- a/crates/lexis/src/types.rs +++ b/crates/lexis/src/types.rs @@ -163,7 +163,7 @@ mod tests { #[test] fn check_comparable_always_accepts_identical_types(sql_type in any_sql_type()) { - proptest::prop_assert!(sql_type.check_comparable(sql_type).is_ok()); + proptest::prop_assert_eq!(sql_type.check_comparable(sql_type).is_ok(), true); } } } diff --git a/crates/lexis/src/value.rs b/crates/lexis/src/value.rs index 32a959c..69cc864 100644 --- a/crates/lexis/src/value.rs +++ b/crates/lexis/src/value.rs @@ -191,13 +191,14 @@ impl From for Value { #[cfg(test)] mod tests { - use super::*; // WHY explicit: `.prop_filter(...)` below is a `Strategy` trait method // called via dot-syntax, which requires the trait in scope even though // every other proptest item here is called through a fully-qualified // path. use proptest::strategy::Strategy as _; + use super::*; + #[test] fn real_value_accepts_finite() { let real = RealValue::try_from(3.5).expect("finite value is valid"); diff --git a/crates/phylaxis/Cargo.toml b/crates/phylaxis/Cargo.toml index 051deca..25240b7 100644 --- a/crates/phylaxis/Cargo.toml +++ b/crates/phylaxis/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true homepage.workspace = true repository.workspace = true authors.workspace = true -description = "Integrity and access guards for pinax: MVCC snapshot isolation, per-page AEAD encryption, and the heurēma index adapter (not yet implemented)." +description = "(scaffold) Integrity and access guards for pinax: MVCC snapshot isolation, per-page AEAD encryption, and the heurēma index adapter." readme = "../../README.md" keywords = ["sql", "mvcc", "encryption"] categories = ["data-structures"] diff --git a/crates/pinax/Cargo.toml b/crates/pinax/Cargo.toml index 6c9e67e..6609d45 100644 --- a/crates/pinax/Cargo.toml +++ b/crates/pinax/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true homepage.workspace = true repository.workspace = true authors.workspace = true -description = "Sovereign relational storage facade for the forkwright fleet: pager, buffer pool, B-tree, SQL surface, async API, migrations, and CLI (not yet implemented)." +description = "(scaffold) Relational storage facade for the forkwright fleet: pager, buffer pool, B-tree, SQL surface, async API, migrations, and CLI." readme = "../../README.md" keywords = ["sql", "database", "storage"] categories = ["database"] From f5db1040fdac5933ddf0cd648b814fcb292b6c0b Mon Sep 17 00:00:00 2001 From: forkwright Date: Sun, 16 Aug 2026 01:13:45 +0000 Subject: [PATCH 7/7] chore(gate): carry the gate stamp for feat/2-phase01-workspace-lexis Gate-Passed: kanon 0.12.0 +stages:fmt,check,clippy,nextest,lint sha:e6789841601ba770c40cf6a50f7ba3eedcc935a6