@stackables/bridge 1.2.0 → 1.3.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.
@@ -10,10 +10,34 @@ 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
+ }
13
26
  export function parseBridge(text) {
14
27
  // Normalize: CRLF → LF, tabs → 2 spaces
15
28
  const normalized = text.replace(/\r\n?/g, "\n").replace(/\t/g, " ");
16
29
  const allLines = normalized.split("\n");
30
+ // Version check — first non-blank, non-comment line must be `version 1.4`
31
+ const firstContentIdx = allLines.findIndex((l) => l.trim() !== "" && !l.trim().startsWith("#"));
32
+ if (firstContentIdx === -1 || !/^version\s+/.test(allLines[firstContentIdx].trim())) {
33
+ throw new Error(`Missing version declaration. Bridge files must begin with: version ${BRIDGE_VERSION}`);
34
+ }
35
+ const versionToken = allLines[firstContentIdx].trim().replace(/^version\s+/, "");
36
+ if (versionToken !== BRIDGE_VERSION) {
37
+ throw new Error(`Unsupported bridge version "${versionToken}". This parser requires: version ${BRIDGE_VERSION}`);
38
+ }
39
+ // Blank out the version line so block-splitting ignores it
40
+ allLines[firstContentIdx] = "";
17
41
  // Find separator lines (--- with optional surrounding whitespace)
18
42
  const isSep = (line) => /^\s*---\s*$/.test(line);
19
43
  // Collect block ranges as [start, end) line indices
@@ -35,7 +59,7 @@ export function parseBridge(text) {
35
59
  let currentOffset = start;
36
60
  for (let i = 0; i < blockLines.length; i++) {
37
61
  const trimmed = blockLines[i].trim();
38
- if (/^(tool|bridge|const|extend)\s/i.test(trimmed) && currentLines.length > 0) {
62
+ if (/^(tool|bridge|const|define)\s/i.test(trimmed) && currentLines.length > 0) {
39
63
  // Check if any non-blank content exists
40
64
  if (currentLines.some((l) => l.trim())) {
41
65
  subBlocks.push({ startOffset: currentOffset, lines: currentLines });
@@ -58,17 +82,20 @@ export function parseBridge(text) {
58
82
  while (firstContentLine < sub.lines.length && !sub.lines[firstContentLine].trim())
59
83
  firstContentLine++;
60
84
  const firstLine = sub.lines[firstContentLine]?.trim();
61
- if (firstLine && /^(tool|extend)\s/i.test(firstLine)) {
85
+ if (firstLine && /^tool\s/i.test(firstLine)) {
62
86
  instructions.push(parseToolBlock(subText, sub.startOffset + firstContentLine, instructions));
63
87
  }
88
+ else if (firstLine && /^define\s/i.test(firstLine)) {
89
+ instructions.push(parseDefineBlock(subText, sub.startOffset + firstContentLine));
90
+ }
64
91
  else if (firstLine && /^bridge\s/i.test(firstLine)) {
65
- instructions.push(...parseBridgeBlock(subText, sub.startOffset + firstContentLine));
92
+ instructions.push(...parseBridgeBlock(subText, sub.startOffset + firstContentLine, instructions));
66
93
  }
67
94
  else if (firstLine && /^const\s/i.test(firstLine)) {
68
95
  instructions.push(...parseConstLines(subText, sub.startOffset + firstContentLine));
69
96
  }
70
97
  else if (firstLine && !firstLine.startsWith("#")) {
71
- throw new Error(`Line ${sub.startOffset + firstContentLine + 1}: Expected "tool", "extend", "bridge", or "const" declaration, got: ${firstLine}`);
98
+ throw new Error(`Line ${sub.startOffset + firstContentLine + 1}: Expected "tool", "define", "bridge", or "const" declaration, got: ${firstLine}`);
72
99
  }
73
100
  }
74
101
  }
@@ -84,7 +111,30 @@ function isJsonLiteral(s) {
84
111
  return /^["\{\[\d]/.test(s) || /^-\d/.test(s) ||
85
112
  s === "true" || s === "false" || s === "null";
86
113
  }
87
- function parseBridgeBlock(block, lineOffset) {
114
+ function parseBridgeBlock(block, lineOffset, previousInstructions) {
115
+ // ── Passthrough shorthand: `bridge Type.field with <name>` ──────────
116
+ // Expands into a full bridge that wires all input through the named
117
+ // handle (typically a define) and returns its output directly.
118
+ const shorthandMatch = block.match(/^bridge\s+(\w+)\.(\w+)\s+with\s+(\S+)\s*$/im);
119
+ if (shorthandMatch) {
120
+ const [, sType, sField, sName] = shorthandMatch;
121
+ const sHandle = sName.includes(".") ? sName.substring(sName.lastIndexOf(".") + 1) : sName;
122
+ const expanded = [
123
+ `bridge ${sType}.${sField} {`,
124
+ ` with ${sName} as ${sHandle}`,
125
+ ` with input`,
126
+ ` with output as __out`,
127
+ ` ${sHandle} <- input`,
128
+ ` __out <- ${sHandle}`,
129
+ `}`,
130
+ ].join("\n");
131
+ const result = parseBridgeBlock(expanded, lineOffset, previousInstructions);
132
+ // Tag the bridge instruction with the passthrough name for serialization
133
+ const bridgeInst = result.find((i) => i.kind === "bridge");
134
+ if (bridgeInst)
135
+ bridgeInst.passthrough = sName;
136
+ return result;
137
+ }
88
138
  // Validate mandatory braces: `bridge Foo.bar {` ... `}`
89
139
  const rawLines = block.split("\n");
90
140
  const keywordIdx = rawLines.findIndex((l) => /^bridge\s/i.test(l.trim()));
@@ -134,7 +184,7 @@ function parseBridgeBlock(block, lineOffset) {
134
184
  if (!bridgeType) {
135
185
  throw new Error(`Line ${ln(i)}: "with" declaration must come after "bridge" declaration`);
136
186
  }
137
- parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBindings, instanceCounters, instructions, ln(i));
187
+ parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBindings, instanceCounters, previousInstructions ?? [], ln(i));
138
188
  continue;
139
189
  }
140
190
  // First non-header line — body starts here
@@ -147,6 +197,8 @@ function parseBridgeBlock(block, lineOffset) {
147
197
  // ── Parse wire lines ────────────────────────────────────────────────
148
198
  const wires = [];
149
199
  let currentArrayToPath = null;
200
+ let currentIterHandle = null;
201
+ const arrayIterators = {};
150
202
  /** Monotonically-increasing index; combined with a high base to produce
151
203
  * fork instances that can never collide with regular handle instances. */
152
204
  let nextForkSeq = 0;
@@ -167,7 +219,7 @@ function parseBridgeBlock(block, lineOffset) {
167
219
  function buildSourceExpr(sourceStr, lineNum, forceOnOutermost) {
168
220
  const parts = sourceStr.split(":");
169
221
  if (parts.length === 1) {
170
- return resolveAddress(sourceStr, handleRes, bridgeType, bridgeField);
222
+ return resolveAddress(sourceStr, handleRes, bridgeType, bridgeField, lineNum);
171
223
  }
172
224
  // Pipe chain
173
225
  const actualSource = parts[parts.length - 1];
@@ -184,7 +236,7 @@ function parseBridgeBlock(block, lineOffset) {
184
236
  throw new Error(`Line ${lineNum}: Undeclared handle in pipe: "${handleName}". Add 'with <tool> as ${handleName}' to the bridge header.`);
185
237
  }
186
238
  }
187
- let prevOutRef = resolveAddress(actualSource, handleRes, bridgeType, bridgeField);
239
+ let prevOutRef = resolveAddress(actualSource, handleRes, bridgeType, bridgeField, lineNum);
188
240
  const reversedTokens = [...tokenChain].reverse();
189
241
  for (let idx = 0; idx < reversedTokens.length; idx++) {
190
242
  const tok = reversedTokens[idx];
@@ -205,72 +257,165 @@ function parseBridgeBlock(block, lineOffset) {
205
257
  }
206
258
  return prevOutRef; // fork-root ref
207
259
  }
260
+ // ── Whether we are inside an element-mapping brace block
261
+ let inElementBlock = false;
208
262
  for (let i = bodyStartIndex; i < lines.length; i++) {
209
263
  const raw = lines[i];
210
264
  const line = raw.trim();
211
265
  if (!line || line.startsWith("#")) {
212
266
  continue;
213
267
  }
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;
268
+ // Closing brace of an element mapping block `{ ... }`
269
+ if (line === "}") {
270
+ if (inElementBlock) {
271
+ currentArrayToPath = null;
272
+ currentIterHandle = null;
273
+ inElementBlock = false;
274
+ continue;
275
+ }
276
+ throw new Error(`Line ${ln(i)}: Unexpected "}" — not inside an element block`);
277
+ }
278
+ // Element mapping lines (inside a brace block)
279
+ if (inElementBlock && currentArrayToPath) {
280
+ if (!line.startsWith(".")) {
281
+ throw new Error(`Line ${ln(i)}: Element mapping lines must start with ".": ${line}`);
282
+ }
283
+ // Constant: .target = "value" or .target = value
284
+ const elemConstMatch = line.match(/^\.(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
285
+ if (elemConstMatch) {
286
+ const [, fieldName, quotedValue, unquotedValue] = elemConstMatch;
287
+ const value = quotedValue ?? unquotedValue;
288
+ wires.push({
289
+ value,
290
+ to: {
291
+ module: SELF_MODULE,
292
+ type: bridgeType,
293
+ field: bridgeField,
294
+ element: true,
295
+ path: [...currentArrayToPath, ...parsePath(fieldName)],
296
+ },
297
+ });
298
+ continue;
299
+ }
300
+ // Simple pull: .target <- <iter>.source (element-relative, no fallbacks)
301
+ const iterPfx = `${currentIterHandle}.`;
302
+ const elemRelMatch = line.match(/^\.(\S+)\s*<-\s*(\S+)$/);
303
+ if (elemRelMatch && elemRelMatch[2].startsWith(iterPfx)) {
304
+ const toPath = [...currentArrayToPath, ...parsePath(elemRelMatch[1])];
305
+ const fromPath = parsePath(elemRelMatch[2].slice(iterPfx.length));
306
+ wires.push({
307
+ from: {
308
+ module: SELF_MODULE,
309
+ type: bridgeType,
310
+ field: bridgeField,
311
+ element: true,
312
+ path: fromPath,
313
+ },
314
+ to: {
315
+ module: SELF_MODULE,
316
+ type: bridgeType,
317
+ field: bridgeField,
318
+ path: toPath,
319
+ },
320
+ });
321
+ continue;
322
+ }
323
+ // Pull with fallbacks: .target <- source || "fallback" ?? errorSrc (relative or handle path)
324
+ const elemArrowMatch = line.match(/^\.(\S+)\s*<-\s*(.+)$/);
325
+ if (elemArrowMatch) {
326
+ const [, toField, rhs] = elemArrowMatch;
327
+ const toPath = [...currentArrayToPath, ...parsePath(toField)];
328
+ const toRef = { module: SELF_MODULE, type: bridgeType, field: bridgeField, path: toPath };
329
+ // Strip ?? tail
330
+ let exprCore = rhs.trim();
331
+ let fallback;
332
+ let fallbackRefStr;
333
+ const qqIdx = exprCore.lastIndexOf(" ?? ");
334
+ if (qqIdx !== -1) {
335
+ const tail = exprCore.slice(qqIdx + 4).trim();
336
+ exprCore = exprCore.slice(0, qqIdx).trim();
337
+ if (isJsonLiteral(tail))
338
+ fallback = tail;
339
+ else
340
+ fallbackRefStr = tail;
341
+ }
342
+ // Split on || — last may be JSON literal (nullFallback)
343
+ const orParts = exprCore.split(" || ").map((s) => s.trim());
344
+ let nullFallback;
345
+ let sourceParts = orParts;
346
+ if (orParts.length > 1 && isJsonLiteral(orParts[orParts.length - 1])) {
347
+ nullFallback = orParts[orParts.length - 1];
348
+ sourceParts = orParts.slice(0, -1);
349
+ }
350
+ let fallbackRef;
351
+ let fallbackInternalWires = [];
352
+ if (fallbackRefStr) {
353
+ const preLen = wires.length;
354
+ fallbackRef = buildSourceExpr(fallbackRefStr, ln(i), false);
355
+ fallbackInternalWires = wires.splice(preLen);
356
+ }
357
+ for (let ci = 0; ci < sourceParts.length; ci++) {
358
+ const srcStr = sourceParts[ci];
359
+ const isLast = ci === sourceParts.length - 1;
360
+ // Element-relative source: starts with "<iter>."
361
+ const iterPrefix = `${currentIterHandle}.`;
362
+ let fromRef;
363
+ if (srcStr.startsWith(iterPrefix)) {
364
+ fromRef = {
365
+ module: SELF_MODULE,
366
+ type: bridgeType,
367
+ field: bridgeField,
368
+ element: true,
369
+ path: parsePath(srcStr.slice(iterPrefix.length)),
370
+ };
371
+ }
372
+ else if (srcStr.startsWith(".")) {
373
+ throw new Error(`Line ${ln(i)}: Use "${currentIterHandle}.field" to reference element fields, not ".field"`);
374
+ }
375
+ else {
376
+ fromRef = buildSourceExpr(srcStr, ln(i), false);
377
+ }
378
+ const lastAttrs = isLast ? {
379
+ ...(nullFallback ? { nullFallback } : {}),
380
+ ...(fallback ? { fallback } : {}),
381
+ ...(fallbackRef ? { fallbackRef } : {}),
382
+ } : {};
383
+ wires.push({ from: fromRef, to: toRef, ...lastAttrs });
384
+ }
385
+ wires.push(...fallbackInternalWires);
386
+ continue;
387
+ }
388
+ throw new Error(`Line ${ln(i)}: Invalid element mapping line: ${line}`);
238
389
  }
239
- // End of array mapping block
240
- currentArrayToPath = null;
241
390
  // Constant wire: target = "value" or target = value (unquoted)
242
391
  const constantMatch = line.match(/^(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
243
392
  if (constantMatch) {
244
393
  const [, targetStr, quotedValue, unquotedValue] = constantMatch;
245
394
  const value = quotedValue ?? unquotedValue;
246
- const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
395
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
247
396
  wires.push({ value, to: toRef });
248
397
  continue;
249
398
  }
250
399
  // ── 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
400
  const arrowMatch = line.match(/^(\S+)\s*<-(!?)\s*(.+)$/);
262
401
  if (arrowMatch) {
263
402
  const [, targetStr, forceFlag, rhs] = arrowMatch;
264
403
  const force = forceFlag === "!";
265
404
  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);
405
+ // ── Array mapping: target <- source[] as <iter> {
406
+ // Opens a brace-delimited element block.
407
+ const arrayBraceMatch = rhsTrimmed.match(/^(\S+)\[\]\s+as\s+(\w+)\s*\{\s*$/);
408
+ if (arrayBraceMatch) {
409
+ const fromClean = arrayBraceMatch[1];
410
+ const iterHandle = arrayBraceMatch[2];
411
+ assertNotReserved(iterHandle, ln(i), "iterator handle");
412
+ const fromRef = resolveAddress(fromClean, handleRes, bridgeType, bridgeField, ln(i));
413
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
272
414
  wires.push({ from: fromRef, to: toRef });
273
415
  currentArrayToPath = toRef.path;
416
+ currentIterHandle = iterHandle;
417
+ arrayIterators[toRef.path[0]] = iterHandle;
418
+ inElementBlock = true;
274
419
  continue;
275
420
  }
276
421
  // ── Strip the ?? tail (last " ?? " wins in case source contains " ?? ")
@@ -300,38 +445,27 @@ function parseBridgeBlock(block, lineOffset) {
300
445
  if (sourceParts.length === 0) {
301
446
  throw new Error(`Line ${ln(i)}: Wire has no source expression: ${line}`);
302
447
  }
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
448
  let fallbackRef;
308
449
  let fallbackInternalWires = [];
309
450
  if (fallbackRefStr) {
310
451
  const preLen = wires.length;
311
452
  fallbackRef = buildSourceExpr(fallbackRefStr, ln(i), false);
312
- // Splice out internal wires buildSourceExpr just added; push after sources.
313
453
  fallbackInternalWires = wires.splice(preLen);
314
454
  }
315
- // ── Build wires for each coalesce part
316
- const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
455
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
317
456
  for (let ci = 0; ci < sourceParts.length; ci++) {
318
457
  const isFirst = ci === 0;
319
458
  const isLast = ci === sourceParts.length - 1;
320
459
  const srcStr = sourceParts[ci];
321
- // Parse source expression; for pipe chains buildSourceExpr pushes
322
- // intermediate wires and returns the fork-root ref.
323
460
  const termRef = buildSourceExpr(srcStr, ln(i), force && isFirst);
324
461
  const isPipeFork = termRef.instance != null && termRef.path.length === 0
325
462
  && srcStr.includes(":");
326
- // attrs carried only on the LAST wire of the coalesce chain
327
463
  const lastAttrs = isLast ? {
328
464
  ...(nullFallback ? { nullFallback } : {}),
329
465
  ...(fallback ? { fallback } : {}),
330
466
  ...(fallbackRef ? { fallbackRef } : {}),
331
467
  } : {};
332
468
  if (isPipeFork) {
333
- // Terminal pipe wire: fork-root → target (force only on outermost
334
- // intermediate wire, already set inside buildSourceExpr)
335
469
  wires.push({ from: termRef, to: toRef, pipe: true, ...lastAttrs });
336
470
  }
337
471
  else {
@@ -343,19 +477,29 @@ function parseBridgeBlock(block, lineOffset) {
343
477
  });
344
478
  }
345
479
  }
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
480
  wires.push(...fallbackInternalWires);
349
481
  continue;
350
482
  }
351
483
  throw new Error(`Line ${ln(i)}: Unrecognized line: ${line}`);
352
484
  }
485
+ // ── Inline define invocations ───────────────────────────────────────
486
+ const nextForkSeqRef = { value: nextForkSeq };
487
+ for (const hb of handleBindings) {
488
+ if (hb.kind !== "define")
489
+ continue;
490
+ const def = previousInstructions?.find((inst) => inst.kind === "define" && inst.name === hb.name);
491
+ if (!def) {
492
+ throw new Error(`Define "${hb.name}" referenced by handle "${hb.handle}" not found`);
493
+ }
494
+ inlineDefine(hb.handle, def, bridgeType, bridgeField, wires, pipeHandleEntries, handleBindings, instanceCounters, nextForkSeqRef);
495
+ }
353
496
  instructions.unshift({
354
497
  kind: "bridge",
355
498
  type: bridgeType,
356
499
  field: bridgeField,
357
500
  handles: handleBindings,
358
501
  wires,
502
+ arrayIterators: Object.keys(arrayIterators).length > 0 ? arrayIterators : undefined,
359
503
  pipeHandles: pipeHandleEntries.length > 0 ? pipeHandleEntries : undefined,
360
504
  });
361
505
  return instructions;
@@ -390,6 +534,45 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
390
534
  });
391
535
  return;
392
536
  }
537
+ // with input (shorthand — handle defaults to "input")
538
+ match = line.match(/^with\s+input$/i);
539
+ if (match) {
540
+ const handle = "input";
541
+ checkDuplicate(handle);
542
+ handleBindings.push({ handle, kind: "input" });
543
+ handleRes.set(handle, {
544
+ module: SELF_MODULE,
545
+ type: bridgeType,
546
+ field: bridgeField,
547
+ });
548
+ return;
549
+ }
550
+ // with output as <handle>
551
+ match = line.match(/^with\s+output\s+as\s+(\w+)$/i);
552
+ if (match) {
553
+ const handle = match[1];
554
+ checkDuplicate(handle);
555
+ handleBindings.push({ handle, kind: "output" });
556
+ handleRes.set(handle, {
557
+ module: SELF_MODULE,
558
+ type: bridgeType,
559
+ field: bridgeField,
560
+ });
561
+ return;
562
+ }
563
+ // with output (shorthand — handle defaults to "output")
564
+ match = line.match(/^with\s+output$/i);
565
+ if (match) {
566
+ const handle = "output";
567
+ checkDuplicate(handle);
568
+ handleBindings.push({ handle, kind: "output" });
569
+ handleRes.set(handle, {
570
+ module: SELF_MODULE,
571
+ type: bridgeType,
572
+ field: bridgeField,
573
+ });
574
+ return;
575
+ }
393
576
  // with context as <handle>
394
577
  match = line.match(/^with\s+context\s+as\s+(\w+)$/i);
395
578
  if (match) {
@@ -442,13 +625,24 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
442
625
  });
443
626
  return;
444
627
  }
445
- // with <name> as <handle> — tool reference (covers dotted names like hereapi.geocode)
628
+ // with <name> as <handle> — check for define invocation first
446
629
  match = line.match(/^with\s+(\S+)\s+as\s+(\w+)$/i);
447
630
  if (match) {
448
631
  const name = match[1];
449
632
  const handle = match[2];
450
633
  checkDuplicate(handle);
451
- // Split dotted name into module.field for NodeRef resolution
634
+ assertNotReserved(handle, lineNum, "handle alias");
635
+ // Check if name matches a known define
636
+ const defineDef = instructions.find((inst) => inst.kind === "define" && inst.name === name);
637
+ if (defineDef) {
638
+ handleBindings.push({ handle, kind: "define", name });
639
+ handleRes.set(handle, {
640
+ module: `__define_${handle}`,
641
+ type: bridgeType,
642
+ field: bridgeField,
643
+ });
644
+ return;
645
+ }
452
646
  const lastDot = name.lastIndexOf(".");
453
647
  if (lastDot !== -1) {
454
648
  const modulePart = name.substring(0, lastDot);
@@ -487,6 +681,17 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
487
681
  const lastDot = name.lastIndexOf(".");
488
682
  const handle = lastDot !== -1 ? name.substring(lastDot + 1) : name;
489
683
  checkDuplicate(handle);
684
+ // Check if name matches a known define
685
+ const defineDef = instructions.find((inst) => inst.kind === "define" && inst.name === name);
686
+ if (defineDef) {
687
+ handleBindings.push({ handle, kind: "define", name });
688
+ handleRes.set(handle, {
689
+ module: `__define_${handle}`,
690
+ type: bridgeType,
691
+ field: bridgeField,
692
+ });
693
+ return;
694
+ }
490
695
  if (lastDot !== -1) {
491
696
  const modulePart = name.substring(0, lastDot);
492
697
  const fieldPart = name.substring(lastDot + 1);
@@ -507,6 +712,155 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
507
712
  }
508
713
  throw new Error(`Line ${lineNum}: Invalid with declaration: ${line}`);
509
714
  }
715
+ // ── Define inlining ─────────────────────────────────────────────────────────
716
+ /**
717
+ * Inline a define invocation into a bridge's wires.
718
+ *
719
+ * Splits the define handle into separate input/output synthetic trunks,
720
+ * clones the define's internal wires with remapped references, and adds
721
+ * them to the bridge. Tool instances are remapped to avoid collisions.
722
+ *
723
+ * The executor treats synthetic trunks (module starting with `__define_`)
724
+ * as pass-through data containers — no tool function is called.
725
+ */
726
+ function inlineDefine(defineHandle, defineDef, bridgeType, bridgeField, wires, pipeHandleEntries, handleBindings, instanceCounters, nextForkSeqRef) {
727
+ const genericModule = `__define_${defineHandle}`;
728
+ const inModule = `__define_in_${defineHandle}`;
729
+ const outModule = `__define_out_${defineHandle}`;
730
+ // The define was parsed as synthetic `bridge Define.<name>`, so its
731
+ // internal refs use type="Define", field=defineName for I/O, and
732
+ // standard tool resolutions for tools.
733
+ const defType = "Define";
734
+ const defField = defineDef.name;
735
+ // ── 1. Build trunk remapping for define's tool handles ──────────────
736
+ // Replay define's instance counter to determine original instances
737
+ const defCounters = new Map();
738
+ const trunkRemap = new Map();
739
+ for (const hb of defineDef.handles) {
740
+ if (hb.kind === "input" || hb.kind === "output" || hb.kind === "context" || hb.kind === "const")
741
+ continue;
742
+ if (hb.kind === "define")
743
+ continue; // nested defines — future
744
+ const name = hb.kind === "tool" ? hb.name : "";
745
+ if (!name)
746
+ continue;
747
+ const lastDot = name.lastIndexOf(".");
748
+ let oldModule, oldType, oldField, instanceKey, bridgeKey;
749
+ if (lastDot !== -1) {
750
+ oldModule = name.substring(0, lastDot);
751
+ oldType = defType;
752
+ oldField = name.substring(lastDot + 1);
753
+ instanceKey = `${oldModule}:${oldField}`;
754
+ bridgeKey = instanceKey;
755
+ }
756
+ else {
757
+ oldModule = SELF_MODULE;
758
+ oldType = "Tools";
759
+ oldField = name;
760
+ instanceKey = `Tools:${name}`;
761
+ bridgeKey = instanceKey;
762
+ }
763
+ // Old instance (from define's isolated counter)
764
+ const oldInstance = (defCounters.get(instanceKey) ?? 0) + 1;
765
+ defCounters.set(instanceKey, oldInstance);
766
+ // New instance (from bridge's counter)
767
+ const newInstance = (instanceCounters.get(bridgeKey) ?? 0) + 1;
768
+ instanceCounters.set(bridgeKey, newInstance);
769
+ const oldKey = `${oldModule}:${oldType}:${oldField}:${oldInstance}`;
770
+ trunkRemap.set(oldKey, { module: oldModule, type: oldType, field: oldField, instance: newInstance });
771
+ // Add internal tool handle to bridge's handle bindings (namespaced)
772
+ handleBindings.push({ handle: `${defineHandle}$${hb.handle}`, kind: "tool", name });
773
+ }
774
+ // ── 2. Remap bridge wires involving the define handle ───────────────
775
+ for (const wire of wires) {
776
+ if ("from" in wire) {
777
+ if (wire.to.module === genericModule) {
778
+ wire.to = { ...wire.to, module: inModule };
779
+ }
780
+ if (wire.from.module === genericModule) {
781
+ wire.from = { ...wire.from, module: outModule };
782
+ }
783
+ if (wire.fallbackRef?.module === genericModule) {
784
+ wire.fallbackRef = { ...wire.fallbackRef, module: outModule };
785
+ }
786
+ }
787
+ if ("value" in wire && wire.to.module === genericModule) {
788
+ wire.to = { ...wire.to, module: inModule };
789
+ }
790
+ }
791
+ // ── 3. Clone, remap, and add define's wires ────────────────────────
792
+ // Compute fork instance offset (define's fork instances start at 100000,
793
+ // bridge's may overlap — offset them to avoid collision)
794
+ const forkOffset = nextForkSeqRef.value;
795
+ let maxDefForkSeq = 0;
796
+ function remapRef(ref, side) {
797
+ // Define I/O trunk → split into input/output synthetic trunks
798
+ if (ref.module === SELF_MODULE && ref.type === defType && ref.field === defField) {
799
+ const targetModule = side === "from" ? inModule : outModule;
800
+ return { ...ref, module: targetModule, type: bridgeType, field: bridgeField };
801
+ }
802
+ // Tool trunk → remap instance
803
+ const key = `${ref.module}:${ref.type}:${ref.field}:${ref.instance ?? ""}`;
804
+ const newTrunk = trunkRemap.get(key);
805
+ if (newTrunk) {
806
+ return { ...ref, module: newTrunk.module, type: newTrunk.type, field: newTrunk.field, instance: newTrunk.instance };
807
+ }
808
+ // Fork instance → offset (fork instances are >= 100000)
809
+ if (ref.instance != null && ref.instance >= 100000) {
810
+ const defSeq = ref.instance - 100000;
811
+ if (defSeq + 1 > maxDefForkSeq)
812
+ maxDefForkSeq = defSeq + 1;
813
+ return { ...ref, instance: ref.instance + forkOffset };
814
+ }
815
+ return ref;
816
+ }
817
+ for (const wire of defineDef.wires) {
818
+ const cloned = JSON.parse(JSON.stringify(wire));
819
+ if ("from" in cloned) {
820
+ cloned.from = remapRef(cloned.from, "from");
821
+ cloned.to = remapRef(cloned.to, "to");
822
+ if (cloned.fallbackRef) {
823
+ cloned.fallbackRef = remapRef(cloned.fallbackRef, "from");
824
+ }
825
+ }
826
+ else {
827
+ // Constant wire
828
+ cloned.to = remapRef(cloned.to, "to");
829
+ }
830
+ wires.push(cloned);
831
+ }
832
+ // Advance bridge's fork counter past define's forks
833
+ nextForkSeqRef.value += maxDefForkSeq;
834
+ // ── 4. Remap and merge pipe handles ─────────────────────────────────
835
+ if (defineDef.pipeHandles) {
836
+ for (const ph of defineDef.pipeHandles) {
837
+ const parts = ph.key.split(":");
838
+ // key format: "module:type:field:instance"
839
+ const phInstance = parseInt(parts[parts.length - 1]);
840
+ let newKey = ph.key;
841
+ if (phInstance >= 100000) {
842
+ const newInst = phInstance + forkOffset;
843
+ parts[parts.length - 1] = String(newInst);
844
+ newKey = parts.join(":");
845
+ }
846
+ // Remap baseTrunk
847
+ const bt = ph.baseTrunk;
848
+ const btKey = `${bt.module}:${defType}:${bt.field}:${bt.instance ?? ""}`;
849
+ const newBt = trunkRemap.get(btKey);
850
+ // Also try with Tools type for simple names
851
+ const btKey2 = `${bt.module}:Tools:${bt.field}:${bt.instance ?? ""}`;
852
+ const newBt2 = trunkRemap.get(btKey2);
853
+ const resolvedBt = newBt ?? newBt2;
854
+ pipeHandleEntries.push({
855
+ key: newKey,
856
+ handle: `${defineHandle}$${ph.handle}`,
857
+ baseTrunk: resolvedBt
858
+ ? { module: resolvedBt.module, type: resolvedBt.type, field: resolvedBt.field, instance: resolvedBt.instance }
859
+ : ph.baseTrunk,
860
+ });
861
+ }
862
+ }
863
+ }
510
864
  /**
511
865
  * Resolve an address string into a structured NodeRef.
512
866
  *
@@ -516,7 +870,7 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
516
870
  * 3. Prefix matches a declared handle → resolve via handle binding
517
871
  * 4. Otherwise → nested output path (e.g., topPick.address)
518
872
  */
519
- function resolveAddress(address, handles, bridgeType, bridgeField) {
873
+ function resolveAddress(address, handles, bridgeType, bridgeField, lineNum) {
520
874
  const dotIndex = address.indexOf(".");
521
875
  if (dotIndex === -1) {
522
876
  // Whole address is a declared handle → resolve to its root (path: [])
@@ -532,13 +886,9 @@ function resolveAddress(address, handles, bridgeType, bridgeField) {
532
886
  ref.instance = resolution.instance;
533
887
  return ref;
534
888
  }
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
- };
889
+ // Strict scoping: every reference must go through a declared handle.
890
+ throw new Error(`${lineNum != null ? `Line ${lineNum}: ` : ""}Undeclared reference "${address}". ` +
891
+ `Add 'with output as o' for output fields, or 'with ${address}' for a tool.`);
542
892
  }
543
893
  const prefix = address.substring(0, dotIndex);
544
894
  const rest = address.substring(dotIndex + 1);
@@ -557,23 +907,9 @@ function resolveAddress(address, handles, bridgeType, bridgeField) {
557
907
  }
558
908
  return ref;
559
909
  }
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
- };
910
+ // Strict scoping: prefix must be a known handle.
911
+ throw new Error(`${lineNum != null ? `Line ${lineNum}: ` : ""}Undeclared handle "${prefix}". ` +
912
+ `Add 'with ${prefix}' or 'with ${prefix} as ${prefix}' to the bridge header.`);
577
913
  }
578
914
  // ── Const block parser ──────────────────────────────────────────────────────
579
915
  /**
@@ -604,6 +940,7 @@ function parseConstLines(block, lineOffset) {
604
940
  throw new Error(`Line ${ln(i)}: Expected const declaration, got: ${line}`);
605
941
  }
606
942
  const name = constMatch[1];
943
+ assertNotReserved(name, ln(i), "const name");
607
944
  let valuePart = constMatch[2].trim();
608
945
  // Multi-line: if value starts with { or [ and isn't balanced, read more lines
609
946
  if (/^[{[]/.test(valuePart)) {
@@ -642,38 +979,92 @@ function parseConstLines(block, lineOffset) {
642
979
  }
643
980
  return results;
644
981
  }
645
- // ── Tool block parser ───────────────────────────────────────────────────────
982
+ // ── Define block parser ─────────────────────────────────────────────────────
646
983
  /**
647
- * Parse a `tool` or `extend` block into a ToolDef instruction.
984
+ * Parse a `define` block into a DefineDef instruction.
648
985
  *
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
986
+ * Delegates to parseBridgeBlock with a synthetic `bridge Define.<name>` header,
987
+ * then converts the resulting Bridge to a DefineDef template.
654
988
  *
655
- * Legacy format (child tool with extends):
656
- * tool hereapi.geocode extends hereapi
657
- * method = GET
658
- * path = /geocode
989
+ * Example:
990
+ * define secureProfile {
991
+ * with userApi as api
992
+ * with input as i
993
+ * with output as o
994
+ * api.id <- i.userId
995
+ * o.name <- api.login
996
+ * }
997
+ */
998
+ function parseDefineBlock(block, lineOffset) {
999
+ const rawLines = block.split("\n");
1000
+ // Find the define header line
1001
+ let headerIdx = -1;
1002
+ let defineName = "";
1003
+ for (let i = 0; i < rawLines.length; i++) {
1004
+ const line = rawLines[i].trim();
1005
+ if (!line || line.startsWith("#"))
1006
+ continue;
1007
+ const m = line.match(/^define\s+(\w+)\s*\{?\s*$/i);
1008
+ if (!m) {
1009
+ throw new Error(`Line ${lineOffset + i + 1}: Expected define declaration: define <name> {. Got: ${line}`);
1010
+ }
1011
+ defineName = m[1];
1012
+ assertNotReserved(defineName, lineOffset + i + 1, "define name");
1013
+ headerIdx = i;
1014
+ break;
1015
+ }
1016
+ if (!defineName) {
1017
+ throw new Error(`Line ${lineOffset + 1}: Missing define declaration`);
1018
+ }
1019
+ // Validate braces
1020
+ const kw = rawLines[headerIdx].trim();
1021
+ if (!kw.endsWith("{")) {
1022
+ throw new Error(`Line ${lineOffset + headerIdx + 1}: define block must use braces: define ${defineName} {`);
1023
+ }
1024
+ const hasClose = rawLines.some((l) => l.trimEnd() === "}");
1025
+ if (!hasClose) {
1026
+ throw new Error(`Line ${lineOffset + headerIdx + 1}: define block missing closing }`);
1027
+ }
1028
+ // Rewrite header to a synthetic bridge: `bridge Define.<name> {`
1029
+ const syntheticLines = [...rawLines];
1030
+ syntheticLines[headerIdx] = rawLines[headerIdx]
1031
+ .replace(/^(\s*)define\s+\w+/i, `$1bridge Define.${defineName}`);
1032
+ const syntheticBlock = syntheticLines.join("\n");
1033
+ const results = parseBridgeBlock(syntheticBlock, lineOffset);
1034
+ const bridge = results[0];
1035
+ return {
1036
+ kind: "define",
1037
+ name: defineName,
1038
+ handles: bridge.handles,
1039
+ wires: bridge.wires,
1040
+ ...(bridge.arrayIterators ? { arrayIterators: bridge.arrayIterators } : {}),
1041
+ ...(bridge.pipeHandles ? { pipeHandles: bridge.pipeHandles } : {}),
1042
+ };
1043
+ }
1044
+ // ── Tool block parser ───────────────────────────────────────────────────────
1045
+ /**
1046
+ * Parse a `tool` block into a ToolDef instruction.
659
1047
  *
660
- * New format (extend):
661
- * extend httpCall as hereapi
1048
+ * Format:
1049
+ * tool hereapi from httpCall {
662
1050
  * with context
663
- * baseUrl = "https://geocode.search.hereapi.com/v1"
1051
+ * .baseUrl = "https://geocode.search.hereapi.com/v1"
1052
+ * .headers.apiKey <- context.hereapi.apiKey
1053
+ * }
664
1054
  *
665
- * extend hereapi as hereapi.geocode
666
- * method = GET
667
- * path = /geocode
1055
+ * tool hereapi.geocode from hereapi {
1056
+ * .method = GET
1057
+ * .path = /geocode
1058
+ * }
668
1059
  *
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
1060
+ * When the source matches a previously-defined tool name,
1061
+ * it's treated as inheritance (child inherits parent). Otherwise the source
671
1062
  * is treated as a function name.
672
1063
  */
673
1064
  function parseToolBlock(block, lineOffset, previousInstructions) {
674
1065
  // Validate mandatory braces for blocks that have a body (deps / wires)
675
1066
  const rawLines = block.split("\n");
676
- const keywordIdx = rawLines.findIndex((l) => /^(tool|extend)\s/i.test(l.trim()));
1067
+ const keywordIdx = rawLines.findIndex((l) => /^tool\s/i.test(l.trim()));
677
1068
  if (keywordIdx !== -1) {
678
1069
  // Check if there are non-blank, non-comment body lines after the keyword
679
1070
  const bodyLines = rawLines.slice(keywordIdx + 1).filter((l) => {
@@ -683,11 +1074,11 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
683
1074
  const kw = rawLines[keywordIdx].trim();
684
1075
  if (bodyLines.length > 0) {
685
1076
  if (!kw.endsWith("{")) {
686
- throw new Error(`Line ${lineOffset + keywordIdx + 1}: extend/tool block with body must use braces: extend Foo as bar {`);
1077
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: tool block with body must use braces: tool foo from bar {`);
687
1078
  }
688
1079
  const hasClose = rawLines.some((l) => l.trimEnd() === "}");
689
1080
  if (!hasClose) {
690
- throw new Error(`Line ${lineOffset + keywordIdx + 1}: extend/tool block missing closing }`);
1081
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: tool block missing closing }`);
691
1082
  }
692
1083
  }
693
1084
  }
@@ -696,7 +1087,7 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
696
1087
  const trimmed = l.trimEnd();
697
1088
  if (trimmed === "}")
698
1089
  return "";
699
- if (/^(tool|extend)\s/i.test(trimmed) && trimmed.endsWith("{"))
1090
+ if (/^tool\s/i.test(trimmed) && trimmed.endsWith("{"))
700
1091
  return trimmed.replace(/\s*\{\s*$/, "");
701
1092
  return trimmed;
702
1093
  });
@@ -712,31 +1103,16 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
712
1103
  const line = raw.trim();
713
1104
  if (!line || line.startsWith("#"))
714
1105
  continue;
715
- // Tool declaration: tool <name> <fn> or tool <name> extends <parent>
1106
+ // Tool declaration: tool <name> from <source>
716
1107
  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>`);
1108
+ const toolMatch = line.match(/^tool\s+(\S+)\s+from\s+(\S+)$/i);
1109
+ if (!toolMatch) {
1110
+ throw new Error(`Line ${ln(i)}: Invalid tool declaration: ${line}. Expected: tool <name> from <source>`);
736
1111
  }
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
1112
+ toolName = toolMatch[1];
1113
+ const source = toolMatch[2];
1114
+ assertNotReserved(toolName, ln(i), "tool name");
1115
+ // If source matches a previously-defined tool, it's inheritance; otherwise it's a function name
740
1116
  const isKnownTool = previousInstructions?.some((inst) => inst.kind === "tool" && inst.name === source);
741
1117
  if (isKnownTool) {
742
1118
  toolExtends = source;
@@ -800,8 +1176,8 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
800
1176
  wires.push({ kind: "onError", source: onErrorPullMatch[1] });
801
1177
  continue;
802
1178
  }
803
- // Constant wire: target = "value" or target = value (unquoted)
804
- const constantMatch = line.match(/^(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
1179
+ // Constant wire: .target = "value" or .target = value (unquoted)
1180
+ const constantMatch = line.match(/^\.(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
805
1181
  if (constantMatch) {
806
1182
  const value = constantMatch[2] ?? constantMatch[3];
807
1183
  wires.push({
@@ -811,12 +1187,16 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
811
1187
  });
812
1188
  continue;
813
1189
  }
814
- // Pull wire: target <- source
815
- const pullMatch = line.match(/^(\S+)\s*<-\s*(\S+)$/);
1190
+ // Pull wire: .target <- source
1191
+ const pullMatch = line.match(/^\.(\S+)\s*<-\s*(\S+)$/);
816
1192
  if (pullMatch) {
817
1193
  wires.push({ target: pullMatch[1], kind: "pull", source: pullMatch[2] });
818
1194
  continue;
819
1195
  }
1196
+ // Catch bare param lines without leading dot — give a helpful error
1197
+ if (/^[a-zA-Z]/.test(line)) {
1198
+ throw new Error(`Line ${ln(i)}: Tool params require a dot prefix: ".${line.split(/[\s=<]/)[0]} ...". Only 'with' and 'on error' lines are unprefixed.`);
1199
+ }
820
1200
  throw new Error(`Line ${ln(i)}: Unrecognized tool line: ${line}`);
821
1201
  }
822
1202
  if (!toolName)
@@ -862,7 +1242,8 @@ export function serializeBridge(instructions) {
862
1242
  const bridges = instructions.filter((i) => i.kind === "bridge");
863
1243
  const tools = instructions.filter((i) => i.kind === "tool");
864
1244
  const consts = instructions.filter((i) => i.kind === "const");
865
- if (bridges.length === 0 && tools.length === 0 && consts.length === 0)
1245
+ const defines = instructions.filter((i) => i.kind === "define");
1246
+ if (bridges.length === 0 && tools.length === 0 && consts.length === 0 && defines.length === 0)
866
1247
  return "";
867
1248
  const blocks = [];
868
1249
  // Group const declarations into a single block
@@ -872,21 +1253,20 @@ export function serializeBridge(instructions) {
872
1253
  for (const tool of tools) {
873
1254
  blocks.push(serializeToolBlock(tool));
874
1255
  }
1256
+ for (const def of defines) {
1257
+ blocks.push(serializeDefineBlock(def));
1258
+ }
875
1259
  for (const bridge of bridges) {
876
1260
  blocks.push(serializeBridgeBlock(bridge));
877
1261
  }
878
- return blocks.join("\n\n") + "\n";
1262
+ return `version ${BRIDGE_VERSION}\n\n` + blocks.join("\n\n") + "\n";
879
1263
  }
880
1264
  function serializeToolBlock(tool) {
881
1265
  const lines = [];
882
1266
  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
- }
1267
+ // Declaration line — use `tool <name> from <source>` format
1268
+ const source = tool.extends ?? tool.fn;
1269
+ lines.push(hasBody ? `tool ${tool.name} from ${source} {` : `tool ${tool.name} from ${source}`);
890
1270
  // Dependencies
891
1271
  for (const dep of tool.deps) {
892
1272
  if (dep.kind === "context") {
@@ -922,14 +1302,14 @@ function serializeToolBlock(tool) {
922
1302
  else if (wire.kind === "constant") {
923
1303
  // Use quoted form if value contains spaces or special chars, unquoted otherwise
924
1304
  if (/\s/.test(wire.value) || wire.value === "") {
925
- lines.push(` ${wire.target} = "${wire.value}"`);
1305
+ lines.push(` .${wire.target} = "${wire.value}"`);
926
1306
  }
927
1307
  else {
928
- lines.push(` ${wire.target} = ${wire.value}`);
1308
+ lines.push(` .${wire.target} = ${wire.value}`);
929
1309
  }
930
1310
  }
931
1311
  else {
932
- lines.push(` ${wire.target} <- ${wire.source}`);
1312
+ lines.push(` .${wire.target} <- ${wire.source}`);
933
1313
  }
934
1314
  }
935
1315
  if (hasBody)
@@ -946,7 +1326,7 @@ function serializeToolBlock(tool) {
946
1326
  * This is used to emit `?? handle.path` or `?? pipe:source` for wire
947
1327
  * `fallbackRef` values.
948
1328
  */
949
- function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle) {
1329
+ function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle, outputHandle) {
950
1330
  const refTk = ref.instance != null
951
1331
  ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}`
952
1332
  : `${ref.module}:${ref.type}:${ref.field}`;
@@ -977,13 +1357,37 @@ function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge
977
1357
  }
978
1358
  }
979
1359
  if (actualSourceRef && handleChain.length > 0) {
980
- const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, true);
1360
+ const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, outputHandle, true);
981
1361
  return `${handleChain.join(":")}:${sourceStr}`;
982
1362
  }
983
1363
  }
984
- return serializeRef(ref, bridge, handleMap, inputHandle, true);
1364
+ return serializeRef(ref, bridge, handleMap, inputHandle, outputHandle, true);
1365
+ }
1366
+ /**
1367
+ * Serialize a DefineDef into its textual form.
1368
+ *
1369
+ * Delegates to serializeBridgeBlock with a synthetic Bridge, then replaces
1370
+ * the `bridge Define.<name>` header with `define <name>`.
1371
+ */
1372
+ function serializeDefineBlock(def) {
1373
+ const syntheticBridge = {
1374
+ kind: "bridge",
1375
+ type: "Define",
1376
+ field: def.name,
1377
+ handles: def.handles,
1378
+ wires: def.wires,
1379
+ arrayIterators: def.arrayIterators,
1380
+ pipeHandles: def.pipeHandles,
1381
+ };
1382
+ const bridgeText = serializeBridgeBlock(syntheticBridge);
1383
+ // Replace "bridge Define.<name>" → "define <name>"
1384
+ return bridgeText.replace(/^bridge Define\.(\w+)/, "define $1");
985
1385
  }
986
1386
  function serializeBridgeBlock(bridge) {
1387
+ // ── Passthrough shorthand ───────────────────────────────────────────
1388
+ if (bridge.passthrough) {
1389
+ return `bridge ${bridge.type}.${bridge.field} with ${bridge.passthrough}`;
1390
+ }
987
1391
  const lines = [];
988
1392
  // ── Header ──────────────────────────────────────────────────────────
989
1393
  lines.push(`bridge ${bridge.type}.${bridge.field} {`);
@@ -1002,7 +1406,20 @@ function serializeBridgeBlock(bridge) {
1002
1406
  break;
1003
1407
  }
1004
1408
  case "input":
1005
- lines.push(` with input as ${h.handle}`);
1409
+ if (h.handle === "input") {
1410
+ lines.push(` with input`);
1411
+ }
1412
+ else {
1413
+ lines.push(` with input as ${h.handle}`);
1414
+ }
1415
+ break;
1416
+ case "output":
1417
+ if (h.handle === "output") {
1418
+ lines.push(` with output`);
1419
+ }
1420
+ else {
1421
+ lines.push(` with output as ${h.handle}`);
1422
+ }
1006
1423
  break;
1007
1424
  case "context":
1008
1425
  lines.push(` with context as ${h.handle}`);
@@ -1015,31 +1432,28 @@ function serializeBridgeBlock(bridge) {
1015
1432
  lines.push(` with const as ${h.handle}`);
1016
1433
  }
1017
1434
  break;
1435
+ case "define":
1436
+ lines.push(` with ${h.name} as ${h.handle}`);
1437
+ break;
1018
1438
  }
1019
1439
  }
1020
1440
  lines.push("");
1021
1441
  // Mark where the wire body starts — everything after this gets 2-space indent
1022
1442
  const wireBodyStart = lines.length;
1023
1443
  // ── Build handle map for reverse resolution ─────────────────────────
1024
- const { handleMap, inputHandle } = buildHandleMap(bridge);
1444
+ const { handleMap, inputHandle, outputHandle } = buildHandleMap(bridge);
1025
1445
  // ── 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
1446
  const pipeHandleTrunkKeys = new Set();
1029
1447
  for (const ph of bridge.pipeHandles ?? []) {
1030
1448
  handleMap.set(ph.key, ph.handle);
1031
1449
  pipeHandleTrunkKeys.add(ph.key);
1032
1450
  }
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.
1451
+ // ── Pipe wire detection ─────────────────────────────────────────────
1038
1452
  const refTrunkKey = (ref) => ref.instance != null
1039
1453
  ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}`
1040
1454
  : `${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:[])
1455
+ const toInMap = new Map();
1456
+ const fromOutMap = new Map();
1043
1457
  const pipeWireSet = new Set();
1044
1458
  for (const w of bridge.wires) {
1045
1459
  if (!("from" in w) || !w.pipe)
@@ -1047,70 +1461,94 @@ function serializeBridgeBlock(bridge) {
1047
1461
  const fw = w;
1048
1462
  pipeWireSet.add(w);
1049
1463
  const toTk = refTrunkKey(fw.to);
1050
- // In-wire: single-segment path targeting a known pipe fork
1051
1464
  if (fw.to.path.length === 1 && pipeHandleTrunkKeys.has(toTk)) {
1052
1465
  toInMap.set(toTk, fw);
1053
1466
  }
1054
- // Out-wire: empty path from a known pipe fork
1055
1467
  if (fw.from.path.length === 0 && pipeHandleTrunkKeys.has(refTrunkKey(fw.from))) {
1056
1468
  fromOutMap.set(refTrunkKey(fw.from), fw);
1057
1469
  }
1058
1470
  }
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);
1471
+ // ── Group element wires by array-destination field ──────────────────
1472
+ // Pull wires: from.element=true
1473
+ const elementPullWires = bridge.wires.filter((w) => "from" in w && !!w.from.element);
1474
+ // Constant wires: "value" in w && to.element=true
1475
+ const elementConstWires = bridge.wires.filter((w) => "value" in w && !!w.to.element);
1476
+ // Build grouped maps keyed by the array-destination field name (to.path[0])
1477
+ const elementPullGroups = new Map();
1478
+ const elementConstGroups = new Map();
1479
+ for (const w of elementPullWires) {
1480
+ const key = w.to.path[0];
1481
+ if (!elementPullGroups.has(key))
1482
+ elementPullGroups.set(key, []);
1483
+ elementPullGroups.get(key).push(w);
1484
+ }
1485
+ for (const w of elementConstWires) {
1486
+ const key = w.to.path[0];
1487
+ if (!elementConstGroups.has(key))
1488
+ elementConstGroups.set(key, []);
1489
+ elementConstGroups.get(key).push(w);
1069
1490
  }
1491
+ // Union of keys that have any element wire (pull or constant)
1492
+ const allElementKeys = new Set([...elementPullGroups.keys(), ...elementConstGroups.keys()]);
1493
+ // ── Exclude pipe, element-pull, and element-const wires from main loop
1494
+ const regularWires = bridge.wires.filter((w) => !pipeWireSet.has(w) &&
1495
+ (!("from" in w) || !w.from.element) &&
1496
+ (!("value" in w) || !w.to.element));
1070
1497
  const serializedArrays = new Set();
1498
+ // ── Helper: serialize a reference (forward outputHandle) ─────────────
1499
+ const sRef = (ref, isFrom) => serializeRef(ref, bridge, handleMap, inputHandle, outputHandle, isFrom);
1500
+ const sPipeOrRef = (ref) => serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle, outputHandle);
1071
1501
  for (const w of regularWires) {
1072
1502
  // Constant wire
1073
1503
  if ("value" in w) {
1074
- const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false);
1504
+ const toStr = sRef(w.to, false);
1075
1505
  lines.push(`${toStr} = "${w.value}"`);
1076
1506
  continue;
1077
1507
  }
1078
- // Array mapping
1508
+ // Array mapping — emit brace-delimited element block
1079
1509
  const arrayKey = w.to.path.length === 1 ? w.to.path[0] : null;
1080
- if (arrayKey &&
1081
- elementGroups.has(arrayKey) &&
1082
- !serializedArrays.has(arrayKey)) {
1510
+ if (arrayKey && allElementKeys.has(arrayKey) && !serializedArrays.has(arrayKey)) {
1083
1511
  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);
1512
+ const iterName = bridge.arrayIterators?.[arrayKey] ?? "item";
1513
+ const fromStr = sRef(w.from, true) + "[]";
1514
+ const toStr = sRef(w.to, false);
1515
+ lines.push(`${toStr} <- ${fromStr} as ${iterName} {`);
1516
+ // Element constant wires (e.g. .provider = "RENFE")
1517
+ for (const ew of elementConstGroups.get(arrayKey) ?? []) {
1518
+ const fieldPath = ew.to.path.slice(1); // strip arrayKey prefix
1519
+ const elemTo = "." + serPath(fieldPath);
1520
+ lines.push(` ${elemTo} = "${ew.value}"`);
1521
+ }
1522
+ // Element pull wires (e.g. .name <- iter.title)
1523
+ for (const ew of elementPullGroups.get(arrayKey) ?? []) {
1524
+ const fromPart = ew.from.element
1525
+ ? iterName + "." + serPath(ew.from.path)
1526
+ : sRef(ew.from, true);
1089
1527
  const elemTo = "." + serPath(ew.to.path.slice(1));
1090
- lines.push(` ${elemTo} <- ${elemFrom}`);
1528
+ // Handle fallbacks on element pull wires
1529
+ const nfb = "nullFallback" in ew && ew.nullFallback ? ` || ${ew.nullFallback}` : "";
1530
+ const errf = "fallbackRef" in ew && ew.fallbackRef
1531
+ ? ` ?? ${sPipeOrRef(ew.fallbackRef)}`
1532
+ : "fallback" in ew && ew.fallback ? ` ?? ${ew.fallback}` : "";
1533
+ lines.push(` ${elemTo} <- ${fromPart}${nfb}${errf}`);
1091
1534
  }
1535
+ lines.push(`}`);
1092
1536
  continue;
1093
1537
  }
1094
1538
  // Regular wire
1095
- const fromStr = serializeRef(w.from, bridge, handleMap, inputHandle, true);
1096
- const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false);
1539
+ const fromStr = sRef(w.from, true);
1540
+ const toStr = sRef(w.to, false);
1097
1541
  const arrow = w.force ? "<-!" : "<-";
1098
1542
  const nfb = w.nullFallback ? ` || ${w.nullFallback}` : "";
1099
1543
  const errf = w.fallbackRef
1100
- ? ` ?? ${serializePipeOrRef(w.fallbackRef, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle)}`
1544
+ ? ` ?? ${sPipeOrRef(w.fallbackRef)}`
1101
1545
  : w.fallback ? ` ?? ${w.fallback}` : "";
1102
1546
  lines.push(`${toStr} ${arrow} ${fromStr}${nfb}${errf}`);
1103
1547
  }
1104
1548
  // ── 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
1549
  for (const [tk, outWire] of fromOutMap.entries()) {
1110
- // Non-terminal: this fork's result feeds another fork's input field
1111
1550
  if (pipeHandleTrunkKeys.has(refTrunkKey(outWire.to)))
1112
1551
  continue;
1113
- // Follow chain backward to collect handle names (outermost-first)
1114
1552
  const handleChain = [];
1115
1553
  let currentTk = tk;
1116
1554
  let actualSourceRef = null;
@@ -1119,18 +1557,15 @@ function serializeBridgeBlock(bridge) {
1119
1557
  const handleName = handleMap.get(currentTk);
1120
1558
  if (!handleName)
1121
1559
  break;
1122
- // Token: "handle" when field is "in" (default), otherwise "handle.field"
1123
1560
  const inWire = toInMap.get(currentTk);
1124
1561
  const fieldName = inWire?.to.path[0] ?? "in";
1125
1562
  const token = fieldName === "in" ? handleName : `${handleName}.${fieldName}`;
1126
1563
  handleChain.push(token);
1127
- serializedPipeTrunks.add(currentTk);
1128
1564
  if (inWire?.force)
1129
1565
  chainForced = true;
1130
1566
  if (!inWire)
1131
1567
  break;
1132
1568
  const fromTk = refTrunkKey(inWire.from);
1133
- // Inner source is another pipe fork root (empty path) → continue chain
1134
1569
  if (inWire.from.path.length === 0 && pipeHandleTrunkKeys.has(fromTk)) {
1135
1570
  currentTk = fromTk;
1136
1571
  }
@@ -1140,12 +1575,12 @@ function serializeBridgeBlock(bridge) {
1140
1575
  }
1141
1576
  }
1142
1577
  if (actualSourceRef && handleChain.length > 0) {
1143
- const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, true);
1144
- const destStr = serializeRef(outWire.to, bridge, handleMap, inputHandle, false);
1578
+ const sourceStr = sRef(actualSourceRef, true);
1579
+ const destStr = sRef(outWire.to, false);
1145
1580
  const arrow = chainForced ? "<-!" : "<-";
1146
1581
  const nfb = outWire.nullFallback ? ` || ${outWire.nullFallback}` : "";
1147
1582
  const errf = outWire.fallbackRef
1148
- ? ` ?? ${serializePipeOrRef(outWire.fallbackRef, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle)}`
1583
+ ? ` ?? ${sPipeOrRef(outWire.fallbackRef)}`
1149
1584
  : outWire.fallback ? ` ?? ${outWire.fallback}` : "";
1150
1585
  lines.push(`${destStr} ${arrow} ${handleChain.join(":")}:${sourceStr}${nfb}${errf}`);
1151
1586
  }
@@ -1165,6 +1600,7 @@ function buildHandleMap(bridge) {
1165
1600
  const handleMap = new Map();
1166
1601
  const instanceCounters = new Map();
1167
1602
  let inputHandle;
1603
+ let outputHandle;
1168
1604
  for (const h of bridge.handles) {
1169
1605
  switch (h.kind) {
1170
1606
  case "tool": {
@@ -1190,19 +1626,27 @@ function buildHandleMap(bridge) {
1190
1626
  case "input":
1191
1627
  inputHandle = h.handle;
1192
1628
  break;
1629
+ case "output":
1630
+ outputHandle = h.handle;
1631
+ break;
1193
1632
  case "context":
1194
1633
  handleMap.set(`${SELF_MODULE}:Context:context`, h.handle);
1195
1634
  break;
1196
1635
  case "const":
1197
1636
  handleMap.set(`${SELF_MODULE}:Const:const`, h.handle);
1198
1637
  break;
1638
+ case "define":
1639
+ handleMap.set(`__define_${h.handle}:${bridge.type}:${bridge.field}`, h.handle);
1640
+ break;
1199
1641
  }
1200
1642
  }
1201
- return { handleMap, inputHandle };
1643
+ return { handleMap, inputHandle, outputHandle };
1202
1644
  }
1203
- function serializeRef(ref, bridge, handleMap, inputHandle, isFrom) {
1645
+ function serializeRef(ref, bridge, handleMap, inputHandle, outputHandle, isFrom) {
1204
1646
  if (ref.element) {
1205
- return "." + serPath(ref.path);
1647
+ // Element refs are only serialized inside brace blocks (using the iterator name).
1648
+ // This path should not be reached in normal serialization.
1649
+ return "item." + serPath(ref.path);
1206
1650
  }
1207
1651
  // Bridge's own trunk (no instance, no element)
1208
1652
  const isBridgeTrunk = ref.module === SELF_MODULE &&
@@ -1213,16 +1657,17 @@ function serializeRef(ref, bridge, handleMap, inputHandle, isFrom) {
1213
1657
  if (isBridgeTrunk) {
1214
1658
  if (isFrom && inputHandle) {
1215
1659
  // From side: use input handle (data comes from args)
1216
- return inputHandle + "." + serPath(ref.path);
1660
+ return ref.path.length > 0
1661
+ ? inputHandle + "." + serPath(ref.path)
1662
+ : inputHandle;
1217
1663
  }
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);
1664
+ if (!isFrom && outputHandle) {
1665
+ // To side: use output handle
1666
+ return ref.path.length > 0
1667
+ ? outputHandle + "." + serPath(ref.path)
1668
+ : outputHandle;
1224
1669
  }
1225
- // Bare path (e.g. top-level scalar output, or no-path for the bridge trunk itself)
1670
+ // Fallback (no handle declared legacy/serializer-only path)
1226
1671
  return serPath(ref.path);
1227
1672
  }
1228
1673
  // Lookup by trunk key
@@ -1231,7 +1676,6 @@ function serializeRef(ref, bridge, handleMap, inputHandle, isFrom) {
1231
1676
  : `${ref.module}:${ref.type}:${ref.field}`;
1232
1677
  const handle = handleMap.get(trunkStr);
1233
1678
  if (handle) {
1234
- // Empty path — just the handle name (e.g. pipe result = tool root)
1235
1679
  if (ref.path.length === 0)
1236
1680
  return handle;
1237
1681
  return handle + "." + serPath(ref.path);