From 152cd2088b97a7b7fe0287e164c61fd24b022f1b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 20 Oct 2026 23:36:23 +0100 Subject: [PATCH 1/2] feat(when): gate a wildcard rule per element A when reference with wildcards now names a field of the element the rule is visiting. It is written against the rule's from side, its part through its last wildcard must be the array from iterates at that depth, and it may have fewer wildcards than the rule. While decoding, that part is rebased onto the to side's array, so the gate reads the same element in the target document. readLocus takes an element gate and drops a refused element before reading anything below it, so an element lacking the rule's path is skipped rather than absent. Collection references bind the same way. validateCrosswalk rejects a wildcard reference into another array, one with more wildcards than its rule, and any on a one-way rule. --- src/corpus.ts | 21 ++++++- src/executor.ts | 36 ++++++++--- src/executor.unit.test.ts | 61 ++++++++++++++++++ src/paths.ts | 11 +++- src/validate.ts | 62 +++++++++--------- src/validate.unit.test.ts | 22 +++++++ src/when.ts | 128 +++++++++++++++++++++++++++++++------- 7 files changed, 275 insertions(+), 66 deletions(-) diff --git a/src/corpus.ts b/src/corpus.ts index 2a2e8f9..0e3e969 100644 --- a/src/corpus.ts +++ b/src/corpus.ts @@ -14,7 +14,7 @@ const whenQtyIsBig: When = { kind: "compare", op: "gt", left: { kind: "reference /** * Samples carry only mapped fields (the crosswalk is a total mapping: an unmapped field does not survive encode, so a sample carrying one would fail its own round trip) and use the canonical ISO form decode emits, since any other ISO spelling names the same instant but not the same string. * - * The deterministic corpus: one cell per op-table row (move, scale, enum, format isoDateTime, format numberText, split, drop, default encode-direction, default decode-direction), plus a wildcard-arity-one cell, a when-applied cell, a when-skipped cell, and one cell per path shape beyond records and one wildcard (nesting and flattening, an array of arrays, fixed indices, quoted keys). Scale factors are integers by design: multiply-then-divide is bit-identical for them (a non-integer factor is a documented round-trip caveat, not a corpus case). + * The deterministic corpus: one cell per op-table row (move, scale, enum, format isoDateTime, format numberText, split, drop, default encode-direction, default decode-direction), plus a wildcard-arity-one cell, a when-applied cell, a when-skipped cell, a per-element when cell, and one cell per path shape beyond records and one wildcard (nesting and flattening, an array of arrays, fixed indices, quoted keys). Scale factors are integers by design: multiply-then-divide is bit-identical for them (a non-integer factor is a documented round-trip caveat, not a corpus case). */ export const CORPUS: readonly CorpusCell[] = [ { @@ -125,6 +125,24 @@ export const CORPUS: readonly CorpusCell[] = [ }, samples: [{ qty: 1 }], }, + { + name: "when per element", + crosswalk: { + source: "native", + target: "shared", + rules: [ + { op: "move", from: "items[].kind", to: "shapes[].kind" }, + { + op: "move", + from: "items[].radius", + to: "shapes[].r", + when: { kind: "textCompare", op: "equals", left: { kind: "reference", key: "items[].kind" }, right: { kind: "textLiteral", value: "circle" } }, + }, + ], + oneWay: [], + }, + samples: [{ items: [{ kind: "circle", radius: 2 }, { kind: "square" }] }, { items: [] }], + }, { name: "nest and flatten", crosswalk: { @@ -178,6 +196,7 @@ export const REQUIRED_ROWS: readonly string[] = [ "wildcard arity one", "when applied", "when skipped", + "when per element", "nest and flatten", "array of arrays", "fixed index", diff --git a/src/executor.ts b/src/executor.ts index 4cfd7c0..e94edc7 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -6,10 +6,10 @@ import { moveValue } from "./ops/move"; import { applyOneWayRule } from "./ops/one-way"; import { scaleDecode, scaleEncode } from "./ops/scale"; import { splitDecode, splitEncode } from "./ops/split"; -import { type JsonValue, JsonValueSchema } from "trilean"; -import { bindLocus, ensureArray, findHole, formatPath, parsePathMemo, readLocus, wildcardIndexes, writeMatches } from "./paths"; +import { type JsonValue, JsonValueSchema, type PredicateNode } from "trilean"; +import { bindLocus, type ElementGate, ensureArray, findHole, formatPath, type Locus, parsePathMemo, readLocus, wildcardIndexes, writeMatches } from "./paths"; import { validateCrosswalk } from "./validate"; -import { evaluateWhen } from "./when"; +import { evaluateWhen, whenArity } from "./when"; /** * Encodes a native document into the shared shape. Every rule's `from` is read from `doc` exactly as given and every `to` is written into a fresh output, so the input is never mutated and rule order carries no meaning (validateCrosswalk, run first, enforces the path uniqueness that makes this safe). A rule's `when` is evaluated against `doc`: definite true applies, definite false or indeterminate skips. Two-way rules write first, then the output's absent paths gain any due `default` values. @@ -45,14 +45,15 @@ function run(crosswalk: Crosswalk, doc: unknown, direction: "encode" | "decode") } const target: Record = {}; crosswalk.rules.forEach((rule, index) => { - if (rule.when !== undefined && !evaluateWhen(rule.when, doc)) { - return; - } const fromPath = direction === "encode" ? rule.from : rule.to; const toPath = direction === "encode" ? rule.to : rule.from; const fromLocus = parsePathMemo(fromPath); const toLocus = parsePathMemo(toPath); - const reading = readLocus(doc, fromLocus); + const gate = rule.when === undefined ? undefined : elementGate(rule.when, doc, fromLocus); + if (gate === false) { + return; + } + const reading = readLocus(doc, fromLocus, gate); if (reading === undefined) { throw new RuleApplicationError(index, fromPath, "the path is absent from the document"); } @@ -88,6 +89,27 @@ function run(crosswalk: Crosswalk, doc: unknown, direction: "encode" | "decode") return parseJsonValue(target); } +/** + * How a rule's `when` applies: a gate with no wildcard references is decided once for the whole rule (`false` skips it, `undefined` lets it run), and one whose references carry wildcards becomes a per-element gate over the read side, evaluated once per element at the depth its references reach. `governing` is the rule's read-side path in this direction, so the gate's references are rebased onto the arrays it iterates. + */ +function elementGate(when: PredicateNode, doc: unknown, governing: Locus): ElementGate | false | undefined { + const arity = whenArity(when); + if (arity === 0) { + return evaluateWhen(when, doc) ? undefined : false; + } + const wildcardPositions = governing.flatMap((step, position) => (step.kind === "wildcard" ? [position] : [])); + const arrayAt = (wildcard: number): Locus => { + const position = wildcardPositions[wildcard]; + if (position === undefined) { + throw new Error(`the rule's path has no wildcard ${String(wildcard)}: validateCrosswalk bounds a reference's wildcards by the rule's`); + } + + return governing.slice(0, position + 1); + }; + + return { arity, admit: (indexes) => evaluateWhen(when, doc, { indexes, arrayAt }) }; +} + /** Applies one two-way rule's operation to one value in the given direction, turning the operation's own thrown errors into RuleApplicationErrors that name the rule and path. */ function applyOp(rule: TwoWayRule, direction: "encode" | "decode", value: unknown, where: { readonly index: number; readonly path: string }): unknown { try { diff --git a/src/executor.unit.test.ts b/src/executor.unit.test.ts index ea2c604..98e0d26 100644 --- a/src/executor.unit.test.ts +++ b/src/executor.unit.test.ts @@ -239,3 +239,64 @@ describe("paths beyond records and one wildcard", () => { } }); }); + +describe("per-element when", () => { + const isCircle = { kind: "textCompare", op: "equals", left: { kind: "reference", key: "items[].kind" }, right: { kind: "textLiteral", value: "circle" } }; + const shapes = crosswalk({ + source: "n", + target: "s", + rules: [ + { op: "move", from: "items[].kind", to: "shapes[].kind" }, + { op: "move", from: "items[].radius", to: "shapes[].r", when: isCircle }, + ], + oneWay: [], + }); + const native = { items: [{ kind: "circle", radius: 2 }, { kind: "square" }, { kind: "circle", radius: 1 }] }; + + it("applies a rule only to the elements whose own field passes, skipping elements that lack the path", () => { + expect(encode(shapes, native)).toEqual({ shapes: [{ kind: "circle", r: 2 }, { kind: "square" }, { kind: "circle", r: 1 }] }); + }); + + it("rebases the reference onto the target array while decoding", () => { + expect(decode(shapes, encode(shapes, native))).toEqual(native); + }); + + it("gates an inner wildcard on a field of the outer element", () => { + const priced = { kind: "exists", operand: { kind: "reference", key: "orders[].currency" } }; + const cw = crosswalk({ + source: "n", + target: "s", + rules: [ + { op: "move", from: "orders[].id", to: "sales[].id" }, + { op: "move", from: "orders[].currency", to: "sales[].currency", when: priced }, + { op: "scale", from: "orders[].lines[].pence", to: "sales[].lines[].pounds", factor: 100, when: priced }, + ], + oneWay: [], + }); + const orders = { orders: [{ id: "a", currency: "GBP", lines: [{ pence: 1 }] }, { id: "b" }] }; + const shared = encode(cw, orders); + expect(shared).toEqual({ sales: [{ id: "a", currency: "GBP", lines: [{ pounds: 100 }] }, { id: "b" }] }); + expect(decode(cw, shared)).toEqual(orders); + }); + + it("binds a collection reference to the visited element", () => { + const anyHeavy = { kind: "some", collection: "boxes[].parts", item: { kind: "compare", op: "gt", left: { kind: "reference", key: "kg" }, right: { kind: "numberLiteral", value: 1 } } }; + const cw = crosswalk({ + source: "n", + target: "s", + rules: [ + { op: "move", from: "boxes[].parts", to: "crates[].parts" }, + { op: "move", from: "boxes[].label", to: "crates[].warning", when: anyHeavy }, + ], + oneWay: [], + }); + const boxes = { boxes: [{ parts: [{ kg: 2 }], label: "heavy" }, { parts: [{ kg: 1 }], label: "light" }] }; + expect(encode(cw, boxes)).toEqual({ crates: [{ parts: [{ kg: 2 }], warning: "heavy" }, { parts: [{ kg: 1 }] }] }); + }); + + it("throws naming the gap when a gated rule is the only writer of an element", () => { + const cw = crosswalk({ source: "n", target: "s", rules: [{ op: "move", from: "items[].radius", to: "radii[]", when: isCircle }], oneWay: [] }); + expect(() => encode(cw, { items: [{ kind: "square" }, { kind: "circle", radius: 1 }] })).toThrow("radii[0]: no rule wrote this array element"); + }); + +}); diff --git a/src/paths.ts b/src/paths.ts index 52c9c39..41372c7 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -173,6 +173,12 @@ export interface EmptySite { readonly wildcard: number; } +/** A per-element filter for {@link readLocus}: which elements, identified by their indexes at the first `arity` wildcards, a gated rule visits. */ +export interface ElementGate { + readonly arity: number; + readonly admit: (indexes: readonly number[]) => boolean; +} + /** Everything one locus addresses in a document: the matches in index order, and every empty array a wildcard step met on the way. */ export interface LocusReading { readonly matches: readonly PathMatch[]; @@ -183,9 +189,10 @@ export interface LocusReading { * Reads every location a locus addresses in a document. A wildcard step yields one match per element of the array it reaches, so a locus with wildcard arity `a` yields exactly one match per `a`-dimensional index combination of the arrays it traverses; a fixed index step addresses one element. * @param root - The document to read from. * @param locus - The parsed path to read. + * @param gate - Optional per-element admission: once the first `arity` wildcards are expanded, each element is kept only if `admit` returns true for its indexes at those wildcards, and nothing below a refused element is read (so an element that lacks the rest of the path is skipped, not absent). Empty arrays met at or above that depth are still reported. * @returns The reading, or `undefined` when any step of the path is absent from the document: a missing key, a key step meeting a non-record, an index step meeting a non-array or an index past the array's end, or a wildcard meeting a non-array (a wildcard over a missing array is absence too, not an empty list). */ -export function readLocus(root: unknown, locus: Locus): LocusReading | undefined { +export function readLocus(root: unknown, locus: Locus, gate?: ElementGate): LocusReading | undefined { let frontier: readonly PathMatch[] = [{ trail: [], value: root }]; const empties: EmptySite[] = []; let wildcardOrdinal = 0; @@ -220,7 +227,7 @@ export function readLocus(root: unknown, locus: Locus): LocusReading | undefined if (step.kind === "wildcard") { wildcardOrdinal += 1; } - frontier = next; + frontier = gate !== undefined && step.kind === "wildcard" && wildcardOrdinal === gate.arity ? next.filter((site) => gate.admit(wildcardIndexes(locus, site.trail))) : next; } return { matches: frontier, empties }; diff --git a/src/validate.ts b/src/validate.ts index 1229967..2ffd2b4 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,6 +1,7 @@ import type { Crosswalk, OneWayRule, TwoWayRule } from "./crosswalk"; import type { CrosswalkIssue } from "./errors"; import { canonicalPath, formatPath, type Locus, PATH_REGEX, parsePathMemo, wildcardCount } from "./paths"; +import { visitReferences } from "./when"; /** * Validates a syntactically parsed crosswalk against the structural rules that make its rule list executable and decode a true inverse of encode. Schema parsing (parseCrosswalk) checks each rule in isolation; these checks are properties of the rule list as a whole, which is why they are reported as a list rather than thrown mid-parse. Each check below returns its issues and the results are concatenated, so a check with nothing to report returns an empty list. @@ -15,12 +16,12 @@ export function validateCrosswalk(crosswalk: Crosswalk): readonly CrosswalkIssue return [ ...crosswalk.rules.flatMap((rule, index): readonly CrosswalkIssue[] => [ ...checkWildcardAlignment(rule, index), - ...checkWhenPaths(rule.when, index), + ...checkWhenPaths(rule.when, index, parsePathMemo(rule.from)), ]), ...crosswalk.oneWay.flatMap((rule, index): readonly CrosswalkIssue[] => { const oneWayIndex = ruleCount + index; - return rule.op === "default" ? [...checkWhenPaths(rule.when, oneWayIndex), ...checkDefaultTo(rule, oneWayIndex)] : checkWhenPaths(rule.when, oneWayIndex); + return rule.op === "default" ? [...checkWhenPaths(rule.when, oneWayIndex, undefined), ...checkDefaultTo(rule, oneWayIndex)] : checkWhenPaths(rule.when, oneWayIndex, undefined); }), ...checkPathUniqueness(crosswalk), ...checkWildcardRoots(crosswalk), @@ -44,44 +45,43 @@ function checkWildcardAlignment(rule: TwoWayRule, index: number): readonly Cross ]; } -/** Design check (e), plus the same for collection references: a `when` is evaluated against one governing document as a whole, so a wildcard inside it has no element to bind to. The grammar is re-checked too, so an exotic-but-schematic predicate cannot smuggle in a malformed path. */ -function checkWhenPaths(when: unknown, index: number): readonly CrosswalkIssue[] { +/** Design check (e), plus the same for collection references: every reference must be a well-formed path, and a reference with wildcards names a field of the element a two-way rule is visiting, so it is allowed only on a two-way rule, with no more wildcards than the rule's `from`, and with its part through its last wildcard equal to the `from` path's part through the same wildcard (the array the rule iterates there). That last condition is what lets decode rebase the reference onto the `to` side's array; a wildcard into some other array would have no element to bind to. One-way rules have no visited element, so their references are wildcard-free. */ +function checkWhenPaths(when: unknown, index: number, from: Locus | undefined): readonly CrosswalkIssue[] { const issues: CrosswalkIssue[] = []; - walkPredicate(when, (reference) => { - if (typeof reference !== "string" || !PATH_REGEX.test(reference) || wildcardCount(parsePathMemo(reference)) > 0) { - issues.push({ - ruleIndex: index, - message: `when references a path that is malformed or contains a wildcard: ${JSON.stringify(reference)}`, - }); + visitReferences(when, (reference) => { + if (typeof reference !== "string" || !PATH_REGEX.test(reference)) { + issues.push({ ruleIndex: index, message: `when references a path that is malformed: ${JSON.stringify(reference)}` }); + + return; + } + const problem = elementReferenceProblem(parsePathMemo(reference), from); + if (problem !== undefined) { + issues.push({ ruleIndex: index, message: `when reference ${reference} ${problem}` }); } }); return issues; } -/** Visits every string a predicate tree uses as a reference key or collection name, without enumerating trilean node kinds: the tree has already passed PredicateNodeSchema, so an object with a `key`/`collection` string field in these positions is a reference the resolvers will receive. */ -function walkPredicate(node: unknown, visit: (reference: unknown) => void): void { - if (Array.isArray(node)) { - node.forEach((element) => { - walkPredicate(element, visit); - }); - - return; +/** Why a reference's wildcards cannot bind to the element its rule visits, or `undefined` when they can (including when it has none). */ +function elementReferenceProblem(reference: Locus, from: Locus | undefined): string | undefined { + const wildcards = wildcardCount(reference); + if (wildcards === 0) { + return undefined; } - if (!isRecord(node)) { - return; + if (from === undefined) { + return "contains a wildcard, but a one-way rule visits no element to bind it to"; } - if (typeof node["key"] === "string") { - visit(node["key"]); + if (wildcards > wildcardCount(from)) { + return `contains ${String(wildcards)} wildcards where its rule's from has ${String(wildcardCount(from))}`; } - if (typeof node["collection"] === "string") { - visit(node["collection"]); - } - for (const value of Object.values(node)) { - if (typeof value === "object" && value !== null) { - walkPredicate(value, visit); - } + const referenceArray = formatPath(reference.slice(0, reference.findLastIndex((step) => step.kind === "wildcard") + 1)); + const fromArray = wildcardArrays(from)[wildcards - 1]; + if (referenceArray !== fromArray) { + return `contains a wildcard into ${referenceArray}, which is not the array its rule's from iterates at that depth (${String(fromArray)})`; } + + return undefined; } /** A `default` inserts one value at one place: a wildcard in its `to` would need an array length no rule supplies. */ @@ -210,7 +210,3 @@ function mixedArrays(paths: readonly string[]): readonly string[] { return [...indexed].filter((array) => iterated.has(array)); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/validate.unit.test.ts b/src/validate.unit.test.ts index 9d7ffa7..f8f5f4e 100644 --- a/src/validate.unit.test.ts +++ b/src/validate.unit.test.ts @@ -151,3 +151,25 @@ describe("validateCrosswalk", () => { expect(validateCrosswalk(cw).map((issue) => issue.message).join()).toContain("wildcard-free"); }); }); + +describe("validateCrosswalk on per-element when references", () => { + const exists = (key: string) => ({ kind: "exists", operand: { kind: "reference", key } }); + const issuesOf = (rules: readonly unknown[], oneWay: readonly unknown[] = []) => validateCrosswalk(crosswalk({ source: "a", target: "b", rules, oneWay })).map((issue) => issue.message); + + it("accepts a reference into the array the rule iterates, at any depth up to the rule's", () => { + expect(issuesOf([{ op: "move", from: "p[].q[].v", to: "n[].m[].v", when: exists("p[].flag") }])).toEqual([]); + expect(issuesOf([{ op: "move", from: "p[].q[].v", to: "n[].m[].v", when: exists("p[].q[].flag") }])).toEqual([]); + }); + + it("rejects a reference into another array", () => { + expect(issuesOf([{ op: "move", from: "p[].v", to: "n[].v", when: exists("other[].flag") }])).toEqual(["when reference other[].flag contains a wildcard into other[], which is not the array its rule's from iterates at that depth (p[])"]); + }); + + it("rejects a reference with more wildcards than the rule", () => { + expect(issuesOf([{ op: "move", from: "p[].v", to: "n[].v", when: exists("p[].q[].flag") }])).toEqual(["when reference p[].q[].flag contains 2 wildcards where its rule's from has 1"]); + }); + + it("rejects a wildcard reference on a one-way rule", () => { + expect(issuesOf([], [{ op: "drop", from: "p[].v", direction: "encode", when: exists("p[].flag") }])).toEqual(["when reference p[].flag contains a wildcard, but a one-way rule visits no element to bind it to"]); + }); +}); diff --git a/src/when.ts b/src/when.ts index 1a380c5..a9648b7 100644 --- a/src/when.ts +++ b/src/when.ts @@ -1,6 +1,6 @@ import { type ComputedValue, type PredicateNode, type Resolution, createSyncEvaluator, type SyncResolvers } from "trilean"; import { getOwn } from "./own"; -import { parsePathMemo, readLocus } from "./paths"; +import { bindLocus, type Locus, parsePathMemo, readLocus, readTrail, wildcardCount } from "./paths"; /** * One element of a collection a `when` predicate quantifies over (`some`/`every`), wrapped in an unexported class so only this module's own `resolveCollection` can mint the contexts `resolveValue` treats as items: a record-rooted resolver pair can never mistake a hand-built value for an item. @@ -28,16 +28,57 @@ function scalarResolution(value: unknown): Resolution { return { found: false }; } +/** + * The element a wildcard rule is visiting, for a `when` whose references carry wildcards: the index tuple of the current match, and the governing side's array for each of the rule's wildcards. A reference such as `rows[].kind` is written against the rule's `from` side (validateCrosswalk requires its part through its last wildcard to be the `from` path's part through that wildcard), so while decoding that part is replaced by the `to` path's array at the same depth, and the reference reads the element being visited in whichever document governs. + */ +export interface WhenElement { + /** The current match's index at each of the rule's wildcards that the gate covers, outermost first. */ + readonly indexes: readonly number[]; + /** + * The governing side's locus prefix through one of its wildcards: the array that wildcard iterates. + * @param wildcard - The wildcard's ordinal, from zero. + * @returns The locus prefix, ending in that wildcard step. + */ + readonly arrayAt: (wildcard: number) => Locus; +} + +/** + * Resolves a reference path to the one value it names: a wildcard-free path as written (exactly one match, else not found), and a wildcard path rebased onto the visited element's array and bound to its indexes. + * @throws Error for a wildcard reference with no element to bind to, or more wildcards than the element has indexes, which validateCrosswalk rules out before execution. + */ +function locate(doc: unknown, path: string, element: WhenElement | undefined): { readonly found: true; readonly value: unknown } | { readonly found: false } { + const locus = parsePathMemo(path); + const wildcards = wildcardCount(locus); + if (wildcards === 0) { + const matches = readLocus(doc, locus)?.matches; + const match = matches?.[0]; + if (matches?.length !== 1 || match === undefined) { + return { found: false }; + } + + return { found: true, value: match.value }; + } + if (element === undefined || wildcards > element.indexes.length) { + throw new Error(`a when reference with wildcards has no visited element to bind to: ${path}`); + } + const lastWildcard = locus.findLastIndex((step) => step.kind === "wildcard"); + const rebased: Locus = [...element.arrayAt(wildcards - 1), ...locus.slice(lastWildcard + 1)]; + + return readTrail(doc, bindLocus(rebased, element.indexes)); +} + /** * Builds the record-rooted resolvers for one governing document: the source document while encoding, the target document while decoding. * - * `resolveValue` takes a path string against the document root (the same grammar as rule paths; validateCrosswalk rejects wildcard paths inside `when` before execution) and resolves only scalar leaves. Inside a `some`/`every`/`fold`, the context is one item and the key is that item's field name. + * `resolveValue` takes a path string against the document root (the same grammar as rule paths) and resolves only scalar leaves. A path with wildcards names a field of the element the rule is visiting (see {@link WhenElement}). Inside a `some`/`every`/`fold`, the context is one item and the key is that item's field name. * - * `resolveCollection` takes a path string resolving to an array of records, and throws for a missing array or a non-record element: an absent list silently changing a quantifier's meaning is a crosswalk error, not an empty collection. + * `resolveCollection` takes a path string resolving to an array of records, bound to the visited element in the same way, and throws for a missing array or a non-record element: an absent list silently changing a quantifier's meaning is a crosswalk error, not an empty collection. * - * `resolveLookup` throws: v1 crosswalks carry no lookup tables, so a `lookup` node inside a `when` is a documented unsupported reference, surfaced loudly rather than evaluated as indeterminate. + * `resolveLookup` throws: crosswalks carry no lookup tables, so a `lookup` node inside a `when` is a documented unsupported reference, surfaced loudly rather than evaluated as indeterminate. + * @param doc - The governing document. + * @param element - The element a wildcard rule is visiting, for a per-element gate; omitted for a gate on the whole rule. */ -export function createWhenResolvers(doc: unknown): Readonly { +export function createWhenResolvers(doc: unknown, element?: WhenElement): Readonly { return { resolveValue: (key, context): Resolution => { if (typeof key !== "string") { @@ -46,13 +87,9 @@ export function createWhenResolvers(doc: unknown): Readonly { if (context instanceof WhenItem) { return scalarResolution(getOwn(context.values, key)); } - const matches = readLocus(doc, parsePathMemo(key))?.matches; - const match = matches?.[0]; - if (matches?.length !== 1 || match === undefined) { - return { found: false }; - } + const located = locate(doc, key, element); - return scalarResolution(match.value); + return located.found ? scalarResolution(located.value) : { found: false }; }, resolveLookup: (table) => { throw new Error(`lookup tables are not supported in a crosswalk when predicate (table: ${JSON.stringify(table)})`); @@ -61,37 +98,82 @@ export function createWhenResolvers(doc: unknown): Readonly { if (typeof collection !== "string") { throw new Error(`a when collection reference must be a path string, found: ${JSON.stringify(collection)}`); } - const matches = readLocus(doc, parsePathMemo(collection))?.matches; - const match = matches?.[0]; - if (matches === undefined || match === undefined || matches.length !== 1) { + const located = locate(doc, collection, element); + if (!located.found) { throw new Error(`a when collection reference resolved to nothing in the governing document: ${collection}`); } - const array = match.value; + const array = located.value; if (!Array.isArray(array)) { throw new Error(`a when collection reference resolved to a non-array value: ${collection}`); } - return array.map((element: unknown): WhenItem => { - if (!isRecord(element)) { + return array.map((item: unknown): WhenItem => { + if (!isRecord(item)) { throw new Error(`a when collection resolved to a non-record element under ${collection}`); } - return new WhenItem(element); + return new WhenItem(item); }); }, }; } +/** + * Visits every string a predicate tree uses as a reference key or collection name, without enumerating trilean node kinds: an object with a `key`/`collection` string field is a reference the resolvers will receive. Field references inside a `some`/`every` item are visited too; they are plain field names, which the path grammar also accepts. + * @param node - A predicate tree (already accepted by trilean's PredicateNodeSchema) or any part of one. + * @param visit - Called once per reference, with the value found (a string for a well-formed tree). + */ +export function visitReferences(node: unknown, visit: (reference: unknown) => void): void { + if (Array.isArray(node)) { + node.forEach((element) => { + visitReferences(element, visit); + }); + + return; + } + if (!isRecord(node)) { + return; + } + if (typeof node["key"] === "string") { + visit(node["key"]); + } + if (typeof node["collection"] === "string") { + visit(node["collection"]); + } + for (const value of Object.values(node)) { + if (typeof value === "object" && value !== null) { + visitReferences(value, visit); + } + } +} + +/** + * The most wildcards any reference in a `when` carries: zero for a gate on the whole rule, and otherwise the number of the visited element's indexes the gate needs, so the executor evaluates it once per element at that depth. + * @param when - A predicate tree whose references validateCrosswalk has accepted as paths. + * @returns The largest wildcard count over the tree's references. + */ +export function whenArity(when: PredicateNode): number { + let arity = 0; + visitReferences(when, (reference) => { + if (typeof reference === "string") { + arity = Math.max(arity, wildcardCount(parsePathMemo(reference))); + } + }); + + return arity; +} + const evaluator = createSyncEvaluator({}); /** - * Decides whether a rule's `when` predicate lets it apply to a governing document. - * @param when - The predicate tree (trilean grammar; reference keys are paths into `doc`, wildcards forbidden by structural validation). + * Decides whether a rule's `when` predicate lets it apply to a governing document, or to one element of it. + * @param when - The predicate tree (trilean grammar; reference keys are paths into `doc`, and a path with wildcards names a field of the visited element). * @param doc - The governing document: the source document for encode, the target document for decode. - * @returns `true` only for a definite `true`; a definite `false` or an indeterminate evaluation (a reference the document cannot resolve) returns `false`, skipping the rule. + * @param element - The element being visited, required when the tree has wildcard references. + * @returns `true` only for a definite `true`; a definite `false` or an indeterminate evaluation (a reference the document cannot resolve) returns `false`, skipping the rule or the element. */ -export function evaluateWhen(when: PredicateNode, doc: unknown): boolean { - const result = evaluator.evaluatePredicate(when, doc, createWhenResolvers(doc)); +export function evaluateWhen(when: PredicateNode, doc: unknown, element?: WhenElement): boolean { + const result = evaluator.evaluatePredicate(when, doc, createWhenResolvers(doc, element)); return result.status === "definite" && result.value; } From 0512e9eca204c09546c809310be784831f64a5fa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 20 Oct 2026 23:36:52 +0100 Subject: [PATCH 2/2] docs(readme): document per-element when gating Describes how a wildcard reference binds to the visited element, how decode rebases it onto the target array, and that an element every rule skips is a gap error; drops the item from "Not in this version". --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d2b2189..74247ed 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,20 @@ These are the declared lossy remainder, each applying in one named direction. ### Gating with `when` -Every rule may carry `when`, a trilean predicate tree, evaluated against the governing document: the source while encoding and the target while decoding. References are paths in the same grammar, without wildcards. A definite `true` applies the rule. A definite `false` skips it. An indeterminate result, where the data needed to decide is absent, also skips it: that is the one deliberate non-throwing branch in the format. Gating a rule on a presence or value predicate is how an optional native field is expressed. +Every rule may carry `when`, a trilean predicate tree, evaluated against the governing document: the source while encoding and the target while decoding. References are paths in the same grammar. A definite `true` applies the rule. A definite `false` skips it. An indeterminate result, where the data needed to decide is absent, also skips it: that is the one deliberate non-throwing branch in the format. Gating a rule on a presence or value predicate is how an optional native field is expressed. + +A two-way rule that iterates an array can be gated per element. A reference with wildcards names a field of the element the rule is visiting, written against the rule's `from` side: on a rule over `items[].radius`, `items[].kind` is the `kind` of the same item. The reference's part through its last wildcard must be the array the rule's `from` iterates at that depth, and it may have fewer wildcards than the rule, gating every inner element on a field of the outer one. While decoding, that part is rebased onto the `to` side's array, so `items[].kind` reads `shapes[i].kind` when the rule maps `items[]` to `shapes[]`; the field below it has to keep its name in both shapes, as with any gate. A gated element is skipped before anything below it is read, so an element that lacks the rule's path is skipped rather than absent. Collection references bind the same way. One-way rules visit no element, so their references have no wildcards. + +```ts +{ + op: "move", + from: "items[].radius", + to: "shapes[].r", + when: { kind: "textCompare", op: "equals", left: { kind: "reference", key: "items[].kind" }, right: { kind: "textLiteral", value: "circle" } }, +} +``` + +An element that every rule writing into it skips leaves a gap in the output array, which is a `RuleApplicationError` naming the element: map at least one field of every element unconditionally. ### Execution @@ -242,7 +255,6 @@ Each of these is a deliberate omission, listed so it is not mistaken for an over - Lookup against external tables. - Aggregates and folds over arrays. - Array filter and sort. -- Per-element `when` gating inside a wildcard. - Regex split. - One rule writing several targets. - Further `format` kinds.