@stackables/bridge 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/build/{ExecutionTree.d.ts → src/ExecutionTree.d.ts} +1 -0
  2. package/build/src/ExecutionTree.d.ts.map +1 -0
  3. package/build/{ExecutionTree.js → src/ExecutionTree.js} +12 -2
  4. package/build/{bridge-format.d.ts → src/bridge-format.d.ts} +1 -10
  5. package/build/src/bridge-format.d.ts.map +1 -0
  6. package/build/{bridge-format.js → src/bridge-format.js} +680 -220
  7. package/build/{bridge-transform.d.ts → src/bridge-transform.d.ts} +1 -0
  8. package/build/src/bridge-transform.d.ts.map +1 -0
  9. package/build/{index.d.ts → src/index.d.ts} +1 -0
  10. package/build/src/index.d.ts.map +1 -0
  11. package/build/{tools → src/tools}/find-object.d.ts +1 -0
  12. package/build/src/tools/find-object.d.ts.map +1 -0
  13. package/build/{tools → src/tools}/http-call.d.ts +1 -0
  14. package/build/src/tools/http-call.d.ts.map +1 -0
  15. package/build/{tools → src/tools}/index.d.ts +1 -0
  16. package/build/src/tools/index.d.ts.map +1 -0
  17. package/build/{tools → src/tools}/lower-case.d.ts +1 -0
  18. package/build/src/tools/lower-case.d.ts.map +1 -0
  19. package/build/{tools → src/tools}/pick-first.d.ts +1 -0
  20. package/build/src/tools/pick-first.d.ts.map +1 -0
  21. package/build/{tools → src/tools}/to-array.d.ts +1 -0
  22. package/build/src/tools/to-array.d.ts.map +1 -0
  23. package/build/{tools → src/tools}/upper-case.d.ts +1 -0
  24. package/build/src/tools/upper-case.d.ts.map +1 -0
  25. package/build/{types.d.ts → src/types.d.ts} +49 -1
  26. package/build/src/types.d.ts.map +1 -0
  27. package/build/tsconfig.tsbuildinfo +1 -0
  28. package/package.json +9 -6
  29. package/LICENSE +0 -21
  30. package/README.md +0 -361
  31. /package/build/{bridge-transform.js → src/bridge-transform.js} +0 -0
  32. /package/build/{index.js → src/index.js} +0 -0
  33. /package/build/{tools → src/tools}/find-object.js +0 -0
  34. /package/build/{tools → src/tools}/http-call.js +0 -0
  35. /package/build/{tools → src/tools}/index.js +0 -0
  36. /package/build/{tools → src/tools}/lower-case.js +0 -0
  37. /package/build/{tools → src/tools}/pick-first.js +0 -0
  38. /package/build/{tools → src/tools}/to-array.js +0 -0
  39. /package/build/{tools → src/tools}/upper-case.js +0 -0
  40. /package/build/{types.js → src/types.js} +0 -0
@@ -10,10 +10,50 @@ import { SELF_MODULE } from "./types.js";
10
10
  * @param text - Bridge definition text
11
11
  * @returns Array of instructions (Bridge, ToolDef)
12
12
  */
13
+ const BRIDGE_VERSION = "1.4";
14
+ // Keywords that cannot be used as tool names, aliases, or const names
15
+ const RESERVED_KEYWORDS = new Set(["bridge", "with", "as", "from", "const", "tool", "version", "define"]);
16
+ // Source identifiers reserved for their special meaning inside bridge/tool blocks
17
+ const SOURCE_IDENTIFIERS = new Set(["input", "output", "context"]);
18
+ function assertNotReserved(name, lineNum, label) {
19
+ if (RESERVED_KEYWORDS.has(name.toLowerCase())) {
20
+ throw new Error(`Line ${lineNum}: "${name}" is a reserved keyword and cannot be used as a ${label}`);
21
+ }
22
+ if (SOURCE_IDENTIFIERS.has(name.toLowerCase())) {
23
+ throw new Error(`Line ${lineNum}: "${name}" is a reserved source identifier and cannot be used as a ${label}`);
24
+ }
25
+ }
26
+ /**
27
+ * Strip a trailing `# comment` from a single source line, respecting string
28
+ * literals so that a `#` inside `"..."` is never treated as a comment marker.
29
+ * Leading/trailing whitespace of the returned value is preserved (callers trim
30
+ * the result themselves as needed).
31
+ */
32
+ function stripInlineComment(line) {
33
+ let inString = false;
34
+ for (let i = 0; i < line.length; i++) {
35
+ if (line[i] === '"')
36
+ inString = !inString;
37
+ if (!inString && line[i] === "#")
38
+ return line.slice(0, i).trimEnd();
39
+ }
40
+ return line;
41
+ }
13
42
  export function parseBridge(text) {
14
- // Normalize: CRLF → LF, tabs → 2 spaces
43
+ // Normalize: CRLF → LF, tabs → 2 spaces, inline comments stripped
15
44
  const normalized = text.replace(/\r\n?/g, "\n").replace(/\t/g, " ");
16
- const allLines = normalized.split("\n");
45
+ const allLines = normalized.split("\n").map(stripInlineComment);
46
+ // Version check — first non-blank, non-comment line must be `version 1.4`
47
+ const firstContentIdx = allLines.findIndex((l) => l.trim() !== "" && !l.trim().startsWith("#"));
48
+ if (firstContentIdx === -1 || !/^version\s+/.test(allLines[firstContentIdx].trim())) {
49
+ throw new Error(`Missing version declaration. Bridge files must begin with: version ${BRIDGE_VERSION}`);
50
+ }
51
+ const versionToken = allLines[firstContentIdx].trim().replace(/^version\s+/, "");
52
+ if (versionToken !== BRIDGE_VERSION) {
53
+ throw new Error(`Unsupported bridge version "${versionToken}". This parser requires: version ${BRIDGE_VERSION}`);
54
+ }
55
+ // Blank out the version line so block-splitting ignores it
56
+ allLines[firstContentIdx] = "";
17
57
  // Find separator lines (--- with optional surrounding whitespace)
18
58
  const isSep = (line) => /^\s*---\s*$/.test(line);
19
59
  // Collect block ranges as [start, end) line indices
@@ -35,7 +75,7 @@ export function parseBridge(text) {
35
75
  let currentOffset = start;
36
76
  for (let i = 0; i < blockLines.length; i++) {
37
77
  const trimmed = blockLines[i].trim();
38
- if (/^(tool|bridge|const|extend)\s/i.test(trimmed) && currentLines.length > 0) {
78
+ if (/^(tool|bridge|const|define)\s/i.test(trimmed) && currentLines.length > 0) {
39
79
  // Check if any non-blank content exists
40
80
  if (currentLines.some((l) => l.trim())) {
41
81
  subBlocks.push({ startOffset: currentOffset, lines: currentLines });
@@ -58,17 +98,20 @@ export function parseBridge(text) {
58
98
  while (firstContentLine < sub.lines.length && !sub.lines[firstContentLine].trim())
59
99
  firstContentLine++;
60
100
  const firstLine = sub.lines[firstContentLine]?.trim();
61
- if (firstLine && /^(tool|extend)\s/i.test(firstLine)) {
101
+ if (firstLine && /^tool\s/i.test(firstLine)) {
62
102
  instructions.push(parseToolBlock(subText, sub.startOffset + firstContentLine, instructions));
63
103
  }
104
+ else if (firstLine && /^define\s/i.test(firstLine)) {
105
+ instructions.push(parseDefineBlock(subText, sub.startOffset + firstContentLine));
106
+ }
64
107
  else if (firstLine && /^bridge\s/i.test(firstLine)) {
65
- instructions.push(...parseBridgeBlock(subText, sub.startOffset + firstContentLine));
108
+ instructions.push(...parseBridgeBlock(subText, sub.startOffset + firstContentLine, instructions));
66
109
  }
67
110
  else if (firstLine && /^const\s/i.test(firstLine)) {
68
111
  instructions.push(...parseConstLines(subText, sub.startOffset + firstContentLine));
69
112
  }
70
113
  else if (firstLine && !firstLine.startsWith("#")) {
71
- throw new Error(`Line ${sub.startOffset + firstContentLine + 1}: Expected "tool", "extend", "bridge", or "const" declaration, got: ${firstLine}`);
114
+ throw new Error(`Line ${sub.startOffset + firstContentLine + 1}: Expected "tool", "define", "bridge", or "const" declaration, got: ${firstLine}`);
72
115
  }
73
116
  }
74
117
  }
@@ -84,7 +127,30 @@ function isJsonLiteral(s) {
84
127
  return /^["\{\[\d]/.test(s) || /^-\d/.test(s) ||
85
128
  s === "true" || s === "false" || s === "null";
86
129
  }
87
- function parseBridgeBlock(block, lineOffset) {
130
+ function parseBridgeBlock(block, lineOffset, previousInstructions) {
131
+ // ── Passthrough shorthand: `bridge Type.field with <name>` ──────────
132
+ // Expands into a full bridge that wires all input through the named
133
+ // handle (typically a define) and returns its output directly.
134
+ const shorthandMatch = block.match(/^bridge\s+(\w+)\.(\w+)\s+with\s+(\S+)\s*$/im);
135
+ if (shorthandMatch) {
136
+ const [, sType, sField, sName] = shorthandMatch;
137
+ const sHandle = sName.includes(".") ? sName.substring(sName.lastIndexOf(".") + 1) : sName;
138
+ const expanded = [
139
+ `bridge ${sType}.${sField} {`,
140
+ ` with ${sName} as ${sHandle}`,
141
+ ` with input`,
142
+ ` with output as __out`,
143
+ ` ${sHandle} <- input`,
144
+ ` __out <- ${sHandle}`,
145
+ `}`,
146
+ ].join("\n");
147
+ const result = parseBridgeBlock(expanded, lineOffset, previousInstructions);
148
+ // Tag the bridge instruction with the passthrough name for serialization
149
+ const bridgeInst = result.find((i) => i.kind === "bridge");
150
+ if (bridgeInst)
151
+ bridgeInst.passthrough = sName;
152
+ return result;
153
+ }
88
154
  // Validate mandatory braces: `bridge Foo.bar {` ... `}`
89
155
  const rawLines = block.split("\n");
90
156
  const keywordIdx = rawLines.findIndex((l) => /^bridge\s/i.test(l.trim()));
@@ -134,7 +200,7 @@ function parseBridgeBlock(block, lineOffset) {
134
200
  if (!bridgeType) {
135
201
  throw new Error(`Line ${ln(i)}: "with" declaration must come after "bridge" declaration`);
136
202
  }
137
- parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBindings, instanceCounters, instructions, ln(i));
203
+ parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBindings, instanceCounters, previousInstructions ?? [], ln(i));
138
204
  continue;
139
205
  }
140
206
  // First non-header line — body starts here
@@ -147,6 +213,8 @@ function parseBridgeBlock(block, lineOffset) {
147
213
  // ── Parse wire lines ────────────────────────────────────────────────
148
214
  const wires = [];
149
215
  let currentArrayToPath = null;
216
+ let currentIterHandle = null;
217
+ const arrayIterators = {};
150
218
  /** Monotonically-increasing index; combined with a high base to produce
151
219
  * fork instances that can never collide with regular handle instances. */
152
220
  let nextForkSeq = 0;
@@ -167,7 +235,7 @@ function parseBridgeBlock(block, lineOffset) {
167
235
  function buildSourceExpr(sourceStr, lineNum, forceOnOutermost) {
168
236
  const parts = sourceStr.split(":");
169
237
  if (parts.length === 1) {
170
- return resolveAddress(sourceStr, handleRes, bridgeType, bridgeField);
238
+ return resolveAddress(sourceStr, handleRes, bridgeType, bridgeField, lineNum);
171
239
  }
172
240
  // Pipe chain
173
241
  const actualSource = parts[parts.length - 1];
@@ -184,7 +252,7 @@ function parseBridgeBlock(block, lineOffset) {
184
252
  throw new Error(`Line ${lineNum}: Undeclared handle in pipe: "${handleName}". Add 'with <tool> as ${handleName}' to the bridge header.`);
185
253
  }
186
254
  }
187
- let prevOutRef = resolveAddress(actualSource, handleRes, bridgeType, bridgeField);
255
+ let prevOutRef = resolveAddress(actualSource, handleRes, bridgeType, bridgeField, lineNum);
188
256
  const reversedTokens = [...tokenChain].reverse();
189
257
  for (let idx = 0; idx < reversedTokens.length; idx++) {
190
258
  const tok = reversedTokens[idx];
@@ -205,72 +273,165 @@ function parseBridgeBlock(block, lineOffset) {
205
273
  }
206
274
  return prevOutRef; // fork-root ref
207
275
  }
276
+ // ── Whether we are inside an element-mapping brace block
277
+ let inElementBlock = false;
208
278
  for (let i = bodyStartIndex; i < lines.length; i++) {
209
279
  const raw = lines[i];
210
280
  const line = raw.trim();
211
281
  if (!line || line.startsWith("#")) {
212
282
  continue;
213
283
  }
214
- // Element mapping: indented line starting with "."
215
- const indent = raw.search(/\S/);
216
- if (indent >= 2 && line.startsWith(".") && currentArrayToPath) {
217
- const match = line.match(/^\.(\S+)\s*<-\s*\.(\S+)$/);
218
- if (!match)
219
- throw new Error(`Line ${ln(i)}: Invalid element mapping: ${line}`);
220
- const toPath = [...currentArrayToPath, ...parsePath(match[1])];
221
- const fromPath = parsePath(match[2]);
222
- wires.push({
223
- from: {
224
- module: SELF_MODULE,
225
- type: bridgeType,
226
- field: bridgeField,
227
- element: true,
228
- path: fromPath,
229
- },
230
- to: {
231
- module: SELF_MODULE,
232
- type: bridgeType,
233
- field: bridgeField,
234
- path: toPath,
235
- },
236
- });
237
- continue;
284
+ // Closing brace of an element mapping block `{ ... }`
285
+ if (line === "}") {
286
+ if (inElementBlock) {
287
+ currentArrayToPath = null;
288
+ currentIterHandle = null;
289
+ inElementBlock = false;
290
+ continue;
291
+ }
292
+ throw new Error(`Line ${ln(i)}: Unexpected "}" — not inside an element block`);
293
+ }
294
+ // Element mapping lines (inside a brace block)
295
+ if (inElementBlock && currentArrayToPath) {
296
+ if (!line.startsWith(".")) {
297
+ throw new Error(`Line ${ln(i)}: Element mapping lines must start with ".": ${line}`);
298
+ }
299
+ // Constant: .target = "value" or .target = value
300
+ const elemConstMatch = line.match(/^\.(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
301
+ if (elemConstMatch) {
302
+ const [, fieldName, quotedValue, unquotedValue] = elemConstMatch;
303
+ const value = quotedValue ?? unquotedValue;
304
+ wires.push({
305
+ value,
306
+ to: {
307
+ module: SELF_MODULE,
308
+ type: bridgeType,
309
+ field: bridgeField,
310
+ element: true,
311
+ path: [...currentArrayToPath, ...parsePath(fieldName)],
312
+ },
313
+ });
314
+ continue;
315
+ }
316
+ // Simple pull: .target <- <iter>.source (element-relative, no fallbacks)
317
+ const iterPfx = `${currentIterHandle}.`;
318
+ const elemRelMatch = line.match(/^\.(\S+)\s*<-\s*(\S+)$/);
319
+ if (elemRelMatch && elemRelMatch[2].startsWith(iterPfx)) {
320
+ const toPath = [...currentArrayToPath, ...parsePath(elemRelMatch[1])];
321
+ const fromPath = parsePath(elemRelMatch[2].slice(iterPfx.length));
322
+ wires.push({
323
+ from: {
324
+ module: SELF_MODULE,
325
+ type: bridgeType,
326
+ field: bridgeField,
327
+ element: true,
328
+ path: fromPath,
329
+ },
330
+ to: {
331
+ module: SELF_MODULE,
332
+ type: bridgeType,
333
+ field: bridgeField,
334
+ path: toPath,
335
+ },
336
+ });
337
+ continue;
338
+ }
339
+ // Pull with fallbacks: .target <- source || "fallback" ?? errorSrc (relative or handle path)
340
+ const elemArrowMatch = line.match(/^\.(\S+)\s*<-\s*(.+)$/);
341
+ if (elemArrowMatch) {
342
+ const [, toField, rhs] = elemArrowMatch;
343
+ const toPath = [...currentArrayToPath, ...parsePath(toField)];
344
+ const toRef = { module: SELF_MODULE, type: bridgeType, field: bridgeField, path: toPath };
345
+ // Strip ?? tail
346
+ let exprCore = rhs.trim();
347
+ let fallback;
348
+ let fallbackRefStr;
349
+ const qqIdx = exprCore.lastIndexOf(" ?? ");
350
+ if (qqIdx !== -1) {
351
+ const tail = exprCore.slice(qqIdx + 4).trim();
352
+ exprCore = exprCore.slice(0, qqIdx).trim();
353
+ if (isJsonLiteral(tail))
354
+ fallback = tail;
355
+ else
356
+ fallbackRefStr = tail;
357
+ }
358
+ // Split on || — last may be JSON literal (nullFallback)
359
+ const orParts = exprCore.split(" || ").map((s) => s.trim());
360
+ let nullFallback;
361
+ let sourceParts = orParts;
362
+ if (orParts.length > 1 && isJsonLiteral(orParts[orParts.length - 1])) {
363
+ nullFallback = orParts[orParts.length - 1];
364
+ sourceParts = orParts.slice(0, -1);
365
+ }
366
+ let fallbackRef;
367
+ let fallbackInternalWires = [];
368
+ if (fallbackRefStr) {
369
+ const preLen = wires.length;
370
+ fallbackRef = buildSourceExpr(fallbackRefStr, ln(i), false);
371
+ fallbackInternalWires = wires.splice(preLen);
372
+ }
373
+ for (let ci = 0; ci < sourceParts.length; ci++) {
374
+ const srcStr = sourceParts[ci];
375
+ const isLast = ci === sourceParts.length - 1;
376
+ // Element-relative source: starts with "<iter>."
377
+ const iterPrefix = `${currentIterHandle}.`;
378
+ let fromRef;
379
+ if (srcStr.startsWith(iterPrefix)) {
380
+ fromRef = {
381
+ module: SELF_MODULE,
382
+ type: bridgeType,
383
+ field: bridgeField,
384
+ element: true,
385
+ path: parsePath(srcStr.slice(iterPrefix.length)),
386
+ };
387
+ }
388
+ else if (srcStr.startsWith(".")) {
389
+ throw new Error(`Line ${ln(i)}: Use "${currentIterHandle}.field" to reference element fields, not ".field"`);
390
+ }
391
+ else {
392
+ fromRef = buildSourceExpr(srcStr, ln(i), false);
393
+ }
394
+ const lastAttrs = isLast ? {
395
+ ...(nullFallback ? { nullFallback } : {}),
396
+ ...(fallback ? { fallback } : {}),
397
+ ...(fallbackRef ? { fallbackRef } : {}),
398
+ } : {};
399
+ wires.push({ from: fromRef, to: toRef, ...lastAttrs });
400
+ }
401
+ wires.push(...fallbackInternalWires);
402
+ continue;
403
+ }
404
+ throw new Error(`Line ${ln(i)}: Invalid element mapping line: ${line}`);
238
405
  }
239
- // End of array mapping block
240
- currentArrayToPath = null;
241
406
  // Constant wire: target = "value" or target = value (unquoted)
242
407
  const constantMatch = line.match(/^(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
243
408
  if (constantMatch) {
244
409
  const [, targetStr, quotedValue, unquotedValue] = constantMatch;
245
410
  const value = quotedValue ?? unquotedValue;
246
- const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
411
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
247
412
  wires.push({ value, to: toRef });
248
413
  continue;
249
414
  }
250
415
  // ── Wire: target <- A [|| B [|| C]] [|| "nullLiteral"] [?? errorSrc|"errorLiteral"]
251
- //
252
- // A, B, C are source expressions (handle.path or pipe|chain|src).
253
- // `||` separates null-coalescing alternatives — evaluated left to right;
254
- // the last alternative may be a JSON literal (→ nullFallback).
255
- // `??` is the error fallback — fires when ALL sources throw.
256
- // Can be a JSON literal (→ fallback) or a source expression (→ fallbackRef).
257
- //
258
- // Each `||` source becomes a separate wire targeting the same field.
259
- // The last source wire carries nullFallback + error-fallback attributes.
260
- // Desugars transparently: serializer emits back as multiple wire lines.
261
416
  const arrowMatch = line.match(/^(\S+)\s*<-(!?)\s*(.+)$/);
262
417
  if (arrowMatch) {
263
418
  const [, targetStr, forceFlag, rhs] = arrowMatch;
264
419
  const force = forceFlag === "!";
265
420
  const rhsTrimmed = rhs.trim();
266
- // ── Array mapping: target[] <- source[] (no || or ?? here)
267
- if (targetStr.endsWith("[]") && /^\S+\[\]$/.test(rhsTrimmed)) {
268
- const toClean = targetStr.slice(0, -2);
269
- const fromClean = rhsTrimmed.slice(0, -2);
270
- const fromRef = resolveAddress(fromClean, handleRes, bridgeType, bridgeField);
271
- const toRef = resolveAddress(toClean, handleRes, bridgeType, bridgeField);
421
+ // ── Array mapping: target <- source[] as <iter> {
422
+ // Opens a brace-delimited element block.
423
+ const arrayBraceMatch = rhsTrimmed.match(/^(\S+)\[\]\s+as\s+(\w+)\s*\{\s*$/);
424
+ if (arrayBraceMatch) {
425
+ const fromClean = arrayBraceMatch[1];
426
+ const iterHandle = arrayBraceMatch[2];
427
+ assertNotReserved(iterHandle, ln(i), "iterator handle");
428
+ const fromRef = resolveAddress(fromClean, handleRes, bridgeType, bridgeField, ln(i));
429
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
272
430
  wires.push({ from: fromRef, to: toRef });
273
431
  currentArrayToPath = toRef.path;
432
+ currentIterHandle = iterHandle;
433
+ arrayIterators[toRef.path[0]] = iterHandle;
434
+ inElementBlock = true;
274
435
  continue;
275
436
  }
276
437
  // ── Strip the ?? tail (last " ?? " wins in case source contains " ?? ")
@@ -300,38 +461,27 @@ function parseBridgeBlock(block, lineOffset) {
300
461
  if (sourceParts.length === 0) {
301
462
  throw new Error(`Line ${ln(i)}: Wire has no source expression: ${line}`);
302
463
  }
303
- // ── Parse the ?? source/pipe into a fallbackRef (if needed)
304
- // Wires added by buildSourceExpr for the fallback fork are deferred and
305
- // pushed AFTER the source wires so that wire order is stable across
306
- // parse → serialize → re-parse cycles.
307
464
  let fallbackRef;
308
465
  let fallbackInternalWires = [];
309
466
  if (fallbackRefStr) {
310
467
  const preLen = wires.length;
311
468
  fallbackRef = buildSourceExpr(fallbackRefStr, ln(i), false);
312
- // Splice out internal wires buildSourceExpr just added; push after sources.
313
469
  fallbackInternalWires = wires.splice(preLen);
314
470
  }
315
- // ── Build wires for each coalesce part
316
- const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
471
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
317
472
  for (let ci = 0; ci < sourceParts.length; ci++) {
318
473
  const isFirst = ci === 0;
319
474
  const isLast = ci === sourceParts.length - 1;
320
475
  const srcStr = sourceParts[ci];
321
- // Parse source expression; for pipe chains buildSourceExpr pushes
322
- // intermediate wires and returns the fork-root ref.
323
476
  const termRef = buildSourceExpr(srcStr, ln(i), force && isFirst);
324
477
  const isPipeFork = termRef.instance != null && termRef.path.length === 0
325
478
  && srcStr.includes(":");
326
- // attrs carried only on the LAST wire of the coalesce chain
327
479
  const lastAttrs = isLast ? {
328
480
  ...(nullFallback ? { nullFallback } : {}),
329
481
  ...(fallback ? { fallback } : {}),
330
482
  ...(fallbackRef ? { fallbackRef } : {}),
331
483
  } : {};
332
484
  if (isPipeFork) {
333
- // Terminal pipe wire: fork-root → target (force only on outermost
334
- // intermediate wire, already set inside buildSourceExpr)
335
485
  wires.push({ from: termRef, to: toRef, pipe: true, ...lastAttrs });
336
486
  }
337
487
  else {
@@ -343,19 +493,29 @@ function parseBridgeBlock(block, lineOffset) {
343
493
  });
344
494
  }
345
495
  }
346
- // Push fallbackRef internal wires after all source wires (stable round-trip
347
- // order: same position whether parsed inline or from serialized separate lines)
348
496
  wires.push(...fallbackInternalWires);
349
497
  continue;
350
498
  }
351
499
  throw new Error(`Line ${ln(i)}: Unrecognized line: ${line}`);
352
500
  }
501
+ // ── Inline define invocations ───────────────────────────────────────
502
+ const nextForkSeqRef = { value: nextForkSeq };
503
+ for (const hb of handleBindings) {
504
+ if (hb.kind !== "define")
505
+ continue;
506
+ const def = previousInstructions?.find((inst) => inst.kind === "define" && inst.name === hb.name);
507
+ if (!def) {
508
+ throw new Error(`Define "${hb.name}" referenced by handle "${hb.handle}" not found`);
509
+ }
510
+ inlineDefine(hb.handle, def, bridgeType, bridgeField, wires, pipeHandleEntries, handleBindings, instanceCounters, nextForkSeqRef);
511
+ }
353
512
  instructions.unshift({
354
513
  kind: "bridge",
355
514
  type: bridgeType,
356
515
  field: bridgeField,
357
516
  handles: handleBindings,
358
517
  wires,
518
+ arrayIterators: Object.keys(arrayIterators).length > 0 ? arrayIterators : undefined,
359
519
  pipeHandles: pipeHandleEntries.length > 0 ? pipeHandleEntries : undefined,
360
520
  });
361
521
  return instructions;
@@ -390,6 +550,45 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
390
550
  });
391
551
  return;
392
552
  }
553
+ // with input (shorthand — handle defaults to "input")
554
+ match = line.match(/^with\s+input$/i);
555
+ if (match) {
556
+ const handle = "input";
557
+ checkDuplicate(handle);
558
+ handleBindings.push({ handle, kind: "input" });
559
+ handleRes.set(handle, {
560
+ module: SELF_MODULE,
561
+ type: bridgeType,
562
+ field: bridgeField,
563
+ });
564
+ return;
565
+ }
566
+ // with output as <handle>
567
+ match = line.match(/^with\s+output\s+as\s+(\w+)$/i);
568
+ if (match) {
569
+ const handle = match[1];
570
+ checkDuplicate(handle);
571
+ handleBindings.push({ handle, kind: "output" });
572
+ handleRes.set(handle, {
573
+ module: SELF_MODULE,
574
+ type: bridgeType,
575
+ field: bridgeField,
576
+ });
577
+ return;
578
+ }
579
+ // with output (shorthand — handle defaults to "output")
580
+ match = line.match(/^with\s+output$/i);
581
+ if (match) {
582
+ const handle = "output";
583
+ checkDuplicate(handle);
584
+ handleBindings.push({ handle, kind: "output" });
585
+ handleRes.set(handle, {
586
+ module: SELF_MODULE,
587
+ type: bridgeType,
588
+ field: bridgeField,
589
+ });
590
+ return;
591
+ }
393
592
  // with context as <handle>
394
593
  match = line.match(/^with\s+context\s+as\s+(\w+)$/i);
395
594
  if (match) {
@@ -442,13 +641,24 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
442
641
  });
443
642
  return;
444
643
  }
445
- // with <name> as <handle> — tool reference (covers dotted names like hereapi.geocode)
644
+ // with <name> as <handle> — check for define invocation first
446
645
  match = line.match(/^with\s+(\S+)\s+as\s+(\w+)$/i);
447
646
  if (match) {
448
647
  const name = match[1];
449
648
  const handle = match[2];
450
649
  checkDuplicate(handle);
451
- // Split dotted name into module.field for NodeRef resolution
650
+ assertNotReserved(handle, lineNum, "handle alias");
651
+ // Check if name matches a known define
652
+ const defineDef = instructions.find((inst) => inst.kind === "define" && inst.name === name);
653
+ if (defineDef) {
654
+ handleBindings.push({ handle, kind: "define", name });
655
+ handleRes.set(handle, {
656
+ module: `__define_${handle}`,
657
+ type: bridgeType,
658
+ field: bridgeField,
659
+ });
660
+ return;
661
+ }
452
662
  const lastDot = name.lastIndexOf(".");
453
663
  if (lastDot !== -1) {
454
664
  const modulePart = name.substring(0, lastDot);
@@ -487,6 +697,17 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
487
697
  const lastDot = name.lastIndexOf(".");
488
698
  const handle = lastDot !== -1 ? name.substring(lastDot + 1) : name;
489
699
  checkDuplicate(handle);
700
+ // Check if name matches a known define
701
+ const defineDef = instructions.find((inst) => inst.kind === "define" && inst.name === name);
702
+ if (defineDef) {
703
+ handleBindings.push({ handle, kind: "define", name });
704
+ handleRes.set(handle, {
705
+ module: `__define_${handle}`,
706
+ type: bridgeType,
707
+ field: bridgeField,
708
+ });
709
+ return;
710
+ }
490
711
  if (lastDot !== -1) {
491
712
  const modulePart = name.substring(0, lastDot);
492
713
  const fieldPart = name.substring(lastDot + 1);
@@ -507,6 +728,155 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
507
728
  }
508
729
  throw new Error(`Line ${lineNum}: Invalid with declaration: ${line}`);
509
730
  }
731
+ // ── Define inlining ─────────────────────────────────────────────────────────
732
+ /**
733
+ * Inline a define invocation into a bridge's wires.
734
+ *
735
+ * Splits the define handle into separate input/output synthetic trunks,
736
+ * clones the define's internal wires with remapped references, and adds
737
+ * them to the bridge. Tool instances are remapped to avoid collisions.
738
+ *
739
+ * The executor treats synthetic trunks (module starting with `__define_`)
740
+ * as pass-through data containers — no tool function is called.
741
+ */
742
+ function inlineDefine(defineHandle, defineDef, bridgeType, bridgeField, wires, pipeHandleEntries, handleBindings, instanceCounters, nextForkSeqRef) {
743
+ const genericModule = `__define_${defineHandle}`;
744
+ const inModule = `__define_in_${defineHandle}`;
745
+ const outModule = `__define_out_${defineHandle}`;
746
+ // The define was parsed as synthetic `bridge Define.<name>`, so its
747
+ // internal refs use type="Define", field=defineName for I/O, and
748
+ // standard tool resolutions for tools.
749
+ const defType = "Define";
750
+ const defField = defineDef.name;
751
+ // ── 1. Build trunk remapping for define's tool handles ──────────────
752
+ // Replay define's instance counter to determine original instances
753
+ const defCounters = new Map();
754
+ const trunkRemap = new Map();
755
+ for (const hb of defineDef.handles) {
756
+ if (hb.kind === "input" || hb.kind === "output" || hb.kind === "context" || hb.kind === "const")
757
+ continue;
758
+ if (hb.kind === "define")
759
+ continue; // nested defines — future
760
+ const name = hb.kind === "tool" ? hb.name : "";
761
+ if (!name)
762
+ continue;
763
+ const lastDot = name.lastIndexOf(".");
764
+ let oldModule, oldType, oldField, instanceKey, bridgeKey;
765
+ if (lastDot !== -1) {
766
+ oldModule = name.substring(0, lastDot);
767
+ oldType = defType;
768
+ oldField = name.substring(lastDot + 1);
769
+ instanceKey = `${oldModule}:${oldField}`;
770
+ bridgeKey = instanceKey;
771
+ }
772
+ else {
773
+ oldModule = SELF_MODULE;
774
+ oldType = "Tools";
775
+ oldField = name;
776
+ instanceKey = `Tools:${name}`;
777
+ bridgeKey = instanceKey;
778
+ }
779
+ // Old instance (from define's isolated counter)
780
+ const oldInstance = (defCounters.get(instanceKey) ?? 0) + 1;
781
+ defCounters.set(instanceKey, oldInstance);
782
+ // New instance (from bridge's counter)
783
+ const newInstance = (instanceCounters.get(bridgeKey) ?? 0) + 1;
784
+ instanceCounters.set(bridgeKey, newInstance);
785
+ const oldKey = `${oldModule}:${oldType}:${oldField}:${oldInstance}`;
786
+ trunkRemap.set(oldKey, { module: oldModule, type: oldType, field: oldField, instance: newInstance });
787
+ // Add internal tool handle to bridge's handle bindings (namespaced)
788
+ handleBindings.push({ handle: `${defineHandle}$${hb.handle}`, kind: "tool", name });
789
+ }
790
+ // ── 2. Remap bridge wires involving the define handle ───────────────
791
+ for (const wire of wires) {
792
+ if ("from" in wire) {
793
+ if (wire.to.module === genericModule) {
794
+ wire.to = { ...wire.to, module: inModule };
795
+ }
796
+ if (wire.from.module === genericModule) {
797
+ wire.from = { ...wire.from, module: outModule };
798
+ }
799
+ if (wire.fallbackRef?.module === genericModule) {
800
+ wire.fallbackRef = { ...wire.fallbackRef, module: outModule };
801
+ }
802
+ }
803
+ if ("value" in wire && wire.to.module === genericModule) {
804
+ wire.to = { ...wire.to, module: inModule };
805
+ }
806
+ }
807
+ // ── 3. Clone, remap, and add define's wires ────────────────────────
808
+ // Compute fork instance offset (define's fork instances start at 100000,
809
+ // bridge's may overlap — offset them to avoid collision)
810
+ const forkOffset = nextForkSeqRef.value;
811
+ let maxDefForkSeq = 0;
812
+ function remapRef(ref, side) {
813
+ // Define I/O trunk → split into input/output synthetic trunks
814
+ if (ref.module === SELF_MODULE && ref.type === defType && ref.field === defField) {
815
+ const targetModule = side === "from" ? inModule : outModule;
816
+ return { ...ref, module: targetModule, type: bridgeType, field: bridgeField };
817
+ }
818
+ // Tool trunk → remap instance
819
+ const key = `${ref.module}:${ref.type}:${ref.field}:${ref.instance ?? ""}`;
820
+ const newTrunk = trunkRemap.get(key);
821
+ if (newTrunk) {
822
+ return { ...ref, module: newTrunk.module, type: newTrunk.type, field: newTrunk.field, instance: newTrunk.instance };
823
+ }
824
+ // Fork instance → offset (fork instances are >= 100000)
825
+ if (ref.instance != null && ref.instance >= 100000) {
826
+ const defSeq = ref.instance - 100000;
827
+ if (defSeq + 1 > maxDefForkSeq)
828
+ maxDefForkSeq = defSeq + 1;
829
+ return { ...ref, instance: ref.instance + forkOffset };
830
+ }
831
+ return ref;
832
+ }
833
+ for (const wire of defineDef.wires) {
834
+ const cloned = JSON.parse(JSON.stringify(wire));
835
+ if ("from" in cloned) {
836
+ cloned.from = remapRef(cloned.from, "from");
837
+ cloned.to = remapRef(cloned.to, "to");
838
+ if (cloned.fallbackRef) {
839
+ cloned.fallbackRef = remapRef(cloned.fallbackRef, "from");
840
+ }
841
+ }
842
+ else {
843
+ // Constant wire
844
+ cloned.to = remapRef(cloned.to, "to");
845
+ }
846
+ wires.push(cloned);
847
+ }
848
+ // Advance bridge's fork counter past define's forks
849
+ nextForkSeqRef.value += maxDefForkSeq;
850
+ // ── 4. Remap and merge pipe handles ─────────────────────────────────
851
+ if (defineDef.pipeHandles) {
852
+ for (const ph of defineDef.pipeHandles) {
853
+ const parts = ph.key.split(":");
854
+ // key format: "module:type:field:instance"
855
+ const phInstance = parseInt(parts[parts.length - 1]);
856
+ let newKey = ph.key;
857
+ if (phInstance >= 100000) {
858
+ const newInst = phInstance + forkOffset;
859
+ parts[parts.length - 1] = String(newInst);
860
+ newKey = parts.join(":");
861
+ }
862
+ // Remap baseTrunk
863
+ const bt = ph.baseTrunk;
864
+ const btKey = `${bt.module}:${defType}:${bt.field}:${bt.instance ?? ""}`;
865
+ const newBt = trunkRemap.get(btKey);
866
+ // Also try with Tools type for simple names
867
+ const btKey2 = `${bt.module}:Tools:${bt.field}:${bt.instance ?? ""}`;
868
+ const newBt2 = trunkRemap.get(btKey2);
869
+ const resolvedBt = newBt ?? newBt2;
870
+ pipeHandleEntries.push({
871
+ key: newKey,
872
+ handle: `${defineHandle}$${ph.handle}`,
873
+ baseTrunk: resolvedBt
874
+ ? { module: resolvedBt.module, type: resolvedBt.type, field: resolvedBt.field, instance: resolvedBt.instance }
875
+ : ph.baseTrunk,
876
+ });
877
+ }
878
+ }
879
+ }
510
880
  /**
511
881
  * Resolve an address string into a structured NodeRef.
512
882
  *
@@ -516,7 +886,7 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
516
886
  * 3. Prefix matches a declared handle → resolve via handle binding
517
887
  * 4. Otherwise → nested output path (e.g., topPick.address)
518
888
  */
519
- function resolveAddress(address, handles, bridgeType, bridgeField) {
889
+ function resolveAddress(address, handles, bridgeType, bridgeField, lineNum) {
520
890
  const dotIndex = address.indexOf(".");
521
891
  if (dotIndex === -1) {
522
892
  // Whole address is a declared handle → resolve to its root (path: [])
@@ -532,13 +902,9 @@ function resolveAddress(address, handles, bridgeType, bridgeField) {
532
902
  ref.instance = resolution.instance;
533
903
  return ref;
534
904
  }
535
- // No dot, not a handle output reference on bridge trunk
536
- return {
537
- module: SELF_MODULE,
538
- type: bridgeType,
539
- field: bridgeField,
540
- path: parsePath(address),
541
- };
905
+ // Strict scoping: every reference must go through a declared handle.
906
+ throw new Error(`${lineNum != null ? `Line ${lineNum}: ` : ""}Undeclared reference "${address}". ` +
907
+ `Add 'with output as o' for output fields, or 'with ${address}' for a tool.`);
542
908
  }
543
909
  const prefix = address.substring(0, dotIndex);
544
910
  const rest = address.substring(dotIndex + 1);
@@ -557,23 +923,9 @@ function resolveAddress(address, handles, bridgeType, bridgeField) {
557
923
  }
558
924
  return ref;
559
925
  }
560
- // No handle match nested local path (e.g., topPick.address)
561
- // UNLESS the prefix IS the bridge field itself (e.g., doubled.a when bridge is Query.doubled)
562
- // in that case strip the prefix so path = ["a"], matching the GraphQL resolver path.
563
- if (prefix === bridgeField) {
564
- return {
565
- module: SELF_MODULE,
566
- type: bridgeType,
567
- field: bridgeField,
568
- path: pathParts,
569
- };
570
- }
571
- return {
572
- module: SELF_MODULE,
573
- type: bridgeType,
574
- field: bridgeField,
575
- path: [prefix, ...pathParts],
576
- };
926
+ // Strict scoping: prefix must be a known handle.
927
+ throw new Error(`${lineNum != null ? `Line ${lineNum}: ` : ""}Undeclared handle "${prefix}". ` +
928
+ `Add 'with ${prefix}' or 'with ${prefix} as ${prefix}' to the bridge header.`);
577
929
  }
578
930
  // ── Const block parser ──────────────────────────────────────────────────────
579
931
  /**
@@ -604,6 +956,7 @@ function parseConstLines(block, lineOffset) {
604
956
  throw new Error(`Line ${ln(i)}: Expected const declaration, got: ${line}`);
605
957
  }
606
958
  const name = constMatch[1];
959
+ assertNotReserved(name, ln(i), "const name");
607
960
  let valuePart = constMatch[2].trim();
608
961
  // Multi-line: if value starts with { or [ and isn't balanced, read more lines
609
962
  if (/^[{[]/.test(valuePart)) {
@@ -642,38 +995,92 @@ function parseConstLines(block, lineOffset) {
642
995
  }
643
996
  return results;
644
997
  }
645
- // ── Tool block parser ───────────────────────────────────────────────────────
998
+ // ── Define block parser ─────────────────────────────────────────────────────
646
999
  /**
647
- * Parse a `tool` or `extend` block into a ToolDef instruction.
1000
+ * Parse a `define` block into a DefineDef instruction.
648
1001
  *
649
- * Legacy format (root tool):
650
- * tool hereapi httpCall
651
- * with context
652
- * baseUrl = "https://geocode.search.hereapi.com/v1"
653
- * headers.apiKey <- context.hereapi.apiKey
1002
+ * Delegates to parseBridgeBlock with a synthetic `bridge Define.<name>` header,
1003
+ * then converts the resulting Bridge to a DefineDef template.
654
1004
  *
655
- * Legacy format (child tool with extends):
656
- * tool hereapi.geocode extends hereapi
657
- * method = GET
658
- * path = /geocode
1005
+ * Example:
1006
+ * define secureProfile {
1007
+ * with userApi as api
1008
+ * with input as i
1009
+ * with output as o
1010
+ * api.id <- i.userId
1011
+ * o.name <- api.login
1012
+ * }
1013
+ */
1014
+ function parseDefineBlock(block, lineOffset) {
1015
+ const rawLines = block.split("\n");
1016
+ // Find the define header line
1017
+ let headerIdx = -1;
1018
+ let defineName = "";
1019
+ for (let i = 0; i < rawLines.length; i++) {
1020
+ const line = rawLines[i].trim();
1021
+ if (!line || line.startsWith("#"))
1022
+ continue;
1023
+ const m = line.match(/^define\s+(\w+)\s*\{?\s*$/i);
1024
+ if (!m) {
1025
+ throw new Error(`Line ${lineOffset + i + 1}: Expected define declaration: define <name> {. Got: ${line}`);
1026
+ }
1027
+ defineName = m[1];
1028
+ assertNotReserved(defineName, lineOffset + i + 1, "define name");
1029
+ headerIdx = i;
1030
+ break;
1031
+ }
1032
+ if (!defineName) {
1033
+ throw new Error(`Line ${lineOffset + 1}: Missing define declaration`);
1034
+ }
1035
+ // Validate braces
1036
+ const kw = rawLines[headerIdx].trim();
1037
+ if (!kw.endsWith("{")) {
1038
+ throw new Error(`Line ${lineOffset + headerIdx + 1}: define block must use braces: define ${defineName} {`);
1039
+ }
1040
+ const hasClose = rawLines.some((l) => l.trimEnd() === "}");
1041
+ if (!hasClose) {
1042
+ throw new Error(`Line ${lineOffset + headerIdx + 1}: define block missing closing }`);
1043
+ }
1044
+ // Rewrite header to a synthetic bridge: `bridge Define.<name> {`
1045
+ const syntheticLines = [...rawLines];
1046
+ syntheticLines[headerIdx] = rawLines[headerIdx]
1047
+ .replace(/^(\s*)define\s+\w+/i, `$1bridge Define.${defineName}`);
1048
+ const syntheticBlock = syntheticLines.join("\n");
1049
+ const results = parseBridgeBlock(syntheticBlock, lineOffset);
1050
+ const bridge = results[0];
1051
+ return {
1052
+ kind: "define",
1053
+ name: defineName,
1054
+ handles: bridge.handles,
1055
+ wires: bridge.wires,
1056
+ ...(bridge.arrayIterators ? { arrayIterators: bridge.arrayIterators } : {}),
1057
+ ...(bridge.pipeHandles ? { pipeHandles: bridge.pipeHandles } : {}),
1058
+ };
1059
+ }
1060
+ // ── Tool block parser ───────────────────────────────────────────────────────
1061
+ /**
1062
+ * Parse a `tool` block into a ToolDef instruction.
659
1063
  *
660
- * New format (extend):
661
- * extend httpCall as hereapi
1064
+ * Format:
1065
+ * tool hereapi from httpCall {
662
1066
  * with context
663
- * baseUrl = "https://geocode.search.hereapi.com/v1"
1067
+ * .baseUrl = "https://geocode.search.hereapi.com/v1"
1068
+ * .headers.apiKey <- context.hereapi.apiKey
1069
+ * }
664
1070
  *
665
- * extend hereapi as hereapi.geocode
666
- * method = GET
667
- * path = /geocode
1071
+ * tool hereapi.geocode from hereapi {
1072
+ * .method = GET
1073
+ * .path = /geocode
1074
+ * }
668
1075
  *
669
- * When using `extend`, if the source matches a previously-defined tool name,
670
- * it's treated as an extends (child inherits parent). Otherwise the source
1076
+ * When the source matches a previously-defined tool name,
1077
+ * it's treated as inheritance (child inherits parent). Otherwise the source
671
1078
  * is treated as a function name.
672
1079
  */
673
1080
  function parseToolBlock(block, lineOffset, previousInstructions) {
674
1081
  // Validate mandatory braces for blocks that have a body (deps / wires)
675
1082
  const rawLines = block.split("\n");
676
- const keywordIdx = rawLines.findIndex((l) => /^(tool|extend)\s/i.test(l.trim()));
1083
+ const keywordIdx = rawLines.findIndex((l) => /^tool\s/i.test(l.trim()));
677
1084
  if (keywordIdx !== -1) {
678
1085
  // Check if there are non-blank, non-comment body lines after the keyword
679
1086
  const bodyLines = rawLines.slice(keywordIdx + 1).filter((l) => {
@@ -683,11 +1090,11 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
683
1090
  const kw = rawLines[keywordIdx].trim();
684
1091
  if (bodyLines.length > 0) {
685
1092
  if (!kw.endsWith("{")) {
686
- throw new Error(`Line ${lineOffset + keywordIdx + 1}: extend/tool block with body must use braces: extend Foo as bar {`);
1093
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: tool block with body must use braces: tool foo from bar {`);
687
1094
  }
688
1095
  const hasClose = rawLines.some((l) => l.trimEnd() === "}");
689
1096
  if (!hasClose) {
690
- throw new Error(`Line ${lineOffset + keywordIdx + 1}: extend/tool block missing closing }`);
1097
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: tool block missing closing }`);
691
1098
  }
692
1099
  }
693
1100
  }
@@ -696,7 +1103,7 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
696
1103
  const trimmed = l.trimEnd();
697
1104
  if (trimmed === "}")
698
1105
  return "";
699
- if (/^(tool|extend)\s/i.test(trimmed) && trimmed.endsWith("{"))
1106
+ if (/^tool\s/i.test(trimmed) && trimmed.endsWith("{"))
700
1107
  return trimmed.replace(/\s*\{\s*$/, "");
701
1108
  return trimmed;
702
1109
  });
@@ -712,31 +1119,16 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
712
1119
  const line = raw.trim();
713
1120
  if (!line || line.startsWith("#"))
714
1121
  continue;
715
- // Tool declaration: tool <name> <fn> or tool <name> extends <parent>
1122
+ // Tool declaration: tool <name> from <source>
716
1123
  if (/^tool\s/i.test(line)) {
717
- const extendsMatch = line.match(/^tool\s+(\S+)\s+extends\s+(\S+)$/i);
718
- if (extendsMatch) {
719
- toolName = extendsMatch[1];
720
- toolExtends = extendsMatch[2];
721
- continue;
722
- }
723
- const fnMatch = line.match(/^tool\s+(\S+)\s+(\S+)$/i);
724
- if (fnMatch) {
725
- toolName = fnMatch[1];
726
- toolFn = fnMatch[2];
727
- continue;
728
- }
729
- throw new Error(`Line ${ln(i)}: Invalid tool declaration: ${line}`);
730
- }
731
- // Extend declaration: extend <source> as <name>
732
- if (/^extend\s/i.test(line)) {
733
- const extendMatch = line.match(/^extend\s+(\S+)\s+as\s+(\S+)$/i);
734
- if (!extendMatch) {
735
- throw new Error(`Line ${ln(i)}: Invalid extend declaration: ${line}. Expected: extend <source> as <name>`);
1124
+ const toolMatch = line.match(/^tool\s+(\S+)\s+from\s+(\S+)$/i);
1125
+ if (!toolMatch) {
1126
+ throw new Error(`Line ${ln(i)}: Invalid tool declaration: ${line}. Expected: tool <name> from <source>`);
736
1127
  }
737
- const source = extendMatch[1];
738
- toolName = extendMatch[2];
739
- // If source matches a previously-defined tool, it's an extends; otherwise it's a function name
1128
+ toolName = toolMatch[1];
1129
+ const source = toolMatch[2];
1130
+ assertNotReserved(toolName, ln(i), "tool name");
1131
+ // If source matches a previously-defined tool, it's inheritance; otherwise it's a function name
740
1132
  const isKnownTool = previousInstructions?.some((inst) => inst.kind === "tool" && inst.name === source);
741
1133
  if (isKnownTool) {
742
1134
  toolExtends = source;
@@ -800,8 +1192,8 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
800
1192
  wires.push({ kind: "onError", source: onErrorPullMatch[1] });
801
1193
  continue;
802
1194
  }
803
- // Constant wire: target = "value" or target = value (unquoted)
804
- const constantMatch = line.match(/^(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
1195
+ // Constant wire: .target = "value" or .target = value (unquoted)
1196
+ const constantMatch = line.match(/^\.(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
805
1197
  if (constantMatch) {
806
1198
  const value = constantMatch[2] ?? constantMatch[3];
807
1199
  wires.push({
@@ -811,12 +1203,16 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
811
1203
  });
812
1204
  continue;
813
1205
  }
814
- // Pull wire: target <- source
815
- const pullMatch = line.match(/^(\S+)\s*<-\s*(\S+)$/);
1206
+ // Pull wire: .target <- source
1207
+ const pullMatch = line.match(/^\.(\S+)\s*<-\s*(\S+)$/);
816
1208
  if (pullMatch) {
817
1209
  wires.push({ target: pullMatch[1], kind: "pull", source: pullMatch[2] });
818
1210
  continue;
819
1211
  }
1212
+ // Catch bare param lines without leading dot — give a helpful error
1213
+ if (/^[a-zA-Z]/.test(line)) {
1214
+ throw new Error(`Line ${ln(i)}: Tool params require a dot prefix: ".${line.split(/[\s=<]/)[0]} ...". Only 'with' and 'on error' lines are unprefixed.`);
1215
+ }
820
1216
  throw new Error(`Line ${ln(i)}: Unrecognized tool line: ${line}`);
821
1217
  }
822
1218
  if (!toolName)
@@ -862,7 +1258,8 @@ export function serializeBridge(instructions) {
862
1258
  const bridges = instructions.filter((i) => i.kind === "bridge");
863
1259
  const tools = instructions.filter((i) => i.kind === "tool");
864
1260
  const consts = instructions.filter((i) => i.kind === "const");
865
- if (bridges.length === 0 && tools.length === 0 && consts.length === 0)
1261
+ const defines = instructions.filter((i) => i.kind === "define");
1262
+ if (bridges.length === 0 && tools.length === 0 && consts.length === 0 && defines.length === 0)
866
1263
  return "";
867
1264
  const blocks = [];
868
1265
  // Group const declarations into a single block
@@ -872,21 +1269,20 @@ export function serializeBridge(instructions) {
872
1269
  for (const tool of tools) {
873
1270
  blocks.push(serializeToolBlock(tool));
874
1271
  }
1272
+ for (const def of defines) {
1273
+ blocks.push(serializeDefineBlock(def));
1274
+ }
875
1275
  for (const bridge of bridges) {
876
1276
  blocks.push(serializeBridgeBlock(bridge));
877
1277
  }
878
- return blocks.join("\n\n") + "\n";
1278
+ return `version ${BRIDGE_VERSION}\n\n` + blocks.join("\n\n") + "\n";
879
1279
  }
880
1280
  function serializeToolBlock(tool) {
881
1281
  const lines = [];
882
1282
  const hasBody = tool.deps.length > 0 || tool.wires.length > 0;
883
- // Declaration line — use `extend` format
884
- if (tool.extends) {
885
- lines.push(hasBody ? `extend ${tool.extends} as ${tool.name} {` : `extend ${tool.extends} as ${tool.name}`);
886
- }
887
- else {
888
- lines.push(hasBody ? `extend ${tool.fn} as ${tool.name} {` : `extend ${tool.fn} as ${tool.name}`);
889
- }
1283
+ // Declaration line — use `tool <name> from <source>` format
1284
+ const source = tool.extends ?? tool.fn;
1285
+ lines.push(hasBody ? `tool ${tool.name} from ${source} {` : `tool ${tool.name} from ${source}`);
890
1286
  // Dependencies
891
1287
  for (const dep of tool.deps) {
892
1288
  if (dep.kind === "context") {
@@ -922,14 +1318,14 @@ function serializeToolBlock(tool) {
922
1318
  else if (wire.kind === "constant") {
923
1319
  // Use quoted form if value contains spaces or special chars, unquoted otherwise
924
1320
  if (/\s/.test(wire.value) || wire.value === "") {
925
- lines.push(` ${wire.target} = "${wire.value}"`);
1321
+ lines.push(` .${wire.target} = "${wire.value}"`);
926
1322
  }
927
1323
  else {
928
- lines.push(` ${wire.target} = ${wire.value}`);
1324
+ lines.push(` .${wire.target} = ${wire.value}`);
929
1325
  }
930
1326
  }
931
1327
  else {
932
- lines.push(` ${wire.target} <- ${wire.source}`);
1328
+ lines.push(` .${wire.target} <- ${wire.source}`);
933
1329
  }
934
1330
  }
935
1331
  if (hasBody)
@@ -946,7 +1342,7 @@ function serializeToolBlock(tool) {
946
1342
  * This is used to emit `?? handle.path` or `?? pipe:source` for wire
947
1343
  * `fallbackRef` values.
948
1344
  */
949
- function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle) {
1345
+ function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle, outputHandle) {
950
1346
  const refTk = ref.instance != null
951
1347
  ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}`
952
1348
  : `${ref.module}:${ref.type}:${ref.field}`;
@@ -977,13 +1373,37 @@ function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge
977
1373
  }
978
1374
  }
979
1375
  if (actualSourceRef && handleChain.length > 0) {
980
- const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, true);
1376
+ const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, outputHandle, true);
981
1377
  return `${handleChain.join(":")}:${sourceStr}`;
982
1378
  }
983
1379
  }
984
- return serializeRef(ref, bridge, handleMap, inputHandle, true);
1380
+ return serializeRef(ref, bridge, handleMap, inputHandle, outputHandle, true);
1381
+ }
1382
+ /**
1383
+ * Serialize a DefineDef into its textual form.
1384
+ *
1385
+ * Delegates to serializeBridgeBlock with a synthetic Bridge, then replaces
1386
+ * the `bridge Define.<name>` header with `define <name>`.
1387
+ */
1388
+ function serializeDefineBlock(def) {
1389
+ const syntheticBridge = {
1390
+ kind: "bridge",
1391
+ type: "Define",
1392
+ field: def.name,
1393
+ handles: def.handles,
1394
+ wires: def.wires,
1395
+ arrayIterators: def.arrayIterators,
1396
+ pipeHandles: def.pipeHandles,
1397
+ };
1398
+ const bridgeText = serializeBridgeBlock(syntheticBridge);
1399
+ // Replace "bridge Define.<name>" → "define <name>"
1400
+ return bridgeText.replace(/^bridge Define\.(\w+)/, "define $1");
985
1401
  }
986
1402
  function serializeBridgeBlock(bridge) {
1403
+ // ── Passthrough shorthand ───────────────────────────────────────────
1404
+ if (bridge.passthrough) {
1405
+ return `bridge ${bridge.type}.${bridge.field} with ${bridge.passthrough}`;
1406
+ }
987
1407
  const lines = [];
988
1408
  // ── Header ──────────────────────────────────────────────────────────
989
1409
  lines.push(`bridge ${bridge.type}.${bridge.field} {`);
@@ -1002,7 +1422,20 @@ function serializeBridgeBlock(bridge) {
1002
1422
  break;
1003
1423
  }
1004
1424
  case "input":
1005
- lines.push(` with input as ${h.handle}`);
1425
+ if (h.handle === "input") {
1426
+ lines.push(` with input`);
1427
+ }
1428
+ else {
1429
+ lines.push(` with input as ${h.handle}`);
1430
+ }
1431
+ break;
1432
+ case "output":
1433
+ if (h.handle === "output") {
1434
+ lines.push(` with output`);
1435
+ }
1436
+ else {
1437
+ lines.push(` with output as ${h.handle}`);
1438
+ }
1006
1439
  break;
1007
1440
  case "context":
1008
1441
  lines.push(` with context as ${h.handle}`);
@@ -1015,31 +1448,28 @@ function serializeBridgeBlock(bridge) {
1015
1448
  lines.push(` with const as ${h.handle}`);
1016
1449
  }
1017
1450
  break;
1451
+ case "define":
1452
+ lines.push(` with ${h.name} as ${h.handle}`);
1453
+ break;
1018
1454
  }
1019
1455
  }
1020
1456
  lines.push("");
1021
1457
  // Mark where the wire body starts — everything after this gets 2-space indent
1022
1458
  const wireBodyStart = lines.length;
1023
1459
  // ── Build handle map for reverse resolution ─────────────────────────
1024
- const { handleMap, inputHandle } = buildHandleMap(bridge);
1460
+ const { handleMap, inputHandle, outputHandle } = buildHandleMap(bridge);
1025
1461
  // ── Pipe fork registry ──────────────────────────────────────────────
1026
- // Extend handleMap with fork → handle-name entries and build the set of
1027
- // known fork trunk keys so the wire classifiers below can use it.
1028
1462
  const pipeHandleTrunkKeys = new Set();
1029
1463
  for (const ph of bridge.pipeHandles ?? []) {
1030
1464
  handleMap.set(ph.key, ph.handle);
1031
1465
  pipeHandleTrunkKeys.add(ph.key);
1032
1466
  }
1033
- // ── Pipe wire detection ───────────────────────────────────────────────────────
1034
- // Pipe wires are marked pipe:true. Classify them into two maps:
1035
- // toInMap: forkTrunkKey → wire feeding the fork's input field
1036
- // fromOutMap: forkTrunkKey → wire reading the fork's root result
1037
- // Terminal out-wires (destination is NOT another fork) are chain anchors.
1467
+ // ── Pipe wire detection ─────────────────────────────────────────────
1038
1468
  const refTrunkKey = (ref) => ref.instance != null
1039
1469
  ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}`
1040
1470
  : `${ref.module}:${ref.type}:${ref.field}`;
1041
- const toInMap = new Map(); // forkTrunkKey → wire with to = fork's input field
1042
- const fromOutMap = new Map(); // forkTrunkKey → wire with from = fork root (path:[])
1471
+ const toInMap = new Map();
1472
+ const fromOutMap = new Map();
1043
1473
  const pipeWireSet = new Set();
1044
1474
  for (const w of bridge.wires) {
1045
1475
  if (!("from" in w) || !w.pipe)
@@ -1047,70 +1477,94 @@ function serializeBridgeBlock(bridge) {
1047
1477
  const fw = w;
1048
1478
  pipeWireSet.add(w);
1049
1479
  const toTk = refTrunkKey(fw.to);
1050
- // In-wire: single-segment path targeting a known pipe fork
1051
1480
  if (fw.to.path.length === 1 && pipeHandleTrunkKeys.has(toTk)) {
1052
1481
  toInMap.set(toTk, fw);
1053
1482
  }
1054
- // Out-wire: empty path from a known pipe fork
1055
1483
  if (fw.from.path.length === 0 && pipeHandleTrunkKeys.has(refTrunkKey(fw.from))) {
1056
1484
  fromOutMap.set(refTrunkKey(fw.from), fw);
1057
1485
  }
1058
1486
  }
1059
- // ── Wires ───────────────────────────────────────────────────────────
1060
- const elementWires = bridge.wires.filter((w) => "from" in w && !!w.from.element);
1061
- // Exclude pipe wires and element wires from the regular loop
1062
- const regularWires = bridge.wires.filter((w) => !pipeWireSet.has(w) && (!("from" in w) || !w.from.element));
1063
- const elementGroups = new Map();
1064
- for (const w of elementWires) {
1065
- const parent = w.to.path[0];
1066
- if (!elementGroups.has(parent))
1067
- elementGroups.set(parent, []);
1068
- elementGroups.get(parent).push(w);
1487
+ // ── Group element wires by array-destination field ──────────────────
1488
+ // Pull wires: from.element=true
1489
+ const elementPullWires = bridge.wires.filter((w) => "from" in w && !!w.from.element);
1490
+ // Constant wires: "value" in w && to.element=true
1491
+ const elementConstWires = bridge.wires.filter((w) => "value" in w && !!w.to.element);
1492
+ // Build grouped maps keyed by the array-destination field name (to.path[0])
1493
+ const elementPullGroups = new Map();
1494
+ const elementConstGroups = new Map();
1495
+ for (const w of elementPullWires) {
1496
+ const key = w.to.path[0];
1497
+ if (!elementPullGroups.has(key))
1498
+ elementPullGroups.set(key, []);
1499
+ elementPullGroups.get(key).push(w);
1500
+ }
1501
+ for (const w of elementConstWires) {
1502
+ const key = w.to.path[0];
1503
+ if (!elementConstGroups.has(key))
1504
+ elementConstGroups.set(key, []);
1505
+ elementConstGroups.get(key).push(w);
1069
1506
  }
1507
+ // Union of keys that have any element wire (pull or constant)
1508
+ const allElementKeys = new Set([...elementPullGroups.keys(), ...elementConstGroups.keys()]);
1509
+ // ── Exclude pipe, element-pull, and element-const wires from main loop
1510
+ const regularWires = bridge.wires.filter((w) => !pipeWireSet.has(w) &&
1511
+ (!("from" in w) || !w.from.element) &&
1512
+ (!("value" in w) || !w.to.element));
1070
1513
  const serializedArrays = new Set();
1514
+ // ── Helper: serialize a reference (forward outputHandle) ─────────────
1515
+ const sRef = (ref, isFrom) => serializeRef(ref, bridge, handleMap, inputHandle, outputHandle, isFrom);
1516
+ const sPipeOrRef = (ref) => serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle, outputHandle);
1071
1517
  for (const w of regularWires) {
1072
1518
  // Constant wire
1073
1519
  if ("value" in w) {
1074
- const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false);
1520
+ const toStr = sRef(w.to, false);
1075
1521
  lines.push(`${toStr} = "${w.value}"`);
1076
1522
  continue;
1077
1523
  }
1078
- // Array mapping
1524
+ // Array mapping — emit brace-delimited element block
1079
1525
  const arrayKey = w.to.path.length === 1 ? w.to.path[0] : null;
1080
- if (arrayKey &&
1081
- elementGroups.has(arrayKey) &&
1082
- !serializedArrays.has(arrayKey)) {
1526
+ if (arrayKey && allElementKeys.has(arrayKey) && !serializedArrays.has(arrayKey)) {
1083
1527
  serializedArrays.add(arrayKey);
1084
- const fromStr = serializeRef(w.from, bridge, handleMap, inputHandle, true) + "[]";
1085
- const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false) + "[]";
1086
- lines.push(`${toStr} <- ${fromStr}`);
1087
- for (const ew of elementGroups.get(arrayKey)) {
1088
- const elemFrom = "." + serPath(ew.from.path);
1528
+ const iterName = bridge.arrayIterators?.[arrayKey] ?? "item";
1529
+ const fromStr = sRef(w.from, true) + "[]";
1530
+ const toStr = sRef(w.to, false);
1531
+ lines.push(`${toStr} <- ${fromStr} as ${iterName} {`);
1532
+ // Element constant wires (e.g. .provider = "RENFE")
1533
+ for (const ew of elementConstGroups.get(arrayKey) ?? []) {
1534
+ const fieldPath = ew.to.path.slice(1); // strip arrayKey prefix
1535
+ const elemTo = "." + serPath(fieldPath);
1536
+ lines.push(` ${elemTo} = "${ew.value}"`);
1537
+ }
1538
+ // Element pull wires (e.g. .name <- iter.title)
1539
+ for (const ew of elementPullGroups.get(arrayKey) ?? []) {
1540
+ const fromPart = ew.from.element
1541
+ ? iterName + "." + serPath(ew.from.path)
1542
+ : sRef(ew.from, true);
1089
1543
  const elemTo = "." + serPath(ew.to.path.slice(1));
1090
- lines.push(` ${elemTo} <- ${elemFrom}`);
1544
+ // Handle fallbacks on element pull wires
1545
+ const nfb = "nullFallback" in ew && ew.nullFallback ? ` || ${ew.nullFallback}` : "";
1546
+ const errf = "fallbackRef" in ew && ew.fallbackRef
1547
+ ? ` ?? ${sPipeOrRef(ew.fallbackRef)}`
1548
+ : "fallback" in ew && ew.fallback ? ` ?? ${ew.fallback}` : "";
1549
+ lines.push(` ${elemTo} <- ${fromPart}${nfb}${errf}`);
1091
1550
  }
1551
+ lines.push(`}`);
1092
1552
  continue;
1093
1553
  }
1094
1554
  // Regular wire
1095
- const fromStr = serializeRef(w.from, bridge, handleMap, inputHandle, true);
1096
- const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false);
1555
+ const fromStr = sRef(w.from, true);
1556
+ const toStr = sRef(w.to, false);
1097
1557
  const arrow = w.force ? "<-!" : "<-";
1098
1558
  const nfb = w.nullFallback ? ` || ${w.nullFallback}` : "";
1099
1559
  const errf = w.fallbackRef
1100
- ? ` ?? ${serializePipeOrRef(w.fallbackRef, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle)}`
1560
+ ? ` ?? ${sPipeOrRef(w.fallbackRef)}`
1101
1561
  : w.fallback ? ` ?? ${w.fallback}` : "";
1102
1562
  lines.push(`${toStr} ${arrow} ${fromStr}${nfb}${errf}`);
1103
1563
  }
1104
1564
  // ── Pipe wires ───────────────────────────────────────────────────────
1105
- // Find terminal fromOutMap entries — their destination is NOT another
1106
- // pipe handle's .in. Follow the chain backward to reconstruct:
1107
- // dest <- h1:h2:…:source
1108
- const serializedPipeTrunks = new Set();
1109
1565
  for (const [tk, outWire] of fromOutMap.entries()) {
1110
- // Non-terminal: this fork's result feeds another fork's input field
1111
1566
  if (pipeHandleTrunkKeys.has(refTrunkKey(outWire.to)))
1112
1567
  continue;
1113
- // Follow chain backward to collect handle names (outermost-first)
1114
1568
  const handleChain = [];
1115
1569
  let currentTk = tk;
1116
1570
  let actualSourceRef = null;
@@ -1119,18 +1573,15 @@ function serializeBridgeBlock(bridge) {
1119
1573
  const handleName = handleMap.get(currentTk);
1120
1574
  if (!handleName)
1121
1575
  break;
1122
- // Token: "handle" when field is "in" (default), otherwise "handle.field"
1123
1576
  const inWire = toInMap.get(currentTk);
1124
1577
  const fieldName = inWire?.to.path[0] ?? "in";
1125
1578
  const token = fieldName === "in" ? handleName : `${handleName}.${fieldName}`;
1126
1579
  handleChain.push(token);
1127
- serializedPipeTrunks.add(currentTk);
1128
1580
  if (inWire?.force)
1129
1581
  chainForced = true;
1130
1582
  if (!inWire)
1131
1583
  break;
1132
1584
  const fromTk = refTrunkKey(inWire.from);
1133
- // Inner source is another pipe fork root (empty path) → continue chain
1134
1585
  if (inWire.from.path.length === 0 && pipeHandleTrunkKeys.has(fromTk)) {
1135
1586
  currentTk = fromTk;
1136
1587
  }
@@ -1140,12 +1591,12 @@ function serializeBridgeBlock(bridge) {
1140
1591
  }
1141
1592
  }
1142
1593
  if (actualSourceRef && handleChain.length > 0) {
1143
- const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, true);
1144
- const destStr = serializeRef(outWire.to, bridge, handleMap, inputHandle, false);
1594
+ const sourceStr = sRef(actualSourceRef, true);
1595
+ const destStr = sRef(outWire.to, false);
1145
1596
  const arrow = chainForced ? "<-!" : "<-";
1146
1597
  const nfb = outWire.nullFallback ? ` || ${outWire.nullFallback}` : "";
1147
1598
  const errf = outWire.fallbackRef
1148
- ? ` ?? ${serializePipeOrRef(outWire.fallbackRef, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle)}`
1599
+ ? ` ?? ${sPipeOrRef(outWire.fallbackRef)}`
1149
1600
  : outWire.fallback ? ` ?? ${outWire.fallback}` : "";
1150
1601
  lines.push(`${destStr} ${arrow} ${handleChain.join(":")}:${sourceStr}${nfb}${errf}`);
1151
1602
  }
@@ -1165,6 +1616,7 @@ function buildHandleMap(bridge) {
1165
1616
  const handleMap = new Map();
1166
1617
  const instanceCounters = new Map();
1167
1618
  let inputHandle;
1619
+ let outputHandle;
1168
1620
  for (const h of bridge.handles) {
1169
1621
  switch (h.kind) {
1170
1622
  case "tool": {
@@ -1190,19 +1642,27 @@ function buildHandleMap(bridge) {
1190
1642
  case "input":
1191
1643
  inputHandle = h.handle;
1192
1644
  break;
1645
+ case "output":
1646
+ outputHandle = h.handle;
1647
+ break;
1193
1648
  case "context":
1194
1649
  handleMap.set(`${SELF_MODULE}:Context:context`, h.handle);
1195
1650
  break;
1196
1651
  case "const":
1197
1652
  handleMap.set(`${SELF_MODULE}:Const:const`, h.handle);
1198
1653
  break;
1654
+ case "define":
1655
+ handleMap.set(`__define_${h.handle}:${bridge.type}:${bridge.field}`, h.handle);
1656
+ break;
1199
1657
  }
1200
1658
  }
1201
- return { handleMap, inputHandle };
1659
+ return { handleMap, inputHandle, outputHandle };
1202
1660
  }
1203
- function serializeRef(ref, bridge, handleMap, inputHandle, isFrom) {
1661
+ function serializeRef(ref, bridge, handleMap, inputHandle, outputHandle, isFrom) {
1204
1662
  if (ref.element) {
1205
- return "." + serPath(ref.path);
1663
+ // Element refs are only serialized inside brace blocks (using the iterator name).
1664
+ // This path should not be reached in normal serialization.
1665
+ return "item." + serPath(ref.path);
1206
1666
  }
1207
1667
  // Bridge's own trunk (no instance, no element)
1208
1668
  const isBridgeTrunk = ref.module === SELF_MODULE &&
@@ -1213,16 +1673,17 @@ function serializeRef(ref, bridge, handleMap, inputHandle, isFrom) {
1213
1673
  if (isBridgeTrunk) {
1214
1674
  if (isFrom && inputHandle) {
1215
1675
  // From side: use input handle (data comes from args)
1216
- return inputHandle + "." + serPath(ref.path);
1676
+ return ref.path.length > 0
1677
+ ? inputHandle + "." + serPath(ref.path)
1678
+ : inputHandle;
1217
1679
  }
1218
- // To side: sub-fields of the bridge's own return type are prefixed with the
1219
- // bridge field name so `path: ["a"]` serializes as `doubled.a` (not bare "a").
1220
- // This is needed for bridges whose output type has named sub-fields
1221
- // (e.g. `bridge Query.doubled` with `doubled.a <- ...`).
1222
- if (!isFrom && ref.path.length > 0) {
1223
- return bridge.field + "." + serPath(ref.path);
1680
+ if (!isFrom && outputHandle) {
1681
+ // To side: use output handle
1682
+ return ref.path.length > 0
1683
+ ? outputHandle + "." + serPath(ref.path)
1684
+ : outputHandle;
1224
1685
  }
1225
- // Bare path (e.g. top-level scalar output, or no-path for the bridge trunk itself)
1686
+ // Fallback (no handle declared legacy/serializer-only path)
1226
1687
  return serPath(ref.path);
1227
1688
  }
1228
1689
  // Lookup by trunk key
@@ -1231,7 +1692,6 @@ function serializeRef(ref, bridge, handleMap, inputHandle, isFrom) {
1231
1692
  : `${ref.module}:${ref.type}:${ref.field}`;
1232
1693
  const handle = handleMap.get(trunkStr);
1233
1694
  if (handle) {
1234
- // Empty path — just the handle name (e.g. pipe result = tool root)
1235
1695
  if (ref.path.length === 0)
1236
1696
  return handle;
1237
1697
  return handle + "." + serPath(ref.path);