diff --git a/commitlint.config.ts b/commitlint.config.ts index 569891c..800e4e4 100644 --- a/commitlint.config.ts +++ b/commitlint.config.ts @@ -7,6 +7,7 @@ export default { }, // dependabot-auto-merge.yml lands Dependabot's rebase-merged commits on main verbatim, including its generated Bumps/Release notes/Changelog body, which routinely contains a markdown link line over the inherited body-max-line-length limit and cannot be reformatted by this repo. Skip linting entirely for those commits, identified by the "Signed-off-by: dependabot[bot]" trailer that fetch-metadata's auto-merge always appends, while leaving every rule fully enforced for human-authored commits. ignores: [ - (message: string) => /^Signed-off-by: dependabot\[bot\]/m.test(message), + (message: string): boolean => + /^Signed-off-by: dependabot\[bot\]/m.test(message), ], }; diff --git a/eslint.config.ts b/eslint.config.ts index cff2115..acced4a 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,12 +1,15 @@ import { exadevConfig } from "@exadev/eslint-config"; +import { defineConfig } from "eslint/config"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import globals from "globals"; -// Lints the workspace root's own files -- its TypeScript tooling configs and its prose and data -- and nothing else. -// -// packages/** stays ignored here. The package keeps its own eslint.config.ts, because file scoping, tsconfig wiring, and the isomorphism import bans are genuinely per-package concerns; a root run that also walked the package would apply this program's tsconfig to source files it does not contain. -export default exadevConfig( - {}, +/** + * Lints the workspace root's own files -- its TypeScript tooling configs and its prose and data -- and nothing else. + * + * packages/** stays ignored here. The package keeps its own eslint.config.ts, because file scoping, tsconfig wiring, and the isomorphism import bans are genuinely per-package concerns; a root run that also walked the package would apply this program's tsconfig to source files it does not contain. + */ +export default defineConfig( + ...exadevConfig(), { ignores: [ "packages/**", @@ -28,6 +31,7 @@ export default exadevConfig( }, }, { + files: ["**/*.{ts,tsx,mts,cts}"], rules: { "@typescript-eslint/consistent-type-imports": [ "error", diff --git a/lint-staged.config.ts b/lint-staged.config.ts index 5dd9b87..5044bc5 100644 --- a/lint-staged.config.ts +++ b/lint-staged.config.ts @@ -18,6 +18,7 @@ function packageDirectoryOf(repoRelativePath: string): string | undefined { ) { return undefined; } + return `${PACKAGES_DIR}${sep}${second}`; } @@ -35,6 +36,7 @@ function groupByDirectory( existing.push(repoRelativePath); } } + return grouped; } @@ -44,6 +46,7 @@ const config: Configuration = { const pathsRelativeToDirectory = paths.map( (path) => `"${directory === "." ? path : relative(directory, path)}"`, ); + return `pnpm --dir ${directory} exec eslint --fix ${pathsRelativeToDirectory.join(" ")}`; }), }; diff --git a/package.json b/package.json index 91a1fd6..76bf6e4 100644 --- a/package.json +++ b/package.json @@ -1,41 +1,14 @@ { "name": "trilean-monorepo", - "private": true, "description": "Workspace root for the trilean ecosystem. Never published -- it exists to hold the workspace, the task pipeline, the shared tooling configuration, and the release orchestration.", - "type": "module", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/ExaDev/trilean.git" - }, - "homepage": "https://github.com/ExaDev/trilean#readme", "bugs": { "url": "https://github.com/ExaDev/trilean/issues" }, - "engines": { - "node": ">=20" - }, - "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", - "scripts": { - "build": "turbo run _build", - "lint": "turbo run _lint", - "_lint": "eslint . --fix --cache --max-warnings 0", - "typecheck": "turbo run _typecheck _typecheck:attw", - "_typecheck": "tsc -p tsconfig.json", - "test": "turbo run _test", - "test:coverage": "turbo run _test:coverage", - "test:integration": "turbo run _test:integration", - "test:smoke": "turbo run _test:smoke", - "test:workers": "turbo run _test:workers", - "prepush": "turbo run _prepush", - "release": "semantic-release-workspace release --config release-workspace.config.ts", - "prepare": "husky" - }, "devDependencies": { "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@eslint/js": "^10.0.1", - "@exadev/eslint-config": "2.12.1", + "@exadev/eslint-config": "3.0.0", "@exadev/semantic-release-workspace": "3.0.2", "@semantic-release/changelog": "^7.0.0", "@semantic-release/commit-analyzer": "^13.0.1", @@ -56,5 +29,32 @@ "turbo": "^2.11.5", "typescript": "^6.0.3", "typescript-eslint": "^8.68.0" - } + }, + "engines": { + "node": ">=20" + }, + "homepage": "https://github.com/ExaDev/trilean#readme", + "license": "MIT", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/trilean.git" + }, + "scripts": { + "_lint": "eslint . --fix --cache --max-warnings 0", + "_typecheck": "tsc -p tsconfig.json", + "build": "turbo run _build", + "lint": "turbo run _lint", + "prepare": "husky", + "prepush": "turbo run _prepush", + "release": "semantic-release-workspace release --config release-workspace.config.ts", + "test": "turbo run _test", + "test:coverage": "turbo run _test:coverage", + "test:integration": "turbo run _test:integration", + "test:smoke": "turbo run _test:smoke", + "test:workers": "turbo run _test:workers", + "typecheck": "turbo run _typecheck _typecheck:attw" + }, + "type": "module" } diff --git a/packages/trilean-regex/eslint.config.ts b/packages/trilean-regex/eslint.config.ts index 47b246e..7b71fa3 100644 --- a/packages/trilean-regex/eslint.config.ts +++ b/packages/trilean-regex/eslint.config.ts @@ -1,5 +1,6 @@ import { builtinModules } from "node:module"; import { exadevConfig } from "@exadev/eslint-config"; +import { defineConfig } from "eslint/config"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import globals from "globals"; @@ -14,8 +15,8 @@ const nodeBuiltinBaseModules = [ ].sort(); const bareNodeBuiltinPattern = `^(${nodeBuiltinBaseModules.join("|")})(/.*)?$`; -export default exadevConfig( - {}, +export default defineConfig( + ...exadevConfig(), { ignores: ["dist", "coverage", "node_modules", ".turbo"], }, @@ -29,6 +30,7 @@ export default exadevConfig( }, }, { + files: ["**/*.{ts,tsx,mts,cts}"], rules: { "@typescript-eslint/consistent-type-imports": [ "error", diff --git a/packages/trilean-regex/package.json b/packages/trilean-regex/package.json index 713bf18..7a2e7a2 100644 --- a/packages/trilean-regex/package.json +++ b/packages/trilean-regex/package.json @@ -1,17 +1,30 @@ { "name": "trilean-regex", - "version": "1.0.3", "description": "A portable regular-expression grammar -- a true regular language, with a Thompson-construction reference matcher -- so a pattern means the same thing in process and wherever trilean-sql can push it down to a database's own engine.", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/ExaDev/trilean.git", - "directory": "packages/trilean-regex" - }, - "homepage": "https://github.com/ExaDev/trilean/tree/main/packages/trilean-regex", + "version": "1.0.3", "bugs": { "url": "https://github.com/ExaDev/trilean/issues" }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@exadev/eslint-config": "3.0.0", + "@types/node": "^26.4.0", + "@vitest/coverage-v8": "^5.0.0", + "eslint": "^10.9.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "globals": "^17.11.0", + "prettier": "^3.9.6", + "publint": "^0.3.24", + "tsdown": "^0.23.0", + "turbo": "^2.10.12", + "typescript": "^6.0.3", + "typescript-eslint": "^8.68.0", + "vitest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, "exports": { ".": { "types": { @@ -30,63 +43,50 @@ "require": "./dist/*.cjs" } }, - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist" ], + "homepage": "https://github.com/ExaDev/trilean/tree/main/packages/trilean-regex", + "keywords": [ + "finite-automaton", + "isomorphic", + "nfa", + "parser", + "regex", + "regular-expression", + "regular-language", + "trilean" + ], + "license": "MIT", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "publishConfig": { "access": "public", "provenance": true, "registry": "https://registry.npmjs.org/" }, - "sideEffects": false, - "engines": { - "node": ">=20" + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/trilean.git", + "directory": "packages/trilean-regex" }, - "license": "MIT", "scripts": { - "build": "turbo run _build", "_build": "tsdown", - "lint": "turbo run _lint", "_lint": "eslint . --fix --cache --max-warnings 0", - "typecheck": "turbo run _typecheck _typecheck:attw", + "_prepush": "true", + "_test": "vitest run --project unit", + "_test:coverage": "vitest run --project unit --coverage", "_typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", "_typecheck:attw": "attw --pack", + "build": "turbo run _build", + "lint": "turbo run _lint", + "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack", + "prepush": "turbo run _prepush", "test": "turbo run _test", - "_test": "vitest run --project unit", "test:coverage": "turbo run _test:coverage", - "_test:coverage": "vitest run --project unit --coverage", - "prepush": "turbo run _prepush", - "_prepush": "true", - "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack" + "typecheck": "turbo run _typecheck _typecheck:attw" }, - "keywords": [ - "regex", - "regular-expression", - "regular-language", - "finite-automaton", - "nfa", - "parser", - "trilean", - "isomorphic" - ], - "devDependencies": { - "@arethetypeswrong/cli": "^0.18.5", - "@exadev/eslint-config": "2.12.1", - "@types/node": "^26.4.0", - "@vitest/coverage-v8": "^5.0.0", - "eslint": "^10.9.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.6", - "globals": "^17.11.0", - "prettier": "^3.9.6", - "publint": "^0.3.24", - "tsdown": "^0.23.0", - "turbo": "^2.10.12", - "typescript": "^6.0.3", - "typescript-eslint": "^8.68.0", - "vitest": "^5.0.0" - } + "sideEffects": false, + "type": "module", + "types": "./dist/index.d.ts" } diff --git a/packages/trilean-regex/src/ast.ts b/packages/trilean-regex/src/ast.ts index 5c464c7..a7c8b52 100644 --- a/packages/trilean-regex/src/ast.ts +++ b/packages/trilean-regex/src/ast.ts @@ -99,5 +99,6 @@ export function rangeContains(range: CharRange, unit: number): boolean { /** Whether `unit` is a member of a character class, honouring `negated`. */ export function charClassMatches(node: CharClassNode, unit: number): boolean { const inRanges = node.ranges.some((range) => rangeContains(range, unit)); + return node.negated ? !inRanges : inRanges; } diff --git a/packages/trilean-regex/src/ast.test.ts b/packages/trilean-regex/src/ast.unit.test.ts similarity index 100% rename from packages/trilean-regex/src/ast.test.ts rename to packages/trilean-regex/src/ast.unit.test.ts diff --git a/packages/trilean-regex/src/errors.ts b/packages/trilean-regex/src/errors.ts index 949272d..fcbf2cc 100644 --- a/packages/trilean-regex/src/errors.ts +++ b/packages/trilean-regex/src/errors.ts @@ -5,6 +5,7 @@ */ export class RegexParseError extends Error { readonly pattern: string; + readonly index: number; constructor(pattern: string, index: number, reason: string) { diff --git a/packages/trilean-regex/src/matcher.ts b/packages/trilean-regex/src/matcher.ts index 41c29af..a2f61b1 100644 --- a/packages/trilean-regex/src/matcher.ts +++ b/packages/trilean-regex/src/matcher.ts @@ -39,6 +39,7 @@ function closure( break; } } + return result; } @@ -54,6 +55,7 @@ function step( if (state?.kind !== "char") continue; if (state.test(codeUnit)) next.add(state.to); } + return next; } @@ -75,6 +77,7 @@ export function matchesNfa(nfa: CompiledNfa, input: string): boolean { current = closure(nfa, [...stepped], pos + 1, length); if (current.has(nfa.matchState)) return true; } + return false; } @@ -92,6 +95,7 @@ export interface CompiledPattern { */ export function compilePattern(pattern: string): CompiledPattern { const nfa = compileToNfa(parseRegex(pattern)); + return { test: (input: string) => matchesNfa(nfa, input) }; } diff --git a/packages/trilean-regex/src/matcher.test.ts b/packages/trilean-regex/src/matcher.unit.test.ts similarity index 100% rename from packages/trilean-regex/src/matcher.test.ts rename to packages/trilean-regex/src/matcher.unit.test.ts diff --git a/packages/trilean-regex/src/nfa.ts b/packages/trilean-regex/src/nfa.ts index 8a78ac5..741283e 100644 --- a/packages/trilean-regex/src/nfa.ts +++ b/packages/trilean-regex/src/nfa.ts @@ -39,6 +39,7 @@ class NfaBuilder { private add(state: NfaState): number { this.states.push(state); + return this.states.length - 1; } @@ -69,6 +70,7 @@ class NfaBuilder { /** A fragment that matches the empty string unconditionally: both of a split's own branches dangle straight out, so whichever target they are later patched to is reached having consumed nothing. Used for `{0}`/`{0,0}` repetition, the one shape bounded repetition can produce that no other node kind already covers. */ private emptyFrag(): Frag { const idx = this.add({ kind: "split", to1: -1, to2: -1 }); + return { start: idx, out: [ @@ -86,6 +88,7 @@ class NfaBuilder { const parts: RegexNode[] = []; for (let i = 0; i < min - 1; i += 1) parts.push(operand); parts.push({ kind: "plus", operand }); + return this.compileNode({ kind: "concat", operands: parts }); } // {min,max}, both bounded: `min` mandatory copies, then (max - min) independently optional copies in sequence. Concatenated optionals of the same atom already produce exactly the counting language {0..(max-min)} repetitions -- each one independently consumes the atom or skips it, and NFA nondeterminism explores every combination -- so no further node kind is needed for the bounded tail. @@ -95,6 +98,7 @@ class NfaBuilder { parts.push({ kind: "optional", operand }); } if (parts.length === 0) return this.emptyFrag(); + return this.compileNode({ kind: "concat", operands: parts }); } @@ -107,10 +111,12 @@ class NfaBuilder { test: (unit) => unit === codeUnit, to: -1, }); + return { start: idx, out: [{ state: idx, field: "to" }] }; } case "anyChar": { const idx = this.add({ kind: "char", test: () => true, to: -1 }); + return { start: idx, out: [{ state: idx, field: "to" }] }; } case "charClass": { @@ -119,14 +125,17 @@ class NfaBuilder { test: (unit) => charClassMatches(node, unit), to: -1, }); + return { start: idx, out: [{ state: idx, field: "to" }] }; } case "anchorStart": { const idx = this.add({ kind: "anchorStart", to: -1 }); + return { start: idx, out: [{ state: idx, field: "to" }] }; } case "anchorEnd": { const idx = this.add({ kind: "anchorEnd", to: -1 }); + return { start: idx, out: [{ state: idx, field: "to" }] }; } case "concat": { @@ -143,6 +152,7 @@ class NfaBuilder { this.patch(frag.out, next.start); frag = { start: frag.start, out: next.out }; } + return frag; } case "alternation": { @@ -162,6 +172,7 @@ class NfaBuilder { }); frag = { start: splitIdx, out: [...frag.out, ...next.out] }; } + return frag; } case "star": { @@ -172,6 +183,7 @@ class NfaBuilder { to2: -1, }); this.patch(inner.out, splitIdx); + return { start: splitIdx, out: [{ state: splitIdx, field: "to2" }] }; } case "plus": { @@ -182,6 +194,7 @@ class NfaBuilder { to2: -1, }); this.patch(inner.out, splitIdx); + return { start: inner.start, out: [{ state: splitIdx, field: "to2" }] }; } case "optional": { @@ -191,6 +204,7 @@ class NfaBuilder { to1: inner.start, to2: -1, }); + return { start: splitIdx, out: [...inner.out, { state: splitIdx, field: "to2" }], @@ -208,6 +222,7 @@ class NfaBuilder { const frag = this.compileNode(root); const matchState = this.add({ kind: "match" }); this.patch(frag.out, matchState); + return { states: this.states, start: frag.start, matchState }; } } diff --git a/packages/trilean-regex/src/parser.ts b/packages/trilean-regex/src/parser.ts index 4f87181..00dbda5 100644 --- a/packages/trilean-regex/src/parser.ts +++ b/packages/trilean-regex/src/parser.ts @@ -45,6 +45,7 @@ type QuantifierSpec = */ class Parser { private readonly pattern: string; + private pos = 0; constructor(pattern: string) { @@ -73,6 +74,7 @@ class Parser { ); } this.pos += 1; + return char; } @@ -83,6 +85,7 @@ class Parser { this.advance(); laterBranches.push(this.parseSequence()); } + return laterBranches.length === 0 ? firstBranch : { kind: "alternation", branches: [firstBranch, ...laterBranches] }; @@ -97,6 +100,7 @@ class Parser { while (!this.atEnd() && !SEQUENCE_TERMINATORS.has(this.peek() ?? "")) { laterOperands.push(this.parseQuantified()); } + return laterOperands.length === 0 ? firstOperand : { kind: "concat", operands: [firstOperand, ...laterOperands] }; @@ -113,6 +117,7 @@ class Parser { `an anchor ('${atom.kind === "anchorStart" ? "^" : "$"}') cannot be quantified`, ); } + return atom; } const quantifier = this.tryParseQuantifier(); @@ -152,8 +157,10 @@ class Parser { const savedPos = this.pos; const bound = this.tryParseBound(); this.pos = savedPos; + return bound !== undefined; } + return false; } @@ -161,21 +168,26 @@ class Parser { const c = this.peek(); if (c === "*") { this.advance(); + return { kind: "star" }; } if (c === "+") { this.advance(); + return { kind: "plus" }; } if (c === "?") { this.advance(); + return { kind: "optional" }; } if (c === "{") { const bound = this.tryParseBound(); if (bound === undefined) return undefined; + return { kind: "repeat", min: bound.min, max: bound.max }; } + return undefined; } @@ -187,10 +199,12 @@ class Parser { private tryParseBound(): { min: number; max: number | undefined } | undefined { const savedPos = this.pos; - this.pos += 1; // consume '{' speculatively + // consume '{' speculatively + this.pos += 1; const minDigits = this.readDigits(); if (minDigits === "") { this.pos = savedPos; + return undefined; } let maxDigits: string | undefined; @@ -202,9 +216,11 @@ class Parser { } if (this.peek() !== "}") { this.pos = savedPos; + return undefined; } - this.pos += 1; // consume '}' + // consume '}' + this.pos += 1; const min = Number.parseInt(minDigits, 10); const max = !hasComma ? min @@ -218,6 +234,7 @@ class Parser { `quantifier range is backwards: {${minDigits}${hasComma ? "," + (maxDigits ?? "") : ""}} would require between ${String(min)} and ${String(max)} repetitions`, ); } + return { min, max }; } @@ -229,6 +246,7 @@ class Parser { digits += c; this.pos += 1; } + return digits; } @@ -243,14 +261,17 @@ class Parser { } if (c === "^") { this.advance(); + return { kind: "anchorStart" }; } if (c === "$") { this.advance(); + return { kind: "anchorEnd" }; } if (c === ".") { this.advance(); + return { kind: "anyChar" }; } if (c === "(") return this.parseGroup(); @@ -264,12 +285,14 @@ class Parser { ); } this.advance(); + return { kind: "literal", codeUnit: c.charCodeAt(0) }; } private parseGroup(): RegexNode { const start = this.pos; - this.advance(); // '(' + // '(' + this.advance(); if (this.peek() === "?" && this.peek(1) === ":") { this.advance(); this.advance(); @@ -289,12 +312,14 @@ class Parser { ); } this.advance(); + return inner; } private parseEscapedAtom(): RegexNode { const backslashPos = this.pos; - this.advance(); // '\' + // '\' + this.advance(); if (this.atEnd()) { throw new RegexParseError( this.pattern, @@ -326,7 +351,8 @@ class Parser { private parseCharClass(): RegexNode { const start = this.pos; - this.advance(); // '[' + // '[' + this.advance(); let negated = false; if (this.peek() === "^") { this.advance(); @@ -354,6 +380,7 @@ class Parser { } ranges.push(...this.parseClassItem()); } + return { kind: "charClass", negated, ranges }; } @@ -388,6 +415,7 @@ class Parser { `unknown escape sequence '\\${escaped}'`, ); } + return this.parseOptionalRangeFrom(literal.charCodeAt(0)); } if (c === undefined) { @@ -398,6 +426,7 @@ class Parser { ); } this.advance(); + return this.parseOptionalRangeFrom(c.charCodeAt(0)); } @@ -408,7 +437,8 @@ class Parser { this.peek(1) !== "]" ) { const hyphenPos = this.pos; - this.advance(); // '-' + // '-' + this.advance(); const toCode = this.parseClassRangeEndpoint(); if (toCode < fromCode) { throw new RegexParseError( @@ -417,8 +447,10 @@ class Parser { `invalid range in character class: '${String.fromCharCode(fromCode)}-${String.fromCharCode(toCode)}' is reversed`, ); } + return [{ from: fromCode, to: toCode }]; } + return [{ from: fromCode, to: fromCode }]; } @@ -457,6 +489,7 @@ class Parser { `unknown escape sequence '\\${escaped}'`, ); } + return literal.charCodeAt(0); } if (c === undefined) { @@ -467,6 +500,7 @@ class Parser { ); } this.advance(); + return c.charCodeAt(0); } } @@ -487,5 +521,6 @@ export function parseRegex(pattern: string): RegexNode { "unexpected character -- if this was meant literally, escape it with a backslash", ); } + return node; } diff --git a/packages/trilean-regex/src/parser.test.ts b/packages/trilean-regex/src/parser.unit.test.ts similarity index 100% rename from packages/trilean-regex/src/parser.test.ts rename to packages/trilean-regex/src/parser.unit.test.ts diff --git a/packages/trilean-regex/src/shorthand-classes.ts b/packages/trilean-regex/src/shorthand-classes.ts index 927cc51..a768ffe 100644 --- a/packages/trilean-regex/src/shorthand-classes.ts +++ b/packages/trilean-regex/src/shorthand-classes.ts @@ -13,6 +13,7 @@ function range(fromChar: string, toChar: string): CharRange { function single(char: string): CharRange { const codeUnit = codeOf(char); + return { from: codeUnit, to: codeUnit }; } diff --git a/packages/trilean-regex/src/shorthand-classes.test.ts b/packages/trilean-regex/src/shorthand-classes.unit.test.ts similarity index 100% rename from packages/trilean-regex/src/shorthand-classes.test.ts rename to packages/trilean-regex/src/shorthand-classes.unit.test.ts diff --git a/packages/trilean-regex/tsconfig.json b/packages/trilean-regex/tsconfig.json index f4bd317..36d2142 100644 --- a/packages/trilean-regex/tsconfig.json +++ b/packages/trilean-regex/tsconfig.json @@ -1,10 +1,10 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { // Same isomorphism gate packages/trilean uses: no "node" types and a WebWorker lib, so a Node-only global referenced from runtime src/ fails to compile here rather than at some consumer's runtime. Parsing and matching touch no I/O, so there is no reason to reach for a platform API at all. "lib": ["ES2024", "WebWorker"], "types": [] }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/test-support/**/*.ts"] + "exclude": ["src/**/*.test.ts", "src/test-support/**/*.ts"], + "extends": "../../tsconfig.base.json", + "include": ["src"] } diff --git a/packages/trilean-regex/tsconfig.node.json b/packages/trilean-regex/tsconfig.node.json index 5780c40..b6529c9 100644 --- a/packages/trilean-regex/tsconfig.node.json +++ b/packages/trilean-regex/tsconfig.node.json @@ -1,16 +1,16 @@ { - "extends": "./tsconfig.json", "compilerOptions": { + "allowImportingTsExtensions": true, "lib": ["ES2024"], - "types": ["node"], - "allowImportingTsExtensions": true + "types": ["node"] }, + "exclude": [], + "extends": "./tsconfig.json", "include": [ "src/**/*.test.ts", "src/test-support/**/*.ts", "test/**/*.ts", "*.config.ts", "eslint.config.ts" - ], - "exclude": [] + ] } diff --git a/packages/trilean-sql/eslint.config.ts b/packages/trilean-sql/eslint.config.ts index 47b246e..5d3636f 100644 --- a/packages/trilean-sql/eslint.config.ts +++ b/packages/trilean-sql/eslint.config.ts @@ -1,5 +1,6 @@ import { builtinModules } from "node:module"; import { exadevConfig } from "@exadev/eslint-config"; +import { defineConfig } from "eslint/config"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import globals from "globals"; @@ -14,8 +15,8 @@ const nodeBuiltinBaseModules = [ ].sort(); const bareNodeBuiltinPattern = `^(${nodeBuiltinBaseModules.join("|")})(/.*)?$`; -export default exadevConfig( - {}, +export default defineConfig( + ...exadevConfig(), { ignores: ["dist", "coverage", "node_modules", ".turbo"], }, @@ -29,6 +30,7 @@ export default exadevConfig( }, }, { + files: ["**/*.{ts,tsx,mts,cts}"], rules: { "@typescript-eslint/consistent-type-imports": [ "error", @@ -76,5 +78,15 @@ export default exadevConfig( ], }, }, + // The guard's helper keeps the positional signature its call sites already use. + { + files: ["src/guard.ts"], + rules: { "max-params": "off" }, + }, + // Fixture rows are seeded and evaluated one after another so their order is deterministic. + { + files: ["test/integration/*-test-support.ts"], + rules: { "no-await-in-loop": "off" }, + }, eslintPluginPrettierRecommended, ); diff --git a/packages/trilean-sql/package.json b/packages/trilean-sql/package.json index d50baf0..b7393f2 100644 --- a/packages/trilean-sql/package.json +++ b/packages/trilean-sql/package.json @@ -1,17 +1,40 @@ { "name": "trilean-sql", - "version": "2.2.4", "description": "Compiles a trilean predicate tree into a parameterised SQL WHERE fragment, pushing three-valued logic down onto the database's own NULL propagation instead of evaluating it in process.", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/ExaDev/trilean.git", - "directory": "packages/trilean-sql" - }, - "homepage": "https://github.com/ExaDev/trilean/tree/main/packages/trilean-sql", + "version": "2.2.4", "bugs": { "url": "https://github.com/ExaDev/trilean/issues" }, + "dependencies": { + "trilean": "1.7.1", + "trilean-regex": "1.0.3" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@electric-sql/pglite": "^0.5.8", + "@exadev/eslint-config": "3.0.0", + "@testcontainers/postgresql": "^12.1.0", + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^26.4.0", + "@types/pg": "^8.15.6", + "@vitest/coverage-v8": "^5.0.0", + "better-sqlite3": "^13.0.3", + "eslint": "^10.9.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "globals": "^17.11.0", + "pg": "^8.16.3", + "prettier": "^3.9.6", + "publint": "^0.3.24", + "tsdown": "^0.23.0", + "turbo": "^2.10.12", + "typescript": "^6.0.3", + "typescript-eslint": "^8.68.0", + "vitest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, "exports": { ".": { "types": { @@ -30,76 +53,53 @@ "require": "./dist/*.cjs" } }, - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist" ], + "homepage": "https://github.com/ExaDev/trilean/tree/main/packages/trilean-sql", + "keywords": [ + "postgres", + "predicate", + "predicate-pushdown", + "query-builder", + "rules-engine", + "sql", + "sqlite", + "three-valued-logic", + "trilean" + ], + "license": "MIT", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "publishConfig": { "access": "public", "provenance": true, "registry": "https://registry.npmjs.org/" }, - "sideEffects": false, - "engines": { - "node": ">=20" - }, - "license": "MIT", - "dependencies": { - "trilean": "1.7.1", - "trilean-regex": "1.0.3" + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/trilean.git", + "directory": "packages/trilean-sql" }, "scripts": { - "build": "turbo run _build", "_build": "tsdown", - "lint": "turbo run _lint", "_lint": "eslint . --fix --cache --max-warnings 0", - "typecheck": "turbo run _typecheck _typecheck:attw", + "_prepush": "true", + "_test": "vitest run --project unit", + "_test:coverage": "vitest run --project unit --coverage", + "_test:integration": "vitest run --project integration", "_typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", "_typecheck:attw": "attw --pack", + "build": "turbo run _build", + "lint": "turbo run _lint", + "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run test:integration && pnpm run build && publint && attw --pack", + "prepush": "turbo run _prepush", "test": "turbo run _test", - "_test": "vitest run --project unit", "test:coverage": "turbo run _test:coverage", - "_test:coverage": "vitest run --project unit --coverage", "test:integration": "turbo run _test:integration", - "_test:integration": "vitest run --project integration", - "prepush": "turbo run _prepush", - "_prepush": "true", - "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run test:integration && pnpm run build && publint && attw --pack" + "typecheck": "turbo run _typecheck _typecheck:attw" }, - "keywords": [ - "sql", - "postgres", - "sqlite", - "predicate", - "three-valued-logic", - "query-builder", - "predicate-pushdown", - "trilean", - "rules-engine" - ], - "devDependencies": { - "@arethetypeswrong/cli": "^0.18.5", - "@electric-sql/pglite": "^0.5.8", - "@exadev/eslint-config": "2.12.1", - "@testcontainers/postgresql": "^12.1.0", - "@types/better-sqlite3": "^9.6.0", - "@types/node": "^26.4.0", - "@types/pg": "^8.15.6", - "@vitest/coverage-v8": "^5.0.0", - "better-sqlite3": "^13.0.3", - "eslint": "^10.9.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.6", - "globals": "^17.11.0", - "pg": "^8.16.3", - "prettier": "^3.9.6", - "publint": "^0.3.24", - "tsdown": "^0.23.0", - "turbo": "^2.10.12", - "typescript": "^6.0.3", - "typescript-eslint": "^8.68.0", - "vitest": "^5.0.0" - } + "sideEffects": false, + "type": "module", + "types": "./dist/index.d.ts" } diff --git a/packages/trilean-sql/src/compile-test-helpers.ts b/packages/trilean-sql/src/compile-test-helpers.ts index 360755e..5fcad84 100644 --- a/packages/trilean-sql/src/compile-test-helpers.ts +++ b/packages/trilean-sql/src/compile-test-helpers.ts @@ -1,17 +1,19 @@ import type { PredicateNode } from "trilean"; import { compilePredicateNode } from "./compile"; -import type { SqlCompileOptions } from "./options"; +import type { CompiledSql, SqlCompileOptions } from "./options"; import { subjectOptions } from "./test-support/columns"; /** Compiles against `subjectOptions` by default, so each split `compile.*.test.ts` file below only has to name a dialect/column configuration explicitly when it actually differs from the shared default. */ export function compile( node: PredicateNode, options: Readonly = subjectOptions, -) { +): CompiledSql { return compilePredicateNode(node, options); } -// Named rather than written twice, so each case's expected `params` is the same value the tree was built from rather than a literal that could drift away from it. +/** + * Named rather than written twice, so each case's expected `params` is the same value the tree was built from rather than a literal that could drift away from it. + */ export const ADULT_AGE = 18; export const SAMPLE_AGE = 40; export const EXCLUDED_AGE = 7; diff --git a/packages/trilean-sql/src/compile.dialects.test.ts b/packages/trilean-sql/src/compile.dialects.unit.test.ts similarity index 100% rename from packages/trilean-sql/src/compile.dialects.test.ts rename to packages/trilean-sql/src/compile.dialects.unit.test.ts diff --git a/packages/trilean-sql/src/compile.mechanics.test.ts b/packages/trilean-sql/src/compile.mechanics.unit.test.ts similarity index 100% rename from packages/trilean-sql/src/compile.mechanics.test.ts rename to packages/trilean-sql/src/compile.mechanics.unit.test.ts diff --git a/packages/trilean-sql/src/compile.predicates.test.ts b/packages/trilean-sql/src/compile.predicates.unit.test.ts similarity index 100% rename from packages/trilean-sql/src/compile.predicates.test.ts rename to packages/trilean-sql/src/compile.predicates.unit.test.ts diff --git a/packages/trilean-sql/src/compile.quantifiers.test.ts b/packages/trilean-sql/src/compile.quantifiers.unit.test.ts similarity index 99% rename from packages/trilean-sql/src/compile.quantifiers.test.ts rename to packages/trilean-sql/src/compile.quantifiers.unit.test.ts index 1bcad67..622f2c3 100644 --- a/packages/trilean-sql/src/compile.quantifiers.test.ts +++ b/packages/trilean-sql/src/compile.quantifiers.unit.test.ts @@ -19,6 +19,7 @@ const tagsOptions: SqlCompileOptions = { if (collectionKey !== "tags") { throw new Error(`no collection mapped for '${collectionKey}'`); } + return { table: "tags", join: `"tags"."subjectId" = "subjects"."id"`, diff --git a/packages/trilean-sql/src/compile.ts b/packages/trilean-sql/src/compile.ts index 10067aa..3d03b4b 100644 --- a/packages/trilean-sql/src/compile.ts +++ b/packages/trilean-sql/src/compile.ts @@ -87,6 +87,7 @@ function placeholder( castTo: SqlParamType, ): string { context.params.push(value); + return context.dialect.placeholder(context.params.length, castTo); } @@ -109,6 +110,7 @@ function quoteColumn( : "a dot-separated segment is empty", ); } + return segments .map((segment) => `"${segment.replaceAll('"', '""')}"`) .join("."); @@ -130,6 +132,7 @@ function quoteTable(collectionKey: string, table: string): string { : "a dot-separated segment is empty", ); } + return segments .map((segment) => `"${segment.replaceAll('"', '""')}"`) .join("."); @@ -142,6 +145,7 @@ function bindingOf( if (node.kind !== "reference" || typeof node.key !== "string") { return undefined; } + return { referenceKey: node.key, binding: context.options.columnFor(node.key), @@ -192,6 +196,7 @@ function compileCollection( node.filter === undefined ? undefined : compilePredicate(node.filter, itemContext); + return { table, join: binding.join, itemContext, filterSql }; } @@ -212,6 +217,7 @@ function compilePortablePattern( context.options.dialect === "postgres" ? renderPostgresPattern(ast) : renderSqliteGlobPattern(ast); + return placeholder(context, translated, "text"); } catch (error) { throw new UnsupportedNodeError({ @@ -230,6 +236,7 @@ function compileExpression( case "reference": { const resolved = bindingOf(context, node); if (resolved === undefined) return refuse(node.kind, "expression"); + return quoteColumn(resolved.referenceKey, resolved.binding); } case "textLiteral": @@ -239,7 +246,8 @@ function compileExpression( return placeholder(context, node.value, PARAM_TYPE_OF_LITERAL[node.kind]); case "fold": { if (node.combiner.mode === "reduce") - return refuse(node.kind, "expression"); // guard already refused this; drift safety net + // guard already refused this; drift safety net + return refuse(node.kind, "expression"); const { table, join, itemContext, filterSql } = compileCollection( node, context, @@ -252,6 +260,7 @@ function compileExpression( `(SELECT ${filterColumn} AS "filter_ok", ${itemValueSql} AS "item_value" FROM ${table} WHERE ${join}) AS "t" ` + `WHERE "filter_ok" IS NULL OR "filter_ok"`; const aggregate = node.combiner.mode === "max" ? "MAX" : "MIN"; + return ( `(SELECT CASE ` + `WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_value" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ` + @@ -270,6 +279,7 @@ function compileExpression( case "treeReference": break; } + return refuse(node.kind, "expression"); } @@ -291,11 +301,13 @@ function compilePredicate( return node.kind === "allOf" ? "(TRUE)" : "(FALSE)"; } const joiner = node.kind === "allOf" ? " AND " : " OR "; + return `(${node.operands.map((operand) => compilePredicate(operand, context)).join(joiner)})`; } case "compare": { const left = compileExpression(node.left, context); const right = compileExpression(node.right, context); + return `(${left} ${COMPARISON_SQL[node.op]} ${right})`; } case "textCompare": { @@ -304,6 +316,7 @@ function compilePredicate( node.op === "portableMatches" || node.op === "portableNotMatches" ? compilePortablePattern(node, context) : compileExpression(node.right, context); + return `(${left} ${context.textComparison[node.op]} ${right})`; } case "memberOf": { @@ -311,6 +324,7 @@ function compilePredicate( if (node.candidates.length === 0) { // `IN ()` is a syntax error, and the two constants it would be tempting to fold to are both wrong: an empty `in` is false and an empty `notIn` is true only once the operand itself is known, and stay unknown while it is NULL. These two forms reproduce that exactly -- `NULL IS NULL AND NULL` is NULL while ` IS NULL AND NULL` is FALSE, and the `notIn` form is its mirror image -- which a bare FALSE/TRUE would not, most visibly under a surrounding NOT. The suffix is the dialect's own boolean annotation on that bare NULL, empty for a dialect with no boolean type to annotate. const nullLiteral = `NULL${context.dialect.emptyMemberOfNullSuffix}`; + return node.op === "in" ? `(${operand} IS NULL AND ${nullLiteral})` : `(${operand} IS NOT NULL OR ${nullLiteral})`; @@ -318,6 +332,7 @@ function compilePredicate( const candidates = node.candidates .map((candidate) => compileExpression(candidate, context)) .join(", "); + return `(${operand} ${node.op === "in" ? "IN" : "NOT IN"} (${candidates}))`; } case "exists": @@ -345,6 +360,7 @@ function compilePredicate( `ELSE FALSE END FROM (${participating}) AS "v")` ); } + // The mirror image for `every`'s AND-fold absorption (`combineAnd`): a definite false from any participating row wins outright regardless of any other row's indeterminacy, then an indeterminate participant makes the rest indeterminate, and only once neither has happened -- every vote true, or no participating rows at all, `every`'s own empty-collection identity -- is the result true. return ( `(SELECT CASE ` + @@ -356,6 +372,7 @@ function compilePredicate( case "treeReference": break; } + return refuse(node.kind, "predicate"); } @@ -389,6 +406,7 @@ export function compilePredicateNode( if (cached !== undefined) return cached; const binding = options.columnFor(referenceKey); bindings.set(referenceKey, binding); + return binding; }, sqliteRegexpAvailable: options.sqliteRegexpAvailable, @@ -406,10 +424,12 @@ export function compilePredicateNode( if (cachedColumn !== undefined) return cachedColumn; const columnBinding = binding.columnFor(referenceKey); columnBindings.set(referenceKey, columnBinding); + return columnBinding; }, }; collectionBindings.set(collectionKey, memoisedBinding); + return memoisedBinding; }, }), @@ -425,5 +445,6 @@ export function compilePredicateNode( textComparison: textComparisonSqlFor(dialect), params: [], }; + return { sql: compilePredicate(node, context), params: context.params }; } diff --git a/packages/trilean-sql/src/errors.ts b/packages/trilean-sql/src/errors.ts index 84c73c7..95ff32b 100644 --- a/packages/trilean-sql/src/errors.ts +++ b/packages/trilean-sql/src/errors.ts @@ -15,7 +15,9 @@ export class TrileanSqlError extends Error { */ export class UnsupportedNodeError extends TrileanSqlError { readonly nodeKind: string; + readonly path: string; + readonly reason: string; constructor( @@ -44,6 +46,7 @@ export class UnsupportedNodeError extends TrileanSqlError { */ export class UnknownDialectError extends TrileanSqlError { readonly dialect: string; + readonly implemented: readonly string[]; constructor(dialect: string, implemented: readonly string[]) { @@ -63,6 +66,7 @@ export class UnknownDialectError extends TrileanSqlError { */ export class InvalidColumnError extends TrileanSqlError { readonly referenceKey: string; + readonly column: string; constructor(referenceKey: string, column: string, reason: string) { @@ -82,6 +86,7 @@ export class InvalidColumnError extends TrileanSqlError { */ export class InvalidCollectionTableError extends TrileanSqlError { readonly collectionKey: string; + readonly table: string; constructor(collectionKey: string, table: string, reason: string) { diff --git a/packages/trilean-sql/src/guard.dialect.test.ts b/packages/trilean-sql/src/guard.dialect.unit.test.ts similarity index 100% rename from packages/trilean-sql/src/guard.dialect.test.ts rename to packages/trilean-sql/src/guard.dialect.unit.test.ts diff --git a/packages/trilean-sql/src/guard.quantification.test.ts b/packages/trilean-sql/src/guard.quantification.unit.test.ts similarity index 99% rename from packages/trilean-sql/src/guard.quantification.test.ts rename to packages/trilean-sql/src/guard.quantification.unit.test.ts index fbeb720..09ab2fc 100644 --- a/packages/trilean-sql/src/guard.quantification.test.ts +++ b/packages/trilean-sql/src/guard.quantification.unit.test.ts @@ -13,6 +13,7 @@ describe("quantification over a collection", () => { if (collectionKey !== "tags") { throw new Error(`no collection mapped for '${collectionKey}'`); } + return { table: "tags", join: `"tags"."subjectId" = "subjects"."id"`, diff --git a/packages/trilean-sql/src/guard.references.test.ts b/packages/trilean-sql/src/guard.references.unit.test.ts similarity index 100% rename from packages/trilean-sql/src/guard.references.test.ts rename to packages/trilean-sql/src/guard.references.unit.test.ts diff --git a/packages/trilean-sql/src/guard.supported.test.ts b/packages/trilean-sql/src/guard.supported.unit.test.ts similarity index 100% rename from packages/trilean-sql/src/guard.supported.test.ts rename to packages/trilean-sql/src/guard.supported.unit.test.ts diff --git a/packages/trilean-sql/src/guard.ts b/packages/trilean-sql/src/guard.ts index a5604a0..3abdb33 100644 --- a/packages/trilean-sql/src/guard.ts +++ b/packages/trilean-sql/src/guard.ts @@ -100,6 +100,7 @@ function staticValueKindOf( if (node.kind !== "reference") return STATIC_KIND_OF_LITERAL[node.kind]; if (options === undefined || typeof node.key !== "string") return undefined; const paramType = options.columnFor(node.key).paramType; + return paramType === undefined ? undefined : STATIC_KIND_OF_PARAM_TYPE[paramType]; @@ -139,6 +140,7 @@ function resolveCollectionForGuard( } const binding: Readonly = options.collectionFor(collection); + return { itemOptions: { ...options, columnFor: binding.columnFor }, }; @@ -170,6 +172,7 @@ function findUnpushableExpression( "a reference declaring a unit asserts that the resolved value carries that same unit, and a SQL column carries no unit for that assertion to be checked against", }; } + return undefined; } case "numberLiteral": @@ -185,6 +188,7 @@ function findUnpushableExpression( if (Number.isNaN(node.value)) { return { kind: node.kind, path, reason: divergence.nan }; } + return undefined; case "textLiteral": case "booleanLiteral": @@ -277,6 +281,7 @@ function findUnpushableExpression( reason: `a '${node.combiner.mode}' fold orders participating items by comparing them, and booleans have no ordering in trilean -- whereas ${divergence.booleanOrdering}`, }; } + return undefined; } case "accumulator": @@ -301,6 +306,7 @@ function findUnpushableExpression( default: { // Unreachable while the switch above covers every ExpressionNode kind, and the assertion is what makes a kind added to trilean later a compile error here rather than a silent fall-through. The branch still returns rather than throwing, so a tree built by an older or newer trilean than this package was compiled against is reported as unpushable instead of crashing the walk. node satisfies never; + return { kind: unrecognisedKind, path, @@ -338,6 +344,7 @@ function findKindDivergence( reason: `trilean treats a comparison between a '${first.staticKind}' value (${first.path}) and a '${mismatched.staticKind}' value (${mismatched.path}) as wrong-type, whereas ${divergence.crossKindCoercion}`, }; } + return undefined; } @@ -383,6 +390,7 @@ function findUnpushablePredicate( ); if (unpushable !== undefined) return unpushable; } + return undefined; } case "compare": { @@ -424,6 +432,7 @@ function findUnpushablePredicate( }; } } + return undefined; } case "textCompare": { @@ -497,6 +506,7 @@ function findUnpushablePredicate( }; } } + return undefined; } case "memberOf": { @@ -516,6 +526,7 @@ function findUnpushablePredicate( ); if (unpushable !== undefined) return unpushable; } + return findKindDivergence(node.kind, path, operands, options, divergence); } case "exists": @@ -543,6 +554,7 @@ function findUnpushablePredicate( ); if (unpushableItem !== undefined) return unpushableItem; if (node.filter === undefined) return undefined; + return findUnpushablePredicate( node.filter, `${path}.filter`, @@ -559,6 +571,7 @@ function findUnpushablePredicate( }; default: { node satisfies never; + return { kind: unrecognisedKind, path, @@ -586,6 +599,7 @@ export function findUnpushableNodeKind( ): UnpushableNode | undefined { const dialect = options?.dialect ?? "postgres"; assertImplementedDialect(dialect); + return findUnpushablePredicate( node, "$", diff --git a/packages/trilean-sql/src/portable-pattern.ts b/packages/trilean-sql/src/portable-pattern.ts index 8625ad3..40952ba 100644 --- a/packages/trilean-sql/src/portable-pattern.ts +++ b/packages/trilean-sql/src/portable-pattern.ts @@ -38,11 +38,13 @@ const POSTGRES_MAX_BOUND = 255; function escapePostgresLiteral(codeUnit: number): string { const char = String.fromCharCode(codeUnit); + return POSTGRES_METACHARS.has(char) ? `\\${char}` : char; } function escapePostgresClassChar(codeUnit: number): string { const char = String.fromCharCode(codeUnit); + return POSTGRES_CLASS_METACHARS.has(char) ? `\\${char}` : char; } @@ -54,6 +56,7 @@ function renderPostgresClass(node: CharClassNode): string { : `${escapePostgresClassChar(range.from)}-${escapePostgresClassChar(range.to)}`, ) .join(""); + return `[${node.negated ? "^" : ""}${body}]`; } @@ -68,6 +71,7 @@ function renderPostgresBound(min: number, max: number | undefined): string { } if (max === undefined) return `{${String(min)},}`; if (max === min) return `{${String(min)}}`; + return `{${String(min)},${String(max)}}`; } @@ -78,6 +82,7 @@ function needsPostgresGrouping(node: RegexNode): boolean { function renderPostgresSubcomponent(node: RegexNode): string { const rendered = renderPostgresPattern(node); + return needsPostgresGrouping(node) ? `(?:${rendered})` : rendered; } @@ -141,11 +146,13 @@ function rangeContainsAnyOf( for (const codeUnit of codeUnits) { if (codeUnit >= range.from && codeUnit <= range.to) return true; } + return false; } function escapeGlobLiteral(codeUnit: number): string { const char = String.fromCharCode(codeUnit); + // GLOB has no backslash escape mechanism (confirmed: SQLite's own documentation describes none); wrapping a wildcard character in a single-member bracket expression is the standard glob(3)-style technique for matching it literally instead. return char === "*" || char === "?" || char === "[" ? `[${char}]` : char; } @@ -172,6 +179,7 @@ function renderGlobClass(node: CharClassNode): string { : `${String.fromCharCode(range.from)}-${String.fromCharCode(range.to)}`, ) .join(""); + return `[${body}]`; } @@ -237,6 +245,7 @@ function planGlobBody(node: RegexNode): GlobPlan { ); } } + return { anchoredStart, anchoredEnd, body: operands }; } @@ -248,6 +257,7 @@ export function renderSqliteGlobPattern(node: RegexNode): string { const plan = planGlobBody(node); const body = plan.body.map((atom) => renderGlobAtom(atom)).join(""); const withWildcards = `${plan.anchoredStart ? "" : "*"}${body}${plan.anchoredEnd ? "" : "*"}`; + // Cosmetic only: collapses a run of consecutive unescaped '*' (e.g. from an unanchored pattern whose body itself starts or ends with 'star(anyChar)') into one. Never touches a '[*]' escape token, which contains no adjacent '*' pair. return withWildcards.replaceAll(/\*{2,}/g, "*"); } diff --git a/packages/trilean-sql/src/portable-pattern.test.ts b/packages/trilean-sql/src/portable-pattern.unit.test.ts similarity index 100% rename from packages/trilean-sql/src/portable-pattern.test.ts rename to packages/trilean-sql/src/portable-pattern.unit.test.ts diff --git a/packages/trilean-sql/src/test-support/columns.ts b/packages/trilean-sql/src/test-support/columns.ts index 647e269..379f592 100644 --- a/packages/trilean-sql/src/test-support/columns.ts +++ b/packages/trilean-sql/src/test-support/columns.ts @@ -22,6 +22,7 @@ function columnForSubject(referenceKey: string): SqlColumnBinding { if (binding === undefined) { throw new Error(`no column mapped for reference key '${referenceKey}'`); } + return binding; } @@ -55,6 +56,7 @@ function columnForSubjectTag(referenceKey: string): SqlColumnBinding { if (binding === undefined) { throw new Error(`no column mapped for reference key '${referenceKey}'`); } + return binding; } @@ -67,6 +69,7 @@ export function collectionForSubjectTags( `no collection mapped for collection key '${collectionKey}'`, ); } + return { table: "subject_tags", join: `"subject_tags"."subjectId" = "subjects"."id"`, diff --git a/packages/trilean-sql/test/integration/pglite-test-support.ts b/packages/trilean-sql/test/integration/pglite-test-support.ts index 558b330..e644a15 100644 --- a/packages/trilean-sql/test/integration/pglite-test-support.ts +++ b/packages/trilean-sql/test/integration/pglite-test-support.ts @@ -30,7 +30,7 @@ const SCHEMA = ` `; /** - * The one correlated child table the `some`/`every`/`fold` parity suite in `pglite.quantifiers.test.ts` needs, matching `subjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- so its declared case survives PostgreSQL's default unquoted-identifier folding. + * The one correlated child table the `some`/`every`/`fold` parity suite in `pglite.quantifiers.integration.test.ts` needs, matching `subjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- so its declared case survives PostgreSQL's default unquoted-identifier folding. */ const TAGS_SCHEMA = ` CREATE TABLE subject_tags ( @@ -94,7 +94,7 @@ interface TagRow { } /** - * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used in `pglite.quantifiers.test.ts` without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. + * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used in `pglite.quantifiers.integration.test.ts` without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. */ const TAGS: readonly TagRow[] = [ { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, @@ -149,9 +149,11 @@ function resolversFor(row: Readonly): Resolvers { value: { kind: "text", value }, }); } + return Promise.resolve({ found: false }); } const value = typeof key === "string" ? known[key] : undefined; + return Promise.resolve( value === undefined ? { found: false } : { found: true, value }, ); @@ -161,6 +163,7 @@ function resolversFor(row: Readonly): Resolvers { }, resolveCollection: async (collection: JsonValue) => { if (collection !== "tags") return Promise.resolve([]); + return Promise.resolve( TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ tag: tagRow.tag, @@ -205,6 +208,7 @@ async function selectMatching( `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, compiled.params, ); + return result.rows.map((row) => row.id); } @@ -222,6 +226,7 @@ export async function evaluatorMatching( matched.push(row.id); } } + return matched.sort(); } @@ -235,5 +240,6 @@ export async function agreeingRows( evaluatorMatching(node), ]); expect(viaSql).toEqual(viaEvaluator); + return viaSql; } diff --git a/packages/trilean-sql/test/integration/pglite.edge-cases.test.ts b/packages/trilean-sql/test/integration/pglite.edge-cases.integration.test.ts similarity index 100% rename from packages/trilean-sql/test/integration/pglite.edge-cases.test.ts rename to packages/trilean-sql/test/integration/pglite.edge-cases.integration.test.ts diff --git a/packages/trilean-sql/test/integration/pglite.predicates.test.ts b/packages/trilean-sql/test/integration/pglite.predicates.integration.test.ts similarity index 100% rename from packages/trilean-sql/test/integration/pglite.predicates.test.ts rename to packages/trilean-sql/test/integration/pglite.predicates.integration.test.ts diff --git a/packages/trilean-sql/test/integration/pglite.quantifiers.test.ts b/packages/trilean-sql/test/integration/pglite.quantifiers.integration.test.ts similarity index 100% rename from packages/trilean-sql/test/integration/pglite.quantifiers.test.ts rename to packages/trilean-sql/test/integration/pglite.quantifiers.integration.test.ts diff --git a/packages/trilean-sql/test/integration/postgres-test-support.ts b/packages/trilean-sql/test/integration/postgres-test-support.ts index c29c072..9cd4402 100644 --- a/packages/trilean-sql/test/integration/postgres-test-support.ts +++ b/packages/trilean-sql/test/integration/postgres-test-support.ts @@ -34,7 +34,7 @@ const SCHEMA = ` `; /** - * The one correlated child table the `some`/`every`/`fold` parity suite in `postgres.quantifiers.test.ts` needs, matching `subjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- so its declared case survives PostgreSQL's default unquoted-identifier folding. + * The one correlated child table the `some`/`every`/`fold` parity suite in `postgres.quantifiers.integration.test.ts` needs, matching `subjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- so its declared case survives PostgreSQL's default unquoted-identifier folding. */ const TAGS_SCHEMA = ` CREATE TABLE subject_tags ( @@ -98,7 +98,7 @@ interface TagRow { } /** - * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used in `postgres.quantifiers.test.ts` without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. + * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used in `postgres.quantifiers.integration.test.ts` without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. */ const TAGS: readonly TagRow[] = [ { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, @@ -153,9 +153,11 @@ function resolversFor(row: Readonly): Resolvers { value: { kind: "text", value }, }); } + return Promise.resolve({ found: false }); } const value = typeof key === "string" ? known[key] : undefined; + return Promise.resolve( value === undefined ? { found: false } : { found: true, value }, ); @@ -165,6 +167,7 @@ function resolversFor(row: Readonly): Resolvers { }, resolveCollection: async (collection: JsonValue) => { if (collection !== "tags") return Promise.resolve([]); + return Promise.resolve( TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ tag: tagRow.tag, @@ -212,6 +215,7 @@ async function selectMatching( `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, compiled.params, ); + return result.rows.map((row) => row.id); } @@ -229,6 +233,7 @@ export async function evaluatorMatching( matched.push(row.id); } } + return matched.sort(); } @@ -242,5 +247,6 @@ export async function agreeingRows( evaluatorMatching(node), ]); expect(viaSql).toEqual(viaEvaluator); + return viaSql; } diff --git a/packages/trilean-sql/test/integration/postgres.edge-cases.test.ts b/packages/trilean-sql/test/integration/postgres.edge-cases.integration.test.ts similarity index 100% rename from packages/trilean-sql/test/integration/postgres.edge-cases.test.ts rename to packages/trilean-sql/test/integration/postgres.edge-cases.integration.test.ts diff --git a/packages/trilean-sql/test/integration/postgres.predicates.test.ts b/packages/trilean-sql/test/integration/postgres.predicates.integration.test.ts similarity index 100% rename from packages/trilean-sql/test/integration/postgres.predicates.test.ts rename to packages/trilean-sql/test/integration/postgres.predicates.integration.test.ts diff --git a/packages/trilean-sql/test/integration/postgres.quantifiers.test.ts b/packages/trilean-sql/test/integration/postgres.quantifiers.integration.test.ts similarity index 100% rename from packages/trilean-sql/test/integration/postgres.quantifiers.test.ts rename to packages/trilean-sql/test/integration/postgres.quantifiers.integration.test.ts diff --git a/packages/trilean-sql/test/integration/sqlite-test-support.ts b/packages/trilean-sql/test/integration/sqlite-test-support.ts index 5757bb3..4b8be6d 100644 --- a/packages/trilean-sql/test/integration/sqlite-test-support.ts +++ b/packages/trilean-sql/test/integration/sqlite-test-support.ts @@ -18,7 +18,7 @@ import { sqliteSubjectOptions } from "../../src/test-support/columns"; * Splitting the original single file by tested concern (predicates, edge cases, quantifiers) is purely a file-size matter -- vitest still gives each split file its own isolated module instance, so each one opens, seeds, and closes its own in-memory connection via the `beforeAll`/`afterAll` registered here, exactly as the unsplit file did for itself. */ -/** Exported for `sqlite.predicates.test.ts`'s own REGEXP-registration proofs, which need a second, bare connection sharing this same schema but not this file's registered `regexp` function or seeded rows. */ +/** Exported for `sqlite.predicates.integration.test.ts`'s own REGEXP-registration proofs, which need a second, bare connection sharing this same schema but not this file's registered `regexp` function or seeded rows. */ export const SCHEMA = ` CREATE TABLE subjects ( id TEXT PRIMARY KEY, @@ -31,7 +31,7 @@ export const SCHEMA = ` `; /** - * A second, deliberately tiny table whose only purpose is the coercion proofs in `sqlite.edge-cases.test.ts`. + * A second, deliberately tiny table whose only purpose is the coercion proofs in `sqlite.edge-cases.integration.test.ts`. * * They need a column whose declared affinity does the coercing -- affinity is a property of a column, and two bound parameters compared against each other have none -- and they need values chosen so that the coerced answer and the honest one differ. Keeping them out of `subjects` leaves that fixture identical in shape to the PostgreSQL suite's, so a case comparing the two suites is comparing like with like. */ @@ -44,7 +44,7 @@ const COERCION_SCHEMA = ` `; /** - * The one correlated child table the `some`/`every`/`fold` parity suite in `sqlite.quantifiers.test.ts` needs, matching `sqliteSubjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- matching the same quoted-identifier convention the PostgreSQL/PGlite suites use for it. + * The one correlated child table the `some`/`every`/`fold` parity suite in `sqlite.quantifiers.integration.test.ts` needs, matching `sqliteSubjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- matching the same quoted-identifier convention the PostgreSQL/PGlite suites use for it. */ const TAGS_SCHEMA = ` CREATE TABLE subject_tags ( @@ -108,7 +108,7 @@ interface TagRow { } /** - * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used in `sqlite.quantifiers.test.ts` without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. + * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used in `sqlite.quantifiers.integration.test.ts` without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. */ const TAGS: readonly TagRow[] = [ { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, @@ -163,9 +163,11 @@ function resolversFor(row: Readonly): Resolvers { value: { kind: "text", value }, }); } + return Promise.resolve({ found: false }); } const value = typeof key === "string" ? known[key] : undefined; + return Promise.resolve( value === undefined ? { found: false } : { found: true, value }, ); @@ -175,6 +177,7 @@ function resolversFor(row: Readonly): Resolvers { }, resolveCollection: async (collection: JsonValue) => { if (collection !== "tags") return Promise.resolve([]); + return Promise.resolve( TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ tag: tagRow.tag, @@ -266,6 +269,7 @@ function selectMatching( `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, ) .all(...compiled.params.map(bindable)); + return rows.map((row) => row.id); } @@ -283,10 +287,11 @@ export async function evaluatorMatching( matched.push(row.id); } } + return matched.sort(); } -/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. `options` defaults to `sqliteSubjectOptions`; `sqlite.quantifiers.test.ts` passes `sqliteSubjectOptionsWithTags` instead. */ +/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. `options` defaults to `sqliteSubjectOptions`; `sqlite.quantifiers.integration.test.ts` passes `sqliteSubjectOptionsWithTags` instead. */ export async function agreeingRows( node: PredicateNode, options: Readonly = sqliteSubjectOptions, @@ -294,5 +299,6 @@ export async function agreeingRows( const viaSql = selectMatching(node, options); const viaEvaluator = await evaluatorMatching(node); expect(viaSql).toEqual(viaEvaluator); + return viaSql; } diff --git a/packages/trilean-sql/test/integration/sqlite.edge-cases.test.ts b/packages/trilean-sql/test/integration/sqlite.edge-cases.integration.test.ts similarity index 100% rename from packages/trilean-sql/test/integration/sqlite.edge-cases.test.ts rename to packages/trilean-sql/test/integration/sqlite.edge-cases.integration.test.ts diff --git a/packages/trilean-sql/test/integration/sqlite.predicates.test.ts b/packages/trilean-sql/test/integration/sqlite.predicates.integration.test.ts similarity index 100% rename from packages/trilean-sql/test/integration/sqlite.predicates.test.ts rename to packages/trilean-sql/test/integration/sqlite.predicates.integration.test.ts diff --git a/packages/trilean-sql/test/integration/sqlite.quantifiers.test.ts b/packages/trilean-sql/test/integration/sqlite.quantifiers.integration.test.ts similarity index 100% rename from packages/trilean-sql/test/integration/sqlite.quantifiers.test.ts rename to packages/trilean-sql/test/integration/sqlite.quantifiers.integration.test.ts diff --git a/packages/trilean-sql/tsconfig.json b/packages/trilean-sql/tsconfig.json index d882bde..01a0d4c 100644 --- a/packages/trilean-sql/tsconfig.json +++ b/packages/trilean-sql/tsconfig.json @@ -1,10 +1,10 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { // Same isomorphism gate packages/trilean uses: no "node" types and a WebWorker lib, so a Node-only global referenced from runtime src/ fails to compile here rather than at some consumer's runtime. The compiler emits SQL text and a parameter array and touches no I/O, so it has no reason to reach for a platform API at all. "lib": ["ES2024", "WebWorker"], "types": [] }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/test-support/**/*.ts"] + "exclude": ["src/**/*.test.ts", "src/test-support/**/*.ts"], + "extends": "../../tsconfig.base.json", + "include": ["src"] } diff --git a/packages/trilean-sql/tsconfig.node.json b/packages/trilean-sql/tsconfig.node.json index 5780c40..b6529c9 100644 --- a/packages/trilean-sql/tsconfig.node.json +++ b/packages/trilean-sql/tsconfig.node.json @@ -1,16 +1,16 @@ { - "extends": "./tsconfig.json", "compilerOptions": { + "allowImportingTsExtensions": true, "lib": ["ES2024"], - "types": ["node"], - "allowImportingTsExtensions": true + "types": ["node"] }, + "exclude": [], + "extends": "./tsconfig.json", "include": [ "src/**/*.test.ts", "src/test-support/**/*.ts", "test/**/*.ts", "*.config.ts", "eslint.config.ts" - ], - "exclude": [] + ] } diff --git a/packages/trilean-sql/vitest.config.ts b/packages/trilean-sql/vitest.config.ts index b9020c3..77b4a2a 100644 --- a/packages/trilean-sql/vitest.config.ts +++ b/packages/trilean-sql/vitest.config.ts @@ -1,10 +1,12 @@ import { defineConfig } from "vitest/config"; -// Two projects, mirroring packages/trilean's own split: "unit" covers the compiler itself (pure string and parameter production, no I/O), "integration" runs the compiled fragments against a real engine. -// -// The integration project is where the three-valued-logic claim is actually tested rather than asserted: a compiled fragment's truth table is only meaningful once the engine's own planner has evaluated it against real NULLs, so those tests execute SQL rather than comparing strings. Every file under test/integration/ belongs to it, and the three there today run the same parity suite against three ways of reaching that planner: postgres.test.ts against a server started as an ephemeral container, which needs a working Docker daemon (testcontainers); pglite.test.ts against PGlite, the same PostgreSQL compiled to WebAssembly and run in process, which needs nothing beyond Node; and sqlite.test.ts against a real SQLite engine in memory, which likewise needs nothing beyond Node. The Docker requirement of the first is why the project as a whole is separate from the default `pnpm test` and has its own CI job. -// -// One project rather than one per engine, because they are the same tests: `vitest run --project integration` is the whole integration surface, and a further engine is a new file rather than new configuration. +/** + * Two projects, mirroring packages/trilean's own split: "unit" covers the compiler itself (pure string and parameter production, no I/O), "integration" runs the compiled fragments against a real engine. + * + * The integration project is where the three-valued-logic claim is actually tested rather than asserted: a compiled fragment's truth table is only meaningful once the engine's own planner has evaluated it against real NULLs, so those tests execute SQL rather than comparing strings. Every file under test/integration/ belongs to it, and the three there today run the same parity suite against three ways of reaching that planner: postgres.test.ts against a server started as an ephemeral container, which needs a working Docker daemon (testcontainers); pglite.test.ts against PGlite, the same PostgreSQL compiled to WebAssembly and run in process, which needs nothing beyond Node; and sqlite.test.ts against a real SQLite engine in memory, which likewise needs nothing beyond Node. The Docker requirement of the first is why the project as a whole is separate from the default `pnpm test` and has its own CI job. + * + * One project rather than one per engine, because they are the same tests: `vitest run --project integration` is the whole integration surface, and a further engine is a new file rather than new configuration. + */ export default defineConfig({ test: { coverage: { diff --git a/packages/trilean/eslint.config.ts b/packages/trilean/eslint.config.ts index 3a779e7..9029710 100644 --- a/packages/trilean/eslint.config.ts +++ b/packages/trilean/eslint.config.ts @@ -1,5 +1,6 @@ import { builtinModules } from "node:module"; import { exadevConfig } from "@exadev/eslint-config"; +import { defineConfig } from "eslint/config"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import globals from "globals"; @@ -16,8 +17,8 @@ const bareNodeBuiltinPattern = `^(${nodeBuiltinBaseModules.join("|")})(/.*)?$`; const runtimeSrcExemptions = ["src/**/*.test.ts", "src/test-support/**"]; -export default exadevConfig( - {}, +export default defineConfig( + ...exadevConfig(), { ignores: ["dist", "coverage", "node_modules", ".turbo", "schemas"], }, @@ -31,6 +32,7 @@ export default exadevConfig( }, }, { + files: ["**/*.{ts,tsx,mts,cts}"], rules: { "@typescript-eslint/consistent-type-imports": [ "error", @@ -83,5 +85,20 @@ export default exadevConfig( ], }, }, + // `count(collection, filter?, probe?)` is part of the published API, so its signature stays as released. + { + files: ["src/derived-aggregates.ts"], + rules: { "exadev/prefer-options-object-param": "off" }, + }, + // The recursive evaluator passes its state positionally on the hot path, so the call signature stays as it is. + { + files: ["src/evaluator.ts", "src/evaluator-collection.ts"], + rules: { "max-params": "off" }, + }, + // Each resumption of a task depends on the result of the one before it, so the async driver awaits them in turn. + { + files: ["src/evaluator-effect.ts"], + rules: { "no-await-in-loop": "off" }, + }, eslintPluginPrettierRecommended, ); diff --git a/packages/trilean/package.json b/packages/trilean/package.json index eccb00c..5832309 100644 --- a/packages/trilean/package.json +++ b/packages/trilean/package.json @@ -1,17 +1,37 @@ { "name": "trilean", - "version": "1.7.1", "description": "Three-valued predicate and expression evaluation trees, stored as JSON and evaluated against injected resolvers, for domain logic — business rules, eligibility checks, formulas, search filters, and more — that needs to be data instead of code.", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/ExaDev/trilean.git", - "directory": "packages/trilean" - }, - "homepage": "https://github.com/ExaDev/trilean/tree/main/packages/trilean", + "version": "1.7.1", "bugs": { "url": "https://github.com/ExaDev/trilean/issues" }, + "dependencies": { + "trilean-regex": "1.0.3", + "zod": "^4.5.4" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@cloudflare/vitest-pool-workers": "^0.22.0", + "@exadev/eslint-config": "3.0.0", + "@types/node": "^26.4.0", + "@vitest/coverage-v8": "^4.1.11", + "canonicalize": "^4.0.0", + "eslint": "^10.9.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "globals": "^17.11.0", + "prettier": "^3.9.6", + "publint": "^0.3.24", + "tsdown": "^0.23.0", + "tsx": "^4.23.13", + "turbo": "^2.10.12", + "typescript": "^6.0.3", + "typescript-eslint": "^8.68.0", + "vitest": "^4.1.11" + }, + "engines": { + "node": ">=20" + }, "exports": { ".": { "types": { @@ -31,77 +51,57 @@ }, "./schemas/*.schema.json": "./schemas/*.schema.json" }, - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist", "schemas" ], + "homepage": "https://github.com/ExaDev/trilean/tree/main/packages/trilean", + "keywords": [ + "evaluator", + "expression", + "isomorphic", + "json", + "predicate", + "rules-engine", + "three-valued-logic", + "zod" + ], + "license": "MIT", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "publishConfig": { "access": "public", "provenance": true, "registry": "https://registry.npmjs.org/" }, - "sideEffects": false, - "engines": { - "node": ">=20" - }, - "license": "MIT", - "dependencies": { - "trilean-regex": "1.0.3", - "zod": "^4.5.4" + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/trilean.git", + "directory": "packages/trilean" }, "scripts": { - "build": "turbo run _build", "_build": "tsdown && tsx scripts/generate-json-schema.ts", - "lint": "turbo run _lint", "_lint": "eslint . --fix --cache --max-warnings 0", - "typecheck": "turbo run _typecheck _typecheck:attw", + "_prepush": "true", + "_test": "vitest run --project unit", + "_test:coverage": "vitest run --project unit --coverage", + "_test:integration": "vitest run --project integration", + "_test:smoke": "vitest run --project smoke", + "_test:workers": "vitest run --project workers", "_typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", "_typecheck:attw": "attw --pack", + "build": "turbo run _build", + "lint": "turbo run _lint", + "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run test:integration && pnpm run build && pnpm run test:smoke && publint && attw --pack", + "prepush": "turbo run _prepush", "test": "turbo run _test", - "_test": "vitest run --project unit", "test:coverage": "turbo run _test:coverage", - "_test:coverage": "vitest run --project unit --coverage", "test:integration": "turbo run _test:integration", - "_test:integration": "vitest run --project integration", "test:smoke": "turbo run _test:smoke", - "_test:smoke": "vitest run --project smoke", "test:workers": "turbo run _test:workers", - "_test:workers": "vitest run --project workers", - "prepush": "turbo run _prepush", - "_prepush": "true", - "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run test:integration && pnpm run build && pnpm run test:smoke && publint && attw --pack" + "typecheck": "turbo run _typecheck _typecheck:attw" }, - "keywords": [ - "json", - "predicate", - "expression", - "evaluator", - "three-valued-logic", - "zod", - "rules-engine", - "isomorphic" - ], - "devDependencies": { - "@arethetypeswrong/cli": "^0.18.5", - "@cloudflare/vitest-pool-workers": "^0.22.0", - "@exadev/eslint-config": "2.12.1", - "@types/node": "^26.4.0", - "@vitest/coverage-v8": "^4.1.11", - "canonicalize": "^4.0.0", - "eslint": "^10.9.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.6", - "globals": "^17.11.0", - "prettier": "^3.9.6", - "publint": "^0.3.24", - "tsdown": "^0.23.0", - "tsx": "^4.23.13", - "turbo": "^2.10.12", - "typescript": "^6.0.3", - "typescript-eslint": "^8.68.0", - "vitest": "^4.1.11" - } + "sideEffects": false, + "type": "module", + "types": "./dist/index.d.ts" } diff --git a/packages/trilean/scripts/generate-json-schema.ts b/packages/trilean/scripts/generate-json-schema.ts index e6b2352..3414c92 100644 --- a/packages/trilean/scripts/generate-json-schema.ts +++ b/packages/trilean/scripts/generate-json-schema.ts @@ -1,16 +1,16 @@ -// Generates schemas/trilean.schema.json from the package's own Zod schemas via z.toJSONSchema() (https://zod.dev/json-schema). Run as part of `pnpm build` (see package.json's "_build" script), so schemas/ is always fresh for a real `npm publish` -- prepublishOnly runs the full build, not tsdown alone. -// -// Imports from the freshly-built ../dist/tree.js rather than ../dist/index.js: tsdown's glob multi-entry config (tsdown.config.ts) builds one output file per top-level src/*.ts module, and PredicateNodeSchema/ExpressionNodeSchema both live in src/tree.ts alone (they are mutually recursive via z.lazy(), which is exactly why they are co-located there rather than split across files -- see src/tree.ts's own top comment). -// -// PredicateNode and ExpressionNode are themselves mutually recursive (a compare/textCompare/memberOf PredicateNode holds ExpressionNode operands; a conditional ExpressionNode's cases hold PredicateNode guards), so the two trees cannot be split into independent JSON Schema documents without duplicating every shared sub-schema across both files. A single z.registry() with both schemas registered produces one combined document instead. -// -// zod's own registry-conversion output (z.toJSONSchema(registry)) is a *bag* of separately-addressable documents -- { schemas: { PredicateNode: {...}, ExpressionNode: {...} } } -- with cross-references between them written as bare id strings (e.g. {"$ref": "ExpressionNode"}). That shape is deliberate on zod's part for the case it's designed for (serving each entry at its own URL -- https://zod.dev/json-schema's own registries section: "useful for generating separate JSON files for web serving"), but a bare id string is not a resolvable $ref inside a single JSON document: nothing here ever writes these entries to separate files or serves them at matching URLs, so left as-is a real JSON Schema tool (Ajv, etc.) handed this file could never resolve those cross-references. The `uri` callback below redirects every such reference to an in-document JSON Pointer fragment (`#/$defs/`) instead, and the schemas are then spliced together into one genuinely self-contained document with a single $defs block -- not zod's raw multi-entry bag. -// -// "__shared" is the fixed key zod's own registry-conversion path uses internally for schemas reused across more than one registered root (see node_modules zod's to-json-schema.ts, makeURI) -- JsonValueSchema is exactly this case here, reused across collection/key/table/payload fields on both trees. Mapping "__shared" to the empty string keeps its own already-correct internal "#/$defs/" fragment intact instead of doubling up into an invalid two-fragment URI ("#/$defs/__shared#/$defs/"). -// -// This script imports the freshly-built dist/tree.js, so it is a member of tsconfig.node.json's program rather than tsconfig.json's runtime src/ one -- turbo's "_typecheck" and "_lint" tasks both depend on "_build" (see turbo.json) so dist/ genuinely exists by the time either tsc or eslint's typed rules resolve this import. -// -// No try/catch anywhere in this script -- any failure (a Zod throw, a filesystem error) crashes it loudly with a non-zero exit, matching this project's standing "never silently swallow a failure" convention. +/* Generates schemas/trilean.schema.json from the package's own Zod schemas via z.toJSONSchema() (https://zod.dev/json-schema). Run as part of `pnpm build` (see package.json's "_build" script), so schemas/ is always fresh for a real `npm publish` -- prepublishOnly runs the full build, not tsdown alone. + + Imports from the freshly-built ../dist/tree.js rather than ../dist/index.js: tsdown's glob multi-entry config (tsdown.config.ts) builds one output file per top-level src/*.ts module, and PredicateNodeSchema/ExpressionNodeSchema both live in src/tree.ts alone (they are mutually recursive via z.lazy(), which is exactly why they are co-located there rather than split across files -- see src/tree.ts's own top comment). + + PredicateNode and ExpressionNode are themselves mutually recursive (a compare/textCompare/memberOf PredicateNode holds ExpressionNode operands; a conditional ExpressionNode's cases hold PredicateNode guards), so the two trees cannot be split into independent JSON Schema documents without duplicating every shared sub-schema across both files. A single z.registry() with both schemas registered produces one combined document instead. + + zod's own registry-conversion output (z.toJSONSchema(registry)) is a *bag* of separately-addressable documents -- { schemas: { PredicateNode: {...}, ExpressionNode: {...} } } -- with cross-references between them written as bare id strings (e.g. {"$ref": "ExpressionNode"}). That shape is deliberate on zod's part for the case it's designed for (serving each entry at its own URL -- https://zod.dev/json-schema's own registries section: "useful for generating separate JSON files for web serving"), but a bare id string is not a resolvable $ref inside a single JSON document: nothing here ever writes these entries to separate files or serves them at matching URLs, so left as-is a real JSON Schema tool (Ajv, etc.) handed this file could never resolve those cross-references. The `uri` callback below redirects every such reference to an in-document JSON Pointer fragment (`#/$defs/`) instead, and the schemas are then spliced together into one genuinely self-contained document with a single $defs block -- not zod's raw multi-entry bag. + + "__shared" is the fixed key zod's own registry-conversion path uses internally for schemas reused across more than one registered root (see node_modules zod's to-json-schema.ts, makeURI) -- JsonValueSchema is exactly this case here, reused across collection/key/table/payload fields on both trees. Mapping "__shared" to the empty string keeps its own already-correct internal "#/$defs/" fragment intact instead of doubling up into an invalid two-fragment URI ("#/$defs/__shared#/$defs/"). + + This script imports the freshly-built dist/tree.js, so it is a member of tsconfig.node.json's program rather than tsconfig.json's runtime src/ one -- turbo's "_typecheck" and "_lint" tasks both depend on "_build" (see turbo.json) so dist/ genuinely exists by the time either tsc or eslint's typed rules resolve this import. + + No try/catch anywhere in this script -- any failure (a Zod throw, a filesystem error) crashes it loudly with a non-zero exit, matching this project's standing "never silently swallow a failure" convention. */ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -47,9 +47,9 @@ const { schemas } = z.toJSONSchema(registry, { uri: (id) => (id === "__shared" ? "" : `#/$defs/${id}`), }); -// z.toJSONSchema's registry overload types `schemas` as a plain Record, so noUncheckedIndexedAccess -// widens each lookup to `T | undefined` even though both ids were just registered above and are -// therefore always present -- fail loudly rather than let a silently-`undefined` entry flow into the spread below (which would produce an empty {} instead of a real schema). +/* z.toJSONSchema's registry overload types `schemas` as a plain Record, so noUncheckedIndexedAccess + widens each lookup to `T | undefined` even though both ids were just registered above and are + therefore always present -- fail loudly rather than let a silently-`undefined` entry flow into the spread below (which would produce an empty {} instead of a real schema). */ const predicateNodeSchema = schemas.PredicateNode; if (predicateNodeSchema === undefined) { throw new Error( diff --git a/packages/trilean/src/complex.ts b/packages/trilean/src/complex.ts index a03921a..fb73e56 100644 --- a/packages/trilean/src/complex.ts +++ b/packages/trilean/src/complex.ts @@ -28,6 +28,7 @@ export const complexLiteralFromPolar = ( unit?: Unit, ): ComplexLiteralNode => { const { re, im } = complexFromPolar(magnitude, phase, unit); + return { kind: "complexLiteral", re, im, unit }; }; diff --git a/packages/trilean/src/complex.test.ts b/packages/trilean/src/complex.unit.test.ts similarity index 100% rename from packages/trilean/src/complex.test.ts rename to packages/trilean/src/complex.unit.test.ts diff --git a/packages/trilean/src/computed-value.ts b/packages/trilean/src/computed-value.ts index 72ecab3..095f4d9 100644 --- a/packages/trilean/src/computed-value.ts +++ b/packages/trilean/src/computed-value.ts @@ -39,6 +39,7 @@ function normalizeUnit(unit: Unit | undefined): Unit { for (const [dimension, exponent] of Object.entries(unit)) { if (exponent !== 0) normalized[dimension] = exponent; } + return normalized; } @@ -49,6 +50,7 @@ export function unitsEqual(a: Unit | undefined, b: Unit | undefined): boolean { const dimensionsA = Object.keys(normalizedA); const dimensionsB = Object.keys(normalizedB); if (dimensionsA.length !== dimensionsB.length) return false; + return dimensionsA.every( (dimension) => normalizedA[dimension] === normalizedB[dimension], ); @@ -65,6 +67,7 @@ export function combineUnitsForMultiply( for (const [dimension, exponent] of Object.entries(normalizedB)) { combined[dimension] = (combined[dimension] ?? 0) + exponent; } + return normalizeUnit(combined); } @@ -79,5 +82,6 @@ export function combineUnitsForDivide( for (const [dimension, exponent] of Object.entries(normalizedB)) { combined[dimension] = (combined[dimension] ?? 0) - exponent; } + return normalizeUnit(combined); } diff --git a/packages/trilean/src/computed-value.test.ts b/packages/trilean/src/computed-value.unit.test.ts similarity index 100% rename from packages/trilean/src/computed-value.test.ts rename to packages/trilean/src/computed-value.unit.test.ts diff --git a/packages/trilean/src/derived-aggregates.ts b/packages/trilean/src/derived-aggregates.ts index 3ff69c2..6d55884 100644 --- a/packages/trilean/src/derived-aggregates.ts +++ b/packages/trilean/src/derived-aggregates.ts @@ -37,7 +37,8 @@ export const presenceOf = (probe: ExpressionNode): ExpressionNode => ({ then: { kind: "numberLiteral", value: 1 }, }, ], - fallback: { kind: "numberLiteral", value: 0 }, // unreachable: a definite probe is always a member of the single-element list containing only itself + // unreachable: a definite probe is always a member of the single-element list containing only itself + fallback: { kind: "numberLiteral", value: 0 }, }); export const count = ( diff --git a/packages/trilean/src/derived-aggregates.test.ts b/packages/trilean/src/derived-aggregates.unit.test.ts similarity index 98% rename from packages/trilean/src/derived-aggregates.test.ts rename to packages/trilean/src/derived-aggregates.unit.test.ts index daf340d..4d172fc 100644 --- a/packages/trilean/src/derived-aggregates.test.ts +++ b/packages/trilean/src/derived-aggregates.unit.test.ts @@ -41,13 +41,16 @@ const resolvers: Resolvers = { if (typeof value === "string") { return Promise.resolve({ found: true, value: { kind: "text", value } }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), resolveCollection: async (collection) => { if (collection === "items") return Promise.resolve(itemsCollection); if (collection === "mixedCode") return Promise.resolve(mixedCodeCollection); - return Promise.resolve([]); // "empty", and anything else unrecognised + + // "empty", and anything else unrecognised + return Promise.resolve([]); }, }; diff --git a/packages/trilean/src/derived-connectives.test.ts b/packages/trilean/src/derived-connectives.unit.test.ts similarity index 99% rename from packages/trilean/src/derived-connectives.test.ts rename to packages/trilean/src/derived-connectives.unit.test.ts index 11baf90..254a471 100644 --- a/packages/trilean/src/derived-connectives.test.ts +++ b/packages/trilean/src/derived-connectives.unit.test.ts @@ -50,6 +50,7 @@ function expectTruthValue( ): void { if (expected === "U") { expect(evaluation.status).toBe("indeterminate"); + return; } expect(evaluation).toEqual({ status: "definite", value: expected === "T" }); @@ -170,6 +171,7 @@ describe("none", () => { value: { kind: "number", value: context }, }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), diff --git a/packages/trilean/src/derived-patterns.ts b/packages/trilean/src/derived-patterns.ts index 34a6e8a..16e4508 100644 --- a/packages/trilean/src/derived-patterns.ts +++ b/packages/trilean/src/derived-patterns.ts @@ -23,6 +23,7 @@ function escapeRegexLiteral(character: string): string { /** A prefix match that respects word boundaries: the subject is either the prefix exactly, or the prefix followed by a space and anything at all. `"ls"` therefore matches `"ls"` and `"ls -la"` but never `"lsof"`. The prefix itself is a plain literal -- every character in it is escaped, so it carries no wildcard or escape syntax of its own. */ function compilePrefixPattern(prefix: string): string { const escaped = Array.from(prefix, escapeRegexLiteral).join(""); + return `^${escaped}(?: ${ANY_CHARACTERS})?$`; } @@ -60,6 +61,7 @@ function compileWildcardPattern(pattern: string): string { const trailingSpaceAndWildcard = ANY_CHARACTERS.length + 1; compiled = `${compiled.slice(0, -trailingSpaceAndWildcard)}(?: ${ANY_CHARACTERS})?`; } + return `^${compiled}$`; } @@ -87,6 +89,7 @@ function compileHierarchicalGlobPattern(pattern: string): string { compiled += escapeRegexLiteral(character); index += 1; } + return `^${compiled}$`; } diff --git a/packages/trilean/src/derived-patterns.test.ts b/packages/trilean/src/derived-patterns.unit.test.ts similarity index 99% rename from packages/trilean/src/derived-patterns.test.ts rename to packages/trilean/src/derived-patterns.unit.test.ts index 3e1d578..18ce400 100644 --- a/packages/trilean/src/derived-patterns.test.ts +++ b/packages/trilean/src/derived-patterns.unit.test.ts @@ -37,6 +37,7 @@ async function matches(node: PredicateNode, text: string): Promise { if (result.status !== "definite") { throw new Error("expected a definite match outcome"); } + return result.value; } @@ -49,6 +50,7 @@ function compiledPattern(node: PredicateNode): string { if (node.right.kind !== "textLiteral") { throw new Error("expected a textLiteral pattern operand"); } + return node.right.value; } diff --git a/packages/trilean/src/derived-values.test.ts b/packages/trilean/src/derived-values.unit.test.ts similarity index 99% rename from packages/trilean/src/derived-values.test.ts rename to packages/trilean/src/derived-values.unit.test.ts index 3e9b250..79de95d 100644 --- a/packages/trilean/src/derived-values.test.ts +++ b/packages/trilean/src/derived-values.unit.test.ts @@ -87,6 +87,7 @@ describe("coalesce", () => { ...resolvers, resolveValue: async (key, context) => { if (key === "later-candidate-marker") laterCandidateEvaluations += 1; + return resolvers.resolveValue(key, context); }, }; diff --git a/packages/trilean/src/evaluation.ts b/packages/trilean/src/evaluation.ts index ed174e0..03c8e33 100644 --- a/packages/trilean/src/evaluation.ts +++ b/packages/trilean/src/evaluation.ts @@ -36,5 +36,6 @@ export function firstIndeterminate( for (const result of results) { if (result?.status === "indeterminate") return result.reason; } + return undefined; } diff --git a/packages/trilean/src/evaluator-budget.ts b/packages/trilean/src/evaluator-budget.ts index dd3578c..be16954 100644 --- a/packages/trilean/src/evaluator-budget.ts +++ b/packages/trilean/src/evaluator-budget.ts @@ -27,6 +27,7 @@ export function createEvaluationBudget( ): 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; @@ -43,6 +44,7 @@ export function createEvaluationBudget( `evaluation nesting depth exceeds the maximum of ${maxNestingDepth.toString()} (resource exhausted)`, ); } + return undefined; }, }; diff --git a/packages/trilean/src/evaluator-collection.ts b/packages/trilean/src/evaluator-collection.ts index 6581dc0..8ce67c7 100644 --- a/packages/trilean/src/evaluator-collection.ts +++ b/packages/trilean/src/evaluator-collection.ts @@ -44,6 +44,7 @@ export function* resolveParticipatingItems( const candidates: unknown[] = yield* wait( resolvers.resolveCollection(collection, context), ); + return yield* all( candidates.map(function* (item): Task { if (filter === undefined) return { item, filterOutcome: "include" }; @@ -61,6 +62,7 @@ export function* resolveParticipatingItems( if (filterResult.status === "indeterminate") { return { item, filterOutcome: filterResult }; } + return { item, filterOutcome: filterResult.value ? "include" : "exclude", @@ -77,5 +79,6 @@ export function firstFilterIndeterminate( if (filterOutcome === "include" || filterOutcome === "exclude") continue; if (filterOutcome.status === "indeterminate") return filterOutcome.reason; } + return undefined; } diff --git a/packages/trilean/src/evaluator-effect.ts b/packages/trilean/src/evaluator-effect.ts index aed4b1f..b24cb68 100644 --- a/packages/trilean/src/evaluator-effect.ts +++ b/packages/trilean/src/evaluator-effect.ts @@ -59,6 +59,7 @@ export async function runAsync(task: Readonly>): Promise { : await Promise.all(suspension.tasks.map(runAsync)); step = task.next(result); } + return step.value; } @@ -82,5 +83,6 @@ export function runSync(task: Readonly>): T { } step = task.next(result); } + return step.value; } diff --git a/packages/trilean/src/evaluator-indeterminacy-helpers.ts b/packages/trilean/src/evaluator-indeterminacy-helpers.ts index 0f24378..1d3b591 100644 --- a/packages/trilean/src/evaluator-indeterminacy-helpers.ts +++ b/packages/trilean/src/evaluator-indeterminacy-helpers.ts @@ -10,7 +10,7 @@ import type { EvaluationContext, Resolvers } from "./resolvers"; import type { ExpressionNode, PredicateNode } from "./tree"; /** - * Shared fixture machinery for the two split indeterminacy test files (evaluator.indeterminacy-table.test.ts, evaluator.indeterminacy-cases.test.ts): systematic, table-driven coverage of README.md's "Indeterminacy reference" section, one fixture per node-kind x reason-code combination that section documents, for every `PredicateNode`/`ExpressionNode` kind implemented. This complements, rather than replaces, the ad hoc behavioural tests already spread across the evaluator.*.test.ts files and truth-tables.test.ts. + * Shared fixture machinery for the two split indeterminacy test files (evaluator.indeterminacy-table.unit.test.ts, evaluator.indeterminacy-cases.unit.test.ts): systematic, table-driven coverage of README.md's "Indeterminacy reference" section, one fixture per node-kind x reason-code combination that section documents, for every `PredicateNode`/`ExpressionNode` kind implemented. This complements, rather than replaces, the ad hoc behavioural tests already spread across the evaluator.*.test.ts files and truth-tables.unit.test.ts. */ export function isPlainRecord( @@ -53,6 +53,7 @@ export const baseResolvers: Resolvers = { }); } } + return Promise.resolve({ found: false }); }, resolveLookup: async (table) => { @@ -62,6 +63,7 @@ export const baseResolvers: Resolvers = { value: { kind: "number", value: 100 }, }); } + return Promise.resolve({ found: false }); }, resolveCollection: async (collection, context) => { @@ -80,8 +82,10 @@ export const baseResolvers: Resolvers = { } if (collection === "values" && isPlainRecord(context)) { const values = context.values; + return Promise.resolve(isUnknownArray(values) ? values : []); } + return Promise.resolve([]); }, }; @@ -187,6 +191,7 @@ export function pred( const { evaluatePredicate: evaluate } = functions ? createEvaluator({ functions }) : { evaluatePredicate }; + return { description, expected, @@ -204,6 +209,7 @@ export function expr( const { evaluateValue: evaluate } = functions ? createEvaluator({ functions }) : { evaluateValue }; + return { description, expected, diff --git a/packages/trilean/src/evaluator-operations.ts b/packages/trilean/src/evaluator-operations.ts index 23d981c..1a82205 100644 --- a/packages/trilean/src/evaluator-operations.ts +++ b/packages/trilean/src/evaluator-operations.ts @@ -28,6 +28,7 @@ export function combineAnd( if (right.status === "definite" && !right.value) return definite(false); const reason = firstIndeterminate(left, right); if (reason !== undefined) return { status: "indeterminate", reason }; + return definite(true); } @@ -40,6 +41,7 @@ export function combineOr( if (right.status === "definite" && right.value) return definite(true); const reason = firstIndeterminate(left, right); if (reason !== undefined) return { status: "indeterminate", reason }; + return definite(false); } @@ -82,6 +84,7 @@ function toMilliseconds(value: number, unit: DurationUnit): number { /** `Date.parse` reports an unparseable timestamp as `NaN`, and every downstream use of that `NaN` then fails silently rather than loudly: every comparison against it is `false` (so `neq` between two unparseable instants would come back definitely `true`), a subtraction yields a `NaN`-magnitude duration, and `new Date(NaN).toISOString()` throws a `RangeError` outright -- a thrown exception for a data-quality problem, which this design never does (see `Evaluation`'s own doc comment in evaluation.ts). An `instant` whose string will not parse is a value the operation cannot use, so every caller below reports it as `wrong-type`, exactly like any other unusable operand. */ function toEpochMilliseconds(value: string): number | undefined { const epochMilliseconds = Date.parse(value); + return Number.isNaN(epochMilliseconds) ? undefined : epochMilliseconds; } @@ -103,6 +106,7 @@ function instantFromEpochMilliseconds( "the resulting instant falls outside the representable timestamp range", ); } + return definite({ kind: "instant", value: shifted.toISOString() }); } @@ -126,6 +130,7 @@ export function compareValues( "cannot compare numbers with incompatible units", ); } + return definite(applyComparisonOperator(op, left.value, right.value)); case "text": return indeterminate( @@ -145,6 +150,7 @@ export function compareValues( `booleans have no natural ordering; '${op}' is only defined for 'eq'/'neq'`, ); } + return definite( op === "eq" ? left.value === right.value : left.value !== right.value, ); @@ -159,6 +165,7 @@ export function compareValues( if (leftEpoch === undefined) return unparseableInstant(left.value); const rightEpoch = toEpochMilliseconds(right.value); if (rightEpoch === undefined) return unparseableInstant(right.value); + return definite(applyComparisonOperator(op, leftEpoch, rightEpoch)); } case "duration": @@ -168,6 +175,7 @@ export function compareValues( `cannot compare a 'duration' value with a '${right.kind}' value`, ); } + return definite( applyComparisonOperator( op, @@ -195,6 +203,7 @@ export function compareValues( "cannot compare complex values with incompatible units", ); } + return definite( op === "eq" ? left.re === right.re && left.im === right.im @@ -240,6 +249,7 @@ export function compareText( ); } const isMatch = pattern.test(left.value); + return definite(op === "matches" ? isMatch : !isMatch); } case "portableMatches": @@ -250,12 +260,14 @@ export function compareText( } catch (error) { const reason = error instanceof RegexParseError ? error.message : String(error); + return indeterminate( "wrong-type", `'${right.value}' is not a valid trilean-regex pattern: ${reason}`, ); } const isMatch = compiled.test(left.value); + return definite(op === "portableMatches" ? isMatch : !isMatch); } default: @@ -282,6 +294,7 @@ export function computeMembershipMatch( "cannot compare numbers with incompatible units for membership", ); } + return definite(operand.value === candidate.value); case "text": if (candidate.kind !== "text") { @@ -290,6 +303,7 @@ export function computeMembershipMatch( `cannot compare a 'text' value with a '${candidate.kind}' value for membership`, ); } + return definite(operand.value === candidate.value); case "boolean": if (candidate.kind !== "boolean") { @@ -298,6 +312,7 @@ export function computeMembershipMatch( `cannot compare a 'boolean' value with a '${candidate.kind}' value for membership`, ); } + return definite(operand.value === candidate.value); case "instant": { if (candidate.kind !== "instant") { @@ -312,6 +327,7 @@ export function computeMembershipMatch( if (candidateEpoch === undefined) { return unparseableInstant(candidate.value); } + return definite(operandEpoch === candidateEpoch); } case "duration": @@ -321,6 +337,7 @@ export function computeMembershipMatch( `cannot compare a 'duration' value with a '${candidate.kind}' value for membership`, ); } + return definite( toMilliseconds(operand.value, operand.unit) === toMilliseconds(candidate.value, candidate.unit), @@ -338,6 +355,7 @@ export function computeMembershipMatch( "cannot compare complex values with incompatible units for membership", ); } + return definite( operand.re === candidate.re && operand.im === candidate.im, ); @@ -398,6 +416,7 @@ function applyArithmeticOnNumbers( "cannot add numbers with incompatible units", ); } + return definite({ kind: "number", value: left.value + right.value, @@ -410,6 +429,7 @@ function applyArithmeticOnNumbers( "cannot subtract numbers with incompatible units", ); } + return definite({ kind: "number", value: left.value - right.value, @@ -425,6 +445,7 @@ function applyArithmeticOnNumbers( if (right.value === 0) { return indeterminate("domain-error", "division by zero"); } + return definite({ kind: "number", value: left.value / right.value, @@ -450,6 +471,7 @@ function applyArithmeticOnNumbers( "zero raised to a negative power is a division by zero", ); } + return definite({ kind: "number", value: left.value ** right.value }); case "modulo": if (!isDimensionless(left.unit) || !isDimensionless(right.unit)) { @@ -461,6 +483,7 @@ function applyArithmeticOnNumbers( if (right.value === 0) { return indeterminate("domain-error", "modulo by zero"); } + return definite({ kind: "number", value: left.value % right.value }); default: throw new Error("unreachable arithmetic operator"); @@ -508,6 +531,7 @@ function toComplexValue( value: Readonly>, ): Extract { if (value.kind === "complex") return value; + return { kind: "complex", re: value.value, im: 0, unit: value.unit }; } @@ -538,6 +562,7 @@ function applyArithmeticOnComplex( "cannot add complex values with incompatible units", ); } + return definite({ kind: "complex", re: left.re + right.re, @@ -551,6 +576,7 @@ function applyArithmeticOnComplex( "cannot subtract complex values with incompatible units", ); } + return definite({ kind: "complex", re: left.re - right.re, @@ -566,6 +592,7 @@ function applyArithmeticOnComplex( if (divisorSquaredMagnitude === 0) { return indeterminate("domain-error", "division by zero"); } + return definite({ kind: "complex", re: (left.re * right.re + left.im * right.im) / divisorSquaredMagnitude, @@ -613,6 +640,7 @@ function applyComplexPower( repeatedProduct = multiplyComplexValues(repeatedProduct, complexBase); } if (exponent.value >= 0) return definite(repeatedProduct); + // A negative exponent is the reciprocal of the positive one by definition, so it reuses the division above rather than re-deriving it -- which also means a zero base inherits that operator's own division-by-zero domain-error instead of needing its own check. return applyArithmeticOnComplex("divide", complexUnit, repeatedProduct); } @@ -634,6 +662,7 @@ export function applyArithmetic( if (leftEpoch === undefined) return unparseableInstant(left.value); const rightEpoch = toEpochMilliseconds(right.value); if (rightEpoch === undefined) return unparseableInstant(right.value); + return definite({ kind: "duration", value: leftEpoch - rightEpoch, @@ -643,6 +672,7 @@ export function applyArithmetic( if (left.kind === "instant" && right.kind === "duration" && op === "add") { const leftEpoch = toEpochMilliseconds(left.value); if (leftEpoch === undefined) return unparseableInstant(left.value); + return instantFromEpochMilliseconds( leftEpoch + toMilliseconds(right.value, right.unit), ); @@ -650,6 +680,7 @@ export function applyArithmetic( if (left.kind === "duration" && right.kind === "instant" && op === "add") { const rightEpoch = toEpochMilliseconds(right.value); if (rightEpoch === undefined) return unparseableInstant(right.value); + return instantFromEpochMilliseconds( rightEpoch + toMilliseconds(left.value, left.unit), ); @@ -683,6 +714,7 @@ export function applyArithmetic( } if (left.kind === "complex" || right.kind === "complex") { if (op === "power") return applyComplexPower(left, right); + // Both operands are `number` or `complex` by this point, so promoting the real side is always possible; the result is complex whenever either operand is, regardless of what the components turn out to be. return applyArithmeticOnComplex( op, @@ -690,6 +722,7 @@ export function applyArithmetic( toComplexValue(right), ); } + return applyArithmeticOnNumbers(op, left, right); } @@ -712,6 +745,7 @@ export function checkReferenceUnit( "the resolved value's unit does not match the reference's expected unit", ); } + return undefined; } @@ -732,5 +766,6 @@ export function invokeRegisteredFunction( if ("domainError" in outcome) { return indeterminate("domain-error", outcome.domainError); } + return definite(outcome); } diff --git a/packages/trilean/src/evaluator.arithmetic.test.ts b/packages/trilean/src/evaluator.arithmetic.unit.test.ts similarity index 96% rename from packages/trilean/src/evaluator.arithmetic.test.ts rename to packages/trilean/src/evaluator.arithmetic.unit.test.ts index 4c7aff5..d288b19 100644 --- a/packages/trilean/src/evaluator.arithmetic.test.ts +++ b/packages/trilean/src/evaluator.arithmetic.unit.test.ts @@ -7,7 +7,7 @@ import { resolvers, } from "./evaluator-test-helpers"; -/** `arithmetic`'s own indeterminate-propagation table -- contrast this against `and`/`or`'s absorption (see truth-tables.test.ts and `combineAnd`/`combineOr` in evaluator-operations.ts): arithmetic has no absorbing value at all, so a definite operand on one side never rescues an indeterminate operand on the other, unlike OR's absorbing `true` or AND's absorbing `false`. */ +/** `arithmetic`'s own indeterminate-propagation table -- contrast this against `and`/`or`'s absorption (see truth-tables.unit.test.ts and `combineAnd`/`combineOr` in evaluator-operations.ts): arithmetic has no absorbing value at all, so a definite operand on one side never rescues an indeterminate operand on the other, unlike OR's absorbing `true` or AND's absorbing `false`. */ describe("arithmetic indeterminate propagation (no absorbing value)", () => { const definiteOperand: ExpressionNode = { kind: "numberLiteral", value: 1 }; const indeterminateOperand: ExpressionNode = { diff --git a/packages/trilean/src/evaluator.collections.test.ts b/packages/trilean/src/evaluator.collections.unit.test.ts similarity index 99% rename from packages/trilean/src/evaluator.collections.test.ts rename to packages/trilean/src/evaluator.collections.unit.test.ts index b2d4fee..e280012 100644 --- a/packages/trilean/src/evaluator.collections.test.ts +++ b/packages/trilean/src/evaluator.collections.unit.test.ts @@ -23,6 +23,7 @@ describe("lookup", () => { value: { kind: "number", value: 2 }, }); } + return Promise.resolve({ found: false }); }, resolveLookup: async (table) => @@ -40,6 +41,7 @@ describe("lookup", () => { ...lookupResolvers, resolveLookup: async (table, keys, context) => { receivedKeys.push(...keys); + return lookupResolvers.resolveLookup(table, keys, context); }, }; @@ -119,6 +121,7 @@ describe("fold", () => { if (typeof value === "string") { return Promise.resolve({ found: true, value: { kind: "text", value } }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -133,6 +136,7 @@ describe("fold", () => { ]); } if (collection === "empty") return Promise.resolve([]); + return Promise.resolve([]); }, }; diff --git a/packages/trilean/src/evaluator.comparison.test.ts b/packages/trilean/src/evaluator.comparison.unit.test.ts similarity index 100% rename from packages/trilean/src/evaluator.comparison.test.ts rename to packages/trilean/src/evaluator.comparison.unit.test.ts diff --git a/packages/trilean/src/evaluator.complex-arithmetic.test.ts b/packages/trilean/src/evaluator.complex-arithmetic.unit.test.ts similarity index 100% rename from packages/trilean/src/evaluator.complex-arithmetic.test.ts rename to packages/trilean/src/evaluator.complex-arithmetic.unit.test.ts diff --git a/packages/trilean/src/evaluator.control-flow.test.ts b/packages/trilean/src/evaluator.control-flow.unit.test.ts similarity index 99% rename from packages/trilean/src/evaluator.control-flow.test.ts rename to packages/trilean/src/evaluator.control-flow.unit.test.ts index 3a0178e..3c30cfb 100644 --- a/packages/trilean/src/evaluator.control-flow.test.ts +++ b/packages/trilean/src/evaluator.control-flow.unit.test.ts @@ -87,6 +87,7 @@ describe("conditional", () => { ...resolvers, resolveValue: async (key, context) => { if (key === "later-then-marker") laterThenEvaluations += 1; + return resolvers.resolveValue(key, context); }, }; @@ -123,6 +124,7 @@ describe("conditional", () => { ...resolvers, resolveValue: async (key, context) => { if (key === "second-then-marker") secondThenEvaluations += 1; + return resolvers.resolveValue(key, context); }, }; @@ -342,6 +344,7 @@ describe("conditional", () => { ...resolvers, resolveValue: async (key, context) => { if (key === "when-marker") whenEvaluationCount += 1; + return resolvers.resolveValue(key, context); }, }; @@ -686,6 +689,7 @@ describe("call", () => { if (arg?.kind !== "number") { return { domainError: "double requires a single number argument" }; } + return { kind: "number", value: arg.value + arg.value }; }, }; diff --git a/packages/trilean/src/evaluator.indeterminacy-cases.test.ts b/packages/trilean/src/evaluator.indeterminacy-cases.unit.test.ts similarity index 99% rename from packages/trilean/src/evaluator.indeterminacy-cases.test.ts rename to packages/trilean/src/evaluator.indeterminacy-cases.unit.test.ts index ebd4b95..1c2763d 100644 --- a/packages/trilean/src/evaluator.indeterminacy-cases.test.ts +++ b/packages/trilean/src/evaluator.indeterminacy-cases.unit.test.ts @@ -27,6 +27,7 @@ describe("lookup: any-indeterminate-key short-circuit", () => { ...baseResolvers, resolveLookup: async (table, keys, context) => { resolveLookupCalls += 1; + return baseResolvers.resolveLookup(table, keys, context); }, }; @@ -47,6 +48,7 @@ describe("conditional: must not skip an unresolved guard", () => { ...baseResolvers, resolveValue: async (key, context) => { if (key === "later-case-marker") laterCaseEvaluations += 1; + return baseResolvers.resolveValue(key, context); }, }; @@ -152,6 +154,7 @@ describe("fold: reduce vs max/min over an empty collection", () => { ...baseResolvers, resolveValue: async (key, context) => { if (key === "combine-marker") combineEvaluations += 1; + return baseResolvers.resolveValue(key, context); }, }; @@ -370,6 +373,7 @@ describe("call: unregistered-name vs out-of-domain-argument distinction", () => } if (arg.value === 0) return { domainError: "reciprocal of zero is undefined" }; + return { kind: "number", value: 1 / arg.value }; }, }, diff --git a/packages/trilean/src/evaluator.indeterminacy-table.test.ts b/packages/trilean/src/evaluator.indeterminacy-table.unit.test.ts similarity index 99% rename from packages/trilean/src/evaluator.indeterminacy-table.test.ts rename to packages/trilean/src/evaluator.indeterminacy-table.unit.test.ts index 4e56af0..90eadbb 100644 --- a/packages/trilean/src/evaluator.indeterminacy-table.test.ts +++ b/packages/trilean/src/evaluator.indeterminacy-table.unit.test.ts @@ -407,6 +407,7 @@ const fixtures: readonly Fixture[] = [ } if (arg.value === 0) return { domainError: "reciprocal of zero is undefined" }; + return { kind: "number", value: 1 / arg.value }; }, }, diff --git a/packages/trilean/src/evaluator.integration.test.ts b/packages/trilean/src/evaluator.integration.test.ts index 56d18f5..5253273 100644 --- a/packages/trilean/src/evaluator.integration.test.ts +++ b/packages/trilean/src/evaluator.integration.test.ts @@ -24,6 +24,7 @@ describe("evaluation resource limits", () => { for (let level = 0; level < depth; level += 1) { node = { kind: "not", operand: node }; } + return node; } @@ -170,6 +171,7 @@ describe("golden example (README Worked example)", () => { return Promise.resolve({ found: false }); } const value = context[key]; + return Promise.resolve( typeof value === "number" ? { found: true, value: { kind: "number", value } } @@ -182,6 +184,7 @@ describe("golden example (README Worked example)", () => { return Promise.resolve([]); } const items = context.items; + return Promise.resolve(isUnknownArray(items) ? items : []); }, }; diff --git a/packages/trilean/src/evaluator.membership.test.ts b/packages/trilean/src/evaluator.membership.unit.test.ts similarity index 98% rename from packages/trilean/src/evaluator.membership.test.ts rename to packages/trilean/src/evaluator.membership.unit.test.ts index fedf758..4c72d3b 100644 --- a/packages/trilean/src/evaluator.membership.test.ts +++ b/packages/trilean/src/evaluator.membership.unit.test.ts @@ -210,8 +210,10 @@ describe("memberOf", () => { op: "in", operand: { kind: "numberLiteral", value: 5 }, candidates: [ - { kind: "textLiteral", value: "5" }, // incompatible kind -> wrong-type - { kind: "reference", key: "missing" }, // not-found + // incompatible kind -> wrong-type + { kind: "textLiteral", value: "5" }, + // not-found + { kind: "reference", key: "missing" }, ], }, undefined, @@ -226,6 +228,7 @@ describe("memberOf", () => { ...resolvers, resolveValue: async (key, context) => { if (key === "candidate-marker") candidateEvaluations += 1; + return resolvers.resolveValue(key, context); }, }; @@ -328,6 +331,7 @@ describe("some, every", () => { if (typeof value === "string") { return Promise.resolve({ found: true, value: { kind: "text", value } }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -344,9 +348,11 @@ describe("some, every", () => { if (collection === "flagged") { return Promise.resolve([ { flag: 1, amount: 100 }, - { amount: 5 }, // no `flag` field -- the filter itself is indeterminate for this item + // no `flag` field -- the filter itself is indeterminate for this item + { amount: 5 }, ]); } + return Promise.resolve([]); }, }; diff --git a/packages/trilean/src/evaluator.sync.test.ts b/packages/trilean/src/evaluator.sync.unit.test.ts similarity index 99% rename from packages/trilean/src/evaluator.sync.test.ts rename to packages/trilean/src/evaluator.sync.unit.test.ts index a6fc431..657fed3 100644 --- a/packages/trilean/src/evaluator.sync.test.ts +++ b/packages/trilean/src/evaluator.sync.unit.test.ts @@ -22,6 +22,7 @@ function toComputed(value: unknown): Resolution { if (typeof value === "boolean") { return { found: true, value: { kind: "boolean", value } }; } + return { found: false }; } @@ -45,11 +46,13 @@ const syncResolvers: SyncResolvers = { if (table === "doubling" && first?.kind === "number") { return { found: true, value: { kind: "number", value: first.value * 2 } }; } + return { found: false }; }, resolveCollection: (collection, context) => { if (typeof collection !== "string" || !isPlainRecord(context)) return []; const items = context[collection]; + return isUnknownArray(items) ? items : []; }, resolveDelegate: (system, payload) => @@ -83,6 +86,7 @@ const asyncResolvers: Resolvers = { const functions = { double: (args: readonly ComputedValue[]) => { const [first] = args; + return first?.kind === "number" ? { kind: "number" as const, value: first.value * 2 } : { domainError: "double takes a number" }; diff --git a/packages/trilean/src/evaluator.temporal.test.ts b/packages/trilean/src/evaluator.temporal.unit.test.ts similarity index 100% rename from packages/trilean/src/evaluator.temporal.test.ts rename to packages/trilean/src/evaluator.temporal.unit.test.ts diff --git a/packages/trilean/src/evaluator.ts b/packages/trilean/src/evaluator.ts index 190ebf6..75b0fde 100644 --- a/packages/trilean/src/evaluator.ts +++ b/packages/trilean/src/evaluator.ts @@ -67,6 +67,7 @@ export function* evaluatePredicateInternal( nestingDepth + 1, ); if (operand.status === "indeterminate") return operand; + return definite(!operand.value); } case "and": { @@ -94,6 +95,7 @@ export function* evaluatePredicateInternal( nestingDepth + 1, ), ]); + return combineAnd(left, right); } case "or": { @@ -121,6 +123,7 @@ export function* evaluatePredicateInternal( nestingDepth + 1, ), ]); + return combineOr(left, right); } case "allOf": { @@ -139,6 +142,7 @@ export function* evaluatePredicateInternal( ), ), ); + return operandResults.reduce>( combineAnd, definite(true), @@ -160,6 +164,7 @@ export function* evaluatePredicateInternal( ), ), ); + return operandResults.reduce>( combineOr, definite(false), @@ -192,6 +197,7 @@ export function* evaluatePredicateInternal( ]); if (left.status === "indeterminate") return left; if (right.status === "indeterminate") return right; + return compareValues(node.op, left.value, right.value); } case "textCompare": { @@ -221,6 +227,7 @@ export function* evaluatePredicateInternal( ]); if (left.status === "indeterminate") return left; if (right.status === "indeterminate") return right; + return compareText(node.op, left.value, right.value); } case "memberOf": { @@ -254,6 +261,7 @@ export function* evaluatePredicateInternal( if (candidateResult.status === "indeterminate") { return candidateResult; } + return computeMembershipMatch( operandResult.value, candidateResult.value, @@ -269,6 +277,7 @@ export function* evaluatePredicateInternal( // No definite match: indeterminate (first candidate's reason, per the tie-break rule) if any candidate was itself indeterminate or of an incompatible kind/unit; otherwise every candidate was a definite non-match. An empty `candidates` array falls straight through to this same definite non-match result, with no separate empty-list branch needed. const reason = firstIndeterminate(...candidateOutcomes); if (reason !== undefined) return { status: "indeterminate", reason }; + return definite(node.op === "notIn"); } case "exists": { @@ -290,6 +299,7 @@ export function* evaluatePredicateInternal( ) { return definite(false); } + return definite(true); } case "some": @@ -314,6 +324,7 @@ export function* evaluatePredicateInternal( }): Task | undefined> { if (filterOutcome === "exclude") return undefined; if (filterOutcome !== "include") return filterOutcome; + return yield* evaluatePredicateInternal( node.item, item, @@ -330,6 +341,7 @@ export function* evaluatePredicateInternal( // `some` is an OR fold seeded at `false`; `every` an AND fold seeded at `true` -- exactly `anyOf`/`allOf`'s own pairwise fold, so an empty `votes` list (an empty collection, or every candidate filtered out) already reduces to `anyOf([])`/`allOf([])`'s own identity values with no separate empty-collection branch. const combine = node.kind === "some" ? combineOr : combineAnd; const identity = node.kind === "some" ? definite(false) : definite(true); + return votes.reduce>(combine, identity); } case "treeReference": { @@ -363,6 +375,7 @@ export function* evaluatePredicateInternal( "the referenced tree is not a valid PredicateNode", ); } + return yield* evaluatePredicateInternal( parsed.data, context, @@ -404,6 +417,7 @@ export function* evaluateValueInternal( } const unitError = checkReferenceUnit(node.unit, resolution.value); if (unitError !== undefined) return unitError; + return definite(resolution.value); } case "accumulator": { @@ -413,6 +427,7 @@ export function* evaluateValueInternal( "accumulator used outside a reduce fold's combine expression", ); } + return definite(accumulator); } case "call": { @@ -436,6 +451,7 @@ export function* evaluateValueInternal( if (result.status === "indeterminate") return result; args.push(result.value); } + return invokeRegisteredFunction(functions, node.fn, args); } case "numberLiteral": @@ -462,6 +478,7 @@ export function* evaluateValueInternal( unit: node.unit, }); } + return definite(complexFromPolar(node.magnitude, node.phase, node.unit)); case "arithmetic": { const [left, right] = yield* all([ @@ -491,6 +508,7 @@ export function* evaluateValueInternal( // Unlike `and`/`or` (see `combineAnd`/`combineOr` above), arithmetic has no absorbing value: any indeterminate operand always makes the whole node indeterminate, regardless of what the other operand would have been, tie-broken left before right per the tie-break rule. if (left.status === "indeterminate") return left; if (right.status === "indeterminate") return right; + return applyArithmetic(node.op, left.value, right.value); } case "negate": { @@ -506,6 +524,7 @@ export function* evaluateValueInternal( nestingDepth + 1, ); if (operand.status === "indeterminate") return operand; + return applyNegate(operand.value); } case "lookup": { @@ -539,6 +558,7 @@ export function* evaluateValueInternal( `no match found in lookup table ${JSON.stringify(node.table)}`, ); } + return definite(resolution.value); } case "conditional": { @@ -572,6 +592,7 @@ export function* evaluateValueInternal( ); } } + return yield* evaluateValueInternal( node.fallback, context, @@ -602,6 +623,7 @@ export function* evaluateValueInternal( budget, nestingDepth + 1, ); + return { then, whenResult }; }), ); @@ -633,6 +655,7 @@ export function* evaluateValueInternal( nestingDepth + 1, ); } + return yield* evaluateValueInternal( match.then, context, @@ -696,6 +719,7 @@ export function* evaluateValueInternal( if (stepResult.status === "indeterminate") return stepResult; runningAccumulator = stepResult.value; } + return definite(runningAccumulator); } @@ -739,6 +763,7 @@ export function* evaluateValueInternal( "unreachable: max/min over a non-empty participating list produced no running result", ); } + return definite(runningExtremum); } case "delegate": { @@ -757,6 +782,7 @@ export function* evaluateValueInternal( `delegate handler for external system '${node.system}' reported no value`, ); } + return definite(resolution.value); } case "treeReference": { @@ -790,6 +816,7 @@ export function* evaluateValueInternal( "the referenced tree is not a valid ExpressionNode", ); } + return yield* evaluateValueInternal( parsed.data, context, diff --git a/packages/trilean/src/evaluator.values.test.ts b/packages/trilean/src/evaluator.values.unit.test.ts similarity index 100% rename from packages/trilean/src/evaluator.values.test.ts rename to packages/trilean/src/evaluator.values.unit.test.ts diff --git a/packages/trilean/src/golden-examples.test.ts b/packages/trilean/src/golden-examples.unit.test.ts similarity index 100% rename from packages/trilean/src/golden-examples.test.ts rename to packages/trilean/src/golden-examples.unit.test.ts diff --git a/packages/trilean/src/test-support/golden-example.ts b/packages/trilean/src/test-support/golden-example.ts index 7c833ec..e2924e4 100644 --- a/packages/trilean/src/test-support/golden-example.ts +++ b/packages/trilean/src/test-support/golden-example.ts @@ -12,7 +12,7 @@ function isUnknownArray(value: unknown): value is unknown[] { } /** - * The exact worked example from README.md's "Worked example" section: `isActive` equals `true` AND `sum(items.amount)` -- the literal `fold` tree the `sum` derived-aggregate builder assembles -- is greater than `x + y`. Exported here, rather than defined separately in each consuming test file, so the unit-project gate (src/golden-examples.test.ts) and the Cloudflare Workers re-run (test/workers/trilean.test.ts) share one byte-identical fixture and can never silently drift apart. + * The exact worked example from README.md's "Worked example" section: `isActive` equals `true` AND `sum(items.amount)` -- the literal `fold` tree the `sum` derived-aggregate builder assembles -- is greater than `x + y`. Exported here, rather than defined separately in each consuming test file, so the unit-project gate (src/golden-examples.unit.test.ts) and the Cloudflare Workers re-run (test/workers/trilean.e2e.test.ts) share one byte-identical fixture and can never silently drift apart. */ export const goldenExampleTree: PredicateNode = { kind: "and", @@ -65,18 +65,21 @@ export const goldenExampleResolvers: Resolvers = { value: { kind: "boolean", value }, }); } + return Promise.resolve( typeof value === "number" ? { found: true, value: { kind: "number", value } } : { found: false }, ); }, - resolveLookup: async () => Promise.resolve({ found: false }), // unused by this example + // unused by this example + resolveLookup: async () => Promise.resolve({ found: false }), resolveCollection: async (collection, context) => { if (collection !== "items" || !isPlainRecord(context)) { return Promise.resolve([]); } const items = context.items; + return Promise.resolve(isUnknownArray(items) ? items : []); }, }; diff --git a/packages/trilean/src/tree.test.ts b/packages/trilean/src/tree.unit.test.ts similarity index 100% rename from packages/trilean/src/tree.test.ts rename to packages/trilean/src/tree.unit.test.ts diff --git a/packages/trilean/src/truth-tables.test.ts b/packages/trilean/src/truth-tables.unit.test.ts similarity index 99% rename from packages/trilean/src/truth-tables.test.ts rename to packages/trilean/src/truth-tables.unit.test.ts index 7cb5c17..6a4057c 100644 --- a/packages/trilean/src/truth-tables.test.ts +++ b/packages/trilean/src/truth-tables.unit.test.ts @@ -38,6 +38,7 @@ function expectTruthValue( ): void { if (expected === "U") { expect(evaluation.status).toBe("indeterminate"); + return; } expect(evaluation).toEqual({ status: "definite", value: expected === "T" }); diff --git a/packages/trilean/test/declaration-shape.test.ts b/packages/trilean/test/declaration-shape.e2e.test.ts similarity index 87% rename from packages/trilean/test/declaration-shape.test.ts rename to packages/trilean/test/declaration-shape.e2e.test.ts index cf0294f..357f950 100644 --- a/packages/trilean/test/declaration-shape.test.ts +++ b/packages/trilean/test/declaration-shape.e2e.test.ts @@ -1,8 +1,8 @@ -// Guards the shape of the built declaration files for the two recursive node trees: every top-level declaration in dist/tree.d.ts and dist/tree.d.cts carries its own `export` modifier, none is declared bare and re-exported through a trailing `export { ... }` list. -// -// Why the shape matters: a consumer that re-exports `z.array(PredicateNodeSchema)` (or the expression equivalent) without an annotation makes TypeScript serialise the inferred type into the consumer's own declaration output. When the tree's declarations are exported in place, TypeScript can name them and the output stays small. When the declaration bundler emits bare `declare const X` plus a trailing `export { X }` list (tsdown before 0.23 did), the consumer's inferred type expands the whole recursive tree and fails with TS7056 ("the inferred type of this node exceeds the maximum length the compiler will serialize") in tsdown, tsc and any other declaration generator. The shape is decided by the bundler, not by anything in src/tree.ts, so a bundler change can reintroduce the failure with no diff to the schemas; this test is what would notice. -// -// Checks the built package like smoke.test.ts does (the `_test:smoke` turbo task depends on `_build`), and is a member of tsconfig.node.json's program, so it may use Node APIs. +/* Guards the shape of the built declaration files for the two recursive node trees: every top-level declaration in dist/tree.d.ts and dist/tree.d.cts carries its own `export` modifier, none is declared bare and re-exported through a trailing `export { ... }` list. + + Why the shape matters: a consumer that re-exports `z.array(PredicateNodeSchema)` (or the expression equivalent) without an annotation makes TypeScript serialise the inferred type into the consumer's own declaration output. When the tree's declarations are exported in place, TypeScript can name them and the output stays small. When the declaration bundler emits bare `declare const X` plus a trailing `export { X }` list (tsdown before 0.23 did), the consumer's inferred type expands the whole recursive tree and fails with TS7056 ("the inferred type of this node exceeds the maximum length the compiler will serialize") in tsdown, tsc and any other declaration generator. The shape is decided by the bundler, not by anything in src/tree.ts, so a bundler change can reintroduce the failure with no diff to the schemas; this test is what would notice. + + Checks the built package like smoke.e2e.test.ts does (the `_test:smoke` turbo task depends on `_build`), and is a member of tsconfig.node.json's program, so it may use Node APIs. */ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; diff --git a/packages/trilean/test/integration/complex-arithmetic.test.ts b/packages/trilean/test/integration/complex-arithmetic.integration.test.ts similarity index 99% rename from packages/trilean/test/integration/complex-arithmetic.test.ts rename to packages/trilean/test/integration/complex-arithmetic.integration.test.ts index 240168f..46abc30 100644 --- a/packages/trilean/test/integration/complex-arithmetic.test.ts +++ b/packages/trilean/test/integration/complex-arithmetic.integration.test.ts @@ -32,6 +32,7 @@ const resolvers: Resolvers = { value: { kind: "number", value: 2 }, }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -45,6 +46,7 @@ const functions: FunctionRegistry = { if (arg?.kind !== "complex") { return { domainError: "expected a complex argument" }; } + return complexMagnitude(arg); }, }; diff --git a/packages/trilean/test/integration/composed-rules.test.ts b/packages/trilean/test/integration/composed-rules.integration.test.ts similarity index 99% rename from packages/trilean/test/integration/composed-rules.test.ts rename to packages/trilean/test/integration/composed-rules.integration.test.ts index 7c7eda9..8fdf5bb 100644 --- a/packages/trilean/test/integration/composed-rules.test.ts +++ b/packages/trilean/test/integration/composed-rules.integration.test.ts @@ -163,6 +163,7 @@ describe("membership eligibility (allOf, anyOf, compare, textCompare, memberOf, if (typeof value === "string") { return Promise.resolve({ found: true, value: { kind: "text", value } }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -174,6 +175,7 @@ describe("membership eligibility (allOf, anyOf, compare, textCompare, memberOf, ) { return Promise.resolve(context.orders); } + return Promise.resolve([]); }, }; @@ -236,6 +238,7 @@ describe("units-aware rate calculation (multiply/divide across differently unit- } const value = context[key]; if (typeof value !== "number") return Promise.resolve({ found: false }); + return Promise.resolve({ found: true, value: { kind: "number", value, unit: unitByKey[key] }, @@ -250,6 +253,7 @@ describe("units-aware rate calculation (multiply/divide across differently unit- ) { return Promise.resolve(context.parcels); } + return Promise.resolve([]); }, }; @@ -343,11 +347,13 @@ describe("units-aware rate calculation (multiply/divide across differently unit- ) { const raw = context.weightKg; if (typeof raw !== "number") return Promise.resolve({ found: false }); + return Promise.resolve({ found: true, value: { kind: "number", value: raw, unit: { g: 1 } }, }); } + return resolvers.resolveValue(key, context); }, }; @@ -379,6 +385,7 @@ describe("temporal arithmetic with a quantifier and a conditional fallback (inst } const value = context[key]; if (typeof value !== "string") return Promise.resolve({ found: false }); + return Promise.resolve({ found: true, value: { kind: "instant", value }, @@ -393,6 +400,7 @@ describe("temporal arithmetic with a quantifier and a conditional fallback (inst ) { return Promise.resolve(context.sessions); } + return Promise.resolve([]); }, }; @@ -493,6 +501,7 @@ describe("fold's max combiner alongside memberOf and a derived connective (impli } const value = context[key]; if (typeof value !== "number") return Promise.resolve({ found: false }); + return Promise.resolve({ found: true, value: { kind: "number", value } }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -504,6 +513,7 @@ describe("fold's max combiner alongside memberOf and a derived connective (impli ) { return Promise.resolve(context.bids); } + return Promise.resolve([]); }, }; diff --git a/packages/trilean/test/integration/delegate-handler.test.ts b/packages/trilean/test/integration/delegate-handler.integration.test.ts similarity index 99% rename from packages/trilean/test/integration/delegate-handler.test.ts rename to packages/trilean/test/integration/delegate-handler.integration.test.ts index 79281d4..5d30034 100644 --- a/packages/trilean/test/integration/delegate-handler.test.ts +++ b/packages/trilean/test/integration/delegate-handler.integration.test.ts @@ -39,6 +39,7 @@ describe("delegate result flows through arithmetic and a conditional's fallback if (typeof value === "string") { return Promise.resolve({ found: true, value: { kind: "text", value } }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -53,6 +54,7 @@ describe("delegate result flows through arithmetic and a conditional's fallback return Promise.resolve({ found: false }); } const rate = taxRatesByRegion[context.region]; + return Promise.resolve( rate === undefined ? { found: false } @@ -156,6 +158,7 @@ describe("delegate result feeds into a fold aggregation, resolved per participat if (typeof value === "string") { return Promise.resolve({ found: true, value: { kind: "text", value } }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -164,6 +167,7 @@ describe("delegate result feeds into a fold aggregation, resolved per participat return Promise.resolve([]); } const items = context.lineItems; + return Promise.resolve(isUnknownArray(items) ? items : []); }, // Resolved per participating item -- each fold step's own context is the line item itself (see the fold/reduce section of README.md), so this handler reads the item's own `category` rather than anything from the outer order. @@ -176,6 +180,7 @@ describe("delegate result feeds into a fold aggregation, resolved per participat return Promise.resolve({ found: false }); } const rate = categoryRates[context.category]; + return Promise.resolve( rate === undefined ? { found: false } diff --git a/packages/trilean/test/integration/function-registry.test.ts b/packages/trilean/test/integration/function-registry.integration.test.ts similarity index 99% rename from packages/trilean/test/integration/function-registry.test.ts rename to packages/trilean/test/integration/function-registry.integration.test.ts index f571263..0f7707c 100644 --- a/packages/trilean/test/integration/function-registry.test.ts +++ b/packages/trilean/test/integration/function-registry.integration.test.ts @@ -25,6 +25,7 @@ const resolvers: Resolvers = { return Promise.resolve({ found: false }); } const value = context[key]; + return typeof value === "number" ? Promise.resolve({ found: true, value: { kind: "number", value } }) : Promise.resolve({ found: false }); @@ -43,6 +44,7 @@ function requireNumberArg( domainError: `expected argument ${String(index)} to be a number`, }; } + return arg; } @@ -58,17 +60,20 @@ const functions: FunctionRegistry = { domainError: "squareRoot of a negative number is not a real number", }; } + return { kind: "number", value: Math.sqrt(arg.value) }; }, absoluteValue: (args) => { const arg = requireNumberArg(args, 0); if ("domainError" in arg) return arg; + return { kind: "number", value: Math.abs(arg.value) }; }, // A custom, domain-specific function -- not one of README.md's own starting examples -- proving the registry is genuinely open-ended, not limited to a fixed built-in set. toleranceBand: (args) => { const arg = requireNumberArg(args, 0); if ("domainError" in arg) return arg; + return { kind: "number", value: Math.abs(arg.value) * TOLERANCE_RATIO }; }, }; diff --git a/packages/trilean/test/integration/json-schema-consistency.test.ts b/packages/trilean/test/integration/json-schema-consistency.integration.test.ts similarity index 96% rename from packages/trilean/test/integration/json-schema-consistency.test.ts rename to packages/trilean/test/integration/json-schema-consistency.integration.test.ts index 07c066a..ac1be33 100644 --- a/packages/trilean/test/integration/json-schema-consistency.test.ts +++ b/packages/trilean/test/integration/json-schema-consistency.integration.test.ts @@ -5,7 +5,7 @@ import { ExpressionNodeSchema, PredicateNodeSchema } from "../../src/tree"; /** * Converts each node tree to JSON Schema straight from src/tree.ts and asserts two things the conversion must preserve: every `kind` the Zod discriminated union accepts, and the reference structure that mutual recursion forces the converter to emit. The conversion is the fragile half of README.md's "One schema, mechanically derived artefacts" principle: both trees are built from getter-backed, mutually recursive `z.object` shapes, so a change to tree.ts (or a zod upgrade) could make `z.toJSONSchema` throw, inline a branch away, emit a degenerate branch carrying no `kind` const at all, or change how it points a recursive schema back at itself -- and the wire-format schema would silently stop describing the type the runtime validator actually enforces. * - * Scope, deliberately: this proves conversion from source is faithful, needing no `pnpm build`. It does not and cannot check the artefact that actually ships -- scripts/generate-json-schema.ts splices zod's multi-document registry output into one self-contained file, and only a real build produces that. test/smoke.test.ts owns that half, asserting the generated schemas/trilean.schema.json resolves all its own $refs and carries exactly the built package's own discriminant set. Re-deriving the script's splicing here instead would only compare the test's copy of that logic against itself. + * Scope, deliberately: this proves conversion from source is faithful, needing no `pnpm build`. It does not and cannot check the artefact that actually ships -- scripts/generate-json-schema.ts splices zod's multi-document registry output into one self-contained file, and only a real build produces that. test/smoke.e2e.test.ts owns that half, asserting the generated schemas/trilean.schema.json resolves all its own $refs and carries exactly the built package's own discriminant set. Re-deriving the script's splicing here instead would only compare the test's copy of that logic against itself. * * What neither half detects, and no test should pretend to: a node kind added to a union that the script then fails to pick up. The script registers whole unions rather than individual kinds, so there is no per-kind registration to forget and nothing to drift. A new top-level tree registered nowhere would be a real omission, but there are exactly two, both hardcoded, and inventing a check for a third that does not exist would assert a requirement nobody has. */ @@ -22,6 +22,7 @@ function readKindLiteral(option: unknown): string | undefined { const kind = shape.kind; if (!isPlainRecord(kind)) return undefined; const value = kind.value; + return typeof value === "string" ? value : undefined; } @@ -30,6 +31,7 @@ function hasOptionsArray( value: unknown, ): value is { options: readonly unknown[] } { if (!isPlainRecord(value)) return false; + return Array.isArray(value.options); } @@ -67,6 +69,7 @@ function extractKindConst(branch: unknown): string[] { "expected a 'kind' const on a discriminated-union branch, or a nested 'oneOf'/'anyOf' branch list", ); } + return nested.flatMap((entry) => extractKindConst(entry)); } @@ -82,12 +85,14 @@ function extractGeneratedKinds(nodeDefinition: unknown): string[] { "expected the generated node definition to have a 'oneOf' or 'anyOf' branch list", ); } + return branches.flatMap((branch) => extractKindConst(branch)); } function collectRefs(value: unknown, found = new Set()): Set { if (Array.isArray(value)) { for (const entry of value) collectRefs(entry, found); + return found; } if (!isPlainRecord(value)) return found; @@ -95,6 +100,7 @@ function collectRefs(value: unknown, found = new Set()): Set { if (key === "$ref" && typeof entry === "string") found.add(entry); else collectRefs(entry, found); } + return found; } @@ -108,6 +114,7 @@ function resolvesWithinDocument(document: unknown, ref: string): boolean { if (!isPlainRecord(current) || !Object.hasOwn(current, key)) return false; current = current[key]; } + return true; } diff --git a/packages/trilean/test/integration/pattern-matching.test.ts b/packages/trilean/test/integration/pattern-matching.integration.test.ts similarity index 97% rename from packages/trilean/test/integration/pattern-matching.test.ts rename to packages/trilean/test/integration/pattern-matching.integration.test.ts index 6cbc215..1f6667a 100644 --- a/packages/trilean/test/integration/pattern-matching.test.ts +++ b/packages/trilean/test/integration/pattern-matching.integration.test.ts @@ -11,7 +11,7 @@ import { import type { Evaluation, PredicateNode, Resolvers } from "../../src/index"; /** - * The three pattern builders exercised as part of larger composed trees through the public API surface, against a resolver-backed multi-record dataset -- the tier that proves a compiled `textCompare` cooperates with quantifiers, connectives and per-item evaluation contexts as a system, rather than only matching correctly in isolation (see src/derived-patterns.test.ts for the exhaustive per-dialect coverage this builds on top of). + * The three pattern builders exercised as part of larger composed trees through the public API surface, against a resolver-backed multi-record dataset -- the tier that proves a compiled `textCompare` cooperates with quantifiers, connectives and per-item evaluation contexts as a system, rather than only matching correctly in isolation (see src/derived-patterns.unit.test.ts for the exhaustive per-dialect coverage this builds on top of). */ interface Command { @@ -55,6 +55,7 @@ const resolvers: Resolvers = { value: { kind: "text", value: context.target }, }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), diff --git a/packages/trilean/test/integration/schema-pipeline.test.ts b/packages/trilean/test/integration/schema-pipeline.integration.test.ts similarity index 97% rename from packages/trilean/test/integration/schema-pipeline.test.ts rename to packages/trilean/test/integration/schema-pipeline.integration.test.ts index a7ee116..2236bff 100644 --- a/packages/trilean/test/integration/schema-pipeline.test.ts +++ b/packages/trilean/test/integration/schema-pipeline.integration.test.ts @@ -29,6 +29,7 @@ const resolvers: Resolvers = { return Promise.resolve({ found: false }); } const value = context[key]; + return typeof value === "number" ? Promise.resolve({ found: true, value: { kind: "number", value } }) : Promise.resolve({ found: false }); @@ -106,7 +107,7 @@ describe("an expression tree round-trips through untrusted JSON text before eval }); /** - * The two trees are mutually recursive, and src/tree.ts implements that recursion with getter-backed `z.object` shapes specifically so a schema may forward-reference one declared later in the module (see that file's own comment on the TDZ). Every other test of the schema layer parses shallowly: src/tree.test.ts's samples are at most one level deep and never leave the tree they start in, and the round-trip cases above bottom out in a literal after two nodes. Nothing else, anywhere, drives `safeParse` down a tree that crosses the predicate/expression boundary repeatedly -- so nothing else would notice if a getter stopped being lazily invoked partway down, or if one tree's recursion back into the other resolved to the wrong schema. + * The two trees are mutually recursive, and src/tree.ts implements that recursion with getter-backed `z.object` shapes specifically so a schema may forward-reference one declared later in the module (see that file's own comment on the TDZ). Every other test of the schema layer parses shallowly: src/tree.unit.test.ts's samples are at most one level deep and never leave the tree they start in, and the round-trip cases above bottom out in a literal after two nodes. Nothing else, anywhere, drives `safeParse` down a tree that crosses the predicate/expression boundary repeatedly -- so nothing else would notice if a getter stopped being lazily invoked partway down, or if one tree's recursion back into the other resolved to the wrong schema. * * The tree below is authored as JSON text (as a stored rule genuinely arrives) and descends allOf -\> compare -\> conditional -\> some -\> textCompare -\> reference: predicate to expression to predicate and back again, twice in each direction, with a fold/accumulator aggregation on the conditional's own branch. Validating it and then evaluating it proves the same object graph survives both layers, which is the composition this tier exists to check -- neither layer alone can fail this test without the other's result changing. */ @@ -192,6 +193,7 @@ describe("a deep, mutually recursive tree crosses the predicate/expression bound if (typeof value === "string") { return Promise.resolve({ found: true, value: { kind: "text", value } }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -203,6 +205,7 @@ describe("a deep, mutually recursive tree crosses the predicate/expression bound ) { return Promise.resolve(context.shipments); } + return Promise.resolve([]); }, }; @@ -215,6 +218,7 @@ describe("a deep, mutually recursive tree crosses the predicate/expression bound `the deep fixture failed validation: ${validated.error.message}`, ); } + return validated.data; } @@ -334,6 +338,7 @@ describe("a __proto__ key inside an opaque JsonValue payload", () => { for (const key of Object.keys(source)) { target[key] = source[key]; } + return target; } @@ -342,6 +347,7 @@ describe("a __proto__ key inside an opaque JsonValue payload", () => { if (!isPlainRecord(parsed) || !isPlainRecord(parsed.payload)) { throw new Error("expected the fixture to parse to a record payload"); } + return parsed.payload; } @@ -355,6 +361,7 @@ describe("a __proto__ key inside an opaque JsonValue payload", () => { if (!isPlainRecord(payload)) { throw new Error("expected the validated payload to be a record"); } + return payload; } diff --git a/packages/trilean/test/integration/tree-reference.test.ts b/packages/trilean/test/integration/tree-reference.integration.test.ts similarity index 99% rename from packages/trilean/test/integration/tree-reference.test.ts rename to packages/trilean/test/integration/tree-reference.integration.test.ts index c5ff521..483acc4 100644 --- a/packages/trilean/test/integration/tree-reference.test.ts +++ b/packages/trilean/test/integration/tree-reference.integration.test.ts @@ -60,6 +60,7 @@ describe("treeReference composes reusable sub-rules into a larger tree", () => { if (typeof value === "string") { return Promise.resolve({ found: true, value: { kind: "text", value } }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -67,6 +68,7 @@ describe("treeReference composes reusable sub-rules into a larger tree", () => { resolveTree: async (key) => { const node = typeof key === "string" ? namedRules[key] : undefined; if (node === undefined) return Promise.resolve({ found: false }); + return Promise.resolve({ found: true, node }); }, }; @@ -167,6 +169,7 @@ describe("treeReference detects a cycle across genuinely separate stored rules", const node = typeof key === "string" ? crossReferencingRules[key] : undefined; if (node === undefined) return Promise.resolve({ found: false }); + return Promise.resolve({ found: true, node }); }, }; diff --git a/packages/trilean/test/smoke.test.ts b/packages/trilean/test/smoke.e2e.test.ts similarity index 94% rename from packages/trilean/test/smoke.test.ts rename to packages/trilean/test/smoke.e2e.test.ts index 8c89378..360d346 100644 --- a/packages/trilean/test/smoke.test.ts +++ b/packages/trilean/test/smoke.e2e.test.ts @@ -24,16 +24,17 @@ function isUnknownArray(value: unknown): value is unknown[] { return Array.isArray(value); } -/** Narrows to a plain JSON object or throws -- the built package's dist/ output and the generated schema document are both untyped `unknown` at their boundary, per this repo's `JSON.parse`/dynamic-import narrowing convention, so every access below goes through this rather than an `as` assertion. Throwing a descriptive Error rather than returning a placeholder is what makes a malformed built artefact a test failure instead of a silently-tolerated hole (mirrors test/integration/json-schema-consistency.test.ts's extractKindConst/extractGeneratedKinds). */ +/** Narrows to a plain JSON object or throws -- the built package's dist/ output and the generated schema document are both untyped `unknown` at their boundary, per this repo's `JSON.parse`/dynamic-import narrowing convention, so every access below goes through this rather than an `as` assertion. Throwing a descriptive Error rather than returning a placeholder is what makes a malformed built artefact a test failure instead of a silently-tolerated hole (mirrors test/integration/json-schema-consistency.integration.test.ts's extractKindConst/extractGeneratedKinds). */ function asPlainRecord( value: unknown, message: string, ): Record { if (!isPlainRecord(value)) throw new Error(message); + return value; } -// The full public surface: the evaluator entry points and factory, both node-tree schemas, and every derived connective/aggregate builder -- deliberately not asserting internal node-kind object schemas here, since those are covered by src/tree.test.ts against src/ directly. Declared as a typed tuple array, not a `Record`/`Object.entries()` pair, so each export name is checked against IndexModule's own keys at typecheck time -- a renamed or removed export fails here rather than producing a silently-`undefined` lookup at runtime. +// The full public surface: the evaluator entry points and factory, both node-tree schemas, and every derived connective/aggregate builder -- deliberately not asserting internal node-kind object schemas here, since those are covered by src/tree.unit.test.ts against src/ directly. Declared as a typed tuple array, not a `Record`/`Object.entries()` pair, so each export name is checked against IndexModule's own keys at typecheck time -- a renamed or removed export fails here rather than producing a silently-`undefined` lookup at runtime. const expectedIndexExports: readonly [ keyof typeof IndexModule, "function" | "object", @@ -118,7 +119,7 @@ describe("CommonJS entries loaded through Node's own require()", () => { }); }); -// Beyond "the exports exist and are the right typeof" above, this section actually RUNS a substantial composed-rule scenario -- fold's `max` combiner, `memberOf`, and the derived `implies` connective, composed together (adapted from test/integration/composed-rules.test.ts's own "fold's max combiner alongside memberOf and a derived connective" scenario) -- against both built entry points, proving the compiled output behaves identically to source for a realistic multi-kind tree, not only for the minimal golden example already covered by src/golden-examples.test.ts and test/workers/trilean.test.ts. The fixture is duplicated inline, rather than imported from test/integration, to keep this file self-contained (matching its existing style) and independent of TypeScript transpilation. +// Beyond "the exports exist and are the right typeof" above, this section actually RUNS a substantial composed-rule scenario -- fold's `max` combiner, `memberOf`, and the derived `implies` connective, composed together (adapted from test/integration/composed-rules.integration.test.ts's own "fold's max combiner alongside memberOf and a derived connective" scenario) -- against both built entry points, proving the compiled output behaves identically to source for a realistic multi-kind tree, not only for the minimal golden example already covered by src/golden-examples.unit.test.ts and test/workers/trilean.e2e.test.ts. The fixture is duplicated inline, rather than imported from test/integration, to keep this file self-contained (matching its existing style) and independent of TypeScript transpilation. function makeAuctionResolvers(): IndexModule.Resolvers { return { @@ -131,6 +132,7 @@ function makeAuctionResolvers(): IndexModule.Resolvers { return Promise.resolve({ found: false }); } const value = context[key]; + return typeof value === "number" ? Promise.resolve({ found: true, value: { kind: "number", value } }) : Promise.resolve({ found: false }); @@ -139,6 +141,7 @@ function makeAuctionResolvers(): IndexModule.Resolvers { resolveCollection: async (collection, context) => { if (collection !== "bids" || !isPlainRecord(context)) return Promise.resolve([]); + return Promise.resolve(isUnknownArray(context.bids) ? context.bids : []); }, }; @@ -179,6 +182,7 @@ function buildAuctionRule( right: { kind: "numberLiteral", value: 1 }, }, ); + return { kind: "allOf", operands: [highestBidIsPreApproved, highBidRequiresDiscountApproval], @@ -229,6 +233,7 @@ function readKindLiteral(option: unknown): string | undefined { const kind = shape.kind; if (!isPlainRecord(kind)) return undefined; const value = kind.value; + return typeof value === "string" ? value : undefined; } @@ -237,10 +242,11 @@ function hasOptionsArray( value: unknown, ): value is { options: readonly unknown[] } { if (!isPlainRecord(value)) return false; + return Array.isArray(value.options); } -/** Every `kind` a runtime union schema (`PredicateNodeSchema`/`ExpressionNodeSchema`, as built) actually accepts, read off its own `.options`. Recurses when an option is itself a nested union rather than a flat object -- built `ExpressionNodeSchema` is `z.union([CoreExpressionNodeSchema, ComplexLiteralNodeSchema])` (see `extractKindConst` below), so its own top-level `.options` are two further unions, not flat leaf schemas. Mirrors test/integration/json-schema-consistency.test.ts's own `discriminantKinds`, duplicated here rather than imported to keep this file self-contained (see this file's own top comment). */ +/** Every `kind` a runtime union schema (`PredicateNodeSchema`/`ExpressionNodeSchema`, as built) actually accepts, read off its own `.options`. Recurses when an option is itself a nested union rather than a flat object -- built `ExpressionNodeSchema` is `z.union([CoreExpressionNodeSchema, ComplexLiteralNodeSchema])` (see `extractKindConst` below), so its own top-level `.options` are two further unions, not flat leaf schemas. Mirrors test/integration/json-schema-consistency.integration.test.ts's own `discriminantKinds`, duplicated here rather than imported to keep this file self-contained (see this file's own top comment). */ function runtimeDiscriminantKinds(schema: { options: readonly unknown[]; }): string[] { @@ -273,6 +279,7 @@ function extractKindConst(branch: unknown): string[] { "expected a 'kind' const on a discriminated-union branch, or a nested 'oneOf'/'anyOf' branch list", ); } + return nested.flatMap((entry) => extractKindConst(entry)); } @@ -287,12 +294,14 @@ function generatedKinds(nodeDefinition: unknown): string[] { "expected the generated node definition to have a 'oneOf' or 'anyOf' branch list", ); } + return branches.flatMap((branch) => extractKindConst(branch)).sort(); } function collectRefs(value: unknown, found = new Set()): Set { if (Array.isArray(value)) { for (const entry of value) collectRefs(entry, found); + return found; } if (!isPlainRecord(value)) return found; @@ -300,6 +309,7 @@ function collectRefs(value: unknown, found = new Set()): Set { if (key === "$ref" && typeof entry === "string") found.add(entry); else collectRefs(entry, found); } + return found; } @@ -312,6 +322,7 @@ function resolvesWithinDocument(document: unknown, ref: string): boolean { if (!isPlainRecord(current) || !Object.hasOwn(current, key)) return false; current = current[key]; } + return true; } @@ -322,6 +333,7 @@ function isUtf16Ascending(keys: readonly string[]): boolean { if (previous !== undefined && !(previous < key)) return false; previous = key; } + return true; } @@ -373,14 +385,15 @@ function scanJsonStructure(json: string): { } index++; } + return { keysPerObject, whitespaceOutsideStrings }; } -// A genuine proof the "./schemas/*.schema.json" export actually works for a non-TypeScript consumer, which nothing else in this suite tests -- the unit project never touches the generated file at all, and the assertions above stop at dist/'s own JS exports. -// -// This is also the only place the "One schema, mechanically derived artefacts" design principle is checked against the artefact that actually ships, rather than against a re-derivation: the file read here is the real output of scripts/generate-json-schema.ts, regenerated by the `_build` task this tier now depends on. Two independent failure modes are covered. First, ref integrity: the script hand-rolls a `uri` callback plus a "__shared" splice to turn zod's multi-document registry output into one self-contained document, and if either stops matching zod's internals the document silently fills with unresolvable $refs (bare "PredicateNode", or a doubled "#/$defs/__shared#/$defs/schema0") that no consumer's JSON Schema tooling could follow. Second, discriminant parity: the generated document's own "kind" const set is compared against the built package's runtime PredicateNodeSchema/ExpressionNodeSchema options, so a node kind added to the Zod schema but missing from the shipped document -- a stale or wrongly-assembled artefact -- fails here rather than reaching a consumer. -// -// Two further checks below cover the artefact's identity and byte-level shape, both of which only exist against the real generated file for the same reason as the two failure modes above: $id and canonicalization are stamped and computed inside scripts/generate-json-schema.ts itself, so nothing in src/ or the integration tier ever produces them to compare against. +/* A genuine proof the "./schemas/*.schema.json" export actually works for a non-TypeScript consumer, which nothing else in this suite tests -- the unit project never touches the generated file at all, and the assertions above stop at dist/'s own JS exports. + + This is also the only place the "One schema, mechanically derived artefacts" design principle is checked against the artefact that actually ships, rather than against a re-derivation: the file read here is the real output of scripts/generate-json-schema.ts, regenerated by the `_build` task this tier now depends on. Two independent failure modes are covered. First, ref integrity: the script hand-rolls a `uri` callback plus a "__shared" splice to turn zod's multi-document registry output into one self-contained document, and if either stops matching zod's internals the document silently fills with unresolvable $refs (bare "PredicateNode", or a doubled "#/$defs/__shared#/$defs/schema0") that no consumer's JSON Schema tooling could follow. Second, discriminant parity: the generated document's own "kind" const set is compared against the built package's runtime PredicateNodeSchema/ExpressionNodeSchema options, so a node kind added to the Zod schema but missing from the shipped document -- a stale or wrongly-assembled artefact -- fails here rather than reaching a consumer. + + Two further checks below cover the artefact's identity and byte-level shape, both of which only exist against the real generated file for the same reason as the two failure modes above: $id and canonicalization are stamped and computed inside scripts/generate-json-schema.ts itself, so nothing in src/ or the integration tier ever produces them to compare against. */ describe("generated schemas/trilean.schema.json", () => { const rawSchemaFile = readFileSync( new URL("../schemas/trilean.schema.json", import.meta.url), diff --git a/packages/trilean/test/workers/trilean.test.ts b/packages/trilean/test/workers/trilean.e2e.test.ts similarity index 95% rename from packages/trilean/test/workers/trilean.test.ts rename to packages/trilean/test/workers/trilean.e2e.test.ts index 47c72d1..6153cef 100644 --- a/packages/trilean/test/workers/trilean.test.ts +++ b/packages/trilean/test/workers/trilean.e2e.test.ts @@ -26,7 +26,7 @@ function isUnknownArray(value: unknown): value is unknown[] { } /** - * The same README.md "Worked example" fixture as src/golden-examples.test.ts (imported, not duplicated, so the two suites can never silently drift apart), re-run here inside the real Cloudflare Workers runtime (workerd) via the `workers` vitest project. Passing here turns "isomorphic" from an assertion into a runtime-checked fact for the evaluator itself, not merely for the Zod schemas. + * The same README.md "Worked example" fixture as src/golden-examples.unit.test.ts (imported, not duplicated, so the two suites can never silently drift apart), re-run here inside the real Cloudflare Workers runtime (workerd) via the `workers` vitest project. Passing here turns "isomorphic" from an assertion into a runtime-checked fact for the evaluator itself, not merely for the Zod schemas. */ describe("golden example (README.md worked example) under workerd", () => { it("base case: isActive eq true AND sum(items.amount) via reduce fold > x + y -> definite true", async () => { @@ -62,7 +62,7 @@ describe("golden example (README.md worked example) under workerd", () => { }); /** - * Beyond the golden example's narrow AND + compare + reduce-fold path, this section runs a substantially broader composed tree -- allOf, anyOf, compare, textCompare, memberOf, some, every, fold (reduce), and conditional, all in one evaluation -- for real inside the same workerd isolate (adapted from test/integration/composed-rules.test.ts's own "membership eligibility" scenario), so the "isomorphic" guarantee is checked against genuinely more of the evaluator's surface, not only the one path the golden example covers. Duplicated inline, rather than imported from test/integration, to keep this file's dependency graph limited to src/ under the real workers runtime. + * Beyond the golden example's narrow AND + compare + reduce-fold path, this section runs a substantially broader composed tree -- allOf, anyOf, compare, textCompare, memberOf, some, every, fold (reduce), and conditional, all in one evaluation -- for real inside the same workerd isolate (adapted from test/integration/composed-rules.integration.test.ts's own "membership eligibility" scenario), so the "isomorphic" guarantee is checked against genuinely more of the evaluator's surface, not only the one path the golden example covers. Duplicated inline, rather than imported from test/integration, to keep this file's dependency graph limited to src/ under the real workers runtime. */ describe("a substantially broader composed tree (allOf/anyOf/compare/textCompare/memberOf/some/every/fold/conditional) under workerd", () => { const approvedFilter: PredicateNode = { @@ -190,6 +190,7 @@ describe("a substantially broader composed tree (allOf/anyOf/compare/textCompare if (typeof value === "string") { return Promise.resolve({ found: true, value: { kind: "text", value } }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -201,6 +202,7 @@ describe("a substantially broader composed tree (allOf/anyOf/compare/textCompare ) { return Promise.resolve(context.orders); } + return Promise.resolve([]); }, }; @@ -268,6 +270,7 @@ describe("units-aware arithmetic (multiply/divide across differently unit-tagged } const value = context[key]; if (typeof value !== "number") return Promise.resolve({ found: false }); + return Promise.resolve({ found: true, value: { kind: "number", value, unit: unitByKey[key] }, @@ -288,7 +291,7 @@ describe("units-aware arithmetic (multiply/divide across differently unit-tagged }); /** - * `treeReference`, `conditional`'s `"unique"` hit policy, and `coalesce` under workerd -- proving isomorphism for the three most recently added features, not just for the evaluator's pre-existing surface above. Composed together in one tree (a treeReference to a "unique"-hit-policy conditional whose matching case's value flows into a coalesce), the same discipline test/integration/tree-reference.test.ts and src/derived-values.test.ts already apply against source, run here for real inside the workerd isolate. + * `treeReference`, `conditional`'s `"unique"` hit policy, and `coalesce` under workerd -- proving isomorphism for the three most recently added features, not just for the evaluator's pre-existing surface above. Composed together in one tree (a treeReference to a "unique"-hit-policy conditional whose matching case's value flows into a coalesce), the same discipline test/integration/tree-reference.integration.test.ts and src/derived-values.unit.test.ts already apply against source, run here for real inside the workerd isolate. */ describe("treeReference, conditional's 'unique' hit policy, and coalesce under workerd", () => { const namedRules: Record = { @@ -338,6 +341,7 @@ describe("treeReference, conditional's 'unique' hit policy, and coalesce under w if (typeof value === "string") { return Promise.resolve({ found: true, value: { kind: "text", value } }); } + return Promise.resolve({ found: false }); }, resolveLookup: async () => Promise.resolve({ found: false }), @@ -345,6 +349,7 @@ describe("treeReference, conditional's 'unique' hit policy, and coalesce under w resolveTree: async (key) => { const node = typeof key === "string" ? namedRules[key] : undefined; if (node === undefined) return Promise.resolve({ found: false }); + return Promise.resolve({ found: true, node }); }, }; diff --git a/packages/trilean/tsconfig.json b/packages/trilean/tsconfig.json index 13e017d..319d5f9 100644 --- a/packages/trilean/tsconfig.json +++ b/packages/trilean/tsconfig.json @@ -1,11 +1,11 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { // No "node" types and a WebWorker lib: this is the isomorphism gate, so a Node-only global referenced from runtime src/ fails to compile here rather than at some consumer's runtime. "lib": ["ES2024", "WebWorker"], - "types": [], - "resolveJsonModule": true + "resolveJsonModule": true, + "types": [] }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/test-support/**/*.ts"] + "exclude": ["src/**/*.test.ts", "src/test-support/**/*.ts"], + "extends": "../../tsconfig.base.json", + "include": ["src"] } diff --git a/packages/trilean/tsconfig.node.json b/packages/trilean/tsconfig.node.json index 4641383..d70e23b 100644 --- a/packages/trilean/tsconfig.node.json +++ b/packages/trilean/tsconfig.node.json @@ -1,10 +1,17 @@ { - "extends": "./tsconfig.json", "compilerOptions": { + "allowImportingTsExtensions": true, "lib": ["ES2024"], - "types": ["node"], - "allowImportingTsExtensions": true + "types": ["node"] }, - "include": ["src/**/*.test.ts", "src/test-support/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts", "eslint.config.ts"], - "exclude": [] + "exclude": [], + "extends": "./tsconfig.json", + "include": [ + "src/**/*.test.ts", + "src/test-support/**/*.ts", + "test/**/*.ts", + "scripts/**/*.ts", + "*.config.ts", + "eslint.config.ts" + ] } diff --git a/packages/trilean/vitest.config.ts b/packages/trilean/vitest.config.ts index 01694f4..3bcb74f 100644 --- a/packages/trilean/vitest.config.ts +++ b/packages/trilean/vitest.config.ts @@ -1,9 +1,11 @@ import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; import { defineConfig } from "vitest/config"; -// Coverage is a Vitest "unsupported option" inside a project config -- it can only be defined once, here in the root config, and applies across the whole run regardless of which project(s) are selected. -// -// Every project is defined inline rather than as a separate config file: a project entry is itself a full Vite `UserConfig` (Vitest augments Vite's own `UserConfig` type with a `test` field -- see vitest's node/types/vite.ts), so a top-level Vite option such as `plugins` on an inline entry is honoured exactly as it would be in a standalone config file. The workers project below relies on this directly for its `cloudflareTest` plugin registration. +/** + * Coverage is a Vitest "unsupported option" inside a project config -- it can only be defined once, here in the root config, and applies across the whole run regardless of which project(s) are selected. + * + * Every project is defined inline rather than as a separate config file: a project entry is itself a full Vite `UserConfig` (Vitest augments Vite's own `UserConfig` type with a `test` field -- see vitest's node/types/vite.ts), so a top-level Vite option such as `plugins` on an inline entry is honoured exactly as it would be in a standalone config file. The workers project below relies on this directly for its `cloudflareTest` plugin registration. + */ export default defineConfig({ test: { coverage: { @@ -27,10 +29,13 @@ export default defineConfig({ }, }, { - // Checks the built package rather than src/: dist/'s ESM and CJS entry points resolved through package.json's own `exports` map, and the generated schemas/trilean.schema.json. The `_test:smoke` turbo task depends on `_build`, so the output under test is always rebuilt from current source rather than whatever dist/ happened to be left lying around. See test/smoke.test.ts and test/declaration-shape.test.ts. + // Checks the built package rather than src/: dist/'s ESM and CJS entry points resolved through package.json's own `exports` map, and the generated schemas/trilean.schema.json. The `_test:smoke` turbo task depends on `_build`, so the output under test is always rebuilt from current source rather than whatever dist/ happened to be left lying around. See test/smoke.e2e.test.ts and test/declaration-shape.e2e.test.ts. test: { name: "smoke", - include: ["test/smoke.test.ts", "test/declaration-shape.test.ts"], + include: [ + "test/smoke.e2e.test.ts", + "test/declaration-shape.e2e.test.ts", + ], }, }, { diff --git a/packages/trilean/wrangler.jsonc b/packages/trilean/wrangler.jsonc index 3f75d6d..a0c0d91 100644 --- a/packages/trilean/wrangler.jsonc +++ b/packages/trilean/wrangler.jsonc @@ -1,6 +1,6 @@ { // A no-op Worker whose only purpose is to host trilean's source as the module graph the workers test suite (test/workers/**) imports against under the real workerd runtime -- there is no fetch handler to exercise, only the library's own Zod schemas and evaluator running inside a Cloudflare Workers isolate to prove they carry zero Node-only API usage. - "name": "trilean-workers-test", - "main": "src/index.ts", "compatibility_date": "2024-09-23", + "main": "src/index.ts", + "name": "trilean-workers-test", } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45f485d..9e6cd95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,8 +179,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2)) '@exadev/eslint-config': - specifier: 2.12.1 - version: 2.12.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.1)(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) '@exadev/semantic-release-workspace': specifier: 3.0.2 version: 3.0.2(@semantic-release/changelog@7.0.0(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3)))(@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2))(@semantic-release/git@11.0.1(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2))(@semantic-release/github@12.0.9(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2))(@semantic-release/npm@13.1.5(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3)))(@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2))(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(typescript@6.0.3) @@ -258,8 +258,8 @@ importers: specifier: ^0.22.0 version: 0.22.0(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11) '@exadev/eslint-config': - specifier: 2.12.1 - version: 2.12.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.1)(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) '@types/node': specifier: ^26.4.0 version: 26.4.1 @@ -312,8 +312,8 @@ importers: specifier: ^0.18.5 version: 0.18.5 '@exadev/eslint-config': - specifier: 2.12.1 - version: 2.12.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.1)(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) '@types/node': specifier: ^26.4.0 version: 26.4.1 @@ -370,8 +370,8 @@ importers: specifier: ^0.5.8 version: 0.5.8 '@exadev/eslint-config': - specifier: 2.12.1 - version: 2.12.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.1)(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) '@testcontainers/postgresql': specifier: ^12.1.0 version: 12.1.0(supports-color@10.2.2) @@ -499,6 +499,7 @@ packages: '@cloudflare/vitest-pool-workers@0.22.0': resolution: {integrity: sha512-OJv/qikkOgxnKxJ5xrLS7zuOLZhc/6iziU+llqZm4tiQf2CJUYwlMuXN68VaWIQebORd1AUx4w6A0oy8XRbuaQ==} + deprecated: '@cloudflare/vitest-pool-workers has been renamed to @cloudflare/vitest-plugin. This package will not receive future updates.' peerDependencies: '@vitest/runner': ^4.1.0 '@vitest/snapshot': ^4.1.0 @@ -984,6 +985,10 @@ packages: eslint: optional: true + '@eslint/json@2.1.1': + resolution: {integrity: sha512-ffVzPZALaJzxuo46ggBtJ9c8YBWKLMh/R35aFBHk/dRobmFNortJlHJfBWbHGuydoFeVZbvPy0PLX2wvCI9T9w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/object-schema@3.0.5': resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -992,26 +997,42 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@exadev/eslint-config@2.12.1': - resolution: {integrity: sha512-b5JKCX7l5onZy0i0kHdIeuiocxDoiuEwnrG6+j+EV8ZWaLpi18BDzNi1iT3tc647DFRUYJtHWDiJrkWT6E2JjQ==} - engines: {node: '>=20'} + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exadev/eslint-config@3.0.0': + resolution: {integrity: sha512-nvyOjjM1sGKjQo6buZt69vJ5ND0CuE/lxoDvR2oRgv74qj4gJBZr10d6Y/QeTd6LQIPDkPPnH78YYfX/HSzNJA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: + '@eslint/json': ^2.1.0 + '@eslint/markdown': ^8.0.3 '@next/eslint-plugin-next': ^16.3.2 + '@vitest/eslint-plugin': ^1.6.27 eslint: '>=10.0.0' eslint-plugin-jsx-a11y: ^6.10.2 eslint-plugin-react: ^7.37.5 eslint-plugin-react-hooks: ^7.1.1 + eslint-plugin-turbo: ^2.3.1 typescript: '>=4.8.4' typescript-eslint: '>=8.0.0' peerDependenciesMeta: + '@eslint/json': + optional: true + '@eslint/markdown': + optional: true '@next/eslint-plugin-next': optional: true + '@vitest/eslint-plugin': + optional: true eslint-plugin-jsx-a11y: optional: true eslint-plugin-react: optional: true eslint-plugin-react-hooks: optional: true + eslint-plugin-turbo: + optional: true '@exadev/release-gate@1.0.1': resolution: {integrity: sha512-VKU6qZlvl6C+xOLC12mYvv1ygcLm4Gt28rcd2K20fMJB4jz/2IeK5Nn+Ivk4a4xv7TmzvoJCmHUfo7nmwnrVYw==} @@ -1060,6 +1081,10 @@ packages: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} + '@humanwhocodes/momoa@3.3.13': + resolution: {integrity: sha512-AbKq3EW41+HY6pNMJgAOrz68iyiTTf0Nr1JRHHfuG5zwniJ9Dw8J3U7oa1I2ptpqYwmEf1sXKOGK6lbdZ9eqmg==} + engines: {node: '>=18'} + '@humanwhocodes/retry@0.4.3': resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} @@ -1258,6 +1283,9 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@microsoft/tsdoc@0.17.1': + resolution: {integrity: sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA==} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -1560,6 +1588,18 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stylistic/eslint-plugin@6.0.0-beta.6': + resolution: {integrity: sha512-DqDQZSrV/0AZ/4qLxaQUtNxafk3qWfeG5xOHx64ELjHWSHt2GtELZR6ClrNVvQwo/d13Atf8Hrh/s/ZnuJk57w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + oxlint: ^1.55.0 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + '@testcontainers/postgresql@12.1.0': resolution: {integrity: sha512-Pjf2VSVNirEPfz36nidyrVAnZvc2YhajOznY4VgyEsvfTd5qiMNOuPq96drREvxAUtXl5SFLX7vXj7sSq4aTcA==} @@ -1695,6 +1735,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.56.1': resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1703,6 +1749,10 @@ packages: resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.1': resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1715,6 +1765,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.69.0': resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1730,6 +1786,10 @@ packages: resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.1': resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1742,6 +1802,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.56.1': resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1756,6 +1822,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.56.1': resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1764,6 +1837,10 @@ packages: resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@4.1.11': resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: @@ -2514,6 +2591,13 @@ packages: peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-json-canonical@2.1.3: + resolution: {integrity: sha512-qAcRVGkXGZvgduOIEBxHLOZhqUf0NdVY+bye/KXbd0oPH5VvoBBBETFEaJ1qa6efLSCH17C4sss7uvr80TfNTQ==} + engines: {node: '>=20'} + peerDependencies: + '@eslint/json': ^2.1.0 + eslint: '>=10.0.0' + eslint-plugin-prettier@5.5.6: resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4385,6 +4469,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -4873,6 +4962,13 @@ snapshots: optionalDependencies: eslint: 10.9.1(jiti@2.6.1)(supports-color@10.2.2) + '@eslint/json@2.1.1': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.3 + '@humanwhocodes/momoa': 3.3.13 + natural-compare: 1.4.0 + '@eslint/object-schema@3.0.5': {} '@eslint/plugin-kit@0.7.2': @@ -4880,17 +4976,31 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exadev/eslint-config@2.12.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3)': + '@eslint/plugin-kit@0.7.3': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exadev/eslint-config@3.0.0(@eslint/json@2.1.1)(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@eslint/js': 10.0.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2)) - '@typescript-eslint/utils': 8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@microsoft/tsdoc': 0.17.1 + '@stylistic/eslint-plugin': 6.0.0-beta.6(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2)) + '@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) eslint: 10.9.1(jiti@2.6.1)(supports-color@10.2.2) eslint-plugin-jsdoc: 64.3.6(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint-plugin-json-canonical: 2.1.3(@eslint/json@2.1.1)(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2)) eslint-plugin-tsdoc: 0.5.2(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + minimatch: 10.2.6 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 typescript-eslint: 8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + yaml: 2.9.1 + optionalDependencies: + '@eslint/json': 2.1.1 transitivePeerDependencies: + - oxlint - supports-color '@exadev/release-gate@1.0.1(supports-color@10.2.2)(typescript@6.0.3)': @@ -4956,6 +5066,8 @@ snapshots: '@humanwhocodes/module-importer@1.0.1': {} + '@humanwhocodes/momoa@3.3.13': {} + '@humanwhocodes/retry@0.4.3': {} '@img/colour@1.1.0': {} @@ -5108,6 +5220,8 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@microsoft/tsdoc@0.17.1': {} + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.8': @@ -5402,6 +5516,16 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stylistic/eslint-plugin@6.0.0-beta.6(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2)) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 + espree: 11.2.0 + picomatch: 4.0.7 + optionalDependencies: + eslint: 10.9.1(jiti@2.6.1)(supports-color@10.2.2) + '@testcontainers/postgresql@12.1.0(supports-color@10.2.2)': dependencies: testcontainers: 12.1.0(supports-color@10.2.2) @@ -5550,6 +5674,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.71.0(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.56.1': dependencies: '@typescript-eslint/types': 8.56.1 @@ -5560,6 +5693,11 @@ snapshots: '@typescript-eslint/types': 8.69.0 '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/scope-manager@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -5568,6 +5706,10 @@ snapshots: dependencies: typescript: 6.0.3 + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.69.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.69.0 @@ -5584,6 +5726,8 @@ snapshots: '@typescript-eslint/types@8.69.0': {} + '@typescript-eslint/types@8.71.0': {} + '@typescript-eslint/typescript-estree@8.56.1(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.56.1(supports-color@10.2.2)(typescript@6.0.3) @@ -5614,6 +5758,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.71.0(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.56.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2)) @@ -5636,6 +5795,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.71.0(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.6.1)(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.56.1': dependencies: '@typescript-eslint/types': 8.56.1 @@ -5646,6 +5816,11 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + eslint-visitor-keys: 5.0.1 + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -6377,6 +6552,11 @@ snapshots: - supports-color - typescript + eslint-plugin-json-canonical@2.1.3(@eslint/json@2.1.1)(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2)): + dependencies: + '@eslint/json': 2.1.1 + eslint: 10.9.1(jiti@2.6.1)(supports-color@10.2.2) + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2)))(eslint@10.9.1(jiti@2.6.1)(supports-color@10.2.2))(prettier@3.9.6): dependencies: eslint: 10.9.1(jiti@2.6.1)(supports-color@10.2.2) @@ -8137,6 +8317,8 @@ snapshots: yaml@2.9.0: {} + yaml@2.9.1: {} + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index bfc254e..5124794 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,19 +1,19 @@ { // The compiler options every package in this workspace shares, in one place. Each package's own tsconfig.json extends this and keeps only what genuinely differs for it -- its `lib` and `types` (the isomorphism gate), its own `include`/`exclude`. Its tsconfig.node.json extends that package's tsconfig.json in turn, so it inherits this transitively. "compilerOptions": { - "target": "ES2024", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, "module": "ESNext", "moduleResolution": "bundler", - "strict": true, + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, // Every indexed read is typed as possibly-undefined, so the code narrows properly instead of trusting a bound. "noUncheckedIndexedAccess": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "verbatimModuleSyntax": true, - "isolatedModules": true, - "esModuleInterop": true, - "noEmit": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "strict": true, + "target": "ES2024", + "verbatimModuleSyntax": true } } diff --git a/tsconfig.json b/tsconfig.json index 75906d7..81b833b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { - // The workspace root's own tooling configs, which are Node programs rather than library source: ESLint's and Prettier's configs, commitlint's, lint-staged's, and the release orchestrator's. packages/trilean has its own pair of tsconfigs and is not part of this program. - "extends": "./tsconfig.base.json", "compilerOptions": { "lib": ["ES2024"], "types": ["node"] }, + // The workspace root's own tooling configs, which are Node programs rather than library source: ESLint's and Prettier's configs, commitlint's, lint-staged's, and the release orchestrator's. packages/trilean has its own pair of tsconfigs and is not part of this program. + "extends": "./tsconfig.base.json", // A glob rather than a hand-listed set: a config file absent from every program is not a silent no-op, type-aware ESLint fails on it outright with "not found in any of the provided project(s)", so adding one must not also require an edit here. "include": ["*.config.ts"] } diff --git a/turbo.json b/turbo.json index c285b5d..0af44c8 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,6 @@ { - "agentGuidance": false, "$schema": "https://turborepo.com/schema.json", + "agentGuidance": false, "globalDependencies": [ "pnpm-lock.yaml", ".tool-versions", @@ -12,8 +12,31 @@ // // Every task declares `^_build`, its dependencies' builds: a workspace sibling is a symlink whose dist/ exists only once its own build has run, and tsc, type-aware eslint, and vitest all resolve imports through it. With one package that edge is inert today; it is declared so the first sibling added is ordered correctly rather than racing. // - // `_typecheck`, `_lint` and `_test:smoke` additionally depend on the package's OWN `_build`, which is not the same thing and is not optional here: test/smoke.test.ts and scripts/generate-json-schema.ts both import from ../dist/ by relative path, so tsc, eslint's typed rules, and the smoke suite itself all need this package's dist/ present before they run. + // `_typecheck`, `_lint` and `_test:smoke` additionally depend on the package's OWN `_build`, which is not the same thing and is not optional here: test/smoke.e2e.test.ts and scripts/generate-json-schema.ts both import from ../dist/ by relative path, so tsc, eslint's typed rules, and the smoke suite itself all need this package's dist/ present before they run. "tasks": { + // The workspace root's own tooling files (eslint.config.ts, commitlint.config.ts, lint-staged.config.ts, release-workspace.config.ts) are held to the same lint and typecheck gates as package code. Registered as root tasks so `turbo run _lint` and `turbo run _typecheck` cover them without a separate command. + "//#_lint": { + "inputs": [ + "eslint.config.ts", + "prettier.config.ts", + "commitlint.config.ts", + "lint-staged.config.ts", + "release-workspace.config.ts", + "tsconfig.json" + ], + "outputs": [".eslintcache"] + }, + "//#_typecheck": { + "inputs": [ + "eslint.config.ts", + "prettier.config.ts", + "commitlint.config.ts", + "lint-staged.config.ts", + "release-workspace.config.ts", + "tsconfig.json" + ], + "outputs": [] + }, "_build": { "dependsOn": ["^_build"], // scripts/** covers the JSON-schema generation step that runs as part of the build; package.json is an input because that generation reads it. @@ -21,22 +44,23 @@ // schemas/** is the build's second output, alongside tsdown's dist/. "outputs": ["dist/**", "schemas/**"] }, - "_typecheck": { - "dependsOn": ["^_build", "_build"], - "inputs": ["$TURBO_DEFAULT$", "tsconfig.json", "tsconfig.node.json"], - "outputs": [] - }, - // attw --pack inspects the package's own dist/ against its own package.json exports map, so it depends on this package's own `_build` rather than on `^_build` alone. It was previously a bare `pnpm exec attw --pack` step in ci.yml's Typecheck job, run after `pnpm typecheck` purely to reuse the dist/ that task had left behind; as a task it is cached and ordered like everything else. - "_typecheck:attw": { - "dependsOn": ["_build"], - "inputs": ["package.json"], - "outputs": [] - }, "_lint": { "dependsOn": ["^_build", "_build"], "inputs": ["$TURBO_DEFAULT$", "eslint.config.ts"], "outputs": [".eslintcache"] }, + "_prepush": { + "dependsOn": [ + "_lint", + "_typecheck", + "_typecheck:attw", + "_test", + "_test:integration", + "_test:smoke", + "_test:workers" + ], + "outputs": [] + }, "_test": { "dependsOn": ["^_build"], "inputs": ["src/**", "vitest.config.ts", "package.json"], @@ -64,7 +88,7 @@ "tsdown.config.ts", "package.json", "vitest.config.ts", - "test/smoke.test.ts" + "test/smoke.e2e.test.ts" ], // Deliberately empty rather than dist/**: dist/ is `_build`'s output, and two tasks claiming the same output directory means a cache replay of one can overwrite the other's. The smoke run itself produces nothing worth caching. "outputs": [] @@ -80,40 +104,15 @@ ], "outputs": [] }, - "_prepush": { - "dependsOn": [ - "_lint", - "_typecheck", - "_typecheck:attw", - "_test", - "_test:integration", - "_test:smoke", - "_test:workers" - ], + "_typecheck": { + "dependsOn": ["^_build", "_build"], + "inputs": ["$TURBO_DEFAULT$", "tsconfig.json", "tsconfig.node.json"], "outputs": [] }, - - // The workspace root's own tooling files (eslint.config.ts, commitlint.config.ts, lint-staged.config.ts, release-workspace.config.ts) are held to the same lint and typecheck gates as package code. Registered as root tasks so `turbo run _lint` and `turbo run _typecheck` cover them without a separate command. - "//#_lint": { - "inputs": [ - "eslint.config.ts", - "prettier.config.ts", - "commitlint.config.ts", - "lint-staged.config.ts", - "release-workspace.config.ts", - "tsconfig.json" - ], - "outputs": [".eslintcache"] - }, - "//#_typecheck": { - "inputs": [ - "eslint.config.ts", - "prettier.config.ts", - "commitlint.config.ts", - "lint-staged.config.ts", - "release-workspace.config.ts", - "tsconfig.json" - ], + // attw --pack inspects the package's own dist/ against its own package.json exports map, so it depends on this package's own `_build` rather than on `^_build` alone. It was previously a bare `pnpm exec attw --pack` step in ci.yml's Typecheck job, run after `pnpm typecheck` purely to reuse the dist/ that task had left behind; as a task it is cached and ordered like everything else. + "_typecheck:attw": { + "dependsOn": ["_build"], + "inputs": ["package.json"], "outputs": [] } }