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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/seven-files-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@stackables/bridge-compiler": patch
"@stackables/bridge-parser": patch
---

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.
1 change: 1 addition & 0 deletions packages/bridge-compiler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"devDependencies": {
"@stackables/bridge-parser": "workspace:*",
"@types/node": "^25.3.2",
"fast-check": "^4.5.3",
"typescript": "^5.9.3"
},
"repository": {
Expand Down
80 changes: 62 additions & 18 deletions packages/bridge-compiler/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1432,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<string, TreeNode>;
}
const tree: TreeNode = { children: new Map() };
Expand All @@ -1451,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
Expand Down Expand Up @@ -1794,9 +1815,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;
Expand All @@ -1807,9 +1829,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;
Expand Down Expand Up @@ -2230,9 +2253,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) {
Expand Down Expand Up @@ -2526,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.
Expand All @@ -2540,6 +2587,7 @@ class CodegenContext {
// Build tree
interface TreeNode {
expr?: string;
terminal?: boolean;
children: Map<string, TreeNode>;
}
const root: TreeNode = { children: new Map() };
Expand All @@ -2560,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);
Expand Down
58 changes: 58 additions & 0 deletions packages/bridge-compiler/test/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading