From 12646bd5e8452080464dbf5752274982957464b5 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Wed, 4 Mar 2026 18:34:18 +0100 Subject: [PATCH 1/5] feat: add fast-check as a dev dependency and implement fuzz tests for compileBridge --- packages/bridge-compiler/package.json | 1 + packages/bridge-compiler/src/codegen.ts | 37 +- packages/bridge-compiler/test/README.md | 58 ++ packages/bridge-compiler/test/codegen.test.ts | 119 +++- .../bridge-compiler/test/fuzz-compile.test.ts | 523 ++++++++++++++++++ .../test/fuzz-regressions.todo.test.ts | 15 + pnpm-lock.yaml | 16 + 7 files changed, 753 insertions(+), 16 deletions(-) create mode 100644 packages/bridge-compiler/test/README.md create mode 100644 packages/bridge-compiler/test/fuzz-compile.test.ts create mode 100644 packages/bridge-compiler/test/fuzz-regressions.todo.test.ts diff --git a/packages/bridge-compiler/package.json b/packages/bridge-compiler/package.json index f00108f7..a419e752 100644 --- a/packages/bridge-compiler/package.json +++ b/packages/bridge-compiler/package.json @@ -28,6 +28,7 @@ "devDependencies": { "@stackables/bridge-parser": "workspace:*", "@types/node": "^25.3.2", + "fast-check": "^4.5.3", "typescript": "^5.9.3" }, "repository": { diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 3cebe688..bf19ef9d 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -30,10 +30,35 @@ import type { NodeRef, ToolDef, } from "@stackables/bridge-core"; -import { matchesRequestedFields } from "@stackables/bridge-core"; const SELF_MODULE = "_"; +function matchesRequestedFields( + fieldPath: string, + requestedFields: string[] | undefined, +): boolean { + if (!requestedFields || requestedFields.length === 0) return true; + + for (const pattern of requestedFields) { + if (pattern === fieldPath) return true; + + if (fieldPath.startsWith(pattern + ".")) return true; + + if (pattern.startsWith(fieldPath + ".")) return true; + + if (pattern.endsWith(".*")) { + const prefix = pattern.slice(0, -2); + if (fieldPath.startsWith(prefix + ".")) { + const rest = fieldPath.slice(prefix.length + 1); + if (!rest.includes(".")) return true; + } + if (fieldPath === prefix) return true; + } + } + + return false; +} + // ── Public API ────────────────────────────────────────────────────────────── export interface CompileOptions { @@ -1794,9 +1819,10 @@ class CodegenContext { const { leftRef, rightRef, rightValue } = w.condAnd; const left = this.refToExpr(leftRef); let expr: string; - if (rightRef) expr = `(${left} && ${this.refToExpr(rightRef)})`; + if (rightRef) + expr = `(Boolean(${left}) && Boolean(${this.refToExpr(rightRef)}))`; else if (rightValue !== undefined) - expr = `(${left} && ${emitCoerced(rightValue)})`; + expr = `(Boolean(${left}) && Boolean(${emitCoerced(rightValue)}))`; else expr = `Boolean(${left})`; expr = this.applyFallbacks(w, expr); return expr; @@ -1807,9 +1833,10 @@ class CodegenContext { const { leftRef, rightRef, rightValue } = w.condOr; const left = this.refToExpr(leftRef); let expr: string; - if (rightRef) expr = `(${left} || ${this.refToExpr(rightRef)})`; + if (rightRef) + expr = `(Boolean(${left}) || Boolean(${this.refToExpr(rightRef)}))`; else if (rightValue !== undefined) - expr = `(${left} || ${emitCoerced(rightValue)})`; + expr = `(Boolean(${left}) || Boolean(${emitCoerced(rightValue)}))`; else expr = `Boolean(${left})`; expr = this.applyFallbacks(w, expr); return expr; diff --git a/packages/bridge-compiler/test/README.md b/packages/bridge-compiler/test/README.md new file mode 100644 index 00000000..38c43bc0 --- /dev/null +++ b/packages/bridge-compiler/test/README.md @@ -0,0 +1,58 @@ +# bridge-compiler test workflow + +This folder contains unit tests, fuzz/property tests, and a backlog of known fuzz-discovered issues. + +## Files and intent + +- `codegen.test.ts` — deterministic, scenario-based behavior tests. +- `fuzz-compile.test.ts` — property/fuzz tests (syntax safety, determinism, AOT/runtime parity). +- `fuzz-regressions.todo.test.ts` — **backlog** of known issues as `test.todo(...)` entries. + +## Why `test.todo` exists here + +Fuzzing can find valid issues faster than we can fix them. `test.todo` is used to: + +1. Preserve findings immediately (so they are not lost). +2. Keep CI green while investigation/fix work is queued. +3. Make known risk areas visible in test output. + +`test.todo` is not a permanent state. It is a staging area between discovery and a real executable regression test. + +## Preferred process when fuzz finds an issue + +1. **Capture evidence immediately** + - Seed, failure path, and minimized/counterexample input. + - Whether mismatch is `AOT != runtime`, parser/serializer, or runtime crash. + +2. **Add a `test.todo` entry** in `fuzz-regressions.todo.test.ts` + - Include a short class label plus seed (if available). + - Example format: `"nullish fallback parity ... (seed=123456)"`. + +3. **Open a tracking issue/PR note** + - Link to the todo label. + - Add impact, expected behavior, and suspected component (`bridge-core`, `bridge-compiler`, `bridge-parser`). + +4. **Create a deterministic reproducer test** + - Prefer a minimal hand-authored bridge/input over rerunning random fuzz. + - Add to `codegen.test.ts` (or a dedicated regression file) as a normal `test(...)`. + +5. **Fix at root cause** + - Update compiler/runtime/parser behavior. + - Keep fix small and targeted. + +6. **Promote and clean up** + - Ensure reproducer test passes. + - Remove corresponding `test.todo` entry. + - Keep fuzz property in place to guard against nearby regressions. + +## How we fix issues without losing them + +- Discovery path: fuzz failure -> `test.todo` backlog entry. +- Stabilization path: add deterministic reproducer -> implement fix -> remove todo. +- Verification path: run package tests (`pnpm --filter @stackables/bridge-compiler test`) and then broader repo checks as needed. + +## Practical tips + +- Keep generated identifiers/simple values parser-safe in text round-trip fuzzers. +- Constrain parity fuzz generators when an oracle (runtime/parser) has known unstable surfaces. +- Prefer multiple small targeted properties over one giant mixed generator for easier shrinking and diagnosis. diff --git a/packages/bridge-compiler/test/codegen.test.ts b/packages/bridge-compiler/test/codegen.test.ts index 9060fb3a..c34c9e37 100644 --- a/packages/bridge-compiler/test/codegen.test.ts +++ b/packages/bridge-compiler/test/codegen.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; import { parseBridgeFormat } from "@stackables/bridge-parser"; import { executeBridge } from "@stackables/bridge-core"; +import type { BridgeDocument } from "@stackables/bridge-core"; import { compileBridge, executeBridge as executeAot } from "../src/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -466,6 +467,108 @@ bridge Query.conditional { ); assert.equal(capturedInput.mode, "basic"); }); + + test("condAnd parity — matches runtime boolean semantics", async () => { + const document: BridgeDocument = { + instructions: [ + { + kind: "bridge", + type: "Query", + field: "probe", + handles: [ + { kind: "input", handle: "i" }, + { kind: "output", handle: "o" }, + ], + wires: [ + { + condAnd: { + leftRef: { + module: "_", + type: "Query", + field: "probe", + path: ["m"], + }, + rightValue: "null", + }, + to: { + module: "_", + type: "Query", + field: "probe", + path: ["k"], + }, + }, + ], + }, + ], + }; + + const runtime = await executeBridge({ + document, + operation: "Query.probe", + input: {}, + tools: {}, + }); + const aot = await executeAot({ + document, + operation: "Query.probe", + input: {}, + tools: {}, + }); + + assert.deepStrictEqual(aot.data, runtime.data); + assert.deepStrictEqual(aot.data, { k: false }); + }); + + test("condOr parity — matches runtime boolean semantics", async () => { + const document: BridgeDocument = { + instructions: [ + { + kind: "bridge", + type: "Query", + field: "probeOr", + handles: [ + { kind: "input", handle: "i" }, + { kind: "output", handle: "o" }, + ], + wires: [ + { + condOr: { + leftRef: { + module: "_", + type: "Query", + field: "probeOr", + path: ["m"], + }, + rightValue: "null", + }, + to: { + module: "_", + type: "Query", + field: "probeOr", + path: ["k"], + }, + }, + ], + }, + ], + }; + + const runtime = await executeBridge({ + document, + operation: "Query.probeOr", + input: {}, + tools: {}, + }); + const aot = await executeAot({ + document, + operation: "Query.probeOr", + input: {}, + tools: {}, + }); + + assert.deepStrictEqual(aot.data, runtime.data); + assert.deepStrictEqual(aot.data, { k: false }); + }); }); // ── Benchmark: AOT vs Runtime ──────────────────────────────────────────────── @@ -1484,7 +1587,9 @@ bridge Query.contTool { { name: "Carol", itemId: 3 }, ], }), - enricher: (input: any) => ({ data: `enriched-${input.in?.itemId ?? "?"}` }), + enricher: (input: any) => ({ + data: `enriched-${input.in?.itemId ?? "?"}`, + }), }, {}, ); @@ -1593,11 +1698,7 @@ bridge Query.contCatch { {}, { api: () => ({ - results: [ - { name: "Alice" }, - { name: null }, - { name: "Carol" }, - ], + results: [{ name: "Alice" }, { name: null }, { name: "Carol" }], }), enricher: (_input: any) => ({ data: "ok" }), }, @@ -1629,11 +1730,7 @@ bridge Query.subContTool { { api: () => ({ title: "Test", - items: [ - { name: "Alice" }, - { name: null }, - { name: "Carol" }, - ], + items: [{ name: "Alice" }, { name: null }, { name: "Carol" }], }), enricher: (_input: any) => ({ data: "ok" }), }, diff --git a/packages/bridge-compiler/test/fuzz-compile.test.ts b/packages/bridge-compiler/test/fuzz-compile.test.ts new file mode 100644 index 00000000..97989846 --- /dev/null +++ b/packages/bridge-compiler/test/fuzz-compile.test.ts @@ -0,0 +1,523 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import fc from "fast-check"; +import { parseBridgeFormat, serializeBridge } from "@stackables/bridge-parser"; +import type { + Bridge, + BridgeDocument, + NodeRef, + Wire, +} from "@stackables/bridge-core"; +import { executeBridge as executeRuntime } from "@stackables/bridge-core"; +import { compileBridge, executeBridge as executeAot } from "../src/index.ts"; + +const AsyncFunction = Object.getPrototypeOf(async function () {}) + .constructor as new ( + ...args: string[] +) => (...args: unknown[]) => Promise; + +const identifierArb = fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_]{0,20}$/); +const canonicalIdentifierArb = fc.constantFrom( + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", +); +const pathArb = fc.array(identifierArb, { minLength: 1, maxLength: 4 }); +const flatPathArb = fc.array(identifierArb, { minLength: 1, maxLength: 1 }); + +const constantValueArb = fc + .oneof( + fc.string({ maxLength: 64 }), + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.boolean(), + fc.constant(null), + ) + .map((value) => JSON.stringify(value)); + +function inputRef(type: string, field: string, path: string[]): NodeRef { + return { + module: "_", + type, + field, + path, + }; +} + +function outputRef(type: string, field: string, path: string[]): NodeRef { + return { + module: "_", + type, + field, + path, + }; +} + +const wireArb = (type: string, field: string): fc.Arbitrary => { + const toArb = pathArb.map((path) => outputRef(type, field, path)); + const fromArb = pathArb.map((path) => inputRef(type, field, path)); + + return fc.oneof( + // 1. Constant Wires + fc.record({ + value: constantValueArb, + to: toArb, + }), + + // 2. Complex Pull Wires (Randomly injecting fallbacks) + fc.record( + { + from: fromArb, + to: toArb, + falsyFallback: constantValueArb, + nullishFallback: constantValueArb, + catchFallback: constantValueArb, + }, + { requiredKeys: ["from", "to"] }, // Fallbacks are randomly omitted + ), + + // 3. Ternary Conditional Wires + fc.record( + { + cond: fromArb, + to: toArb, + thenValue: constantValueArb, + elseValue: constantValueArb, + }, + { requiredKeys: ["cond", "to"] }, // then/else are randomly omitted + ), + + // 4. Logical AND + fc.record({ + condAnd: fc.record( + { + leftRef: fromArb, + rightValue: constantValueArb, + }, + { requiredKeys: ["leftRef"] }, + ), + to: toArb, + }), + + // 5. Logical OR + fc.record({ + condOr: fc.record( + { + leftRef: fromArb, + rightValue: constantValueArb, + }, + { requiredKeys: ["leftRef"] }, + ), + to: toArb, + }), + ); +}; + +const flatWireArb = (type: string, field: string): fc.Arbitrary => { + const toArb = flatPathArb.map((path) => outputRef(type, field, path)); + const fromArb = flatPathArb.map((path) => inputRef(type, field, path)); + + return fc.oneof( + fc.record({ + value: constantValueArb, + to: toArb, + }), + fc.record({ + from: fromArb, + to: toArb, + }), + ); +}; + +const bridgeArb: fc.Arbitrary = fc + .record({ + type: identifierArb, + field: identifierArb, + }) + .chain(({ type, field }) => + fc.record({ + kind: fc.constant<"bridge">("bridge"), + type: fc.constant(type), + field: fc.constant(field), + handles: fc.constant([ + { kind: "input", handle: "i" } as const, + { kind: "output", handle: "o" } as const, + ]), + wires: fc.array(wireArb(type, field), { minLength: 1, maxLength: 20 }), + }), + ); + +const flatBridgeArb: fc.Arbitrary = fc + .record({ + type: identifierArb, + field: identifierArb, + }) + .chain(({ type, field }) => + fc.record({ + kind: fc.constant<"bridge">("bridge"), + type: fc.constant(type), + field: fc.constant(field), + handles: fc.constant([ + { kind: "input", handle: "i" } as const, + { kind: "output", handle: "o" } as const, + ]), + wires: fc.uniqueArray(flatWireArb(type, field), { + minLength: 1, + maxLength: 20, + selector: (wire) => wire.to.path.join("."), + }), + }), + ); + +const inputArb = fc.dictionary(identifierArb, fc.jsonValue(), { + maxKeys: 16, +}); + +const textConstantValueArb = fc.oneof( + fc.boolean().map((v) => (v ? "true" : "false")), + fc.constant("null"), + fc.integer({ min: -1_000_000, max: 1_000_000 }).map(String), + fc.string({ maxLength: 32 }).map((v) => JSON.stringify(v)), +); + +const wireSpecArb = fc.oneof( + fc.record({ + kind: fc.constant<"pull">("pull"), + to: canonicalIdentifierArb, + from: canonicalIdentifierArb, + }), + fc.record({ + kind: fc.constant<"constant">("constant"), + to: canonicalIdentifierArb, + value: textConstantValueArb, + }), +); + +const bridgeTextSpecArb = fc.record({ + type: canonicalIdentifierArb, + field: canonicalIdentifierArb, + wires: fc.uniqueArray(wireSpecArb, { + minLength: 1, + maxLength: 8, + selector: (wire) => wire.to, + }), + input: inputArb, +}); + +function buildBridgeText(spec: { + type: string; + field: string; + wires: Array< + | { kind: "pull"; to: string; from: string } + | { kind: "constant"; to: string; value: string } + >; +}): string { + const lines = [ + "version 1.5", + `bridge ${spec.type}.${spec.field} {`, + " with input as i", + " with output as o", + "", + ]; + + for (const wire of spec.wires) { + if (wire.kind === "pull") { + lines.push(` o.${wire.to} <- i.${wire.from}`); + } else { + lines.push(` o.${wire.to} = ${wire.value}`); + } + } + + lines.push("}"); + return lines.join("\n"); +} + +const fallbackHeavyBridgeArb: fc.Arbitrary = fc + .record({ + type: identifierArb, + field: identifierArb, + }) + .chain(({ type, field }) => + fc.record({ + kind: fc.constant<"bridge">("bridge"), + type: fc.constant(type), + field: fc.constant(field), + handles: fc.constant([ + { kind: "input", handle: "i" } as const, + { kind: "output", handle: "o" } as const, + ]), + wires: fc.uniqueArray( + fc.record({ + from: flatPathArb.map((path) => inputRef(type, field, path)), + to: flatPathArb.map((path) => outputRef(type, field, path)), + falsyFallback: constantValueArb, + nullishFallback: constantValueArb, + catchFallback: constantValueArb, + }), + { + minLength: 1, + maxLength: 20, + selector: (wire) => wire.to.path.join("."), + }, + ), + }), + ); + +const logicalBridgeArb: fc.Arbitrary = fc + .record({ + type: identifierArb, + field: identifierArb, + }) + .chain(({ type, field }) => { + const toArb = flatPathArb.map((path) => outputRef(type, field, path)); + const fromArb = flatPathArb.map((path) => inputRef(type, field, path)); + + return fc.record({ + kind: fc.constant<"bridge">("bridge"), + type: fc.constant(type), + field: fc.constant(field), + handles: fc.constant([ + { kind: "input", handle: "i" } as const, + { kind: "output", handle: "o" } as const, + ]), + wires: fc.uniqueArray( + fc.oneof( + fc.record( + { + cond: fromArb, + to: toArb, + thenValue: constantValueArb, + elseValue: constantValueArb, + }, + { requiredKeys: ["cond", "to"] }, + ), + fc.record({ + condAnd: fc.record( + { + leftRef: fromArb, + rightValue: constantValueArb, + }, + { requiredKeys: ["leftRef"] }, + ), + to: toArb, + }), + fc.record({ + condOr: fc.record( + { + leftRef: fromArb, + rightValue: constantValueArb, + }, + { requiredKeys: ["leftRef"] }, + ), + to: toArb, + }), + ), + { + minLength: 1, + maxLength: 20, + selector: (wire) => wire.to.path.join("."), + }, + ), + }); + }); + +describe("compileBridge fuzzing", () => { + test( + "never emits syntactically invalid function bodies", + { timeout: 90_000 }, + () => { + fc.assert( + fc.property(bridgeArb, (bridge) => { + const document: BridgeDocument = { + instructions: [bridge], + }; + + const result = compileBridge(document, { + operation: `${bridge.type}.${bridge.field}`, + }); + + assert.equal(typeof result.functionBody, "string"); + assert.ok(result.functionBody.length > 0); + + // If the compiler output is missing a brace, comma, or await keyword + // where it shouldn't be, V8 will immediately throw a SyntaxError here. + assert.doesNotThrow(() => { + new AsyncFunction( + "input", + "tools", + "context", + "__opts", + result.functionBody, + ); + }); + }), + { + numRuns: 10_000, + endOnFailure: true, + }, + ); + }, + ); + + test("is deterministic for the same bridge AST", { timeout: 60_000 }, () => { + fc.assert( + fc.property(bridgeArb, (bridge) => { + const document: BridgeDocument = { + instructions: [bridge], + }; + const operation = `${bridge.type}.${bridge.field}`; + + const first = compileBridge(document, { operation }); + const second = compileBridge(document, { operation }); + + assert.equal(first.code, second.code); + assert.equal(first.functionBody, second.functionBody); + assert.equal(first.functionName, second.functionName); + }), + { + numRuns: 3_000, + endOnFailure: true, + }, + ); + }); + + test( + "AOT execution matches runtime execution on randomized bridges", + { timeout: 120_000 }, + async () => { + await fc.assert( + fc.asyncProperty(flatBridgeArb, inputArb, async (bridge, input) => { + const document: BridgeDocument = { + instructions: [bridge], + }; + const operation = `${bridge.type}.${bridge.field}`; + + const runtime = await executeRuntime({ + document, + operation, + input, + tools: {}, + }); + + const aot = await executeAot({ + document, + operation, + input, + tools: {}, + }); + + assert.deepEqual(aot.data, runtime.data); + }), + { + numRuns: 2_000, + endOnFailure: true, + }, + ); + }, + ); + + test( + "parse -> serialize -> parse keeps AOT/runtime parity on generated bridge text", + { timeout: 120_000 }, + async () => { + await fc.assert( + fc.asyncProperty(bridgeTextSpecArb, async (spec) => { + const operation = `${spec.type}.${spec.field}`; + const sourceText = buildBridgeText(spec); + + const parsed = parseBridgeFormat(sourceText); + const serialized = serializeBridge(parsed); + let reparsed: BridgeDocument; + try { + reparsed = parseBridgeFormat(serialized); + } catch { + fc.pre(false); + return; + } + + const runtime = await executeRuntime({ + document: reparsed, + operation, + input: spec.input, + tools: {}, + }); + + const aot = await executeAot({ + document: reparsed, + operation, + input: spec.input, + tools: {}, + }); + + assert.deepEqual(aot.data, runtime.data); + }), + { + numRuns: 1_500, + endOnFailure: true, + }, + ); + }, + ); + + test( + "never emits invalid JS for fallback-heavy randomized bridges", + { timeout: 90_000 }, + () => { + fc.assert( + fc.property(fallbackHeavyBridgeArb, (bridge) => { + const document: BridgeDocument = { instructions: [bridge] }; + const result = compileBridge(document, { + operation: `${bridge.type}.${bridge.field}`, + }); + + assert.doesNotThrow(() => { + new AsyncFunction( + "input", + "tools", + "context", + "__opts", + result.functionBody, + ); + }); + }), + { + numRuns: 4_000, + endOnFailure: true, + }, + ); + }, + ); + + test( + "never emits invalid JS for logical-wire randomized bridges", + { timeout: 90_000 }, + () => { + fc.assert( + fc.property(logicalBridgeArb, (bridge) => { + const document: BridgeDocument = { instructions: [bridge] }; + const result = compileBridge(document, { + operation: `${bridge.type}.${bridge.field}`, + }); + + assert.doesNotThrow(() => { + new AsyncFunction( + "input", + "tools", + "context", + "__opts", + result.functionBody, + ); + }); + }), + { + numRuns: 4_000, + endOnFailure: true, + }, + ); + }, + ); +}); diff --git a/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts b/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts new file mode 100644 index 00000000..39aa1aa9 --- /dev/null +++ b/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts @@ -0,0 +1,15 @@ +import { describe, test } from "node:test"; + +describe("fuzz-discovered AOT/runtime divergence backlog", () => { + test.todo( + "nullish fallback parity: AOT returned null while runtime returned undefined (seed=1245428388)", + ); + + test.todo( + "overdefinition precedence parity: AOT resolved later constant while runtime kept earlier value (seed=562020200)", + ); + + test.todo( + "parser round-trip: serializeBridge output can be unparsable for some valid parsed documents (seed=1864118703)", + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae2b5295..0d2ebba8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,9 @@ importers: '@types/node': specifier: ^25.3.2 version: 25.3.2 + fast-check: + specifier: ^4.5.3 + version: 4.5.3 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -2865,6 +2868,10 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-check@4.5.3: + resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3755,6 +3762,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -7284,6 +7294,10 @@ snapshots: extendable-error@0.1.7: {} + fast-check@4.5.3: + dependencies: + pure-rand: 7.0.1 + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -8507,6 +8521,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@7.0.1: {} + quansync@0.2.11: {} queue-microtask@1.2.3: {} From 88a567a9befd29e0070d940b8413af42a50e629b Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Wed, 4 Mar 2026 18:37:48 +0100 Subject: [PATCH 2/5] fix: nullish fallback parity --- .changeset/seven-files-rest.md | 5 ++ packages/bridge-compiler/src/codegen.ts | 4 +- packages/bridge-compiler/test/codegen.test.ts | 49 +++++++++++++++++++ .../test/fuzz-regressions.todo.test.ts | 4 -- 4 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 .changeset/seven-files-rest.md diff --git a/.changeset/seven-files-rest.md b/.changeset/seven-files-rest.md new file mode 100644 index 00000000..739f5400 --- /dev/null +++ b/.changeset/seven-files-rest.md @@ -0,0 +1,5 @@ +--- +"@stackables/bridge-compiler": patch +--- + +Bugfixes for ExecutionTree parity diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index bf19ef9d..36a5a778 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -2257,9 +2257,9 @@ class CodegenContext { // Nullish coalescing (??) if ("nullishFallbackRef" in w && w.nullishFallbackRef) { - expr = `(${expr} ?? ${this.refToExpr(w.nullishFallbackRef)})`; // lgtm [js/code-injection] + expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${this.refToExpr(w.nullishFallbackRef)}))`; // lgtm [js/code-injection] } else if ("nullishFallback" in w && w.nullishFallback != null) { - expr = `(${expr} ?? ${emitCoerced(w.nullishFallback)})`; // lgtm [js/code-injection] + expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${emitCoerced(w.nullishFallback)}))`; // lgtm [js/code-injection] } // Nullish control flow (throw/panic on ?? gate) if ("nullishControl" in w && w.nullishControl) { diff --git a/packages/bridge-compiler/test/codegen.test.ts b/packages/bridge-compiler/test/codegen.test.ts index c34c9e37..0929cf83 100644 --- a/packages/bridge-compiler/test/codegen.test.ts +++ b/packages/bridge-compiler/test/codegen.test.ts @@ -305,6 +305,55 @@ bridge Query.refFallback { ); assert.deepEqual(data, { value: "from-backup" }); }); + + test("nullish fallback to null matches runtime overdefinition semantics", async () => { + const document: BridgeDocument = { + instructions: [ + { + kind: "bridge", + type: "Query", + field: "nullishProbe", + handles: [ + { kind: "input", handle: "i" }, + { kind: "output", handle: "o" }, + ], + wires: [ + { + from: { + module: "_", + type: "Query", + field: "nullishProbe", + path: ["m"], + }, + to: { + module: "_", + type: "Query", + field: "nullishProbe", + path: ["k"], + }, + nullishFallback: "null", + }, + ], + }, + ], + }; + + const runtime = await executeBridge({ + document, + operation: "Query.nullishProbe", + input: {}, + tools: {}, + }); + const aot = await executeAot({ + document, + operation: "Query.nullishProbe", + input: {}, + tools: {}, + }); + + assert.deepStrictEqual(aot.data, runtime.data); + assert.deepStrictEqual(aot.data, { k: undefined }); + }); }); // ── Phase 3: Array mapping ─────────────────────────────────────────────────── diff --git a/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts b/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts index 39aa1aa9..d96a5067 100644 --- a/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts +++ b/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts @@ -1,10 +1,6 @@ import { describe, test } from "node:test"; describe("fuzz-discovered AOT/runtime divergence backlog", () => { - test.todo( - "nullish fallback parity: AOT returned null while runtime returned undefined (seed=1245428388)", - ); - test.todo( "overdefinition precedence parity: AOT resolved later constant while runtime kept earlier value (seed=562020200)", ); From f15d5e6a70830f71891d8591b6174e2dbc7fe3f7 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Wed, 4 Mar 2026 18:42:57 +0100 Subject: [PATCH 3/5] fix: overdefinition precedence parity --- packages/bridge-compiler/src/codegen.ts | 39 ++++++++++----- packages/bridge-compiler/test/codegen.test.ts | 49 +++++++++++++++++++ .../test/fuzz-regressions.todo.test.ts | 4 -- 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 36a5a778..0c40409b 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -1457,6 +1457,7 @@ class CodegenContext { // Build a nested tree from scalar wires using their full output path interface TreeNode { expr?: string; + terminal?: boolean; children: Map; } const tree: TreeNode = { children: new Map() }; @@ -1476,12 +1477,7 @@ class CodegenContext { current.children.set(lastSeg, { children: new Map() }); } const node = current.children.get(lastSeg)!; - if (node.expr != null) { - // Overdefinition: combine with ?? — first non-null wins - node.expr = `(${node.expr} ?? ${this.wireToExpr(w)})`; - } else { - node.expr = this.wireToExpr(w); - } + this.mergeOverdefinedExpr(node, w); } // Emit array-mapped fields into the tree as well @@ -2553,6 +2549,30 @@ class CodegenContext { // ── Nested object literal builder ───────────────────────────────────────── + private mergeOverdefinedExpr( + node: { expr?: string; terminal?: boolean }, + wire: Wire, + ): void { + const nextExpr = this.wireToExpr(wire); + const nextIsConstant = "value" in wire; + + if (node.expr == null) { + node.expr = nextExpr; + node.terminal = nextIsConstant; + return; + } + + if (node.terminal) return; + + if (nextIsConstant) { + node.expr = `((__v) => (__v != null ? __v : ${nextExpr}))(${node.expr})`; + node.terminal = true; + return; + } + + node.expr = `(${node.expr} ?? ${nextExpr})`; + } + /** * Build a JavaScript object literal from a set of wires. * Handles nested paths by creating nested object literals. @@ -2567,6 +2587,7 @@ class CodegenContext { // Build tree interface TreeNode { expr?: string; + terminal?: boolean; children: Map; } const root: TreeNode = { children: new Map() }; @@ -2587,11 +2608,7 @@ class CodegenContext { current.children.set(lastSeg, { children: new Map() }); } const node = current.children.get(lastSeg)!; - if (node.expr != null) { - node.expr = `(${node.expr} ?? ${this.wireToExpr(w)})`; - } else { - node.expr = this.wireToExpr(w); - } + this.mergeOverdefinedExpr(node, w); } return this.serializeTreeNode(root, indent); diff --git a/packages/bridge-compiler/test/codegen.test.ts b/packages/bridge-compiler/test/codegen.test.ts index 0929cf83..52a31d55 100644 --- a/packages/bridge-compiler/test/codegen.test.ts +++ b/packages/bridge-compiler/test/codegen.test.ts @@ -1377,6 +1377,55 @@ bridge Query.test { assert.deepStrictEqual(aotNoCache.data, rtNoCache.data); }); + test("constant overdefinition parity — first constant remains terminal", async () => { + const document: BridgeDocument = { + instructions: [ + { + kind: "bridge", + type: "Query", + field: "constantOverdef", + handles: [{ kind: "output", handle: "o" }], + wires: [ + { + value: "null", + to: { + module: "_", + type: "Query", + field: "constantOverdef", + path: ["k"], + }, + }, + { + value: "false", + to: { + module: "_", + type: "Query", + field: "constantOverdef", + path: ["k"], + }, + }, + ], + }, + ], + }; + + const runtime = await executeBridge({ + document, + operation: "Query.constantOverdef", + input: {}, + tools: {}, + }); + const aot = await executeAot({ + document, + operation: "Query.constantOverdef", + input: {}, + tools: {}, + }); + + assert.deepStrictEqual(aot.data, runtime.data); + assert.deepStrictEqual(aot.data, { k: null }); + }); + test("generated code contains conditional wrapping", () => { const code = compileOnly( `version 1.5 diff --git a/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts b/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts index d96a5067..ced76a17 100644 --- a/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts +++ b/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts @@ -1,10 +1,6 @@ import { describe, test } from "node:test"; describe("fuzz-discovered AOT/runtime divergence backlog", () => { - test.todo( - "overdefinition precedence parity: AOT resolved later constant while runtime kept earlier value (seed=562020200)", - ); - test.todo( "parser round-trip: serializeBridge output can be unparsable for some valid parsed documents (seed=1864118703)", ); From 84ec27b2b9ff76e6f19a3e1410477854908ca0c1 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Wed, 4 Mar 2026 18:51:14 +0100 Subject: [PATCH 4/5] fix: parser round-trip: serializeBridge output --- .changeset/seven-files-rest.md | 9 ++++++++- packages/bridge-compiler/test/fuzz-compile.test.ts | 7 ++++--- .../bridge-compiler/test/fuzz-regressions.todo.test.ts | 6 +----- packages/bridge-parser/src/bridge-format.ts | 4 ++-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.changeset/seven-files-rest.md b/.changeset/seven-files-rest.md index 739f5400..6667b7ea 100644 --- a/.changeset/seven-files-rest.md +++ b/.changeset/seven-files-rest.md @@ -1,5 +1,12 @@ --- "@stackables/bridge-compiler": patch +"@stackables/bridge-parser": patch --- -Bugfixes for ExecutionTree parity +Fix several AOT compiler/runtime parity bugs discovered via fuzzing: + +- Fix `condAnd` and `condOr` code generation to match runtime boolean semantics. +- Fix nullish fallback chaining so `??` handling matches runtime overdefinition boundaries. +- Fix overdefinition precedence so the first constant wire remains terminal, matching runtime behavior. +- Fix `serializeBridge` quoting for empty-string and slash-only string constants so parse/serialize/parse round-trips remain valid. +- Add deterministic regression coverage for these parity cases to prevent regressions. diff --git a/packages/bridge-compiler/test/fuzz-compile.test.ts b/packages/bridge-compiler/test/fuzz-compile.test.ts index 97989846..e90236e1 100644 --- a/packages/bridge-compiler/test/fuzz-compile.test.ts +++ b/packages/bridge-compiler/test/fuzz-compile.test.ts @@ -434,9 +434,10 @@ describe("compileBridge fuzzing", () => { let reparsed: BridgeDocument; try { reparsed = parseBridgeFormat(serialized); - } catch { - fc.pre(false); - return; + } catch (error) { + assert.fail( + `serializeBridge produced unparsable output: ${String(error)}\n--- SOURCE ---\n${sourceText}\n--- SERIALIZED ---\n${serialized}`, + ); } const runtime = await executeRuntime({ diff --git a/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts b/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts index ced76a17..2477cc14 100644 --- a/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts +++ b/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts @@ -1,7 +1,3 @@ import { describe, test } from "node:test"; -describe("fuzz-discovered AOT/runtime divergence backlog", () => { - test.todo( - "parser round-trip: serializeBridge output can be unparsable for some valid parsed documents (seed=1864118703)", - ); -}); +describe("fuzz-discovered AOT/runtime divergence backlog", () => {}); diff --git a/packages/bridge-parser/src/bridge-format.ts b/packages/bridge-parser/src/bridge-format.ts index 0e383241..abb98f15 100644 --- a/packages/bridge-parser/src/bridge-format.ts +++ b/packages/bridge-parser/src/bridge-format.ts @@ -74,9 +74,9 @@ export function serializeBridge(doc: BridgeDocument): string { */ function needsQuoting(v: string): boolean { if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) return false; // JSON string literal - if (v === "" || v === "true" || v === "false" || v === "null") return false; + if (v === "true" || v === "false" || v === "null") return false; if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(v)) return false; // number - if (/^\/[\w./-]*$/.test(v)) return false; // /path + if (/^\/[\w./-]+$/.test(v)) return false; // /path if (/^[a-zA-Z_][\w-]*$/.test(v)) return false; // identifier / keyword return true; } From b055a04e1cad3f2154bc6978c17b969e63169382 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Wed, 4 Mar 2026 18:53:18 +0100 Subject: [PATCH 5/5] fix: update fuzz-regressions test structure and comment out todo --- .../bridge-compiler/test/fuzz-regressions.todo.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts b/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts index 2477cc14..6e80ff36 100644 --- a/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts +++ b/packages/bridge-compiler/test/fuzz-regressions.todo.test.ts @@ -1,3 +1,7 @@ -import { describe, test } from "node:test"; +import { describe } from "node:test"; -describe("fuzz-discovered AOT/runtime divergence backlog", () => {}); +describe("fuzz-discovered AOT/runtime divergence backlog", () => { + // test.todo( + // "parser round-trip: serializeBridge output can be unparsable for some valid parsed documents (seed=1864118703)", + // ); +});