@stackables/bridge 1.6.0 → 1.7.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.
@@ -1,1270 +1,14 @@
1
1
  import { SELF_MODULE } from "./types.js";
2
- // ── Parser ──────────────────────────────────────────────────────────────────
2
+ import { parseBridgeChevrotain } from "./parser/index.js";
3
+ export { parsePath } from "./utils.js";
3
4
  /**
4
- * Parse .bridge text format into structured instructions.
5
- *
6
- * The .bridge format is a human-readable representation of connection wires.
7
- * Multiple blocks are separated by `---`.
8
- * Tool blocks define API tools, bridge blocks define wire mappings.
9
- *
10
- * @param text - Bridge definition text
11
- * @returns Array of instructions (Bridge, ToolDef)
12
- */
13
- const BRIDGE_VERSION = "1.4";
14
- // Keywords that cannot be used as tool names, aliases, or const names
15
- const RESERVED_KEYWORDS = new Set(["bridge", "with", "as", "from", "const", "tool", "version", "define"]);
16
- // Source identifiers reserved for their special meaning inside bridge/tool blocks
17
- const SOURCE_IDENTIFIERS = new Set(["input", "output", "context"]);
18
- function assertNotReserved(name, lineNum, label) {
19
- if (RESERVED_KEYWORDS.has(name.toLowerCase())) {
20
- throw new Error(`Line ${lineNum}: "${name}" is a reserved keyword and cannot be used as a ${label}`);
21
- }
22
- if (SOURCE_IDENTIFIERS.has(name.toLowerCase())) {
23
- throw new Error(`Line ${lineNum}: "${name}" is a reserved source identifier and cannot be used as a ${label}`);
24
- }
25
- }
26
- /**
27
- * Strip a trailing `# comment` from a single source line, respecting string
28
- * literals so that a `#` inside `"..."` is never treated as a comment marker.
29
- * Leading/trailing whitespace of the returned value is preserved (callers trim
30
- * the result themselves as needed).
5
+ * Parse .bridge text delegates to the Chevrotain parser.
31
6
  */
32
- function stripInlineComment(line) {
33
- let inString = false;
34
- for (let i = 0; i < line.length; i++) {
35
- if (line[i] === '"')
36
- inString = !inString;
37
- if (!inString && line[i] === "#")
38
- return line.slice(0, i).trimEnd();
39
- }
40
- return line;
41
- }
42
7
  export function parseBridge(text) {
43
- // Normalize: CRLF → LF, tabs → 2 spaces, inline comments stripped
44
- const normalized = text.replace(/\r\n?/g, "\n").replace(/\t/g, " ");
45
- const allLines = normalized.split("\n").map(stripInlineComment);
46
- // Version check — first non-blank, non-comment line must be `version 1.4`
47
- const firstContentIdx = allLines.findIndex((l) => l.trim() !== "" && !l.trim().startsWith("#"));
48
- if (firstContentIdx === -1 || !/^version\s+/.test(allLines[firstContentIdx].trim())) {
49
- throw new Error(`Missing version declaration. Bridge files must begin with: version ${BRIDGE_VERSION}`);
50
- }
51
- const versionToken = allLines[firstContentIdx].trim().replace(/^version\s+/, "");
52
- if (versionToken !== BRIDGE_VERSION) {
53
- throw new Error(`Unsupported bridge version "${versionToken}". This parser requires: version ${BRIDGE_VERSION}`);
54
- }
55
- // Blank out the version line so block-splitting ignores it
56
- allLines[firstContentIdx] = "";
57
- // Find separator lines (--- with optional surrounding whitespace)
58
- const isSep = (line) => /^\s*---\s*$/.test(line);
59
- // Collect block ranges as [start, end) line indices
60
- const blockRanges = [];
61
- let blockStart = 0;
62
- for (let i = 0; i < allLines.length; i++) {
63
- if (isSep(allLines[i])) {
64
- blockRanges.push({ start: blockStart, end: i });
65
- blockStart = i + 1;
66
- }
67
- }
68
- blockRanges.push({ start: blockStart, end: allLines.length });
69
- const instructions = [];
70
- for (const { start, end } of blockRanges) {
71
- const blockLines = allLines.slice(start, end);
72
- // Split into sub-blocks by top-level `tool` or `bridge` keywords
73
- const subBlocks = [];
74
- let currentLines = [];
75
- let currentOffset = start;
76
- for (let i = 0; i < blockLines.length; i++) {
77
- const trimmed = blockLines[i].trim();
78
- if (/^(tool|bridge|const|define)\s/i.test(trimmed) && currentLines.length > 0) {
79
- // Check if any non-blank content exists
80
- if (currentLines.some((l) => l.trim())) {
81
- subBlocks.push({ startOffset: currentOffset, lines: currentLines });
82
- }
83
- currentLines = [blockLines[i]];
84
- currentOffset = start + i;
85
- }
86
- else {
87
- currentLines.push(blockLines[i]);
88
- }
89
- }
90
- if (currentLines.some((l) => l.trim())) {
91
- subBlocks.push({ startOffset: currentOffset, lines: currentLines });
92
- }
93
- for (const sub of subBlocks) {
94
- const subText = sub.lines.join("\n").trim();
95
- if (!subText)
96
- continue;
97
- let firstContentLine = 0;
98
- while (firstContentLine < sub.lines.length && !sub.lines[firstContentLine].trim())
99
- firstContentLine++;
100
- const firstLine = sub.lines[firstContentLine]?.trim();
101
- if (firstLine && /^tool\s/i.test(firstLine)) {
102
- instructions.push(parseToolBlock(subText, sub.startOffset + firstContentLine, instructions));
103
- }
104
- else if (firstLine && /^define\s/i.test(firstLine)) {
105
- instructions.push(parseDefineBlock(subText, sub.startOffset + firstContentLine));
106
- }
107
- else if (firstLine && /^bridge\s/i.test(firstLine)) {
108
- instructions.push(...parseBridgeBlock(subText, sub.startOffset + firstContentLine, instructions));
109
- }
110
- else if (firstLine && /^const\s/i.test(firstLine)) {
111
- instructions.push(...parseConstLines(subText, sub.startOffset + firstContentLine));
112
- }
113
- else if (firstLine && !firstLine.startsWith("#")) {
114
- throw new Error(`Line ${sub.startOffset + firstContentLine + 1}: Expected "tool", "define", "bridge", or "const" declaration, got: ${firstLine}`);
115
- }
116
- }
117
- }
118
- return instructions;
119
- }
120
- // ── Bridge block parser ─────────────────────────────────────────────────────
121
- /**
122
- * Returns true when the token looks like a JSON literal rather than a
123
- * source reference (dotted path or pipe chain).
124
- * JSON start chars: `"`, `{`, `[`, digit, `-` + digit, `true`, `false`, `null`.
125
- */
126
- function isJsonLiteral(s) {
127
- return /^["\{\[\d]/.test(s) || /^-\d/.test(s) ||
128
- s === "true" || s === "false" || s === "null";
129
- }
130
- function parseBridgeBlock(block, lineOffset, previousInstructions) {
131
- // ── Passthrough shorthand: `bridge Type.field with <name>` ──────────
132
- // Expands into a full bridge that wires all input through the named
133
- // handle (typically a define) and returns its output directly.
134
- const shorthandMatch = block.match(/^bridge\s+(\w+)\.(\w+)\s+with\s+(\S+)\s*$/im);
135
- if (shorthandMatch) {
136
- const [, sType, sField, sName] = shorthandMatch;
137
- const sHandle = sName.includes(".") ? sName.substring(sName.lastIndexOf(".") + 1) : sName;
138
- const expanded = [
139
- `bridge ${sType}.${sField} {`,
140
- ` with ${sName} as ${sHandle}`,
141
- ` with input`,
142
- ` with output as __out`,
143
- ` ${sHandle} <- input`,
144
- ` __out <- ${sHandle}`,
145
- `}`,
146
- ].join("\n");
147
- const result = parseBridgeBlock(expanded, lineOffset, previousInstructions);
148
- // Tag the bridge instruction with the passthrough name for serialization
149
- const bridgeInst = result.find((i) => i.kind === "bridge");
150
- if (bridgeInst)
151
- bridgeInst.passthrough = sName;
152
- return result;
153
- }
154
- // Validate mandatory braces: `bridge Foo.bar {` ... `}`
155
- const rawLines = block.split("\n");
156
- const keywordIdx = rawLines.findIndex((l) => /^bridge\s/i.test(l.trim()));
157
- if (keywordIdx !== -1) {
158
- const kw = rawLines[keywordIdx].trim();
159
- if (!kw.endsWith("{")) {
160
- throw new Error(`Line ${lineOffset + keywordIdx + 1}: bridge block must use braces: bridge Type.field {`);
161
- }
162
- const hasClose = rawLines.some((l) => l.trimEnd() === "}");
163
- if (!hasClose) {
164
- throw new Error(`Line ${lineOffset + keywordIdx + 1}: bridge block missing closing }`);
165
- }
166
- }
167
- // Strip braces for internal parsing
168
- const lines = rawLines.map((l) => {
169
- const trimmed = l.trimEnd();
170
- if (trimmed === "}")
171
- return "";
172
- if (/^bridge\s/i.test(trimmed) && trimmed.endsWith("{"))
173
- return trimmed.replace(/\s*\{\s*$/, "");
174
- return trimmed;
175
- });
176
- const instructions = [];
177
- /** 1-based global line number for error messages */
178
- const ln = (i) => lineOffset + i + 1;
179
- // ── Parse header ────────────────────────────────────────────────────
180
- let bridgeType = "";
181
- let bridgeField = "";
182
- const handleRes = new Map();
183
- const handleBindings = [];
184
- const instanceCounters = new Map();
185
- let bodyStartIndex = 0;
186
- for (let i = 0; i < lines.length; i++) {
187
- const line = lines[i].trim();
188
- if (!line || line.startsWith("#")) {
189
- continue;
190
- }
191
- if (/^bridge\s/i.test(line)) {
192
- const match = line.match(/^bridge\s+(\w+)\.(\w+)$/i);
193
- if (!match)
194
- throw new Error(`Line ${ln(i)}: Invalid bridge declaration: ${line}`);
195
- bridgeType = match[1];
196
- bridgeField = match[2];
197
- continue;
198
- }
199
- if (/^with\s/i.test(line)) {
200
- if (!bridgeType) {
201
- throw new Error(`Line ${ln(i)}: "with" declaration must come after "bridge" declaration`);
202
- }
203
- parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBindings, instanceCounters, previousInstructions ?? [], ln(i));
204
- continue;
205
- }
206
- // First non-header line — body starts here
207
- bodyStartIndex = i;
208
- break;
209
- }
210
- if (!bridgeType || !bridgeField) {
211
- throw new Error(`Line ${ln(0)}: Missing bridge declaration`);
212
- }
213
- // ── Parse wire lines ────────────────────────────────────────────────
214
- const wires = [];
215
- let currentArrayToPath = null;
216
- let currentIterHandle = null;
217
- const arrayIterators = {};
218
- /** Monotonically-increasing index; combined with a high base to produce
219
- * fork instances that can never collide with regular handle instances. */
220
- let nextForkSeq = 0;
221
- const pipeHandleEntries = [];
222
- /**
223
- * Parse a source expression (`handle.path` or `h1:h2:source`) into bridge
224
- * wires, returning the terminal NodeRef.
225
- *
226
- * For pipe chains: pushes the intermediate `.in <- prev` wires and registers
227
- * the fork instances, then returns the fork-root ref. The caller is
228
- * responsible for pushing the TERMINAL wire (forkRoot → target).
229
- *
230
- * For simple refs: returns the resolved NodeRef directly (no wires pushed).
231
- *
232
- * @param forceOnOutermost When true, marks the outermost intermediate pipe
233
- * wire with `force: true` (used for `<-!`).
234
- */
235
- function buildSourceExpr(sourceStr, lineNum, forceOnOutermost) {
236
- const parts = sourceStr.split(":");
237
- if (parts.length === 1) {
238
- return resolveAddress(sourceStr, handleRes, bridgeType, bridgeField, lineNum);
239
- }
240
- // Pipe chain
241
- const actualSource = parts[parts.length - 1];
242
- const tokenChain = parts.slice(0, -1);
243
- const parseToken = (t) => {
244
- const dot = t.indexOf(".");
245
- return dot === -1
246
- ? { handleName: t, fieldName: "in" }
247
- : { handleName: t.substring(0, dot), fieldName: t.substring(dot + 1) };
248
- };
249
- for (const tok of tokenChain) {
250
- const { handleName } = parseToken(tok);
251
- if (!handleRes.has(handleName)) {
252
- throw new Error(`Line ${lineNum}: Undeclared handle in pipe: "${handleName}". Add 'with <tool> as ${handleName}' to the bridge header.`);
253
- }
254
- }
255
- let prevOutRef = resolveAddress(actualSource, handleRes, bridgeType, bridgeField, lineNum);
256
- const reversedTokens = [...tokenChain].reverse();
257
- for (let idx = 0; idx < reversedTokens.length; idx++) {
258
- const tok = reversedTokens[idx];
259
- const { handleName, fieldName } = parseToken(tok);
260
- const res = handleRes.get(handleName);
261
- const forkInstance = 100000 + nextForkSeq++;
262
- const forkKey = `${res.module}:${res.type}:${res.field}:${forkInstance}`;
263
- pipeHandleEntries.push({
264
- key: forkKey,
265
- handle: handleName,
266
- baseTrunk: { module: res.module, type: res.type, field: res.field, instance: res.instance },
267
- });
268
- const forkInRef = { module: res.module, type: res.type, field: res.field, instance: forkInstance, path: parsePath(fieldName) };
269
- const forkRootRef = { module: res.module, type: res.type, field: res.field, instance: forkInstance, path: [] };
270
- const isOutermost = idx === reversedTokens.length - 1;
271
- wires.push({ from: prevOutRef, to: forkInRef, pipe: true, ...(forceOnOutermost && isOutermost ? { force: true } : {}) });
272
- prevOutRef = forkRootRef;
273
- }
274
- return prevOutRef; // fork-root ref
275
- }
276
- // ── Whether we are inside an element-mapping brace block
277
- let inElementBlock = false;
278
- for (let i = bodyStartIndex; i < lines.length; i++) {
279
- const raw = lines[i];
280
- const line = raw.trim();
281
- if (!line || line.startsWith("#")) {
282
- continue;
283
- }
284
- // Closing brace of an element mapping block `{ ... }`
285
- if (line === "}") {
286
- if (inElementBlock) {
287
- currentArrayToPath = null;
288
- currentIterHandle = null;
289
- inElementBlock = false;
290
- continue;
291
- }
292
- throw new Error(`Line ${ln(i)}: Unexpected "}" — not inside an element block`);
293
- }
294
- // Element mapping lines (inside a brace block)
295
- if (inElementBlock && currentArrayToPath) {
296
- if (!line.startsWith(".")) {
297
- throw new Error(`Line ${ln(i)}: Element mapping lines must start with ".": ${line}`);
298
- }
299
- // Constant: .target = "value" or .target = value
300
- const elemConstMatch = line.match(/^\.(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
301
- if (elemConstMatch) {
302
- const [, fieldName, quotedValue, unquotedValue] = elemConstMatch;
303
- const value = quotedValue ?? unquotedValue;
304
- wires.push({
305
- value,
306
- to: {
307
- module: SELF_MODULE,
308
- type: bridgeType,
309
- field: bridgeField,
310
- element: true,
311
- path: [...currentArrayToPath, ...parsePath(fieldName)],
312
- },
313
- });
314
- continue;
315
- }
316
- // Simple pull: .target <- <iter>.source (element-relative, no fallbacks)
317
- const iterPfx = `${currentIterHandle}.`;
318
- const elemRelMatch = line.match(/^\.(\S+)\s*<-\s*(\S+)$/);
319
- if (elemRelMatch && elemRelMatch[2].startsWith(iterPfx)) {
320
- const toPath = [...currentArrayToPath, ...parsePath(elemRelMatch[1])];
321
- const fromPath = parsePath(elemRelMatch[2].slice(iterPfx.length));
322
- wires.push({
323
- from: {
324
- module: SELF_MODULE,
325
- type: bridgeType,
326
- field: bridgeField,
327
- element: true,
328
- path: fromPath,
329
- },
330
- to: {
331
- module: SELF_MODULE,
332
- type: bridgeType,
333
- field: bridgeField,
334
- path: toPath,
335
- },
336
- });
337
- continue;
338
- }
339
- // Pull with fallbacks: .target <- source || "fallback" ?? errorSrc (relative or handle path)
340
- const elemArrowMatch = line.match(/^\.(\S+)\s*<-\s*(.+)$/);
341
- if (elemArrowMatch) {
342
- const [, toField, rhs] = elemArrowMatch;
343
- const toPath = [...currentArrayToPath, ...parsePath(toField)];
344
- const toRef = { module: SELF_MODULE, type: bridgeType, field: bridgeField, path: toPath };
345
- // Strip ?? tail
346
- let exprCore = rhs.trim();
347
- let fallback;
348
- let fallbackRefStr;
349
- const qqIdx = exprCore.lastIndexOf(" ?? ");
350
- if (qqIdx !== -1) {
351
- const tail = exprCore.slice(qqIdx + 4).trim();
352
- exprCore = exprCore.slice(0, qqIdx).trim();
353
- if (isJsonLiteral(tail))
354
- fallback = tail;
355
- else
356
- fallbackRefStr = tail;
357
- }
358
- // Split on || — last may be JSON literal (nullFallback)
359
- const orParts = exprCore.split(" || ").map((s) => s.trim());
360
- let nullFallback;
361
- let sourceParts = orParts;
362
- if (orParts.length > 1 && isJsonLiteral(orParts[orParts.length - 1])) {
363
- nullFallback = orParts[orParts.length - 1];
364
- sourceParts = orParts.slice(0, -1);
365
- }
366
- let fallbackRef;
367
- let fallbackInternalWires = [];
368
- if (fallbackRefStr) {
369
- const preLen = wires.length;
370
- fallbackRef = buildSourceExpr(fallbackRefStr, ln(i), false);
371
- fallbackInternalWires = wires.splice(preLen);
372
- }
373
- for (let ci = 0; ci < sourceParts.length; ci++) {
374
- const srcStr = sourceParts[ci];
375
- const isLast = ci === sourceParts.length - 1;
376
- // Element-relative source: starts with "<iter>."
377
- const iterPrefix = `${currentIterHandle}.`;
378
- let fromRef;
379
- if (srcStr.startsWith(iterPrefix)) {
380
- fromRef = {
381
- module: SELF_MODULE,
382
- type: bridgeType,
383
- field: bridgeField,
384
- element: true,
385
- path: parsePath(srcStr.slice(iterPrefix.length)),
386
- };
387
- }
388
- else if (srcStr.startsWith(".")) {
389
- throw new Error(`Line ${ln(i)}: Use "${currentIterHandle}.field" to reference element fields, not ".field"`);
390
- }
391
- else {
392
- fromRef = buildSourceExpr(srcStr, ln(i), false);
393
- }
394
- const lastAttrs = isLast ? {
395
- ...(nullFallback ? { nullFallback } : {}),
396
- ...(fallback ? { fallback } : {}),
397
- ...(fallbackRef ? { fallbackRef } : {}),
398
- } : {};
399
- wires.push({ from: fromRef, to: toRef, ...lastAttrs });
400
- }
401
- wires.push(...fallbackInternalWires);
402
- continue;
403
- }
404
- throw new Error(`Line ${ln(i)}: Invalid element mapping line: ${line}`);
405
- }
406
- // Constant wire: target = "value" or target = value (unquoted)
407
- const constantMatch = line.match(/^(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
408
- if (constantMatch) {
409
- const [, targetStr, quotedValue, unquotedValue] = constantMatch;
410
- const value = quotedValue ?? unquotedValue;
411
- const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
412
- assertNoTargetIndices(toRef, ln(i));
413
- wires.push({ value, to: toRef });
414
- continue;
415
- }
416
- // ── Wire: target <- A [|| B [|| C]] [|| "nullLiteral"] [?? errorSrc|"errorLiteral"]
417
- const arrowMatch = line.match(/^(\S+)\s*<-(!?)\s*(.+)$/);
418
- if (arrowMatch) {
419
- const [, targetStr, forceFlag, rhs] = arrowMatch;
420
- const force = forceFlag === "!";
421
- const rhsTrimmed = rhs.trim();
422
- // ── Array mapping: target <- source[] as <iter> {
423
- // Opens a brace-delimited element block.
424
- const arrayBraceMatch = rhsTrimmed.match(/^(\S+)\[\]\s+as\s+(\w+)\s*\{\s*$/);
425
- if (arrayBraceMatch) {
426
- const fromClean = arrayBraceMatch[1];
427
- const iterHandle = arrayBraceMatch[2];
428
- assertNotReserved(iterHandle, ln(i), "iterator handle");
429
- const fromRef = resolveAddress(fromClean, handleRes, bridgeType, bridgeField, ln(i));
430
- const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
431
- assertNoTargetIndices(toRef, ln(i));
432
- wires.push({ from: fromRef, to: toRef });
433
- currentArrayToPath = toRef.path;
434
- currentIterHandle = iterHandle;
435
- arrayIterators[toRef.path[0]] = iterHandle;
436
- inElementBlock = true;
437
- continue;
438
- }
439
- // ── Strip the ?? tail (last " ?? " wins in case source contains " ?? ")
440
- let exprCore = rhsTrimmed;
441
- let fallback;
442
- let fallbackRefStr;
443
- const qqIdx = rhsTrimmed.lastIndexOf(" ?? ");
444
- if (qqIdx !== -1) {
445
- exprCore = rhsTrimmed.slice(0, qqIdx).trim();
446
- const tail = rhsTrimmed.slice(qqIdx + 4).trim();
447
- if (isJsonLiteral(tail)) {
448
- fallback = tail;
449
- }
450
- else {
451
- fallbackRefStr = tail;
452
- }
453
- }
454
- // ── Split on " || " to get coalesce chain parts
455
- const orParts = exprCore.split(" || ").map((s) => s.trim());
456
- // Last part may be a JSON literal → becomes nullFallback on the last source wire
457
- let nullFallback;
458
- let sourceParts = orParts;
459
- if (orParts.length > 1 && isJsonLiteral(orParts[orParts.length - 1])) {
460
- nullFallback = orParts[orParts.length - 1];
461
- sourceParts = orParts.slice(0, -1);
462
- }
463
- if (sourceParts.length === 0) {
464
- throw new Error(`Line ${ln(i)}: Wire has no source expression: ${line}`);
465
- }
466
- let fallbackRef;
467
- let fallbackInternalWires = [];
468
- if (fallbackRefStr) {
469
- const preLen = wires.length;
470
- fallbackRef = buildSourceExpr(fallbackRefStr, ln(i), false);
471
- fallbackInternalWires = wires.splice(preLen);
472
- }
473
- const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField, ln(i));
474
- assertNoTargetIndices(toRef, ln(i));
475
- for (let ci = 0; ci < sourceParts.length; ci++) {
476
- const isFirst = ci === 0;
477
- const isLast = ci === sourceParts.length - 1;
478
- const srcStr = sourceParts[ci];
479
- const termRef = buildSourceExpr(srcStr, ln(i), force && isFirst);
480
- const isPipeFork = termRef.instance != null && termRef.path.length === 0
481
- && srcStr.includes(":");
482
- const lastAttrs = isLast ? {
483
- ...(nullFallback ? { nullFallback } : {}),
484
- ...(fallback ? { fallback } : {}),
485
- ...(fallbackRef ? { fallbackRef } : {}),
486
- } : {};
487
- if (isPipeFork) {
488
- wires.push({ from: termRef, to: toRef, pipe: true, ...lastAttrs });
489
- }
490
- else {
491
- wires.push({
492
- from: termRef,
493
- to: toRef,
494
- ...(force && isFirst ? { force: true } : {}),
495
- ...lastAttrs,
496
- });
497
- }
498
- }
499
- wires.push(...fallbackInternalWires);
500
- continue;
501
- }
502
- throw new Error(`Line ${ln(i)}: Unrecognized line: ${line}`);
503
- }
504
- // ── Inline define invocations ───────────────────────────────────────
505
- const nextForkSeqRef = { value: nextForkSeq };
506
- for (const hb of handleBindings) {
507
- if (hb.kind !== "define")
508
- continue;
509
- const def = previousInstructions?.find((inst) => inst.kind === "define" && inst.name === hb.name);
510
- if (!def) {
511
- throw new Error(`Define "${hb.name}" referenced by handle "${hb.handle}" not found`);
512
- }
513
- inlineDefine(hb.handle, def, bridgeType, bridgeField, wires, pipeHandleEntries, handleBindings, instanceCounters, nextForkSeqRef);
514
- }
515
- instructions.unshift({
516
- kind: "bridge",
517
- type: bridgeType,
518
- field: bridgeField,
519
- handles: handleBindings,
520
- wires,
521
- arrayIterators: Object.keys(arrayIterators).length > 0 ? arrayIterators : undefined,
522
- pipeHandles: pipeHandleEntries.length > 0 ? pipeHandleEntries : undefined,
523
- });
524
- return instructions;
525
- }
526
- /**
527
- * Parse a `with` declaration into handle bindings + resolution map.
528
- *
529
- * Supported forms:
530
- * with <name> as <handle> — tool reference (dotted or simple name)
531
- * with <name> — shorthand: handle defaults to last segment of name
532
- * with input as <handle>
533
- * with context as <handle>
534
- * with context — shorthand for `with context as context`
535
- */
536
- function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBindings, instanceCounters, instructions, lineNum) {
537
- /** Guard: reject duplicate handle names */
538
- const checkDuplicate = (handle) => {
539
- if (handleRes.has(handle)) {
540
- throw new Error(`Line ${lineNum}: Duplicate handle name "${handle}"`);
541
- }
542
- };
543
- // with input as <handle>
544
- let match = line.match(/^with\s+input\s+as\s+(\w+)$/i);
545
- if (match) {
546
- const handle = match[1];
547
- checkDuplicate(handle);
548
- handleBindings.push({ handle, kind: "input" });
549
- handleRes.set(handle, {
550
- module: SELF_MODULE,
551
- type: bridgeType,
552
- field: bridgeField,
553
- });
554
- return;
555
- }
556
- // with input (shorthand — handle defaults to "input")
557
- match = line.match(/^with\s+input$/i);
558
- if (match) {
559
- const handle = "input";
560
- checkDuplicate(handle);
561
- handleBindings.push({ handle, kind: "input" });
562
- handleRes.set(handle, {
563
- module: SELF_MODULE,
564
- type: bridgeType,
565
- field: bridgeField,
566
- });
567
- return;
568
- }
569
- // with output as <handle>
570
- match = line.match(/^with\s+output\s+as\s+(\w+)$/i);
571
- if (match) {
572
- const handle = match[1];
573
- checkDuplicate(handle);
574
- handleBindings.push({ handle, kind: "output" });
575
- handleRes.set(handle, {
576
- module: SELF_MODULE,
577
- type: bridgeType,
578
- field: bridgeField,
579
- });
580
- return;
581
- }
582
- // with output (shorthand — handle defaults to "output")
583
- match = line.match(/^with\s+output$/i);
584
- if (match) {
585
- const handle = "output";
586
- checkDuplicate(handle);
587
- handleBindings.push({ handle, kind: "output" });
588
- handleRes.set(handle, {
589
- module: SELF_MODULE,
590
- type: bridgeType,
591
- field: bridgeField,
592
- });
593
- return;
594
- }
595
- // with context as <handle>
596
- match = line.match(/^with\s+context\s+as\s+(\w+)$/i);
597
- if (match) {
598
- const handle = match[1];
599
- checkDuplicate(handle);
600
- handleBindings.push({ handle, kind: "context" });
601
- handleRes.set(handle, {
602
- module: SELF_MODULE,
603
- type: "Context",
604
- field: "context",
605
- });
606
- return;
607
- }
608
- // with context (shorthand — handle defaults to "context")
609
- match = line.match(/^with\s+context$/i);
610
- if (match) {
611
- const handle = "context";
612
- checkDuplicate(handle);
613
- handleBindings.push({ handle, kind: "context" });
614
- handleRes.set(handle, {
615
- module: SELF_MODULE,
616
- type: "Context",
617
- field: "context",
618
- });
619
- return;
620
- }
621
- // with const as <handle>
622
- match = line.match(/^with\s+const\s+as\s+(\w+)$/i);
623
- if (match) {
624
- const handle = match[1];
625
- checkDuplicate(handle);
626
- handleBindings.push({ handle, kind: "const" });
627
- handleRes.set(handle, {
628
- module: SELF_MODULE,
629
- type: "Const",
630
- field: "const",
631
- });
632
- return;
633
- }
634
- // with const (shorthand — handle defaults to "const")
635
- match = line.match(/^with\s+const$/i);
636
- if (match) {
637
- const handle = "const";
638
- checkDuplicate(handle);
639
- handleBindings.push({ handle, kind: "const" });
640
- handleRes.set(handle, {
641
- module: SELF_MODULE,
642
- type: "Const",
643
- field: "const",
644
- });
645
- return;
646
- }
647
- // with <name> as <handle> — check for define invocation first
648
- match = line.match(/^with\s+(\S+)\s+as\s+(\w+)$/i);
649
- if (match) {
650
- const name = match[1];
651
- const handle = match[2];
652
- checkDuplicate(handle);
653
- assertNotReserved(handle, lineNum, "handle alias");
654
- // Check if name matches a known define
655
- const defineDef = instructions.find((inst) => inst.kind === "define" && inst.name === name);
656
- if (defineDef) {
657
- handleBindings.push({ handle, kind: "define", name });
658
- handleRes.set(handle, {
659
- module: `__define_${handle}`,
660
- type: bridgeType,
661
- field: bridgeField,
662
- });
663
- return;
664
- }
665
- const lastDot = name.lastIndexOf(".");
666
- if (lastDot !== -1) {
667
- const modulePart = name.substring(0, lastDot);
668
- const fieldPart = name.substring(lastDot + 1);
669
- const key = `${modulePart}:${fieldPart}`;
670
- const instance = (instanceCounters.get(key) ?? 0) + 1;
671
- instanceCounters.set(key, instance);
672
- handleBindings.push({ handle, kind: "tool", name });
673
- handleRes.set(handle, {
674
- module: modulePart,
675
- type: bridgeType,
676
- field: fieldPart,
677
- instance,
678
- });
679
- }
680
- else {
681
- // Simple name — inline tool function
682
- const key = `Tools:${name}`;
683
- const instance = (instanceCounters.get(key) ?? 0) + 1;
684
- instanceCounters.set(key, instance);
685
- handleBindings.push({ handle, kind: "tool", name });
686
- handleRes.set(handle, {
687
- module: SELF_MODULE,
688
- type: "Tools",
689
- field: name,
690
- instance,
691
- });
692
- }
693
- return;
694
- }
695
- // with <name> — shorthand: handle defaults to the last segment of name
696
- // Must come after the `with input` / `with context` guards above.
697
- match = line.match(/^with\s+(\S+)$/i);
698
- if (match) {
699
- const name = match[1];
700
- const lastDot = name.lastIndexOf(".");
701
- const handle = lastDot !== -1 ? name.substring(lastDot + 1) : name;
702
- checkDuplicate(handle);
703
- // Check if name matches a known define
704
- const defineDef = instructions.find((inst) => inst.kind === "define" && inst.name === name);
705
- if (defineDef) {
706
- handleBindings.push({ handle, kind: "define", name });
707
- handleRes.set(handle, {
708
- module: `__define_${handle}`,
709
- type: bridgeType,
710
- field: bridgeField,
711
- });
712
- return;
713
- }
714
- if (lastDot !== -1) {
715
- const modulePart = name.substring(0, lastDot);
716
- const fieldPart = name.substring(lastDot + 1);
717
- const key = `${modulePart}:${fieldPart}`;
718
- const instance = (instanceCounters.get(key) ?? 0) + 1;
719
- instanceCounters.set(key, instance);
720
- handleBindings.push({ handle, kind: "tool", name });
721
- handleRes.set(handle, { module: modulePart, type: bridgeType, field: fieldPart, instance });
722
- }
723
- else {
724
- const key = `Tools:${name}`;
725
- const instance = (instanceCounters.get(key) ?? 0) + 1;
726
- instanceCounters.set(key, instance);
727
- handleBindings.push({ handle, kind: "tool", name });
728
- handleRes.set(handle, { module: SELF_MODULE, type: "Tools", field: name, instance });
729
- }
730
- return;
731
- }
732
- throw new Error(`Line ${lineNum}: Invalid with declaration: ${line}`);
733
- }
734
- // ── Define inlining ─────────────────────────────────────────────────────────
735
- /**
736
- * Inline a define invocation into a bridge's wires.
737
- *
738
- * Splits the define handle into separate input/output synthetic trunks,
739
- * clones the define's internal wires with remapped references, and adds
740
- * them to the bridge. Tool instances are remapped to avoid collisions.
741
- *
742
- * The executor treats synthetic trunks (module starting with `__define_`)
743
- * as pass-through data containers — no tool function is called.
744
- */
745
- function inlineDefine(defineHandle, defineDef, bridgeType, bridgeField, wires, pipeHandleEntries, handleBindings, instanceCounters, nextForkSeqRef) {
746
- const genericModule = `__define_${defineHandle}`;
747
- const inModule = `__define_in_${defineHandle}`;
748
- const outModule = `__define_out_${defineHandle}`;
749
- // The define was parsed as synthetic `bridge Define.<name>`, so its
750
- // internal refs use type="Define", field=defineName for I/O, and
751
- // standard tool resolutions for tools.
752
- const defType = "Define";
753
- const defField = defineDef.name;
754
- // ── 1. Build trunk remapping for define's tool handles ──────────────
755
- // Replay define's instance counter to determine original instances
756
- const defCounters = new Map();
757
- const trunkRemap = new Map();
758
- for (const hb of defineDef.handles) {
759
- if (hb.kind === "input" || hb.kind === "output" || hb.kind === "context" || hb.kind === "const")
760
- continue;
761
- if (hb.kind === "define")
762
- continue; // nested defines — future
763
- const name = hb.kind === "tool" ? hb.name : "";
764
- if (!name)
765
- continue;
766
- const lastDot = name.lastIndexOf(".");
767
- let oldModule, oldType, oldField, instanceKey, bridgeKey;
768
- if (lastDot !== -1) {
769
- oldModule = name.substring(0, lastDot);
770
- oldType = defType;
771
- oldField = name.substring(lastDot + 1);
772
- instanceKey = `${oldModule}:${oldField}`;
773
- bridgeKey = instanceKey;
774
- }
775
- else {
776
- oldModule = SELF_MODULE;
777
- oldType = "Tools";
778
- oldField = name;
779
- instanceKey = `Tools:${name}`;
780
- bridgeKey = instanceKey;
781
- }
782
- // Old instance (from define's isolated counter)
783
- const oldInstance = (defCounters.get(instanceKey) ?? 0) + 1;
784
- defCounters.set(instanceKey, oldInstance);
785
- // New instance (from bridge's counter)
786
- const newInstance = (instanceCounters.get(bridgeKey) ?? 0) + 1;
787
- instanceCounters.set(bridgeKey, newInstance);
788
- const oldKey = `${oldModule}:${oldType}:${oldField}:${oldInstance}`;
789
- trunkRemap.set(oldKey, { module: oldModule, type: oldType, field: oldField, instance: newInstance });
790
- // Add internal tool handle to bridge's handle bindings (namespaced)
791
- handleBindings.push({ handle: `${defineHandle}$${hb.handle}`, kind: "tool", name });
792
- }
793
- // ── 2. Remap bridge wires involving the define handle ───────────────
794
- for (const wire of wires) {
795
- if ("from" in wire) {
796
- if (wire.to.module === genericModule) {
797
- wire.to = { ...wire.to, module: inModule };
798
- }
799
- if (wire.from.module === genericModule) {
800
- wire.from = { ...wire.from, module: outModule };
801
- }
802
- if (wire.fallbackRef?.module === genericModule) {
803
- wire.fallbackRef = { ...wire.fallbackRef, module: outModule };
804
- }
805
- }
806
- if ("value" in wire && wire.to.module === genericModule) {
807
- wire.to = { ...wire.to, module: inModule };
808
- }
809
- }
810
- // ── 3. Clone, remap, and add define's wires ────────────────────────
811
- // Compute fork instance offset (define's fork instances start at 100000,
812
- // bridge's may overlap — offset them to avoid collision)
813
- const forkOffset = nextForkSeqRef.value;
814
- let maxDefForkSeq = 0;
815
- function remapRef(ref, side) {
816
- // Define I/O trunk → split into input/output synthetic trunks
817
- if (ref.module === SELF_MODULE && ref.type === defType && ref.field === defField) {
818
- const targetModule = side === "from" ? inModule : outModule;
819
- return { ...ref, module: targetModule, type: bridgeType, field: bridgeField };
820
- }
821
- // Tool trunk → remap instance
822
- const key = `${ref.module}:${ref.type}:${ref.field}:${ref.instance ?? ""}`;
823
- const newTrunk = trunkRemap.get(key);
824
- if (newTrunk) {
825
- return { ...ref, module: newTrunk.module, type: newTrunk.type, field: newTrunk.field, instance: newTrunk.instance };
826
- }
827
- // Fork instance → offset (fork instances are >= 100000)
828
- if (ref.instance != null && ref.instance >= 100000) {
829
- const defSeq = ref.instance - 100000;
830
- if (defSeq + 1 > maxDefForkSeq)
831
- maxDefForkSeq = defSeq + 1;
832
- return { ...ref, instance: ref.instance + forkOffset };
833
- }
834
- return ref;
835
- }
836
- for (const wire of defineDef.wires) {
837
- const cloned = JSON.parse(JSON.stringify(wire));
838
- if ("from" in cloned) {
839
- cloned.from = remapRef(cloned.from, "from");
840
- cloned.to = remapRef(cloned.to, "to");
841
- if (cloned.fallbackRef) {
842
- cloned.fallbackRef = remapRef(cloned.fallbackRef, "from");
843
- }
844
- }
845
- else {
846
- // Constant wire
847
- cloned.to = remapRef(cloned.to, "to");
848
- }
849
- wires.push(cloned);
850
- }
851
- // Advance bridge's fork counter past define's forks
852
- nextForkSeqRef.value += maxDefForkSeq;
853
- // ── 4. Remap and merge pipe handles ─────────────────────────────────
854
- if (defineDef.pipeHandles) {
855
- for (const ph of defineDef.pipeHandles) {
856
- const parts = ph.key.split(":");
857
- // key format: "module:type:field:instance"
858
- const phInstance = parseInt(parts[parts.length - 1]);
859
- let newKey = ph.key;
860
- if (phInstance >= 100000) {
861
- const newInst = phInstance + forkOffset;
862
- parts[parts.length - 1] = String(newInst);
863
- newKey = parts.join(":");
864
- }
865
- // Remap baseTrunk
866
- const bt = ph.baseTrunk;
867
- const btKey = `${bt.module}:${defType}:${bt.field}:${bt.instance ?? ""}`;
868
- const newBt = trunkRemap.get(btKey);
869
- // Also try with Tools type for simple names
870
- const btKey2 = `${bt.module}:Tools:${bt.field}:${bt.instance ?? ""}`;
871
- const newBt2 = trunkRemap.get(btKey2);
872
- const resolvedBt = newBt ?? newBt2;
873
- pipeHandleEntries.push({
874
- key: newKey,
875
- handle: `${defineHandle}$${ph.handle}`,
876
- baseTrunk: resolvedBt
877
- ? { module: resolvedBt.module, type: resolvedBt.type, field: resolvedBt.field, instance: resolvedBt.instance }
878
- : ph.baseTrunk,
879
- });
880
- }
881
- }
8
+ return parseBridgeChevrotain(text);
882
9
  }
883
- /**
884
- * Resolve an address string into a structured NodeRef.
885
- *
886
- * Resolution rules:
887
- * 1. No dot, but whole address is a declared handle → handle root (path: [])
888
- * 2. No dot, not a handle → output field on the bridge trunk
889
- * 3. Prefix matches a declared handle → resolve via handle binding
890
- * 4. Otherwise → nested output path (e.g., topPick.address)
891
- */
892
- function resolveAddress(address, handles, bridgeType, bridgeField, lineNum) {
893
- const dotIndex = address.indexOf(".");
894
- if (dotIndex === -1) {
895
- // Whole address is a declared handle → resolve to its root (path: [])
896
- const resolution = handles.get(address);
897
- if (resolution) {
898
- const ref = {
899
- module: resolution.module,
900
- type: resolution.type,
901
- field: resolution.field,
902
- path: [],
903
- };
904
- if (resolution.instance != null)
905
- ref.instance = resolution.instance;
906
- return ref;
907
- }
908
- // Strict scoping: every reference must go through a declared handle.
909
- throw new Error(`${lineNum != null ? `Line ${lineNum}: ` : ""}Undeclared reference "${address}". ` +
910
- `Add 'with output as o' for output fields, or 'with ${address}' for a tool.`);
911
- }
912
- const prefix = address.substring(0, dotIndex);
913
- const rest = address.substring(dotIndex + 1);
914
- const pathParts = parsePath(rest);
915
- // Known handle
916
- const resolution = handles.get(prefix);
917
- if (resolution) {
918
- const ref = {
919
- module: resolution.module,
920
- type: resolution.type,
921
- field: resolution.field,
922
- path: pathParts,
923
- };
924
- if (resolution.instance != null) {
925
- ref.instance = resolution.instance;
926
- }
927
- return ref;
928
- }
929
- // Strict scoping: prefix must be a known handle.
930
- throw new Error(`${lineNum != null ? `Line ${lineNum}: ` : ""}Undeclared handle "${prefix}". ` +
931
- `Add 'with ${prefix}' or 'with ${prefix} as ${prefix}' to the bridge header.`);
932
- }
933
- /** Reject explicit array indices on the target (LHS) of a wire. */
934
- function assertNoTargetIndices(ref, lineNum) {
935
- if (ref.path.some((seg) => /^\d+$/.test(seg))) {
936
- throw new Error(`${lineNum != null ? `Line ${lineNum}: ` : ""}` +
937
- `Explicit array index in wire target is not supported. ` +
938
- `Use array mapping (\`[] as iter { }\`) instead.`);
939
- }
940
- }
941
- // ── Const block parser ──────────────────────────────────────────────────────
942
- /**
943
- * Parse `const` declarations into ConstDef instructions.
944
- *
945
- * Supports single-line and multi-line JSON values:
946
- * const fallbackGeo = { "lat": 0, "lon": 0 }
947
- * const bigConfig = {
948
- * "timeout": 5000,
949
- * "retries": 3
950
- * }
951
- * const defaultCurrency = "EUR"
952
- * const limit = 10
953
- */
954
- function parseConstLines(block, lineOffset) {
955
- const lines = block.split("\n");
956
- const results = [];
957
- const ln = (i) => lineOffset + i + 1;
958
- let i = 0;
959
- while (i < lines.length) {
960
- const line = lines[i].trim();
961
- if (!line || line.startsWith("#")) {
962
- i++;
963
- continue;
964
- }
965
- const constMatch = line.match(/^const\s+(\w+)\s*=\s*(.*)/i);
966
- if (!constMatch) {
967
- throw new Error(`Line ${ln(i)}: Expected const declaration, got: ${line}`);
968
- }
969
- const name = constMatch[1];
970
- assertNotReserved(name, ln(i), "const name");
971
- let valuePart = constMatch[2].trim();
972
- // Multi-line: if value starts with { or [ and isn't balanced, read more lines
973
- if (/^[{[]/.test(valuePart)) {
974
- let depth = 0;
975
- for (const ch of valuePart) {
976
- if (ch === "{" || ch === "[")
977
- depth++;
978
- if (ch === "}" || ch === "]")
979
- depth--;
980
- }
981
- while (depth > 0 && i + 1 < lines.length) {
982
- i++;
983
- const nextLine = lines[i];
984
- valuePart += "\n" + nextLine;
985
- for (const ch of nextLine) {
986
- if (ch === "{" || ch === "[")
987
- depth++;
988
- if (ch === "}" || ch === "]")
989
- depth--;
990
- }
991
- }
992
- if (depth !== 0) {
993
- throw new Error(`Line ${ln(i)}: Unbalanced brackets in const "${name}"`);
994
- }
995
- }
996
- // Validate the value is parseable JSON
997
- const jsonValue = valuePart.trim();
998
- try {
999
- JSON.parse(jsonValue);
1000
- }
1001
- catch {
1002
- throw new Error(`Line ${ln(i)}: Invalid JSON value for const "${name}": ${jsonValue}`);
1003
- }
1004
- results.push({ kind: "const", name, value: jsonValue });
1005
- i++;
1006
- }
1007
- return results;
1008
- }
1009
- // ── Define block parser ─────────────────────────────────────────────────────
1010
- /**
1011
- * Parse a `define` block into a DefineDef instruction.
1012
- *
1013
- * Delegates to parseBridgeBlock with a synthetic `bridge Define.<name>` header,
1014
- * then converts the resulting Bridge to a DefineDef template.
1015
- *
1016
- * Example:
1017
- * define secureProfile {
1018
- * with userApi as api
1019
- * with input as i
1020
- * with output as o
1021
- * api.id <- i.userId
1022
- * o.name <- api.login
1023
- * }
1024
- */
1025
- function parseDefineBlock(block, lineOffset) {
1026
- const rawLines = block.split("\n");
1027
- // Find the define header line
1028
- let headerIdx = -1;
1029
- let defineName = "";
1030
- for (let i = 0; i < rawLines.length; i++) {
1031
- const line = rawLines[i].trim();
1032
- if (!line || line.startsWith("#"))
1033
- continue;
1034
- const m = line.match(/^define\s+(\w+)\s*\{?\s*$/i);
1035
- if (!m) {
1036
- throw new Error(`Line ${lineOffset + i + 1}: Expected define declaration: define <name> {. Got: ${line}`);
1037
- }
1038
- defineName = m[1];
1039
- assertNotReserved(defineName, lineOffset + i + 1, "define name");
1040
- headerIdx = i;
1041
- break;
1042
- }
1043
- if (!defineName) {
1044
- throw new Error(`Line ${lineOffset + 1}: Missing define declaration`);
1045
- }
1046
- // Validate braces
1047
- const kw = rawLines[headerIdx].trim();
1048
- if (!kw.endsWith("{")) {
1049
- throw new Error(`Line ${lineOffset + headerIdx + 1}: define block must use braces: define ${defineName} {`);
1050
- }
1051
- const hasClose = rawLines.some((l) => l.trimEnd() === "}");
1052
- if (!hasClose) {
1053
- throw new Error(`Line ${lineOffset + headerIdx + 1}: define block missing closing }`);
1054
- }
1055
- // Rewrite header to a synthetic bridge: `bridge Define.<name> {`
1056
- const syntheticLines = [...rawLines];
1057
- syntheticLines[headerIdx] = rawLines[headerIdx]
1058
- .replace(/^(\s*)define\s+\w+/i, `$1bridge Define.${defineName}`);
1059
- const syntheticBlock = syntheticLines.join("\n");
1060
- const results = parseBridgeBlock(syntheticBlock, lineOffset);
1061
- const bridge = results[0];
1062
- return {
1063
- kind: "define",
1064
- name: defineName,
1065
- handles: bridge.handles,
1066
- wires: bridge.wires,
1067
- ...(bridge.arrayIterators ? { arrayIterators: bridge.arrayIterators } : {}),
1068
- ...(bridge.pipeHandles ? { pipeHandles: bridge.pipeHandles } : {}),
1069
- };
1070
- }
1071
- // ── Tool block parser ───────────────────────────────────────────────────────
1072
- /**
1073
- * Parse a `tool` block into a ToolDef instruction.
1074
- *
1075
- * Format:
1076
- * tool hereapi from httpCall {
1077
- * with context
1078
- * .baseUrl = "https://geocode.search.hereapi.com/v1"
1079
- * .headers.apiKey <- context.hereapi.apiKey
1080
- * }
1081
- *
1082
- * tool hereapi.geocode from hereapi {
1083
- * .method = GET
1084
- * .path = /geocode
1085
- * }
1086
- *
1087
- * When the source matches a previously-defined tool name,
1088
- * it's treated as inheritance (child inherits parent). Otherwise the source
1089
- * is treated as a function name.
1090
- */
1091
- function parseToolBlock(block, lineOffset, previousInstructions) {
1092
- // Validate mandatory braces for blocks that have a body (deps / wires)
1093
- const rawLines = block.split("\n");
1094
- const keywordIdx = rawLines.findIndex((l) => /^tool\s/i.test(l.trim()));
1095
- if (keywordIdx !== -1) {
1096
- // Check if there are non-blank, non-comment body lines after the keyword
1097
- const bodyLines = rawLines.slice(keywordIdx + 1).filter((l) => {
1098
- const t = l.trim();
1099
- return t !== "" && !t.startsWith("#") && t !== "}";
1100
- });
1101
- const kw = rawLines[keywordIdx].trim();
1102
- if (bodyLines.length > 0) {
1103
- if (!kw.endsWith("{")) {
1104
- throw new Error(`Line ${lineOffset + keywordIdx + 1}: tool block with body must use braces: tool foo from bar {`);
1105
- }
1106
- const hasClose = rawLines.some((l) => l.trimEnd() === "}");
1107
- if (!hasClose) {
1108
- throw new Error(`Line ${lineOffset + keywordIdx + 1}: tool block missing closing }`);
1109
- }
1110
- }
1111
- }
1112
- // Strip braces for internal parsing
1113
- const lines = rawLines.map((l) => {
1114
- const trimmed = l.trimEnd();
1115
- if (trimmed === "}")
1116
- return "";
1117
- if (/^tool\s/i.test(trimmed) && trimmed.endsWith("{"))
1118
- return trimmed.replace(/\s*\{\s*$/, "");
1119
- return trimmed;
1120
- });
1121
- /** 1-based global line number for error messages */
1122
- const ln = (i) => lineOffset + i + 1;
1123
- let toolName = "";
1124
- let toolFn;
1125
- let toolExtends;
1126
- const deps = [];
1127
- const wires = [];
1128
- for (let i = 0; i < lines.length; i++) {
1129
- const raw = lines[i];
1130
- const line = raw.trim();
1131
- if (!line || line.startsWith("#"))
1132
- continue;
1133
- // Tool declaration: tool <name> from <source>
1134
- if (/^tool\s/i.test(line)) {
1135
- const toolMatch = line.match(/^tool\s+(\S+)\s+from\s+(\S+)$/i);
1136
- if (!toolMatch) {
1137
- throw new Error(`Line ${ln(i)}: Invalid tool declaration: ${line}. Expected: tool <name> from <source>`);
1138
- }
1139
- toolName = toolMatch[1];
1140
- const source = toolMatch[2];
1141
- assertNotReserved(toolName, ln(i), "tool name");
1142
- // If source matches a previously-defined tool, it's inheritance; otherwise it's a function name
1143
- const isKnownTool = previousInstructions?.some((inst) => inst.kind === "tool" && inst.name === source);
1144
- if (isKnownTool) {
1145
- toolExtends = source;
1146
- }
1147
- else {
1148
- toolFn = source;
1149
- }
1150
- continue;
1151
- }
1152
- // with context or with context as <handle>
1153
- const contextMatch = line.match(/^with\s+context(?:\s+as\s+(\w+))?$/i);
1154
- if (contextMatch) {
1155
- const handle = contextMatch[1] ?? "context";
1156
- deps.push({ kind: "context", handle });
1157
- continue;
1158
- }
1159
- // with const or with const as <handle>
1160
- const constDepMatch = line.match(/^with\s+const(?:\s+as\s+(\w+))?$/i);
1161
- if (constDepMatch) {
1162
- const handle = constDepMatch[1] ?? "const";
1163
- deps.push({ kind: "const", handle });
1164
- continue;
1165
- }
1166
- // with <tool> as <handle>
1167
- const toolDepMatch = line.match(/^with\s+(\S+)\s+as\s+(\w+)$/i);
1168
- if (toolDepMatch) {
1169
- deps.push({ kind: "tool", handle: toolDepMatch[2], tool: toolDepMatch[1] });
1170
- continue;
1171
- }
1172
- // on error = <json> (constant fallback)
1173
- const onErrorConstMatch = line.match(/^on\s+error\s*=\s*(.+)$/i);
1174
- if (onErrorConstMatch) {
1175
- let valuePart = onErrorConstMatch[1].trim();
1176
- // Multi-line JSON: if starts with { or [ and isn't balanced, read more lines
1177
- if (/^[{[]/.test(valuePart)) {
1178
- let depth = 0;
1179
- for (const ch of valuePart) {
1180
- if (ch === "{" || ch === "[")
1181
- depth++;
1182
- if (ch === "}" || ch === "]")
1183
- depth--;
1184
- }
1185
- while (depth > 0 && i + 1 < lines.length) {
1186
- i++;
1187
- const nextLine = lines[i];
1188
- valuePart += "\n" + nextLine;
1189
- for (const ch of nextLine) {
1190
- if (ch === "{" || ch === "[")
1191
- depth++;
1192
- if (ch === "}" || ch === "]")
1193
- depth--;
1194
- }
1195
- }
1196
- }
1197
- wires.push({ kind: "onError", value: valuePart.trim() });
1198
- continue;
1199
- }
1200
- // on error <- source (pull fallback from context/dep)
1201
- const onErrorPullMatch = line.match(/^on\s+error\s*<-\s*(\S+)$/i);
1202
- if (onErrorPullMatch) {
1203
- wires.push({ kind: "onError", source: onErrorPullMatch[1] });
1204
- continue;
1205
- }
1206
- // Constant wire: .target = "value" or .target = value (unquoted)
1207
- const constantMatch = line.match(/^\.(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
1208
- if (constantMatch) {
1209
- const value = constantMatch[2] ?? constantMatch[3];
1210
- wires.push({
1211
- target: constantMatch[1],
1212
- kind: "constant",
1213
- value,
1214
- });
1215
- continue;
1216
- }
1217
- // Pull wire: .target <- source
1218
- const pullMatch = line.match(/^\.(\S+)\s*<-\s*(\S+)$/);
1219
- if (pullMatch) {
1220
- wires.push({ target: pullMatch[1], kind: "pull", source: pullMatch[2] });
1221
- continue;
1222
- }
1223
- // Catch bare param lines without leading dot — give a helpful error
1224
- if (/^[a-zA-Z]/.test(line)) {
1225
- throw new Error(`Line ${ln(i)}: Tool params require a dot prefix: ".${line.split(/[\s=<]/)[0]} ...". Only 'with' and 'on error' lines are unprefixed.`);
1226
- }
1227
- throw new Error(`Line ${ln(i)}: Unrecognized tool line: ${line}`);
1228
- }
1229
- if (!toolName)
1230
- throw new Error(`Line ${ln(0)}: Missing tool name`);
1231
- return {
1232
- kind: "tool",
1233
- name: toolName,
1234
- fn: toolFn,
1235
- extends: toolExtends,
1236
- deps,
1237
- wires,
1238
- };
1239
- }
1240
- // ── Path parser ─────────────────────────────────────────────────────────────
1241
- /**
1242
- * Parse a dot-separated path with optional array indices.
1243
- *
1244
- * "items[0].position.lat" → ["items", "0", "position", "lat"]
1245
- * "properties[]" → ["properties"] ([] is stripped, signals array)
1246
- * "x-message-id" → ["x-message-id"]
1247
- */
1248
- export function parsePath(text) {
1249
- const parts = [];
1250
- for (const segment of text.split(".")) {
1251
- const match = segment.match(/^([^[]+)(?:\[(\d*)\])?$/);
1252
- if (match) {
1253
- parts.push(match[1]);
1254
- if (match[2] !== undefined && match[2] !== "") {
1255
- parts.push(match[2]);
1256
- }
1257
- }
1258
- else {
1259
- parts.push(segment);
1260
- }
1261
- }
1262
- return parts;
1263
- }
1264
- // ── Serializer ──────────────────────────────────────────────────────────────
1265
- /**
1266
- * Serialize structured instructions back to .bridge text format.
1267
- */
10
+ const BRIDGE_VERSION = "1.4";
11
+ // ── Serializer ───────────────────────────────────────────────────────────────
1268
12
  export function serializeBridge(instructions) {
1269
13
  const bridges = instructions.filter((i) => i.kind === "bridge");
1270
14
  const tools = instructions.filter((i) => i.kind === "tool");
@@ -1288,6 +32,21 @@ export function serializeBridge(instructions) {
1288
32
  }
1289
33
  return `version ${BRIDGE_VERSION}\n\n` + blocks.join("\n\n") + "\n";
1290
34
  }
35
+ /**
36
+ * Whether a value string needs quoting to be re-parseable as a bare value.
37
+ * Safe unquoted: number, boolean, null, /path, simple-identifier, keyword.
38
+ */
39
+ function needsQuoting(v) {
40
+ if (v === "" || v === "true" || v === "false" || v === "null")
41
+ return false;
42
+ if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(v))
43
+ return false; // number
44
+ if (/^\/[\w./-]*$/.test(v))
45
+ return false; // /path
46
+ if (/^[a-zA-Z_][\w-]*$/.test(v))
47
+ return false; // identifier / keyword
48
+ return true;
49
+ }
1291
50
  function serializeToolBlock(tool) {
1292
51
  const lines = [];
1293
52
  const hasBody = tool.deps.length > 0 || tool.wires.length > 0;
@@ -1327,8 +86,7 @@ function serializeToolBlock(tool) {
1327
86
  }
1328
87
  }
1329
88
  else if (wire.kind === "constant") {
1330
- // Use quoted form if value contains spaces or special chars, unquoted otherwise
1331
- if (/\s/.test(wire.value) || wire.value === "") {
89
+ if (needsQuoting(wire.value)) {
1332
90
  lines.push(` .${wire.target} = "${wire.value}"`);
1333
91
  }
1334
92
  else {