Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
17 changes: 17 additions & 0 deletions .changeset/compiler-fallback-loop-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@stackables/bridge": patch
"@stackables/bridge-core": patch
"@stackables/bridge-compiler": patch
"@stackables/bridge-parser": patch
---

Add memoized tool handles with compiler fallback.

Bridge `with` declarations now support `memoize` for tool handles, including
loop-scoped tool handles inside array mappings. Memoized handles reuse the same
result for repeated calls with identical inputs, and each declared handle keeps
its own cache.

The AOT compiler does not compile memoized tool handles yet. It now throws a
dedicated incompatibility error for those bridges, and compiler `executeBridge`
automatically falls back to the core ExecutionTree interpreter.
12 changes: 12 additions & 0 deletions .changeset/strict-scope-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@stackables/bridge": patch
"@stackables/bridge-core": patch
"@stackables/bridge-compiler": patch
"@stackables/bridge-parser": patch
---

Fix strict nested scope resolution for array mappings.

Nested scopes can now read iterator aliases from visible parent scopes while
still resolving overlapping names to the nearest inner scope. This also keeps
invalid nested tool input wiring rejected during parsing.
44 changes: 44 additions & 0 deletions packages/bridge-compiler/src/bridge-asserts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Bridge } from "@stackables/bridge-core";

export class BridgeCompilerIncompatibleError extends Error {
constructor(
public readonly operation: string,
message: string,
) {
super(message);
this.name = "BridgeCompilerIncompatibleError";
}
}

export function assertBridgeCompilerCompatible(bridge: Bridge): void {
const operation = `${bridge.type}.${bridge.field}`;
const memoizedHandles = bridge.handles
.filter((handle) => handle.kind === "tool" && handle.memoize)
.map((handle) => handle.handle);

if (memoizedHandles.length > 0) {
throw new BridgeCompilerIncompatibleError(
operation,
`[bridge-compiler] ${operation}: memoized tool handles are not supported by AOT compilation yet (${memoizedHandles.join(", ")}).`,
);
}

const seenHandles = new Set<string>();
const shadowedHandles = new Set<string>();

for (const handle of bridge.handles) {
if (handle.kind !== "tool") continue;
if (seenHandles.has(handle.handle)) {
shadowedHandles.add(handle.handle);
continue;
}
seenHandles.add(handle.handle);
}

if (shadowedHandles.size > 0) {
throw new BridgeCompilerIncompatibleError(
operation,
`[bridge-compiler] ${operation}: shadowed loop-scoped tool handles are not supported by AOT compilation yet (${[...shadowedHandles].join(", ")}).`,
);
}
}
150 changes: 109 additions & 41 deletions packages/bridge-compiler/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
NodeRef,
ToolDef,
} from "@stackables/bridge-core";
import { assertBridgeCompilerCompatible } from "./bridge-asserts.ts";

const SELF_MODULE = "_";

Expand Down Expand Up @@ -109,6 +110,8 @@ export function compileBridge(
if (!bridge)
throw new Error(`No bridge definition found for operation: ${operation}`);

assertBridgeCompilerCompatible(bridge);

// Collect const definitions from the document
const constDefs = new Map<string, string>();
for (const inst of document.instructions) {
Expand Down Expand Up @@ -287,6 +290,8 @@ class CodegenContext {
private elementLocalVars = new Map<string, string>();
/** Current element variable name, set during element wire expression generation. */
private currentElVar: string | undefined;
/** Stack of active element variables from outermost to innermost array scopes. */
private elementVarStack: string[] = [];
/** Map from ToolDef dependency tool name to its emitted variable name.
* Populated lazily by emitToolDeps to avoid duplicating calls. */
private toolDepVars = new Map<string, string>();
Expand Down Expand Up @@ -1454,6 +1459,10 @@ class CodegenContext {
}
// Only check control flow on direct element wires, not sub-array element wires
const directElemWires = elemWires.filter((w) => w.to.path.length === 1);
const currentScopeElemWires = this.filterCurrentElementWires(
elemWires,
arrayIterators,
);
const cf = detectControlFlow(directElemWires);
const anyCf = detectControlFlow(elemWires);
const requiresLabeledLoop = !cf && !!anyCf && anyCf.levels > 1;
Expand All @@ -1465,7 +1474,7 @@ class CodegenContext {
// If so, generate a dual sync/async path with a runtime check.
const canDualPath = !cf && this.asyncOnlyFromTools(elemWires);
const toolRefs = canDualPath
? this.collectElementToolRefs(elemWires)
? this.collectElementToolRefs(currentScopeElemWires)
: [];
const hasDualPath = canDualPath && toolRefs.length > 0;

Expand All @@ -1478,7 +1487,12 @@ class CodegenContext {
// Sync branch — .map() with __callSync
const syncPreamble: string[] = [];
this.elementLocalVars.clear();
this.collectElementPreamble(elemWires, "_el0", syncPreamble, true);
this.collectElementPreamble(
currentScopeElemWires,
"_el0",
syncPreamble,
true,
);
const syncBody = this.buildElementBody(
elemWires,
arrayIterators,
Expand All @@ -1502,7 +1516,11 @@ class CodegenContext {
// Async branch — for...of loop with await
const preambleLines: string[] = [];
this.elementLocalVars.clear();
this.collectElementPreamble(elemWires, "_el0", preambleLines);
this.collectElementPreamble(
currentScopeElemWires,
"_el0",
preambleLines,
);

const body = cf
? this.buildElementBodyWithControlFlow(
Expand Down Expand Up @@ -1692,6 +1710,10 @@ class CodegenContext {
const arrayExpr = this.wireToExpr(sourceW);
// Only check control flow on direct element wires (not sub-array element wires)
const directShifted = shifted.filter((w) => w.to.path.length === 1);
const currentScopeShifted = this.filterCurrentElementWires(
shifted,
arrayIterators,
);
const cf = detectControlFlow(directShifted);
const anyCf = detectControlFlow(shifted);
const requiresLabeledLoop = !cf && !!anyCf && anyCf.levels > 1;
Expand All @@ -1702,7 +1724,7 @@ class CodegenContext {
// Check if we can generate a dual sync/async path
const canDualPath = !cf && this.asyncOnlyFromTools(shifted);
const toolRefs = canDualPath
? this.collectElementToolRefs(shifted)
? this.collectElementToolRefs(currentScopeShifted)
: [];
const hasDualPath = canDualPath && toolRefs.length > 0;

Expand All @@ -1713,22 +1735,27 @@ class CodegenContext {
.join(" && ");
const syncPreamble: string[] = [];
this.elementLocalVars.clear();
this.collectElementPreamble(shifted, "_el0", syncPreamble, true);
const syncBody = this.buildElementBody(
shifted,
arrayIterators,
0,
6,
this.collectElementPreamble(
currentScopeShifted,
"_el0",
syncPreamble,
true,
);
const syncMapExpr = syncPreamble.length > 0
? `(${arrayExpr})?.map((_el0) => { ${syncPreamble.join(" ")} return ${syncBody}; }) ?? null`
: `(${arrayExpr})?.map((_el0) => (${syncBody})) ?? null`;
const syncBody = this.buildElementBody(shifted, arrayIterators, 0, 6);
const syncMapExpr =
syncPreamble.length > 0
? `(${arrayExpr})?.map((_el0) => { ${syncPreamble.join(" ")} return ${syncBody}; }) ?? null`
: `(${arrayExpr})?.map((_el0) => (${syncBody})) ?? null`;
this.elementLocalVars.clear();

// Async branch — for...of inside an async IIFE
const preambleLines: string[] = [];
this.elementLocalVars.clear();
this.collectElementPreamble(shifted, "_el0", preambleLines);
this.collectElementPreamble(
currentScopeShifted,
"_el0",
preambleLines,
);
const asyncBody = ` _result.push(${this.buildElementBody(shifted, arrayIterators, 0, 8)});`;
const preamble = preambleLines.map((l) => ` ${l}`).join("\n");
const asyncExpr = `await (async () => { const _src = ${arrayExpr}; if (_src == null) return null; const _result = []; __loop0: for (const _el0 of _src) {\n try {\n${preamble}\n${asyncBody}\n } catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n } return _result; })()`;
Expand All @@ -1739,7 +1766,11 @@ class CodegenContext {
// Standard async path — for...of inside an async IIFE
const preambleLines: string[] = [];
this.elementLocalVars.clear();
this.collectElementPreamble(shifted, "_el0", preambleLines);
this.collectElementPreamble(
currentScopeShifted,
"_el0",
preambleLines,
);

const asyncBody = cf
? this.buildElementBodyWithControlFlow(
Expand Down Expand Up @@ -1926,17 +1957,31 @@ class CodegenContext {
const innerNeedsAsync = shifted.some((w) => this.wireNeedsAwait(w));
let mapExpr: string;
if (innerNeedsAsync) {
// Inner async loop must use for...of inside an async IIFE
const innerBody = innerCf
? this.buildElementBodyWithControlFlow(
shifted,
arrayIterators,
depth + 1,
indent + 4,
innerCf.kind === "continue" ? "for-continue" : "break",
)
: `${" ".repeat(indent + 4)}_result.push(${this.buildElementBody(shifted, arrayIterators, depth + 1, indent + 4)});`;
mapExpr = `await (async () => { const _src = ${srcExpr}; if (!Array.isArray(_src)) return null; const _result = []; __loop${depth + 1}: for (const ${innerElVar} of _src) {\n${" ".repeat(indent + 4)}try {\n${innerBody}\n${" ".repeat(indent + 4)}} catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n${" ".repeat(indent + 2)}} return _result; })()`;
mapExpr = this.withElementLocalVarScope(() => {
const innerCurrentScope = this.filterCurrentElementWires(
shifted,
arrayIterators,
);
const innerPreambleLines: string[] = [];
this.collectElementPreamble(
innerCurrentScope,
innerElVar,
innerPreambleLines,
);
const innerBody = innerCf
? this.buildElementBodyWithControlFlow(
shifted,
arrayIterators,
depth + 1,
indent + 4,
innerCf.kind === "continue" ? "for-continue" : "break",
)
: `${" ".repeat(indent + 4)}_result.push(${this.buildElementBody(shifted, arrayIterators, depth + 1, indent + 4)});`;
const innerPreamble = innerPreambleLines
.map((line) => `${" ".repeat(indent + 4)}${line}`)
.join("\n");
return `await (async () => { const _src = ${srcExpr}; if (!Array.isArray(_src)) return null; const _result = []; __loop${depth + 1}: for (const ${innerElVar} of _src) {\n${" ".repeat(indent + 4)}try {\n${innerPreamble}${innerPreamble ? "\n" : ""}${innerBody}\n${" ".repeat(indent + 4)}} catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n${" ".repeat(indent + 2)}} return _result; })()`;
});
} else if (innerCf?.kind === "continue" && innerCf.levels === 1) {
const cfBody = this.buildElementBodyWithControlFlow(
shifted,
Expand Down Expand Up @@ -2128,14 +2173,28 @@ class CodegenContext {
/** Convert an element wire (inside array mapping) to an expression. */
private elementWireToExpr(w: Wire, elVar = "_el0"): string {
const prevElVar = this.currentElVar;
this.elementVarStack.push(elVar);
this.currentElVar = elVar;
try {
return this._elementWireToExprInner(w, elVar);
} finally {
this.elementVarStack.pop();
this.currentElVar = prevElVar;
}
}

private refToElementExpr(ref: NodeRef): string {
const depth = ref.elementDepth ?? 0;
const stackIndex = this.elementVarStack.length - 1 - depth;
const elVar =
stackIndex >= 0 ? this.elementVarStack[stackIndex] : this.currentElVar;
if (!elVar) {
throw new Error(`Missing element variable for ${JSON.stringify(ref)}`);
}
if (ref.path.length === 0) return elVar;
return elVar + ref.path.map((p) => `?.[${JSON.stringify(p)}]`).join("");
}

private _elementWireToExprInner(w: Wire, elVar: string): string {
if ("value" in w) return emitCoerced(w.value);

Expand All @@ -2144,8 +2203,7 @@ class CodegenContext {
const condRef = w.cond;
let condExpr: string;
if (condRef.element) {
condExpr =
elVar + condRef.path.map((p) => `?.[${JSON.stringify(p)}]`).join("");
condExpr = this.refToElementExpr(condRef);
} else {
const condKey = refTrunkKey(condRef);
if (this.elementScopedTools.has(condKey)) {
Expand All @@ -2164,10 +2222,7 @@ class CodegenContext {
val: string | undefined,
): string => {
if (ref !== undefined) {
if (ref.element)
return (
elVar + ref.path.map((p) => `?.[${JSON.stringify(p)}]`).join("")
);
if (ref.element) return this.refToElementExpr(ref);
const branchKey = refTrunkKey(ref);
if (this.elementScopedTools.has(branchKey)) {
let e = this.buildInlineToolExpr(branchKey, elVar);
Expand Down Expand Up @@ -2520,14 +2575,31 @@ class CodegenContext {
`const ${vn} = __callSync(${fn}, ${inputObj}, ${JSON.stringify(fnName)});`,
);
} else {
lines.push(
`const ${vn} = ${this.syncAwareCall(fnName, inputObj)};`,
);
lines.push(`const ${vn} = ${this.syncAwareCall(fnName, inputObj)};`);
}
}
}
}

private filterCurrentElementWires(
elemWires: Wire[],
arrayIterators: Record<string, string>,
): Wire[] {
return elemWires.filter(
(w) => !(w.to.path.length > 1 && w.to.path[0]! in arrayIterators),
);
}

private withElementLocalVarScope<T>(fn: () => T): T {
const previous = this.elementLocalVars;
this.elementLocalVars = new Map(previous);
try {
return fn();
} finally {
this.elementLocalVars = previous;
}
}

/**
* Collect the tool function references (as JS expressions) for all
* element-scoped non-internal tools used by the given element wires.
Expand Down Expand Up @@ -2774,12 +2846,8 @@ class CodegenContext {
}

// Handle element refs (from.element = true)
if (ref.element && this.currentElVar) {
if (ref.path.length === 0) return this.currentElVar;
return (
this.currentElVar +
ref.path.map((p) => `?.[${JSON.stringify(p)}]`).join("")
);
if (ref.element) {
return this.refToElementExpr(ref);
}

const varName = this.varMap.get(key);
Expand Down
Loading
Loading