Skip to content

Commit 837ec1c

Browse files
authored
feat: Implement fuzz tests for compileBridge (#84)
* feat: add fast-check as a dev dependency and implement fuzz tests for compileBridge * fix: nullish fallback parity * fix: overdefinition precedence parity * fix: parser round-trip: serializeBridge output * fix: update fuzz-regressions test structure and comment out todo
1 parent badbb78 commit 837ec1c

9 files changed

Lines changed: 888 additions & 31 deletions

File tree

.changeset/seven-files-rest.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@stackables/bridge-compiler": patch
3+
"@stackables/bridge-parser": patch
4+
---
5+
6+
Fix several AOT compiler/runtime parity bugs discovered via fuzzing:
7+
8+
- Fix `condAnd` and `condOr` code generation to match runtime boolean semantics.
9+
- Fix nullish fallback chaining so `??` handling matches runtime overdefinition boundaries.
10+
- Fix overdefinition precedence so the first constant wire remains terminal, matching runtime behavior.
11+
- Fix `serializeBridge` quoting for empty-string and slash-only string constants so parse/serialize/parse round-trips remain valid.
12+
- Add deterministic regression coverage for these parity cases to prevent regressions.

packages/bridge-compiler/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"devDependencies": {
2929
"@stackables/bridge-parser": "workspace:*",
3030
"@types/node": "^25.3.2",
31+
"fast-check": "^4.5.3",
3132
"typescript": "^5.9.3"
3233
},
3334
"repository": {

packages/bridge-compiler/src/codegen.ts

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,35 @@ import type {
3030
NodeRef,
3131
ToolDef,
3232
} from "@stackables/bridge-core";
33-
import { matchesRequestedFields } from "@stackables/bridge-core";
3433

3534
const SELF_MODULE = "_";
3635

36+
function matchesRequestedFields(
37+
fieldPath: string,
38+
requestedFields: string[] | undefined,
39+
): boolean {
40+
if (!requestedFields || requestedFields.length === 0) return true;
41+
42+
for (const pattern of requestedFields) {
43+
if (pattern === fieldPath) return true;
44+
45+
if (fieldPath.startsWith(pattern + ".")) return true;
46+
47+
if (pattern.startsWith(fieldPath + ".")) return true;
48+
49+
if (pattern.endsWith(".*")) {
50+
const prefix = pattern.slice(0, -2);
51+
if (fieldPath.startsWith(prefix + ".")) {
52+
const rest = fieldPath.slice(prefix.length + 1);
53+
if (!rest.includes(".")) return true;
54+
}
55+
if (fieldPath === prefix) return true;
56+
}
57+
}
58+
59+
return false;
60+
}
61+
3762
// ── Public API ──────────────────────────────────────────────────────────────
3863

3964
export interface CompileOptions {
@@ -1432,6 +1457,7 @@ class CodegenContext {
14321457
// Build a nested tree from scalar wires using their full output path
14331458
interface TreeNode {
14341459
expr?: string;
1460+
terminal?: boolean;
14351461
children: Map<string, TreeNode>;
14361462
}
14371463
const tree: TreeNode = { children: new Map() };
@@ -1451,12 +1477,7 @@ class CodegenContext {
14511477
current.children.set(lastSeg, { children: new Map() });
14521478
}
14531479
const node = current.children.get(lastSeg)!;
1454-
if (node.expr != null) {
1455-
// Overdefinition: combine with ?? — first non-null wins
1456-
node.expr = `(${node.expr} ?? ${this.wireToExpr(w)})`;
1457-
} else {
1458-
node.expr = this.wireToExpr(w);
1459-
}
1480+
this.mergeOverdefinedExpr(node, w);
14601481
}
14611482

14621483
// Emit array-mapped fields into the tree as well
@@ -1794,9 +1815,10 @@ class CodegenContext {
17941815
const { leftRef, rightRef, rightValue } = w.condAnd;
17951816
const left = this.refToExpr(leftRef);
17961817
let expr: string;
1797-
if (rightRef) expr = `(${left} && ${this.refToExpr(rightRef)})`;
1818+
if (rightRef)
1819+
expr = `(Boolean(${left}) && Boolean(${this.refToExpr(rightRef)}))`;
17981820
else if (rightValue !== undefined)
1799-
expr = `(${left} && ${emitCoerced(rightValue)})`;
1821+
expr = `(Boolean(${left}) && Boolean(${emitCoerced(rightValue)}))`;
18001822
else expr = `Boolean(${left})`;
18011823
expr = this.applyFallbacks(w, expr);
18021824
return expr;
@@ -1807,9 +1829,10 @@ class CodegenContext {
18071829
const { leftRef, rightRef, rightValue } = w.condOr;
18081830
const left = this.refToExpr(leftRef);
18091831
let expr: string;
1810-
if (rightRef) expr = `(${left} || ${this.refToExpr(rightRef)})`;
1832+
if (rightRef)
1833+
expr = `(Boolean(${left}) || Boolean(${this.refToExpr(rightRef)}))`;
18111834
else if (rightValue !== undefined)
1812-
expr = `(${left} || ${emitCoerced(rightValue)})`;
1835+
expr = `(Boolean(${left}) || Boolean(${emitCoerced(rightValue)}))`;
18131836
else expr = `Boolean(${left})`;
18141837
expr = this.applyFallbacks(w, expr);
18151838
return expr;
@@ -2230,9 +2253,9 @@ class CodegenContext {
22302253

22312254
// Nullish coalescing (??)
22322255
if ("nullishFallbackRef" in w && w.nullishFallbackRef) {
2233-
expr = `(${expr} ?? ${this.refToExpr(w.nullishFallbackRef)})`; // lgtm [js/code-injection]
2256+
expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${this.refToExpr(w.nullishFallbackRef)}))`; // lgtm [js/code-injection]
22342257
} else if ("nullishFallback" in w && w.nullishFallback != null) {
2235-
expr = `(${expr} ?? ${emitCoerced(w.nullishFallback)})`; // lgtm [js/code-injection]
2258+
expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${emitCoerced(w.nullishFallback)}))`; // lgtm [js/code-injection]
22362259
}
22372260
// Nullish control flow (throw/panic on ?? gate)
22382261
if ("nullishControl" in w && w.nullishControl) {
@@ -2526,6 +2549,30 @@ class CodegenContext {
25262549

25272550
// ── Nested object literal builder ─────────────────────────────────────────
25282551

2552+
private mergeOverdefinedExpr(
2553+
node: { expr?: string; terminal?: boolean },
2554+
wire: Wire,
2555+
): void {
2556+
const nextExpr = this.wireToExpr(wire);
2557+
const nextIsConstant = "value" in wire;
2558+
2559+
if (node.expr == null) {
2560+
node.expr = nextExpr;
2561+
node.terminal = nextIsConstant;
2562+
return;
2563+
}
2564+
2565+
if (node.terminal) return;
2566+
2567+
if (nextIsConstant) {
2568+
node.expr = `((__v) => (__v != null ? __v : ${nextExpr}))(${node.expr})`;
2569+
node.terminal = true;
2570+
return;
2571+
}
2572+
2573+
node.expr = `(${node.expr} ?? ${nextExpr})`;
2574+
}
2575+
25292576
/**
25302577
* Build a JavaScript object literal from a set of wires.
25312578
* Handles nested paths by creating nested object literals.
@@ -2540,6 +2587,7 @@ class CodegenContext {
25402587
// Build tree
25412588
interface TreeNode {
25422589
expr?: string;
2590+
terminal?: boolean;
25432591
children: Map<string, TreeNode>;
25442592
}
25452593
const root: TreeNode = { children: new Map() };
@@ -2560,11 +2608,7 @@ class CodegenContext {
25602608
current.children.set(lastSeg, { children: new Map() });
25612609
}
25622610
const node = current.children.get(lastSeg)!;
2563-
if (node.expr != null) {
2564-
node.expr = `(${node.expr} ?? ${this.wireToExpr(w)})`;
2565-
} else {
2566-
node.expr = this.wireToExpr(w);
2567-
}
2611+
this.mergeOverdefinedExpr(node, w);
25682612
}
25692613

25702614
return this.serializeTreeNode(root, indent);
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# bridge-compiler test workflow
2+
3+
This folder contains unit tests, fuzz/property tests, and a backlog of known fuzz-discovered issues.
4+
5+
## Files and intent
6+
7+
- `codegen.test.ts` — deterministic, scenario-based behavior tests.
8+
- `fuzz-compile.test.ts` — property/fuzz tests (syntax safety, determinism, AOT/runtime parity).
9+
- `fuzz-regressions.todo.test.ts`**backlog** of known issues as `test.todo(...)` entries.
10+
11+
## Why `test.todo` exists here
12+
13+
Fuzzing can find valid issues faster than we can fix them. `test.todo` is used to:
14+
15+
1. Preserve findings immediately (so they are not lost).
16+
2. Keep CI green while investigation/fix work is queued.
17+
3. Make known risk areas visible in test output.
18+
19+
`test.todo` is not a permanent state. It is a staging area between discovery and a real executable regression test.
20+
21+
## Preferred process when fuzz finds an issue
22+
23+
1. **Capture evidence immediately**
24+
- Seed, failure path, and minimized/counterexample input.
25+
- Whether mismatch is `AOT != runtime`, parser/serializer, or runtime crash.
26+
27+
2. **Add a `test.todo` entry** in `fuzz-regressions.todo.test.ts`
28+
- Include a short class label plus seed (if available).
29+
- Example format: `"nullish fallback parity ... (seed=123456)"`.
30+
31+
3. **Open a tracking issue/PR note**
32+
- Link to the todo label.
33+
- Add impact, expected behavior, and suspected component (`bridge-core`, `bridge-compiler`, `bridge-parser`).
34+
35+
4. **Create a deterministic reproducer test**
36+
- Prefer a minimal hand-authored bridge/input over rerunning random fuzz.
37+
- Add to `codegen.test.ts` (or a dedicated regression file) as a normal `test(...)`.
38+
39+
5. **Fix at root cause**
40+
- Update compiler/runtime/parser behavior.
41+
- Keep fix small and targeted.
42+
43+
6. **Promote and clean up**
44+
- Ensure reproducer test passes.
45+
- Remove corresponding `test.todo` entry.
46+
- Keep fuzz property in place to guard against nearby regressions.
47+
48+
## How we fix issues without losing them
49+
50+
- Discovery path: fuzz failure -> `test.todo` backlog entry.
51+
- Stabilization path: add deterministic reproducer -> implement fix -> remove todo.
52+
- Verification path: run package tests (`pnpm --filter @stackables/bridge-compiler test`) and then broader repo checks as needed.
53+
54+
## Practical tips
55+
56+
- Keep generated identifiers/simple values parser-safe in text round-trip fuzzers.
57+
- Constrain parity fuzz generators when an oracle (runtime/parser) has known unstable surfaces.
58+
- Prefer multiple small targeted properties over one giant mixed generator for easier shrinking and diagnosis.

0 commit comments

Comments
 (0)