Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Fallback for pnpm 10.x releases older than 10.17, which predate pnpm-workspace.yaml's
# own settings block -- pnpm-workspace.yaml (saveExact, minimumReleaseAgeExclude) is the
# real source of truth on pnpm 10.17+ and pnpm 11+, where .npmrc is registry/auth-only and
# these two lines are simply ignored.
save-exact=true
minimum-release-age-exclude[]=@exadev/eslint-config
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"scripts": {
"build": "turbo run _build",
"lint": "turbo run _lint",
Expand All @@ -35,7 +35,7 @@
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.2",
"@eslint/js": "^10.0.1",
"@exadev/eslint-config": "^2.10.2",
"@exadev/eslint-config": "2.12.1",
"@exadev/semantic-release-workspace": "^1.2.1",
"@semantic-release/changelog": "^7.0.0",
"@semantic-release/commit-analyzer": "^13.0.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/trilean-regex/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
],
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@exadev/eslint-config": "^2.10.2",
"@exadev/eslint-config": "2.12.1",
"@types/node": "^26.4.0",
"@vitest/coverage-v8": "^4.1.11",
"eslint": "^10.9.1",
Expand Down
6 changes: 2 additions & 4 deletions packages/trilean-regex/src/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,7 @@ export interface CompiledPattern {
* Parses `pattern` (this package's grammar; see README.md) and compiles it to a reference matcher.
*
* Prefer this over `testPattern` when the same pattern will be tested against more than one input: it parses and compiles exactly once, and the returned `CompiledPattern.test` is then only the O(length x states) simulation.
*
* @throws {RegexParseError} if `pattern` is not a valid pattern in this grammar.
* @throws `RegexParseError` if `pattern` is not a valid pattern in this grammar.
*/
export function compilePattern(pattern: string): CompiledPattern {
const nfa = compileToNfa(parseRegex(pattern));
Expand All @@ -98,8 +97,7 @@ export function compilePattern(pattern: string): CompiledPattern {

/**
* Parses `pattern` and tests it against `input` in one call. Equivalent to `compilePattern(pattern).test(input)`, and exactly as cheap for a single test -- prefer `compilePattern` instead when the same pattern will be reused, so the parse and compile steps happen once rather than once per input.
*
* @throws {RegexParseError} if `pattern` is not a valid pattern in this grammar.
* @throws `RegexParseError` if `pattern` is not a valid pattern in this grammar.
*/
export function testPattern(pattern: string, input: string): boolean {
return compilePattern(pattern).test(input);
Expand Down
3 changes: 1 addition & 2 deletions packages/trilean-regex/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,7 @@ class Parser {
* Parses a pattern in this package's grammar (see README.md) into a `RegexNode` AST.
*
* Never returns a partial result: a pattern either parses completely, with nothing left over, or this throws `RegexParseError` naming exactly where parsing could not proceed.
*
* @throws {RegexParseError} if `pattern` is not a complete, valid pattern in this grammar.
* @throws `RegexParseError` if `pattern` is not a complete, valid pattern in this grammar.
*/
export function parseRegex(pattern: string): RegexNode {
const parser = new Parser(pattern);
Expand Down
2 changes: 1 addition & 1 deletion packages/trilean-sql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@electric-sql/pglite": "^0.5.8",
"@exadev/eslint-config": "^2.10.2",
"@exadev/eslint-config": "2.12.1",
"@testcontainers/postgresql": "^12.1.0",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^26.4.0",
Expand Down
7 changes: 3 additions & 4 deletions packages/trilean-sql/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,9 @@ function compilePredicate(
* Three-valued logic is not reimplemented on top of SQL; it is delegated to it. SQL's `AND`, `OR` and `NOT` over `TRUE`/`FALSE`/`NULL` are Kleene's strong three-valued tables, which are the same tables trilean's own `combineAnd`, `combineOr` and `not` implement, and a comparison against a NULL column yields `NULL` exactly where the evaluator would have returned `indeterminate` from an unresolved reference. A row excluded by `WHERE` because its condition was unknown is therefore excluded for the same reason, and by the same rule, as a subject the evaluator declines to judge. No indeterminacy column, sentinel value or `CASE` scaffolding is emitted, because none is needed.
*
* Every caller-supplied literal becomes a bind parameter. Nothing but structure, operators, and quoted column identifiers is ever written into the returned `sql`.
*
* @throws {UnknownDialectError} if `options.dialect` names a dialect this version does not implement.
* @throws {UnsupportedNodeError} if any node in the tree is one this compiler will not translate -- see `findUnpushableNodeKind`, which this runs first and which a caller can run itself to choose between pushdown and in-process evaluation without provoking an exception.
* @throws {InvalidColumnError} if `columnFor` returns a column that cannot be rendered as an identifier.
* @throws `UnknownDialectError` if `options.dialect` names a dialect this version does not implement.
* @throws `UnsupportedNodeError` if any node in the tree is one this compiler will not translate -- see `findUnpushableNodeKind`, which this runs first and which a caller can run itself to choose between pushdown and in-process evaluation without provoking an exception.
* @throws `InvalidColumnError` if `columnFor` returns a column that cannot be rendered as an identifier.
*/
export function compilePredicateNode(
node: PredicateNode,
Expand Down
3 changes: 1 addition & 2 deletions packages/trilean-sql/src/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,8 +467,7 @@ function findUnpushablePredicate(
* Passing `options` widens the check: without them the walk is purely structural, and with them it also applies the operand-kind rules that depend on each mapped column's declared `paramType` (and therefore calls `columnFor`).
*
* Which nodes are refused does not depend on the dialect -- every divergence refused here is real in both -- so a structural walk given no `options` refuses exactly what a walk given them would. What `options` also settles is which engine each `reason` describes; with none to read a dialect from, the reasons describe PostgreSQL, the dialect these refusals were first derived against.
*
* @throws {UnknownDialectError} if `options` names a dialect this version does not implement. Reporting a tree as pushable is a promise that `compilePredicateNode` will compile it, and under a dialect that does not exist it cannot -- so this is refused here rather than left to surface from the compiler, which is the one caller this function's answer is for.
* @throws `UnknownDialectError` if `options` names a dialect this version does not implement. Reporting a tree as pushable is a promise that `compilePredicateNode` will compile it, and under a dialect that does not exist it cannot -- so this is refused here rather than left to surface from the compiler, which is the one caller this function's answer is for.
*/
export function findUnpushableNodeKind(
node: PredicateNode,
Expand Down
6 changes: 2 additions & 4 deletions packages/trilean-sql/src/portable-pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ function renderPostgresSubcomponent(node: RegexNode): string {
* Translates a `trilean-regex` AST into PostgreSQL's own Advanced Regular Expression syntax, for binding as the pattern argument to `~`/`!~`.
*
* Every construct this grammar accepts translates structurally, `.` included: measured directly against a real PostgreSQL 17 server (see the "PostgreSQL's own engine treats '.' as matching a newline" case in `test/integration/postgres.test.ts`), PostgreSQL's `.` matches a newline by default in the mode this compiler's `~`/`!~` operators run under -- the same as this grammar's own `.` (see trilean-regex's README) -- so no rewriting is needed here. (PostgreSQL's own prose documentation reads, in isolation, as though newline-sensitive matching were the default; it is not the one this operator uses, and the fact was confirmed against the real server rather than trusted from the prose alone -- see the "Verify this assumption" principle this compiler otherwise applies to every other construct too.)
*
* @throws {PortablePatternUnsupportedError} if a bounded repetition exceeds PostgreSQL's own 0-255 bound limit.
* @throws `PortablePatternUnsupportedError` if a bounded repetition exceeds PostgreSQL's own 0-255 bound limit.
*/
export function renderPostgresPattern(node: RegexNode): string {
switch (node.kind) {
Expand Down Expand Up @@ -243,8 +242,7 @@ function planGlobBody(node: RegexNode): GlobPlan {

/**
* Translates a `trilean-regex` AST into a `GLOB` pattern, for binding as `GLOB`/`NOT GLOB`'s right-hand argument. See the "reachable subset" doc comment above for exactly what this does and does not translate.
*
* @throws {PortablePatternUnsupportedError} if `node` uses any construct outside the reachable subset.
* @throws `PortablePatternUnsupportedError` if `node` uses any construct outside the reachable subset.
*/
export function renderSqliteGlobPattern(node: RegexNode): string {
const plan = planGlobBody(node);
Expand Down
2 changes: 1 addition & 1 deletion packages/trilean/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@cloudflare/vitest-pool-workers": "^0.22.0",
"@exadev/eslint-config": "^2.10.2",
"@exadev/eslint-config": "2.12.1",
"@types/node": "^26.4.0",
"@vitest/coverage-v8": "^4.1.11",
"canonicalize": "^4.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/trilean/src/complex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
complexMagnitude,
complexPhase,
} from "./complex";
import { evaluateValue } from "./evaluator";
import { evaluateValue } from "./evaluator-factory";
import type { Resolvers } from "./resolvers";
import { ExpressionNodeSchema } from "./tree";

Expand Down
2 changes: 1 addition & 1 deletion packages/trilean/src/computed-value.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";

/** Dimension symbol -> exponent, e.g. `{ m: 1, s: -1 }` for metres per second. A bare symbol like `"kg"` is shorthand for `{ kg: 1 }`. */
/** Dimension symbol -\> exponent, e.g. `{ m: 1, s: -1 }` for metres per second. A bare symbol like `"kg"` is shorthand for `{ kg: 1 }`. */
export const UnitSchema = z.record(z.string(), z.number());
export type Unit = z.infer<typeof UnitSchema>;

Expand Down
2 changes: 1 addition & 1 deletion packages/trilean/src/derived-aggregates.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { average, count, sum } from "./derived-aggregates";
import { evaluateValue } from "./evaluator";
import { evaluateValue } from "./evaluator-factory";
import type { Evaluation } from "./evaluation";
import type { ComputedValue } from "./computed-value";
import type { ExpressionNode, PredicateNode } from "./tree";
Expand Down
2 changes: 1 addition & 1 deletion packages/trilean/src/derived-connectives.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { evaluatePredicate } from "./evaluator";
import { evaluatePredicate } from "./evaluator-factory";
import {
and,
iff,
Expand Down
2 changes: 1 addition & 1 deletion packages/trilean/src/derived-patterns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
prefixPattern,
wildcardPattern,
} from "./derived-patterns";
import { createEvaluator, evaluatePredicate } from "./evaluator";
import { createEvaluator, evaluatePredicate } from "./evaluator-factory";
import type { PredicateNode } from "./tree";
import type { Resolvers } from "./resolvers";

Expand Down
2 changes: 1 addition & 1 deletion packages/trilean/src/derived-values.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { coalesce } from "./derived-values";
import { evaluateValue } from "./evaluator";
import { evaluateValue } from "./evaluator-factory";
import type { Evaluation } from "./evaluation";
import type { ComputedValue } from "./computed-value";
import type { ExpressionNode } from "./tree";
Expand Down
49 changes: 49 additions & 0 deletions packages/trilean/src/evaluator-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Evaluation } from "./evaluation";
import { indeterminate } from "./evaluation";

/** A defense-in-depth guard against a long acyclic `treeReference` chain exhausting the call stack -- distinct from, and layered on top of, the cycle detector in evaluator.ts (`visitedTreeKeys`), which catches an actual repeat immediately and more precisely. */
export const MAX_TREE_REFERENCE_DEPTH = 100;

/** Default cap on the total number of predicate/expression nodes a single `evaluatePredicate`/`evaluateValue` call may visit -- see `createEvaluator`'s `maxNodes` option, threaded through to `createEvaluationBudget` below. Chosen generously above any realistic hand-authored business-rule, eligibility, or formula tree (see README.md's own consumer use cases), while still bounding an untrusted, adversarially-authored tree's total evaluation cost to a fixed, small amount of work regardless of how it is shaped. */
export const DEFAULT_MAX_EVALUATION_NODES = 10_000;

/** Default cap on ordinary recursive-descent nesting depth -- see `createEvaluator`'s `maxNestingDepth` option, threaded through to `createEvaluationBudget` below. Set comfortably below the depth at which this evaluator's own recursive descent has been observed to exhaust the host call stack (a chain of plain `not` nodes wrapped around a single leaf, evaluated directly against this file under Node.js, failed consistently somewhere in the low thousands of nesting levels, with some run-to-run variance from whatever else already occupied the stack), while remaining far deeper than any legitimate hand-authored tree is ever likely to nest. Kept well clear of that measured failure point because the exact threshold varies by host runtime (a Cloudflare Workers isolate's own stack is smaller than Node's) and by how much of the stack the rest of the call chain has already used. */
export const DEFAULT_MAX_NESTING_DEPTH = 500;

/**
* A single `evaluatePredicate`/`evaluateValue` call's resource limits, independent of and layered underneath `MAX_TREE_REFERENCE_DEPTH`'s own cross-tree chain guard above: that guard only advances at an actual `treeReference` resolution and says nothing about a plain, self-contained tree built from ordinary `and`/`or`/`fold`/`conditional`/quantifier nesting, with no `treeReference` node anywhere in it. This closes that gap for a consumer evaluating a tree it did not author and cannot fully trust before evaluation (e.g. a tree embedded in a signed but otherwise attacker-controlled payload).
*
* Exposed as a single `checkNode` method rather than a raw mutable counter, following the same all-callback shape `Resolvers` and `FunctionRegistry` use in evaluator.ts: every recursive call site threads this object through as `Readonly<EvaluationBudget>`, and that wrapper genuinely prevents tampering because the running node count lives in `createEvaluationBudget`'s own closure, never as an assignable property on the object itself.
*/
export interface EvaluationBudget {
/** Charges one node visit and checks both caps, returning the `Evaluation` to return immediately if either is now exceeded, or `undefined` if evaluation of this node may proceed. Called as the very first action inside `evaluatePredicateInternal`/`evaluateValueInternal`, before any of that node's own work or further recursion, so an exceeded budget is discovered before it can be spent on additional descent. */
checkNode: (nestingDepth: number) => Evaluation<never> | undefined;
}

/** Constructs a fresh `EvaluationBudget` for one top-level `evaluatePredicate`/`evaluateValue` call -- see `createEvaluator`'s `maxNodes`/`maxNestingDepth` options, which supply `maxNodes`/`maxNestingDepth` here. A fresh closure per call is what keeps `nodesVisited` from leaking between unrelated evaluations. */
export function createEvaluationBudget(
maxNodes: number,
maxNestingDepth: number,
): EvaluationBudget {
/** Total predicate/expression nodes visited so far across the whole call -- shared by every branch of an `and`/`or`/`allOf`/`anyOf`/fold/quantifier through the closure below, so it accumulates across the whole traversal rather than resetting per branch. */
let nodesVisited = 0;
return {
checkNode(nestingDepth) {
nodesVisited += 1;
if (nodesVisited > maxNodes) {
return indeterminate(
"domain-error",
`evaluation exceeded the maximum of ${maxNodes.toString()} nodes visited in a single call (resource exhausted)`,
);
}
// Distinct from `MAX_TREE_REFERENCE_DEPTH`'s own chain-depth counter: `nestingDepth` advances on every recursive descent into a child predicate/expression node, not only at `treeReference` resolution.
if (nestingDepth >= maxNestingDepth) {
return indeterminate(
"domain-error",
`evaluation nesting depth exceeds the maximum of ${maxNestingDepth.toString()} (resource exhausted)`,
);
}
return undefined;
},
};
}
Loading
Loading