@stackables/bridge 1.1.1 → 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,8 +111,52 @@ 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) {
88
- const lines = block.split("\n").map((l) => l.trimEnd());
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
+ }
138
+ // Validate mandatory braces: `bridge Foo.bar {` ... `}`
139
+ const rawLines = block.split("\n");
140
+ const keywordIdx = rawLines.findIndex((l) => /^bridge\s/i.test(l.trim()));
141
+ if (keywordIdx !== -1) {
142
+ const kw = rawLines[keywordIdx].trim();
143
+ if (!kw.endsWith("{")) {
144
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: bridge block must use braces: bridge Type.field {`);
145
+ }
146
+ const hasClose = rawLines.some((l) => l.trimEnd() === "}");
147
+ if (!hasClose) {
148
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: bridge block missing closing }`);
149
+ }
150
+ }
151
+ // Strip braces for internal parsing
152
+ const lines = rawLines.map((l) => {
153
+ const trimmed = l.trimEnd();
154
+ if (trimmed === "}")
155
+ return "";
156
+ if (/^bridge\s/i.test(trimmed) && trimmed.endsWith("{"))
157
+ return trimmed.replace(/\s*\{\s*$/, "");
158
+ return trimmed;
159
+ });
89
160
  const instructions = [];
90
161
  /** 1-based global line number for error messages */
91
162
  const ln = (i) => lineOffset + i + 1;
@@ -113,7 +184,7 @@ function parseBridgeBlock(block, lineOffset) {
113
184
  if (!bridgeType) {
114
185
  throw new Error(`Line ${ln(i)}: "with" declaration must come after "bridge" declaration`);
115
186
  }
116
- parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBindings, instanceCounters, instructions, ln(i));
187
+ parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBindings, instanceCounters, previousInstructions ?? [], ln(i));
117
188
  continue;
118
189
  }
119
190
  // First non-header line — body starts here
@@ -126,6 +197,8 @@ function parseBridgeBlock(block, lineOffset) {
126
197
  // ── Parse wire lines ────────────────────────────────────────────────
127
198
  const wires = [];
128
199
  let currentArrayToPath = null;
200
+ let currentIterHandle = null;
201
+ const arrayIterators = {};
129
202
  /** Monotonically-increasing index; combined with a high base to produce
130
203
  * fork instances that can never collide with regular handle instances. */
131
204
  let nextForkSeq = 0;
@@ -146,7 +219,7 @@ function parseBridgeBlock(block, lineOffset) {
146
219
  function buildSourceExpr(sourceStr, lineNum, forceOnOutermost) {
147
220
  const parts = sourceStr.split(":");
148
221
  if (parts.length === 1) {
149
- return resolveAddress(sourceStr, handleRes, bridgeType, bridgeField);
222
+ return resolveAddress(sourceStr, handleRes, bridgeType, bridgeField, lineNum);
150
223
  }
151
224
  // Pipe chain
152
225
  const actualSource = parts[parts.length - 1];
@@ -163,7 +236,7 @@ function parseBridgeBlock(block, lineOffset) {
163
236
  throw new Error(`Line ${lineNum}: Undeclared handle in pipe: "${handleName}". Add 'with <tool> as ${handleName}' to the bridge header.`);
164
237
  }
165
238
  }
166
- let prevOutRef = resolveAddress(actualSource, handleRes, bridgeType, bridgeField);
239
+ let prevOutRef = resolveAddress(actualSource, handleRes, bridgeType, bridgeField, lineNum);
167
240
  const reversedTokens = [...tokenChain].reverse();
168
241
  for (let idx = 0; idx < reversedTokens.length; idx++) {
169
242
  const tok = reversedTokens[idx];
@@ -184,72 +257,165 @@ function parseBridgeBlock(block, lineOffset) {
184
257
  }
185
258
  return prevOutRef; // fork-root ref
186
259
  }
260
+ // ── Whether we are inside an element-mapping brace block
261
+ let inElementBlock = false;
187
262
  for (let i = bodyStartIndex; i < lines.length; i++) {
188
263
  const raw = lines[i];
189
264
  const line = raw.trim();
190
265
  if (!line || line.startsWith("#")) {
191
266
  continue;
192
267
  }
193
- // Element mapping: indented line starting with "."
194
- const indent = raw.search(/\S/);
195
- if (indent >= 2 && line.startsWith(".") && currentArrayToPath) {
196
- const match = line.match(/^\.(\S+)\s*<-\s*\.(\S+)$/);
197
- if (!match)
198
- throw new Error(`Line ${ln(i)}: Invalid element mapping: ${line}`);
199
- const toPath = [...currentArrayToPath, ...parsePath(match[1])];
200
- const fromPath = parsePath(match[2]);
201
- wires.push({
202
- from: {
203
- module: SELF_MODULE,
204
- type: bridgeType,
205
- field: bridgeField,
206
- element: true,
207
- path: fromPath,
208
- },
209
- to: {
210
- module: SELF_MODULE,
211
- type: bridgeType,
212
- field: bridgeField,
213
- path: toPath,
214
- },
215
- });
216
- 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}`);
217
389
  }
218
- // End of array mapping block
219
- currentArrayToPath = null;
220
390
  // Constant wire: target = "value" or target = value (unquoted)
221
391
  const constantMatch = line.match(/^(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
222
392
  if (constantMatch) {
223
393
  const [, targetStr, quotedValue, unquotedValue] = constantMatch;
224
394
  const value = quotedValue ?? unquotedValue;
225
- const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
395
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
226
396
  wires.push({ value, to: toRef });
227
397
  continue;
228
398
  }
229
399
  // ── Wire: target <- A [|| B [|| C]] [|| "nullLiteral"] [?? errorSrc|"errorLiteral"]
230
- //
231
- // A, B, C are source expressions (handle.path or pipe|chain|src).
232
- // `||` separates null-coalescing alternatives — evaluated left to right;
233
- // the last alternative may be a JSON literal (→ nullFallback).
234
- // `??` is the error fallback — fires when ALL sources throw.
235
- // Can be a JSON literal (→ fallback) or a source expression (→ fallbackRef).
236
- //
237
- // Each `||` source becomes a separate wire targeting the same field.
238
- // The last source wire carries nullFallback + error-fallback attributes.
239
- // Desugars transparently: serializer emits back as multiple wire lines.
240
400
  const arrowMatch = line.match(/^(\S+)\s*<-(!?)\s*(.+)$/);
241
401
  if (arrowMatch) {
242
402
  const [, targetStr, forceFlag, rhs] = arrowMatch;
243
403
  const force = forceFlag === "!";
244
404
  const rhsTrimmed = rhs.trim();
245
- // ── Array mapping: target[] <- source[] (no || or ?? here)
246
- if (targetStr.endsWith("[]") && /^\S+\[\]$/.test(rhsTrimmed)) {
247
- const toClean = targetStr.slice(0, -2);
248
- const fromClean = rhsTrimmed.slice(0, -2);
249
- const fromRef = resolveAddress(fromClean, handleRes, bridgeType, bridgeField);
250
- 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));
251
414
  wires.push({ from: fromRef, to: toRef });
252
415
  currentArrayToPath = toRef.path;
416
+ currentIterHandle = iterHandle;
417
+ arrayIterators[toRef.path[0]] = iterHandle;
418
+ inElementBlock = true;
253
419
  continue;
254
420
  }
255
421
  // ── Strip the ?? tail (last " ?? " wins in case source contains " ?? ")
@@ -279,38 +445,27 @@ function parseBridgeBlock(block, lineOffset) {
279
445
  if (sourceParts.length === 0) {
280
446
  throw new Error(`Line ${ln(i)}: Wire has no source expression: ${line}`);
281
447
  }
282
- // ── Parse the ?? source/pipe into a fallbackRef (if needed)
283
- // Wires added by buildSourceExpr for the fallback fork are deferred and
284
- // pushed AFTER the source wires so that wire order is stable across
285
- // parse → serialize → re-parse cycles.
286
448
  let fallbackRef;
287
449
  let fallbackInternalWires = [];
288
450
  if (fallbackRefStr) {
289
451
  const preLen = wires.length;
290
452
  fallbackRef = buildSourceExpr(fallbackRefStr, ln(i), false);
291
- // Splice out internal wires buildSourceExpr just added; push after sources.
292
453
  fallbackInternalWires = wires.splice(preLen);
293
454
  }
294
- // ── Build wires for each coalesce part
295
- const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
455
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
296
456
  for (let ci = 0; ci < sourceParts.length; ci++) {
297
457
  const isFirst = ci === 0;
298
458
  const isLast = ci === sourceParts.length - 1;
299
459
  const srcStr = sourceParts[ci];
300
- // Parse source expression; for pipe chains buildSourceExpr pushes
301
- // intermediate wires and returns the fork-root ref.
302
460
  const termRef = buildSourceExpr(srcStr, ln(i), force && isFirst);
303
461
  const isPipeFork = termRef.instance != null && termRef.path.length === 0
304
462
  && srcStr.includes(":");
305
- // attrs carried only on the LAST wire of the coalesce chain
306
463
  const lastAttrs = isLast ? {
307
464
  ...(nullFallback ? { nullFallback } : {}),
308
465
  ...(fallback ? { fallback } : {}),
309
466
  ...(fallbackRef ? { fallbackRef } : {}),
310
467
  } : {};
311
468
  if (isPipeFork) {
312
- // Terminal pipe wire: fork-root → target (force only on outermost
313
- // intermediate wire, already set inside buildSourceExpr)
314
469
  wires.push({ from: termRef, to: toRef, pipe: true, ...lastAttrs });
315
470
  }
316
471
  else {
@@ -322,19 +477,29 @@ function parseBridgeBlock(block, lineOffset) {
322
477
  });
323
478
  }
324
479
  }
325
- // Push fallbackRef internal wires after all source wires (stable round-trip
326
- // order: same position whether parsed inline or from serialized separate lines)
327
480
  wires.push(...fallbackInternalWires);
328
481
  continue;
329
482
  }
330
483
  throw new Error(`Line ${ln(i)}: Unrecognized line: ${line}`);
331
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
+ }
332
496
  instructions.unshift({
333
497
  kind: "bridge",
334
498
  type: bridgeType,
335
499
  field: bridgeField,
336
500
  handles: handleBindings,
337
501
  wires,
502
+ arrayIterators: Object.keys(arrayIterators).length > 0 ? arrayIterators : undefined,
338
503
  pipeHandles: pipeHandleEntries.length > 0 ? pipeHandleEntries : undefined,
339
504
  });
340
505
  return instructions;
@@ -369,6 +534,45 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
369
534
  });
370
535
  return;
371
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
+ }
372
576
  // with context as <handle>
373
577
  match = line.match(/^with\s+context\s+as\s+(\w+)$/i);
374
578
  if (match) {
@@ -421,13 +625,24 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
421
625
  });
422
626
  return;
423
627
  }
424
- // with <name> as <handle> — tool reference (covers dotted names like hereapi.geocode)
628
+ // with <name> as <handle> — check for define invocation first
425
629
  match = line.match(/^with\s+(\S+)\s+as\s+(\w+)$/i);
426
630
  if (match) {
427
631
  const name = match[1];
428
632
  const handle = match[2];
429
633
  checkDuplicate(handle);
430
- // 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
+ }
431
646
  const lastDot = name.lastIndexOf(".");
432
647
  if (lastDot !== -1) {
433
648
  const modulePart = name.substring(0, lastDot);
@@ -466,6 +681,17 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
466
681
  const lastDot = name.lastIndexOf(".");
467
682
  const handle = lastDot !== -1 ? name.substring(lastDot + 1) : name;
468
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
+ }
469
695
  if (lastDot !== -1) {
470
696
  const modulePart = name.substring(0, lastDot);
471
697
  const fieldPart = name.substring(lastDot + 1);
@@ -486,6 +712,155 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
486
712
  }
487
713
  throw new Error(`Line ${lineNum}: Invalid with declaration: ${line}`);
488
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
+ }
489
864
  /**
490
865
  * Resolve an address string into a structured NodeRef.
491
866
  *
@@ -495,7 +870,7 @@ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBi
495
870
  * 3. Prefix matches a declared handle → resolve via handle binding
496
871
  * 4. Otherwise → nested output path (e.g., topPick.address)
497
872
  */
498
- function resolveAddress(address, handles, bridgeType, bridgeField) {
873
+ function resolveAddress(address, handles, bridgeType, bridgeField, lineNum) {
499
874
  const dotIndex = address.indexOf(".");
500
875
  if (dotIndex === -1) {
501
876
  // Whole address is a declared handle → resolve to its root (path: [])
@@ -511,13 +886,9 @@ function resolveAddress(address, handles, bridgeType, bridgeField) {
511
886
  ref.instance = resolution.instance;
512
887
  return ref;
513
888
  }
514
- // No dot, not a handle output reference on bridge trunk
515
- return {
516
- module: SELF_MODULE,
517
- type: bridgeType,
518
- field: bridgeField,
519
- path: parsePath(address),
520
- };
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.`);
521
892
  }
522
893
  const prefix = address.substring(0, dotIndex);
523
894
  const rest = address.substring(dotIndex + 1);
@@ -536,23 +907,9 @@ function resolveAddress(address, handles, bridgeType, bridgeField) {
536
907
  }
537
908
  return ref;
538
909
  }
539
- // No handle match nested local path (e.g., topPick.address)
540
- // UNLESS the prefix IS the bridge field itself (e.g., doubled.a when bridge is Query.doubled)
541
- // in that case strip the prefix so path = ["a"], matching the GraphQL resolver path.
542
- if (prefix === bridgeField) {
543
- return {
544
- module: SELF_MODULE,
545
- type: bridgeType,
546
- field: bridgeField,
547
- path: pathParts,
548
- };
549
- }
550
- return {
551
- module: SELF_MODULE,
552
- type: bridgeType,
553
- field: bridgeField,
554
- path: [prefix, ...pathParts],
555
- };
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.`);
556
913
  }
557
914
  // ── Const block parser ──────────────────────────────────────────────────────
558
915
  /**
@@ -583,6 +940,7 @@ function parseConstLines(block, lineOffset) {
583
940
  throw new Error(`Line ${ln(i)}: Expected const declaration, got: ${line}`);
584
941
  }
585
942
  const name = constMatch[1];
943
+ assertNotReserved(name, ln(i), "const name");
586
944
  let valuePart = constMatch[2].trim();
587
945
  // Multi-line: if value starts with { or [ and isn't balanced, read more lines
588
946
  if (/^[{[]/.test(valuePart)) {
@@ -621,36 +979,118 @@ function parseConstLines(block, lineOffset) {
621
979
  }
622
980
  return results;
623
981
  }
624
- // ── Tool block parser ───────────────────────────────────────────────────────
982
+ // ── Define block parser ─────────────────────────────────────────────────────
625
983
  /**
626
- * Parse a `tool` or `extend` block into a ToolDef instruction.
984
+ * Parse a `define` block into a DefineDef instruction.
627
985
  *
628
- * Legacy format (root tool):
629
- * tool hereapi httpCall
630
- * with context
631
- * baseUrl = "https://geocode.search.hereapi.com/v1"
632
- * 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.
633
988
  *
634
- * Legacy format (child tool with extends):
635
- * tool hereapi.geocode extends hereapi
636
- * method = GET
637
- * 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.
638
1047
  *
639
- * New format (extend):
640
- * extend httpCall as hereapi
1048
+ * Format:
1049
+ * tool hereapi from httpCall {
641
1050
  * with context
642
- * baseUrl = "https://geocode.search.hereapi.com/v1"
1051
+ * .baseUrl = "https://geocode.search.hereapi.com/v1"
1052
+ * .headers.apiKey <- context.hereapi.apiKey
1053
+ * }
643
1054
  *
644
- * extend hereapi as hereapi.geocode
645
- * method = GET
646
- * path = /geocode
1055
+ * tool hereapi.geocode from hereapi {
1056
+ * .method = GET
1057
+ * .path = /geocode
1058
+ * }
647
1059
  *
648
- * When using `extend`, if the source matches a previously-defined tool name,
649
- * 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
650
1062
  * is treated as a function name.
651
1063
  */
652
1064
  function parseToolBlock(block, lineOffset, previousInstructions) {
653
- const lines = block.split("\n").map((l) => l.trimEnd());
1065
+ // Validate mandatory braces for blocks that have a body (deps / wires)
1066
+ const rawLines = block.split("\n");
1067
+ const keywordIdx = rawLines.findIndex((l) => /^tool\s/i.test(l.trim()));
1068
+ if (keywordIdx !== -1) {
1069
+ // Check if there are non-blank, non-comment body lines after the keyword
1070
+ const bodyLines = rawLines.slice(keywordIdx + 1).filter((l) => {
1071
+ const t = l.trim();
1072
+ return t !== "" && !t.startsWith("#") && t !== "}";
1073
+ });
1074
+ const kw = rawLines[keywordIdx].trim();
1075
+ if (bodyLines.length > 0) {
1076
+ if (!kw.endsWith("{")) {
1077
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: tool block with body must use braces: tool foo from bar {`);
1078
+ }
1079
+ const hasClose = rawLines.some((l) => l.trimEnd() === "}");
1080
+ if (!hasClose) {
1081
+ throw new Error(`Line ${lineOffset + keywordIdx + 1}: tool block missing closing }`);
1082
+ }
1083
+ }
1084
+ }
1085
+ // Strip braces for internal parsing
1086
+ const lines = rawLines.map((l) => {
1087
+ const trimmed = l.trimEnd();
1088
+ if (trimmed === "}")
1089
+ return "";
1090
+ if (/^tool\s/i.test(trimmed) && trimmed.endsWith("{"))
1091
+ return trimmed.replace(/\s*\{\s*$/, "");
1092
+ return trimmed;
1093
+ });
654
1094
  /** 1-based global line number for error messages */
655
1095
  const ln = (i) => lineOffset + i + 1;
656
1096
  let toolName = "";
@@ -663,31 +1103,16 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
663
1103
  const line = raw.trim();
664
1104
  if (!line || line.startsWith("#"))
665
1105
  continue;
666
- // Tool declaration: tool <name> <fn> or tool <name> extends <parent>
1106
+ // Tool declaration: tool <name> from <source>
667
1107
  if (/^tool\s/i.test(line)) {
668
- const extendsMatch = line.match(/^tool\s+(\S+)\s+extends\s+(\S+)$/i);
669
- if (extendsMatch) {
670
- toolName = extendsMatch[1];
671
- toolExtends = extendsMatch[2];
672
- continue;
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>`);
673
1111
  }
674
- const fnMatch = line.match(/^tool\s+(\S+)\s+(\S+)$/i);
675
- if (fnMatch) {
676
- toolName = fnMatch[1];
677
- toolFn = fnMatch[2];
678
- continue;
679
- }
680
- throw new Error(`Line ${ln(i)}: Invalid tool declaration: ${line}`);
681
- }
682
- // Extend declaration: extend <source> as <name>
683
- if (/^extend\s/i.test(line)) {
684
- const extendMatch = line.match(/^extend\s+(\S+)\s+as\s+(\S+)$/i);
685
- if (!extendMatch) {
686
- throw new Error(`Line ${ln(i)}: Invalid extend declaration: ${line}. Expected: extend <source> as <name>`);
687
- }
688
- const source = extendMatch[1];
689
- toolName = extendMatch[2];
690
- // 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
691
1116
  const isKnownTool = previousInstructions?.some((inst) => inst.kind === "tool" && inst.name === source);
692
1117
  if (isKnownTool) {
693
1118
  toolExtends = source;
@@ -751,8 +1176,8 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
751
1176
  wires.push({ kind: "onError", source: onErrorPullMatch[1] });
752
1177
  continue;
753
1178
  }
754
- // Constant wire: target = "value" or target = value (unquoted)
755
- 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+))$/);
756
1181
  if (constantMatch) {
757
1182
  const value = constantMatch[2] ?? constantMatch[3];
758
1183
  wires.push({
@@ -762,12 +1187,16 @@ function parseToolBlock(block, lineOffset, previousInstructions) {
762
1187
  });
763
1188
  continue;
764
1189
  }
765
- // Pull wire: target <- source
766
- const pullMatch = line.match(/^(\S+)\s*<-\s*(\S+)$/);
1190
+ // Pull wire: .target <- source
1191
+ const pullMatch = line.match(/^\.(\S+)\s*<-\s*(\S+)$/);
767
1192
  if (pullMatch) {
768
1193
  wires.push({ target: pullMatch[1], kind: "pull", source: pullMatch[2] });
769
1194
  continue;
770
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
+ }
771
1200
  throw new Error(`Line ${ln(i)}: Unrecognized tool line: ${line}`);
772
1201
  }
773
1202
  if (!toolName)
@@ -813,7 +1242,8 @@ export function serializeBridge(instructions) {
813
1242
  const bridges = instructions.filter((i) => i.kind === "bridge");
814
1243
  const tools = instructions.filter((i) => i.kind === "tool");
815
1244
  const consts = instructions.filter((i) => i.kind === "const");
816
- 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)
817
1247
  return "";
818
1248
  const blocks = [];
819
1249
  // Group const declarations into a single block
@@ -823,20 +1253,20 @@ export function serializeBridge(instructions) {
823
1253
  for (const tool of tools) {
824
1254
  blocks.push(serializeToolBlock(tool));
825
1255
  }
1256
+ for (const def of defines) {
1257
+ blocks.push(serializeDefineBlock(def));
1258
+ }
826
1259
  for (const bridge of bridges) {
827
1260
  blocks.push(serializeBridgeBlock(bridge));
828
1261
  }
829
- return blocks.join("\n\n---\n\n") + "\n";
1262
+ return `version ${BRIDGE_VERSION}\n\n` + blocks.join("\n\n") + "\n";
830
1263
  }
831
1264
  function serializeToolBlock(tool) {
832
1265
  const lines = [];
833
- // Declaration line use `extend` format
834
- if (tool.extends) {
835
- lines.push(`extend ${tool.extends} as ${tool.name}`);
836
- }
837
- else {
838
- lines.push(`extend ${tool.fn} as ${tool.name}`);
839
- }
1266
+ const hasBody = tool.deps.length > 0 || tool.wires.length > 0;
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}`);
840
1270
  // Dependencies
841
1271
  for (const dep of tool.deps) {
842
1272
  if (dep.kind === "context") {
@@ -872,16 +1302,18 @@ function serializeToolBlock(tool) {
872
1302
  else if (wire.kind === "constant") {
873
1303
  // Use quoted form if value contains spaces or special chars, unquoted otherwise
874
1304
  if (/\s/.test(wire.value) || wire.value === "") {
875
- lines.push(` ${wire.target} = "${wire.value}"`);
1305
+ lines.push(` .${wire.target} = "${wire.value}"`);
876
1306
  }
877
1307
  else {
878
- lines.push(` ${wire.target} = ${wire.value}`);
1308
+ lines.push(` .${wire.target} = ${wire.value}`);
879
1309
  }
880
1310
  }
881
1311
  else {
882
- lines.push(` ${wire.target} <- ${wire.source}`);
1312
+ lines.push(` .${wire.target} <- ${wire.source}`);
883
1313
  }
884
1314
  }
1315
+ if (hasBody)
1316
+ lines.push(`}`);
885
1317
  return lines.join("\n");
886
1318
  }
887
1319
  /**
@@ -894,7 +1326,7 @@ function serializeToolBlock(tool) {
894
1326
  * This is used to emit `?? handle.path` or `?? pipe:source` for wire
895
1327
  * `fallbackRef` values.
896
1328
  */
897
- function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle) {
1329
+ function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle, outputHandle) {
898
1330
  const refTk = ref.instance != null
899
1331
  ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}`
900
1332
  : `${ref.module}:${ref.type}:${ref.field}`;
@@ -925,16 +1357,40 @@ function serializePipeOrRef(ref, pipeHandleTrunkKeys, toInMap, handleMap, bridge
925
1357
  }
926
1358
  }
927
1359
  if (actualSourceRef && handleChain.length > 0) {
928
- const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, true);
1360
+ const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, outputHandle, true);
929
1361
  return `${handleChain.join(":")}:${sourceStr}`;
930
1362
  }
931
1363
  }
932
- 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");
933
1385
  }
934
1386
  function serializeBridgeBlock(bridge) {
1387
+ // ── Passthrough shorthand ───────────────────────────────────────────
1388
+ if (bridge.passthrough) {
1389
+ return `bridge ${bridge.type}.${bridge.field} with ${bridge.passthrough}`;
1390
+ }
935
1391
  const lines = [];
936
1392
  // ── Header ──────────────────────────────────────────────────────────
937
- lines.push(`bridge ${bridge.type}.${bridge.field}`);
1393
+ lines.push(`bridge ${bridge.type}.${bridge.field} {`);
938
1394
  for (const h of bridge.handles) {
939
1395
  switch (h.kind) {
940
1396
  case "tool": {
@@ -950,7 +1406,20 @@ function serializeBridgeBlock(bridge) {
950
1406
  break;
951
1407
  }
952
1408
  case "input":
953
- 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
+ }
954
1423
  break;
955
1424
  case "context":
956
1425
  lines.push(` with context as ${h.handle}`);
@@ -963,29 +1432,28 @@ function serializeBridgeBlock(bridge) {
963
1432
  lines.push(` with const as ${h.handle}`);
964
1433
  }
965
1434
  break;
1435
+ case "define":
1436
+ lines.push(` with ${h.name} as ${h.handle}`);
1437
+ break;
966
1438
  }
967
1439
  }
968
1440
  lines.push("");
1441
+ // Mark where the wire body starts — everything after this gets 2-space indent
1442
+ const wireBodyStart = lines.length;
969
1443
  // ── Build handle map for reverse resolution ─────────────────────────
970
- const { handleMap, inputHandle } = buildHandleMap(bridge);
1444
+ const { handleMap, inputHandle, outputHandle } = buildHandleMap(bridge);
971
1445
  // ── Pipe fork registry ──────────────────────────────────────────────
972
- // Extend handleMap with fork → handle-name entries and build the set of
973
- // known fork trunk keys so the wire classifiers below can use it.
974
1446
  const pipeHandleTrunkKeys = new Set();
975
1447
  for (const ph of bridge.pipeHandles ?? []) {
976
1448
  handleMap.set(ph.key, ph.handle);
977
1449
  pipeHandleTrunkKeys.add(ph.key);
978
1450
  }
979
- // ── Pipe wire detection ───────────────────────────────────────────────────────
980
- // Pipe wires are marked pipe:true. Classify them into two maps:
981
- // toInMap: forkTrunkKey → wire feeding the fork's input field
982
- // fromOutMap: forkTrunkKey → wire reading the fork's root result
983
- // Terminal out-wires (destination is NOT another fork) are chain anchors.
1451
+ // ── Pipe wire detection ─────────────────────────────────────────────
984
1452
  const refTrunkKey = (ref) => ref.instance != null
985
1453
  ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}`
986
1454
  : `${ref.module}:${ref.type}:${ref.field}`;
987
- const toInMap = new Map(); // forkTrunkKey → wire with to = fork's input field
988
- const fromOutMap = new Map(); // forkTrunkKey → wire with from = fork root (path:[])
1455
+ const toInMap = new Map();
1456
+ const fromOutMap = new Map();
989
1457
  const pipeWireSet = new Set();
990
1458
  for (const w of bridge.wires) {
991
1459
  if (!("from" in w) || !w.pipe)
@@ -993,70 +1461,94 @@ function serializeBridgeBlock(bridge) {
993
1461
  const fw = w;
994
1462
  pipeWireSet.add(w);
995
1463
  const toTk = refTrunkKey(fw.to);
996
- // In-wire: single-segment path targeting a known pipe fork
997
1464
  if (fw.to.path.length === 1 && pipeHandleTrunkKeys.has(toTk)) {
998
1465
  toInMap.set(toTk, fw);
999
1466
  }
1000
- // Out-wire: empty path from a known pipe fork
1001
1467
  if (fw.from.path.length === 0 && pipeHandleTrunkKeys.has(refTrunkKey(fw.from))) {
1002
1468
  fromOutMap.set(refTrunkKey(fw.from), fw);
1003
1469
  }
1004
1470
  }
1005
- // ── Wires ───────────────────────────────────────────────────────────
1006
- const elementWires = bridge.wires.filter((w) => "from" in w && !!w.from.element);
1007
- // Exclude pipe wires and element wires from the regular loop
1008
- const regularWires = bridge.wires.filter((w) => !pipeWireSet.has(w) && (!("from" in w) || !w.from.element));
1009
- const elementGroups = new Map();
1010
- for (const w of elementWires) {
1011
- const parent = w.to.path[0];
1012
- if (!elementGroups.has(parent))
1013
- elementGroups.set(parent, []);
1014
- 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);
1015
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);
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));
1016
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);
1017
1501
  for (const w of regularWires) {
1018
1502
  // Constant wire
1019
1503
  if ("value" in w) {
1020
- const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false);
1504
+ const toStr = sRef(w.to, false);
1021
1505
  lines.push(`${toStr} = "${w.value}"`);
1022
1506
  continue;
1023
1507
  }
1024
- // Array mapping
1508
+ // Array mapping — emit brace-delimited element block
1025
1509
  const arrayKey = w.to.path.length === 1 ? w.to.path[0] : null;
1026
- if (arrayKey &&
1027
- elementGroups.has(arrayKey) &&
1028
- !serializedArrays.has(arrayKey)) {
1510
+ if (arrayKey && allElementKeys.has(arrayKey) && !serializedArrays.has(arrayKey)) {
1029
1511
  serializedArrays.add(arrayKey);
1030
- const fromStr = serializeRef(w.from, bridge, handleMap, inputHandle, true) + "[]";
1031
- const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false) + "[]";
1032
- lines.push(`${toStr} <- ${fromStr}`);
1033
- for (const ew of elementGroups.get(arrayKey)) {
1034
- 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);
1035
1527
  const elemTo = "." + serPath(ew.to.path.slice(1));
1036
- 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}`);
1037
1534
  }
1535
+ lines.push(`}`);
1038
1536
  continue;
1039
1537
  }
1040
1538
  // Regular wire
1041
- const fromStr = serializeRef(w.from, bridge, handleMap, inputHandle, true);
1042
- const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false);
1539
+ const fromStr = sRef(w.from, true);
1540
+ const toStr = sRef(w.to, false);
1043
1541
  const arrow = w.force ? "<-!" : "<-";
1044
1542
  const nfb = w.nullFallback ? ` || ${w.nullFallback}` : "";
1045
1543
  const errf = w.fallbackRef
1046
- ? ` ?? ${serializePipeOrRef(w.fallbackRef, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle)}`
1544
+ ? ` ?? ${sPipeOrRef(w.fallbackRef)}`
1047
1545
  : w.fallback ? ` ?? ${w.fallback}` : "";
1048
1546
  lines.push(`${toStr} ${arrow} ${fromStr}${nfb}${errf}`);
1049
1547
  }
1050
1548
  // ── Pipe wires ───────────────────────────────────────────────────────
1051
- // Find terminal fromOutMap entries — their destination is NOT another
1052
- // pipe handle's .in. Follow the chain backward to reconstruct:
1053
- // dest <- h1:h2:…:source
1054
- const serializedPipeTrunks = new Set();
1055
1549
  for (const [tk, outWire] of fromOutMap.entries()) {
1056
- // Non-terminal: this fork's result feeds another fork's input field
1057
1550
  if (pipeHandleTrunkKeys.has(refTrunkKey(outWire.to)))
1058
1551
  continue;
1059
- // Follow chain backward to collect handle names (outermost-first)
1060
1552
  const handleChain = [];
1061
1553
  let currentTk = tk;
1062
1554
  let actualSourceRef = null;
@@ -1065,18 +1557,15 @@ function serializeBridgeBlock(bridge) {
1065
1557
  const handleName = handleMap.get(currentTk);
1066
1558
  if (!handleName)
1067
1559
  break;
1068
- // Token: "handle" when field is "in" (default), otherwise "handle.field"
1069
1560
  const inWire = toInMap.get(currentTk);
1070
1561
  const fieldName = inWire?.to.path[0] ?? "in";
1071
1562
  const token = fieldName === "in" ? handleName : `${handleName}.${fieldName}`;
1072
1563
  handleChain.push(token);
1073
- serializedPipeTrunks.add(currentTk);
1074
1564
  if (inWire?.force)
1075
1565
  chainForced = true;
1076
1566
  if (!inWire)
1077
1567
  break;
1078
1568
  const fromTk = refTrunkKey(inWire.from);
1079
- // Inner source is another pipe fork root (empty path) → continue chain
1080
1569
  if (inWire.from.path.length === 0 && pipeHandleTrunkKeys.has(fromTk)) {
1081
1570
  currentTk = fromTk;
1082
1571
  }
@@ -1086,26 +1575,32 @@ function serializeBridgeBlock(bridge) {
1086
1575
  }
1087
1576
  }
1088
1577
  if (actualSourceRef && handleChain.length > 0) {
1089
- const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, true);
1090
- const destStr = serializeRef(outWire.to, bridge, handleMap, inputHandle, false);
1578
+ const sourceStr = sRef(actualSourceRef, true);
1579
+ const destStr = sRef(outWire.to, false);
1091
1580
  const arrow = chainForced ? "<-!" : "<-";
1092
1581
  const nfb = outWire.nullFallback ? ` || ${outWire.nullFallback}` : "";
1093
1582
  const errf = outWire.fallbackRef
1094
- ? ` ?? ${serializePipeOrRef(outWire.fallbackRef, pipeHandleTrunkKeys, toInMap, handleMap, bridge, inputHandle)}`
1583
+ ? ` ?? ${sPipeOrRef(outWire.fallbackRef)}`
1095
1584
  : outWire.fallback ? ` ?? ${outWire.fallback}` : "";
1096
1585
  lines.push(`${destStr} ${arrow} ${handleChain.join(":")}:${sourceStr}${nfb}${errf}`);
1097
1586
  }
1098
1587
  }
1588
+ // Indent wire body lines and close the block
1589
+ for (let i = wireBodyStart; i < lines.length; i++) {
1590
+ if (lines[i] !== "")
1591
+ lines[i] = ` ${lines[i]}`;
1592
+ }
1593
+ lines.push(`}`);
1099
1594
  return lines.join("\n");
1100
1595
  }
1101
1596
  /**
1102
- * Build a reverse lookup: trunk key → handle name.
1103
1597
  * Recomputes instance numbers from handle bindings in declaration order.
1104
1598
  */
1105
1599
  function buildHandleMap(bridge) {
1106
1600
  const handleMap = new Map();
1107
1601
  const instanceCounters = new Map();
1108
1602
  let inputHandle;
1603
+ let outputHandle;
1109
1604
  for (const h of bridge.handles) {
1110
1605
  switch (h.kind) {
1111
1606
  case "tool": {
@@ -1131,19 +1626,27 @@ function buildHandleMap(bridge) {
1131
1626
  case "input":
1132
1627
  inputHandle = h.handle;
1133
1628
  break;
1629
+ case "output":
1630
+ outputHandle = h.handle;
1631
+ break;
1134
1632
  case "context":
1135
1633
  handleMap.set(`${SELF_MODULE}:Context:context`, h.handle);
1136
1634
  break;
1137
1635
  case "const":
1138
1636
  handleMap.set(`${SELF_MODULE}:Const:const`, h.handle);
1139
1637
  break;
1638
+ case "define":
1639
+ handleMap.set(`__define_${h.handle}:${bridge.type}:${bridge.field}`, h.handle);
1640
+ break;
1140
1641
  }
1141
1642
  }
1142
- return { handleMap, inputHandle };
1643
+ return { handleMap, inputHandle, outputHandle };
1143
1644
  }
1144
- function serializeRef(ref, bridge, handleMap, inputHandle, isFrom) {
1645
+ function serializeRef(ref, bridge, handleMap, inputHandle, outputHandle, isFrom) {
1145
1646
  if (ref.element) {
1146
- 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);
1147
1650
  }
1148
1651
  // Bridge's own trunk (no instance, no element)
1149
1652
  const isBridgeTrunk = ref.module === SELF_MODULE &&
@@ -1154,16 +1657,17 @@ function serializeRef(ref, bridge, handleMap, inputHandle, isFrom) {
1154
1657
  if (isBridgeTrunk) {
1155
1658
  if (isFrom && inputHandle) {
1156
1659
  // From side: use input handle (data comes from args)
1157
- return inputHandle + "." + serPath(ref.path);
1660
+ return ref.path.length > 0
1661
+ ? inputHandle + "." + serPath(ref.path)
1662
+ : inputHandle;
1158
1663
  }
1159
- // To side: sub-fields of the bridge's own return type are prefixed with the
1160
- // bridge field name so `path: ["a"]` serializes as `doubled.a` (not bare "a").
1161
- // This is needed for bridges whose output type has named sub-fields
1162
- // (e.g. `bridge Query.doubled` with `doubled.a <- ...`).
1163
- if (!isFrom && ref.path.length > 0) {
1164
- 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;
1165
1669
  }
1166
- // 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)
1167
1671
  return serPath(ref.path);
1168
1672
  }
1169
1673
  // Lookup by trunk key
@@ -1172,7 +1676,6 @@ function serializeRef(ref, bridge, handleMap, inputHandle, isFrom) {
1172
1676
  : `${ref.module}:${ref.type}:${ref.field}`;
1173
1677
  const handle = handleMap.get(trunkStr);
1174
1678
  if (handle) {
1175
- // Empty path — just the handle name (e.g. pipe result = tool root)
1176
1679
  if (ref.path.length === 0)
1177
1680
  return handle;
1178
1681
  return handle + "." + serPath(ref.path);