@stackables/bridge 1.0.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.
@@ -0,0 +1,1056 @@
1
+ import { SELF_MODULE } from "./types.js";
2
+ // ── Parser ──────────────────────────────────────────────────────────────────
3
+ /**
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
+ export function parseBridge(text) {
14
+ // Normalize: CRLF → LF, tabs → 2 spaces
15
+ const normalized = text.replace(/\r\n?/g, "\n").replace(/\t/g, " ");
16
+ const allLines = normalized.split("\n");
17
+ // Find separator lines (--- with optional surrounding whitespace)
18
+ const isSep = (line) => /^\s*---\s*$/.test(line);
19
+ // Collect block ranges as [start, end) line indices
20
+ const blockRanges = [];
21
+ let blockStart = 0;
22
+ for (let i = 0; i < allLines.length; i++) {
23
+ if (isSep(allLines[i])) {
24
+ blockRanges.push({ start: blockStart, end: i });
25
+ blockStart = i + 1;
26
+ }
27
+ }
28
+ blockRanges.push({ start: blockStart, end: allLines.length });
29
+ const instructions = [];
30
+ for (const { start, end } of blockRanges) {
31
+ const blockLines = allLines.slice(start, end);
32
+ // Split into sub-blocks by top-level `tool` or `bridge` keywords
33
+ const subBlocks = [];
34
+ let currentLines = [];
35
+ let currentOffset = start;
36
+ for (let i = 0; i < blockLines.length; i++) {
37
+ const trimmed = blockLines[i].trim();
38
+ if (/^(tool|bridge|const|extend)\s/i.test(trimmed) && currentLines.length > 0) {
39
+ // Check if any non-blank content exists
40
+ if (currentLines.some((l) => l.trim())) {
41
+ subBlocks.push({ startOffset: currentOffset, lines: currentLines });
42
+ }
43
+ currentLines = [blockLines[i]];
44
+ currentOffset = start + i;
45
+ }
46
+ else {
47
+ currentLines.push(blockLines[i]);
48
+ }
49
+ }
50
+ if (currentLines.some((l) => l.trim())) {
51
+ subBlocks.push({ startOffset: currentOffset, lines: currentLines });
52
+ }
53
+ for (const sub of subBlocks) {
54
+ const subText = sub.lines.join("\n").trim();
55
+ if (!subText)
56
+ continue;
57
+ let firstContentLine = 0;
58
+ while (firstContentLine < sub.lines.length && !sub.lines[firstContentLine].trim())
59
+ firstContentLine++;
60
+ const firstLine = sub.lines[firstContentLine]?.trim();
61
+ if (firstLine && /^(tool|extend)\s/i.test(firstLine)) {
62
+ instructions.push(parseToolBlock(subText, sub.startOffset + firstContentLine, instructions));
63
+ }
64
+ else if (firstLine && /^bridge\s/i.test(firstLine)) {
65
+ instructions.push(...parseBridgeBlock(subText, sub.startOffset + firstContentLine));
66
+ }
67
+ else if (firstLine && /^const\s/i.test(firstLine)) {
68
+ instructions.push(...parseConstLines(subText, sub.startOffset + firstContentLine));
69
+ }
70
+ else if (firstLine && !firstLine.startsWith("#")) {
71
+ throw new Error(`Line ${sub.startOffset + firstContentLine + 1}: Expected "tool", "extend", "bridge", or "const" declaration, got: ${firstLine}`);
72
+ }
73
+ }
74
+ }
75
+ return instructions;
76
+ }
77
+ // ── Bridge block parser ─────────────────────────────────────────────────────
78
+ function parseBridgeBlock(block, lineOffset) {
79
+ const lines = block.split("\n").map((l) => l.trimEnd());
80
+ const instructions = [];
81
+ /** 1-based global line number for error messages */
82
+ const ln = (i) => lineOffset + i + 1;
83
+ // ── Parse header ────────────────────────────────────────────────────
84
+ let bridgeType = "";
85
+ let bridgeField = "";
86
+ const handleRes = new Map();
87
+ const handleBindings = [];
88
+ const instanceCounters = new Map();
89
+ let bodyStartIndex = 0;
90
+ for (let i = 0; i < lines.length; i++) {
91
+ const line = lines[i].trim();
92
+ if (!line || line.startsWith("#")) {
93
+ continue;
94
+ }
95
+ if (/^bridge\s/i.test(line)) {
96
+ const match = line.match(/^bridge\s+(\w+)\.(\w+)$/i);
97
+ if (!match)
98
+ throw new Error(`Line ${ln(i)}: Invalid bridge declaration: ${line}`);
99
+ bridgeType = match[1];
100
+ bridgeField = match[2];
101
+ continue;
102
+ }
103
+ if (/^with\s/i.test(line)) {
104
+ if (!bridgeType) {
105
+ throw new Error(`Line ${ln(i)}: "with" declaration must come after "bridge" declaration`);
106
+ }
107
+ parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBindings, instanceCounters, instructions, ln(i));
108
+ continue;
109
+ }
110
+ // First non-header line — body starts here
111
+ bodyStartIndex = i;
112
+ break;
113
+ }
114
+ if (!bridgeType || !bridgeField) {
115
+ throw new Error(`Line ${ln(0)}: Missing bridge declaration`);
116
+ }
117
+ // ── Parse wire lines ────────────────────────────────────────────────
118
+ const wires = [];
119
+ let currentArrayToPath = null;
120
+ /** Monotonically-increasing index; combined with a high base to produce
121
+ * fork instances that can never collide with regular handle instances. */
122
+ let nextForkSeq = 0;
123
+ const pipeHandleEntries = [];
124
+ for (let i = bodyStartIndex; i < lines.length; i++) {
125
+ const raw = lines[i];
126
+ const line = raw.trim();
127
+ if (!line || line.startsWith("#")) {
128
+ continue;
129
+ }
130
+ // Element mapping: indented line starting with "."
131
+ const indent = raw.search(/\S/);
132
+ if (indent >= 2 && line.startsWith(".") && currentArrayToPath) {
133
+ const match = line.match(/^\.(\S+)\s*<-\s*\.(\S+)$/);
134
+ if (!match)
135
+ throw new Error(`Line ${ln(i)}: Invalid element mapping: ${line}`);
136
+ const toPath = [...currentArrayToPath, ...parsePath(match[1])];
137
+ const fromPath = parsePath(match[2]);
138
+ wires.push({
139
+ from: {
140
+ module: SELF_MODULE,
141
+ type: bridgeType,
142
+ field: bridgeField,
143
+ element: true,
144
+ path: fromPath,
145
+ },
146
+ to: {
147
+ module: SELF_MODULE,
148
+ type: bridgeType,
149
+ field: bridgeField,
150
+ path: toPath,
151
+ },
152
+ });
153
+ continue;
154
+ }
155
+ // End of array mapping block
156
+ currentArrayToPath = null;
157
+ // Constant wire: target = "value" or target = value (unquoted)
158
+ const constantMatch = line.match(/^(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
159
+ if (constantMatch) {
160
+ const [, targetStr, quotedValue, unquotedValue] = constantMatch;
161
+ const value = quotedValue ?? unquotedValue;
162
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
163
+ wires.push({ value, to: toRef });
164
+ continue;
165
+ }
166
+ // Wire: target <- source OR target <-! source (forced)
167
+ // Optional fallback: target <- source ?? <json_value>
168
+ const arrowMatch = line.match(/^(\S+)\s*<-(!?)\s*(\S+(?:\|\S+)*)(?:\s*\?\?\s*(.+))?$/);
169
+ if (arrowMatch) {
170
+ const [, targetStr, forceFlag, sourceStr, fallbackRaw] = arrowMatch;
171
+ const force = forceFlag === "!";
172
+ const fallback = fallbackRaw?.trim();
173
+ // Array mapping: target[] <- source[]
174
+ if (targetStr.endsWith("[]") && sourceStr.endsWith("[]")) {
175
+ const toClean = targetStr.slice(0, -2);
176
+ const fromClean = sourceStr.slice(0, -2);
177
+ const fromRef = resolveAddress(fromClean, handleRes, bridgeType, bridgeField);
178
+ const toRef = resolveAddress(toClean, handleRes, bridgeType, bridgeField);
179
+ wires.push({ from: fromRef, to: toRef });
180
+ currentArrayToPath = toRef.path;
181
+ continue;
182
+ }
183
+ // Pipe chain: target <- tok1|tok2|...|source
184
+ // Each token is either "handle" (input field defaults to "in") or
185
+ // "handle.field" (explicit input field name).
186
+ // Every token creates an INDEPENDENT fork — a fresh tool invocation with
187
+ // its own instance number — so repeated use of the same handle produces
188
+ // separate calls.
189
+ // Execution order: source → tokN → … → tok1 → target (right-to-left).
190
+ const parts = sourceStr.split("|");
191
+ if (parts.length > 1) {
192
+ const actualSource = parts[parts.length - 1];
193
+ const tokenChain = parts.slice(0, -1); // [tok1, …, tokN] outermost→innermost
194
+ /** Parse "handle" or "handle.field" → {handleName, fieldName} */
195
+ const parseToken = (t) => {
196
+ const dot = t.indexOf(".");
197
+ return dot === -1
198
+ ? { handleName: t, fieldName: "in" }
199
+ : { handleName: t.substring(0, dot), fieldName: t.substring(dot + 1) };
200
+ };
201
+ for (const tok of tokenChain) {
202
+ const { handleName } = parseToken(tok);
203
+ if (!handleRes.has(handleName)) {
204
+ throw new Error(`Line ${ln(i)}: Undeclared handle in pipe: "${handleName}". Add 'with <tool> as ${handleName}' to the bridge header.`);
205
+ }
206
+ }
207
+ let prevOutRef = resolveAddress(actualSource, handleRes, bridgeType, bridgeField);
208
+ const reversedTokens = [...tokenChain].reverse();
209
+ for (let idx = 0; idx < reversedTokens.length; idx++) {
210
+ const tok = reversedTokens[idx];
211
+ const { handleName, fieldName } = parseToken(tok);
212
+ const res = handleRes.get(handleName);
213
+ // Allocate a unique fork instance (100000+ avoids collision with
214
+ // regular instances which start at 1).
215
+ const forkInstance = 100000 + nextForkSeq++;
216
+ const forkKey = `${res.module}:${res.type}:${res.field}:${forkInstance}`;
217
+ pipeHandleEntries.push({
218
+ key: forkKey,
219
+ handle: handleName,
220
+ baseTrunk: { module: res.module, type: res.type, field: res.field, instance: res.instance },
221
+ });
222
+ const forkInRef = { module: res.module, type: res.type, field: res.field, instance: forkInstance, path: parsePath(fieldName) };
223
+ const forkRootRef = { module: res.module, type: res.type, field: res.field, instance: forkInstance, path: [] };
224
+ const isOutermost = idx === reversedTokens.length - 1;
225
+ wires.push({ from: prevOutRef, to: forkInRef, pipe: true, ...(force && isOutermost ? { force: true } : {}) });
226
+ prevOutRef = forkRootRef;
227
+ }
228
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
229
+ wires.push({ from: prevOutRef, to: toRef, pipe: true, ...(fallback ? { fallback } : {}) });
230
+ continue;
231
+ }
232
+ const fromRef = resolveAddress(sourceStr, handleRes, bridgeType, bridgeField);
233
+ const toRef = resolveAddress(targetStr, handleRes, bridgeType, bridgeField);
234
+ wires.push({
235
+ from: fromRef,
236
+ to: toRef,
237
+ ...(force ? { force: true } : {}),
238
+ ...(fallback ? { fallback } : {}),
239
+ });
240
+ continue;
241
+ }
242
+ throw new Error(`Line ${ln(i)}: Unrecognized line: ${line}`);
243
+ }
244
+ instructions.unshift({
245
+ kind: "bridge",
246
+ type: bridgeType,
247
+ field: bridgeField,
248
+ handles: handleBindings,
249
+ wires,
250
+ pipeHandles: pipeHandleEntries.length > 0 ? pipeHandleEntries : undefined,
251
+ });
252
+ return instructions;
253
+ }
254
+ /**
255
+ * Parse a `with` declaration into handle bindings + resolution map.
256
+ *
257
+ * Supported forms:
258
+ * with <name> as <handle> — tool reference (dotted or simple name)
259
+ * with <name> — shorthand: handle defaults to last segment of name
260
+ * with input as <handle>
261
+ * with context as <handle>
262
+ * with context — shorthand for `with context as context`
263
+ */
264
+ function parseWithDeclaration(line, bridgeType, bridgeField, handleRes, handleBindings, instanceCounters, instructions, lineNum) {
265
+ /** Guard: reject duplicate handle names */
266
+ const checkDuplicate = (handle) => {
267
+ if (handleRes.has(handle)) {
268
+ throw new Error(`Line ${lineNum}: Duplicate handle name "${handle}"`);
269
+ }
270
+ };
271
+ // with input as <handle>
272
+ let match = line.match(/^with\s+input\s+as\s+(\w+)$/i);
273
+ if (match) {
274
+ const handle = match[1];
275
+ checkDuplicate(handle);
276
+ handleBindings.push({ handle, kind: "input" });
277
+ handleRes.set(handle, {
278
+ module: SELF_MODULE,
279
+ type: bridgeType,
280
+ field: bridgeField,
281
+ });
282
+ return;
283
+ }
284
+ // with context as <handle>
285
+ match = line.match(/^with\s+context\s+as\s+(\w+)$/i);
286
+ if (match) {
287
+ const handle = match[1];
288
+ checkDuplicate(handle);
289
+ handleBindings.push({ handle, kind: "context" });
290
+ handleRes.set(handle, {
291
+ module: SELF_MODULE,
292
+ type: "Context",
293
+ field: "context",
294
+ });
295
+ return;
296
+ }
297
+ // with context (shorthand — handle defaults to "context")
298
+ match = line.match(/^with\s+context$/i);
299
+ if (match) {
300
+ const handle = "context";
301
+ checkDuplicate(handle);
302
+ handleBindings.push({ handle, kind: "context" });
303
+ handleRes.set(handle, {
304
+ module: SELF_MODULE,
305
+ type: "Context",
306
+ field: "context",
307
+ });
308
+ return;
309
+ }
310
+ // with const as <handle>
311
+ match = line.match(/^with\s+const\s+as\s+(\w+)$/i);
312
+ if (match) {
313
+ const handle = match[1];
314
+ checkDuplicate(handle);
315
+ handleBindings.push({ handle, kind: "const" });
316
+ handleRes.set(handle, {
317
+ module: SELF_MODULE,
318
+ type: "Const",
319
+ field: "const",
320
+ });
321
+ return;
322
+ }
323
+ // with const (shorthand — handle defaults to "const")
324
+ match = line.match(/^with\s+const$/i);
325
+ if (match) {
326
+ const handle = "const";
327
+ checkDuplicate(handle);
328
+ handleBindings.push({ handle, kind: "const" });
329
+ handleRes.set(handle, {
330
+ module: SELF_MODULE,
331
+ type: "Const",
332
+ field: "const",
333
+ });
334
+ return;
335
+ }
336
+ // with <name> as <handle> — tool reference (covers dotted names like hereapi.geocode)
337
+ match = line.match(/^with\s+(\S+)\s+as\s+(\w+)$/i);
338
+ if (match) {
339
+ const name = match[1];
340
+ const handle = match[2];
341
+ checkDuplicate(handle);
342
+ // Split dotted name into module.field for NodeRef resolution
343
+ const lastDot = name.lastIndexOf(".");
344
+ if (lastDot !== -1) {
345
+ const modulePart = name.substring(0, lastDot);
346
+ const fieldPart = name.substring(lastDot + 1);
347
+ const key = `${modulePart}:${fieldPart}`;
348
+ const instance = (instanceCounters.get(key) ?? 0) + 1;
349
+ instanceCounters.set(key, instance);
350
+ handleBindings.push({ handle, kind: "tool", name });
351
+ handleRes.set(handle, {
352
+ module: modulePart,
353
+ type: bridgeType,
354
+ field: fieldPart,
355
+ instance,
356
+ });
357
+ }
358
+ else {
359
+ // Simple name — inline tool function
360
+ const key = `Tools:${name}`;
361
+ const instance = (instanceCounters.get(key) ?? 0) + 1;
362
+ instanceCounters.set(key, instance);
363
+ handleBindings.push({ handle, kind: "tool", name });
364
+ handleRes.set(handle, {
365
+ module: SELF_MODULE,
366
+ type: "Tools",
367
+ field: name,
368
+ instance,
369
+ });
370
+ }
371
+ return;
372
+ }
373
+ // with <name> — shorthand: handle defaults to the last segment of name
374
+ // Must come after the `with input` / `with context` guards above.
375
+ match = line.match(/^with\s+(\S+)$/i);
376
+ if (match) {
377
+ const name = match[1];
378
+ const lastDot = name.lastIndexOf(".");
379
+ const handle = lastDot !== -1 ? name.substring(lastDot + 1) : name;
380
+ checkDuplicate(handle);
381
+ if (lastDot !== -1) {
382
+ const modulePart = name.substring(0, lastDot);
383
+ const fieldPart = name.substring(lastDot + 1);
384
+ const key = `${modulePart}:${fieldPart}`;
385
+ const instance = (instanceCounters.get(key) ?? 0) + 1;
386
+ instanceCounters.set(key, instance);
387
+ handleBindings.push({ handle, kind: "tool", name });
388
+ handleRes.set(handle, { module: modulePart, type: bridgeType, field: fieldPart, instance });
389
+ }
390
+ else {
391
+ const key = `Tools:${name}`;
392
+ const instance = (instanceCounters.get(key) ?? 0) + 1;
393
+ instanceCounters.set(key, instance);
394
+ handleBindings.push({ handle, kind: "tool", name });
395
+ handleRes.set(handle, { module: SELF_MODULE, type: "Tools", field: name, instance });
396
+ }
397
+ return;
398
+ }
399
+ throw new Error(`Line ${lineNum}: Invalid with declaration: ${line}`);
400
+ }
401
+ /**
402
+ * Resolve an address string into a structured NodeRef.
403
+ *
404
+ * Resolution rules:
405
+ * 1. No dot, but whole address is a declared handle → handle root (path: [])
406
+ * 2. No dot, not a handle → output field on the bridge trunk
407
+ * 3. Prefix matches a declared handle → resolve via handle binding
408
+ * 4. Otherwise → nested output path (e.g., topPick.address)
409
+ */
410
+ function resolveAddress(address, handles, bridgeType, bridgeField) {
411
+ const dotIndex = address.indexOf(".");
412
+ if (dotIndex === -1) {
413
+ // Whole address is a declared handle → resolve to its root (path: [])
414
+ const resolution = handles.get(address);
415
+ if (resolution) {
416
+ const ref = {
417
+ module: resolution.module,
418
+ type: resolution.type,
419
+ field: resolution.field,
420
+ path: [],
421
+ };
422
+ if (resolution.instance != null)
423
+ ref.instance = resolution.instance;
424
+ return ref;
425
+ }
426
+ // No dot, not a handle — output reference on bridge trunk
427
+ return {
428
+ module: SELF_MODULE,
429
+ type: bridgeType,
430
+ field: bridgeField,
431
+ path: parsePath(address),
432
+ };
433
+ }
434
+ const prefix = address.substring(0, dotIndex);
435
+ const rest = address.substring(dotIndex + 1);
436
+ const pathParts = parsePath(rest);
437
+ // Known handle
438
+ const resolution = handles.get(prefix);
439
+ if (resolution) {
440
+ const ref = {
441
+ module: resolution.module,
442
+ type: resolution.type,
443
+ field: resolution.field,
444
+ path: pathParts,
445
+ };
446
+ if (resolution.instance != null) {
447
+ ref.instance = resolution.instance;
448
+ }
449
+ return ref;
450
+ }
451
+ // No handle match — nested local path (e.g., topPick.address)
452
+ // UNLESS the prefix IS the bridge field itself (e.g., doubled.a when bridge is Query.doubled)
453
+ // — in that case strip the prefix so path = ["a"], matching the GraphQL resolver path.
454
+ if (prefix === bridgeField) {
455
+ return {
456
+ module: SELF_MODULE,
457
+ type: bridgeType,
458
+ field: bridgeField,
459
+ path: pathParts,
460
+ };
461
+ }
462
+ return {
463
+ module: SELF_MODULE,
464
+ type: bridgeType,
465
+ field: bridgeField,
466
+ path: [prefix, ...pathParts],
467
+ };
468
+ }
469
+ // ── Const block parser ──────────────────────────────────────────────────────
470
+ /**
471
+ * Parse `const` declarations into ConstDef instructions.
472
+ *
473
+ * Supports single-line and multi-line JSON values:
474
+ * const fallbackGeo = { "lat": 0, "lon": 0 }
475
+ * const bigConfig = {
476
+ * "timeout": 5000,
477
+ * "retries": 3
478
+ * }
479
+ * const defaultCurrency = "EUR"
480
+ * const limit = 10
481
+ */
482
+ function parseConstLines(block, lineOffset) {
483
+ const lines = block.split("\n");
484
+ const results = [];
485
+ const ln = (i) => lineOffset + i + 1;
486
+ let i = 0;
487
+ while (i < lines.length) {
488
+ const line = lines[i].trim();
489
+ if (!line || line.startsWith("#")) {
490
+ i++;
491
+ continue;
492
+ }
493
+ const constMatch = line.match(/^const\s+(\w+)\s*=\s*(.*)/i);
494
+ if (!constMatch) {
495
+ throw new Error(`Line ${ln(i)}: Expected const declaration, got: ${line}`);
496
+ }
497
+ const name = constMatch[1];
498
+ let valuePart = constMatch[2].trim();
499
+ // Multi-line: if value starts with { or [ and isn't balanced, read more lines
500
+ if (/^[{[]/.test(valuePart)) {
501
+ let depth = 0;
502
+ for (const ch of valuePart) {
503
+ if (ch === "{" || ch === "[")
504
+ depth++;
505
+ if (ch === "}" || ch === "]")
506
+ depth--;
507
+ }
508
+ while (depth > 0 && i + 1 < lines.length) {
509
+ i++;
510
+ const nextLine = lines[i];
511
+ valuePart += "\n" + nextLine;
512
+ for (const ch of nextLine) {
513
+ if (ch === "{" || ch === "[")
514
+ depth++;
515
+ if (ch === "}" || ch === "]")
516
+ depth--;
517
+ }
518
+ }
519
+ if (depth !== 0) {
520
+ throw new Error(`Line ${ln(i)}: Unbalanced brackets in const "${name}"`);
521
+ }
522
+ }
523
+ // Validate the value is parseable JSON
524
+ const jsonValue = valuePart.trim();
525
+ try {
526
+ JSON.parse(jsonValue);
527
+ }
528
+ catch {
529
+ throw new Error(`Line ${ln(i)}: Invalid JSON value for const "${name}": ${jsonValue}`);
530
+ }
531
+ results.push({ kind: "const", name, value: jsonValue });
532
+ i++;
533
+ }
534
+ return results;
535
+ }
536
+ // ── Tool block parser ───────────────────────────────────────────────────────
537
+ /**
538
+ * Parse a `tool` or `extend` block into a ToolDef instruction.
539
+ *
540
+ * Legacy format (root tool):
541
+ * tool hereapi httpCall
542
+ * with context
543
+ * baseUrl = "https://geocode.search.hereapi.com/v1"
544
+ * headers.apiKey <- context.hereapi.apiKey
545
+ *
546
+ * Legacy format (child tool with extends):
547
+ * tool hereapi.geocode extends hereapi
548
+ * method = GET
549
+ * path = /geocode
550
+ *
551
+ * New format (extend):
552
+ * extend httpCall as hereapi
553
+ * with context
554
+ * baseUrl = "https://geocode.search.hereapi.com/v1"
555
+ *
556
+ * extend hereapi as hereapi.geocode
557
+ * method = GET
558
+ * path = /geocode
559
+ *
560
+ * When using `extend`, if the source matches a previously-defined tool name,
561
+ * it's treated as an extends (child inherits parent). Otherwise the source
562
+ * is treated as a function name.
563
+ */
564
+ function parseToolBlock(block, lineOffset, previousInstructions) {
565
+ const lines = block.split("\n").map((l) => l.trimEnd());
566
+ /** 1-based global line number for error messages */
567
+ const ln = (i) => lineOffset + i + 1;
568
+ let toolName = "";
569
+ let toolFn;
570
+ let toolExtends;
571
+ const deps = [];
572
+ const wires = [];
573
+ for (let i = 0; i < lines.length; i++) {
574
+ const raw = lines[i];
575
+ const line = raw.trim();
576
+ if (!line || line.startsWith("#"))
577
+ continue;
578
+ // Tool declaration: tool <name> <fn> or tool <name> extends <parent>
579
+ if (/^tool\s/i.test(line)) {
580
+ const extendsMatch = line.match(/^tool\s+(\S+)\s+extends\s+(\S+)$/i);
581
+ if (extendsMatch) {
582
+ toolName = extendsMatch[1];
583
+ toolExtends = extendsMatch[2];
584
+ continue;
585
+ }
586
+ const fnMatch = line.match(/^tool\s+(\S+)\s+(\S+)$/i);
587
+ if (fnMatch) {
588
+ toolName = fnMatch[1];
589
+ toolFn = fnMatch[2];
590
+ continue;
591
+ }
592
+ throw new Error(`Line ${ln(i)}: Invalid tool declaration: ${line}`);
593
+ }
594
+ // Extend declaration: extend <source> as <name>
595
+ if (/^extend\s/i.test(line)) {
596
+ const extendMatch = line.match(/^extend\s+(\S+)\s+as\s+(\S+)$/i);
597
+ if (!extendMatch) {
598
+ throw new Error(`Line ${ln(i)}: Invalid extend declaration: ${line}. Expected: extend <source> as <name>`);
599
+ }
600
+ const source = extendMatch[1];
601
+ toolName = extendMatch[2];
602
+ // If source matches a previously-defined tool, it's an extends; otherwise it's a function name
603
+ const isKnownTool = previousInstructions?.some((inst) => inst.kind === "tool" && inst.name === source);
604
+ if (isKnownTool) {
605
+ toolExtends = source;
606
+ }
607
+ else {
608
+ toolFn = source;
609
+ }
610
+ continue;
611
+ }
612
+ // with context or with context as <handle>
613
+ const contextMatch = line.match(/^with\s+context(?:\s+as\s+(\w+))?$/i);
614
+ if (contextMatch) {
615
+ const handle = contextMatch[1] ?? "context";
616
+ deps.push({ kind: "context", handle });
617
+ continue;
618
+ }
619
+ // with const or with const as <handle>
620
+ const constDepMatch = line.match(/^with\s+const(?:\s+as\s+(\w+))?$/i);
621
+ if (constDepMatch) {
622
+ const handle = constDepMatch[1] ?? "const";
623
+ deps.push({ kind: "const", handle });
624
+ continue;
625
+ }
626
+ // with <tool> as <handle>
627
+ const toolDepMatch = line.match(/^with\s+(\S+)\s+as\s+(\w+)$/i);
628
+ if (toolDepMatch) {
629
+ deps.push({ kind: "tool", handle: toolDepMatch[2], tool: toolDepMatch[1] });
630
+ continue;
631
+ }
632
+ // on error = <json> (constant fallback)
633
+ const onErrorConstMatch = line.match(/^on\s+error\s*=\s*(.+)$/i);
634
+ if (onErrorConstMatch) {
635
+ let valuePart = onErrorConstMatch[1].trim();
636
+ // Multi-line JSON: if starts with { or [ and isn't balanced, read more lines
637
+ if (/^[{[]/.test(valuePart)) {
638
+ let depth = 0;
639
+ for (const ch of valuePart) {
640
+ if (ch === "{" || ch === "[")
641
+ depth++;
642
+ if (ch === "}" || ch === "]")
643
+ depth--;
644
+ }
645
+ while (depth > 0 && i + 1 < lines.length) {
646
+ i++;
647
+ const nextLine = lines[i];
648
+ valuePart += "\n" + nextLine;
649
+ for (const ch of nextLine) {
650
+ if (ch === "{" || ch === "[")
651
+ depth++;
652
+ if (ch === "}" || ch === "]")
653
+ depth--;
654
+ }
655
+ }
656
+ }
657
+ wires.push({ kind: "onError", value: valuePart.trim() });
658
+ continue;
659
+ }
660
+ // on error <- source (pull fallback from context/dep)
661
+ const onErrorPullMatch = line.match(/^on\s+error\s*<-\s*(\S+)$/i);
662
+ if (onErrorPullMatch) {
663
+ wires.push({ kind: "onError", source: onErrorPullMatch[1] });
664
+ continue;
665
+ }
666
+ // Constant wire: target = "value" or target = value (unquoted)
667
+ const constantMatch = line.match(/^(\S+)\s*=\s*(?:"([^"]*)"|(\S+))$/);
668
+ if (constantMatch) {
669
+ const value = constantMatch[2] ?? constantMatch[3];
670
+ wires.push({
671
+ target: constantMatch[1],
672
+ kind: "constant",
673
+ value,
674
+ });
675
+ continue;
676
+ }
677
+ // Pull wire: target <- source
678
+ const pullMatch = line.match(/^(\S+)\s*<-\s*(\S+)$/);
679
+ if (pullMatch) {
680
+ wires.push({ target: pullMatch[1], kind: "pull", source: pullMatch[2] });
681
+ continue;
682
+ }
683
+ throw new Error(`Line ${ln(i)}: Unrecognized tool line: ${line}`);
684
+ }
685
+ if (!toolName)
686
+ throw new Error(`Line ${ln(0)}: Missing tool name`);
687
+ return {
688
+ kind: "tool",
689
+ name: toolName,
690
+ fn: toolFn,
691
+ extends: toolExtends,
692
+ deps,
693
+ wires,
694
+ };
695
+ }
696
+ // ── Path parser ─────────────────────────────────────────────────────────────
697
+ /**
698
+ * Parse a dot-separated path with optional array indices.
699
+ *
700
+ * "items[0].position.lat" → ["items", "0", "position", "lat"]
701
+ * "properties[]" → ["properties"] ([] is stripped, signals array)
702
+ * "x-message-id" → ["x-message-id"]
703
+ */
704
+ export function parsePath(text) {
705
+ const parts = [];
706
+ for (const segment of text.split(".")) {
707
+ const match = segment.match(/^([^[]+)(?:\[(\d*)\])?$/);
708
+ if (match) {
709
+ parts.push(match[1]);
710
+ if (match[2] !== undefined && match[2] !== "") {
711
+ parts.push(match[2]);
712
+ }
713
+ }
714
+ else {
715
+ parts.push(segment);
716
+ }
717
+ }
718
+ return parts;
719
+ }
720
+ // ── Serializer ──────────────────────────────────────────────────────────────
721
+ /**
722
+ * Serialize structured instructions back to .bridge text format.
723
+ */
724
+ export function serializeBridge(instructions) {
725
+ const bridges = instructions.filter((i) => i.kind === "bridge");
726
+ const tools = instructions.filter((i) => i.kind === "tool");
727
+ const consts = instructions.filter((i) => i.kind === "const");
728
+ if (bridges.length === 0 && tools.length === 0 && consts.length === 0)
729
+ return "";
730
+ const blocks = [];
731
+ // Group const declarations into a single block
732
+ if (consts.length > 0) {
733
+ blocks.push(consts.map((c) => `const ${c.name} = ${c.value}`).join("\n"));
734
+ }
735
+ for (const tool of tools) {
736
+ blocks.push(serializeToolBlock(tool));
737
+ }
738
+ for (const bridge of bridges) {
739
+ blocks.push(serializeBridgeBlock(bridge));
740
+ }
741
+ return blocks.join("\n\n---\n\n") + "\n";
742
+ }
743
+ function serializeToolBlock(tool) {
744
+ const lines = [];
745
+ // Declaration line — use `extend` format
746
+ if (tool.extends) {
747
+ lines.push(`extend ${tool.extends} as ${tool.name}`);
748
+ }
749
+ else {
750
+ lines.push(`extend ${tool.fn} as ${tool.name}`);
751
+ }
752
+ // Dependencies
753
+ for (const dep of tool.deps) {
754
+ if (dep.kind === "context") {
755
+ if (dep.handle === "context") {
756
+ lines.push(` with context`);
757
+ }
758
+ else {
759
+ lines.push(` with context as ${dep.handle}`);
760
+ }
761
+ }
762
+ else if (dep.kind === "const") {
763
+ if (dep.handle === "const") {
764
+ lines.push(` with const`);
765
+ }
766
+ else {
767
+ lines.push(` with const as ${dep.handle}`);
768
+ }
769
+ }
770
+ else {
771
+ lines.push(` with ${dep.tool} as ${dep.handle}`);
772
+ }
773
+ }
774
+ // Wires
775
+ for (const wire of tool.wires) {
776
+ if (wire.kind === "onError") {
777
+ if ("value" in wire) {
778
+ lines.push(` on error = ${wire.value}`);
779
+ }
780
+ else {
781
+ lines.push(` on error <- ${wire.source}`);
782
+ }
783
+ }
784
+ else if (wire.kind === "constant") {
785
+ // Use quoted form if value contains spaces or special chars, unquoted otherwise
786
+ if (/\s/.test(wire.value) || wire.value === "") {
787
+ lines.push(` ${wire.target} = "${wire.value}"`);
788
+ }
789
+ else {
790
+ lines.push(` ${wire.target} = ${wire.value}`);
791
+ }
792
+ }
793
+ else {
794
+ lines.push(` ${wire.target} <- ${wire.source}`);
795
+ }
796
+ }
797
+ return lines.join("\n");
798
+ }
799
+ function serializeBridgeBlock(bridge) {
800
+ const lines = [];
801
+ // ── Header ──────────────────────────────────────────────────────────
802
+ lines.push(`bridge ${bridge.type}.${bridge.field}`);
803
+ for (const h of bridge.handles) {
804
+ switch (h.kind) {
805
+ case "tool": {
806
+ // Short form `with <name>` when handle == last segment of name
807
+ const lastDot = h.name.lastIndexOf(".");
808
+ const defaultHandle = lastDot !== -1 ? h.name.substring(lastDot + 1) : h.name;
809
+ if (h.handle === defaultHandle) {
810
+ lines.push(` with ${h.name}`);
811
+ }
812
+ else {
813
+ lines.push(` with ${h.name} as ${h.handle}`);
814
+ }
815
+ break;
816
+ }
817
+ case "input":
818
+ lines.push(` with input as ${h.handle}`);
819
+ break;
820
+ case "context":
821
+ lines.push(` with context as ${h.handle}`);
822
+ break;
823
+ case "const":
824
+ if (h.handle === "const") {
825
+ lines.push(` with const`);
826
+ }
827
+ else {
828
+ lines.push(` with const as ${h.handle}`);
829
+ }
830
+ break;
831
+ }
832
+ }
833
+ lines.push("");
834
+ // ── Build handle map for reverse resolution ─────────────────────────
835
+ const { handleMap, inputHandle } = buildHandleMap(bridge);
836
+ // ── Pipe fork registry ──────────────────────────────────────────────
837
+ // Extend handleMap with fork → handle-name entries and build the set of
838
+ // known fork trunk keys so the wire classifiers below can use it.
839
+ const pipeHandleTrunkKeys = new Set();
840
+ for (const ph of bridge.pipeHandles ?? []) {
841
+ handleMap.set(ph.key, ph.handle);
842
+ pipeHandleTrunkKeys.add(ph.key);
843
+ }
844
+ // ── Pipe wire detection ───────────────────────────────────────────────────────
845
+ // Pipe wires are marked pipe:true. Classify them into two maps:
846
+ // toInMap: forkTrunkKey → wire feeding the fork's input field
847
+ // fromOutMap: forkTrunkKey → wire reading the fork's root result
848
+ // Terminal out-wires (destination is NOT another fork) are chain anchors.
849
+ const refTrunkKey = (ref) => ref.instance != null
850
+ ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}`
851
+ : `${ref.module}:${ref.type}:${ref.field}`;
852
+ const toInMap = new Map(); // forkTrunkKey → wire with to = fork's input field
853
+ const fromOutMap = new Map(); // forkTrunkKey → wire with from = fork root (path:[])
854
+ const pipeWireSet = new Set();
855
+ for (const w of bridge.wires) {
856
+ if (!("from" in w) || !w.pipe)
857
+ continue;
858
+ const fw = w;
859
+ pipeWireSet.add(w);
860
+ const toTk = refTrunkKey(fw.to);
861
+ // In-wire: single-segment path targeting a known pipe fork
862
+ if (fw.to.path.length === 1 && pipeHandleTrunkKeys.has(toTk)) {
863
+ toInMap.set(toTk, fw);
864
+ }
865
+ // Out-wire: empty path from a known pipe fork
866
+ if (fw.from.path.length === 0 && pipeHandleTrunkKeys.has(refTrunkKey(fw.from))) {
867
+ fromOutMap.set(refTrunkKey(fw.from), fw);
868
+ }
869
+ }
870
+ // ── Wires ───────────────────────────────────────────────────────────
871
+ const elementWires = bridge.wires.filter((w) => "from" in w && !!w.from.element);
872
+ // Exclude pipe wires and element wires from the regular loop
873
+ const regularWires = bridge.wires.filter((w) => !pipeWireSet.has(w) && (!("from" in w) || !w.from.element));
874
+ const elementGroups = new Map();
875
+ for (const w of elementWires) {
876
+ const parent = w.to.path[0];
877
+ if (!elementGroups.has(parent))
878
+ elementGroups.set(parent, []);
879
+ elementGroups.get(parent).push(w);
880
+ }
881
+ const serializedArrays = new Set();
882
+ for (const w of regularWires) {
883
+ // Constant wire
884
+ if ("value" in w) {
885
+ const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false);
886
+ lines.push(`${toStr} = "${w.value}"`);
887
+ continue;
888
+ }
889
+ // Array mapping
890
+ const arrayKey = w.to.path.length === 1 ? w.to.path[0] : null;
891
+ if (arrayKey &&
892
+ elementGroups.has(arrayKey) &&
893
+ !serializedArrays.has(arrayKey)) {
894
+ serializedArrays.add(arrayKey);
895
+ const fromStr = serializeRef(w.from, bridge, handleMap, inputHandle, true) + "[]";
896
+ const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false) + "[]";
897
+ lines.push(`${toStr} <- ${fromStr}`);
898
+ for (const ew of elementGroups.get(arrayKey)) {
899
+ const elemFrom = "." + serPath(ew.from.path);
900
+ const elemTo = "." + serPath(ew.to.path.slice(1));
901
+ lines.push(` ${elemTo} <- ${elemFrom}`);
902
+ }
903
+ continue;
904
+ }
905
+ // Regular wire
906
+ const fromStr = serializeRef(w.from, bridge, handleMap, inputHandle, true);
907
+ const toStr = serializeRef(w.to, bridge, handleMap, inputHandle, false);
908
+ const arrow = w.force ? "<-!" : "<-";
909
+ const fb = w.fallback ? ` ?? ${w.fallback}` : "";
910
+ lines.push(`${toStr} ${arrow} ${fromStr}${fb}`);
911
+ }
912
+ // ── Pipe wires ───────────────────────────────────────────────────────
913
+ // Find terminal fromOutMap entries — their destination is NOT another
914
+ // pipe handle's .in. Follow the chain backward to reconstruct:
915
+ // dest <- h1|h2|…|source
916
+ const serializedPipeTrunks = new Set();
917
+ for (const [tk, outWire] of fromOutMap.entries()) {
918
+ // Non-terminal: this fork's result feeds another fork's input field
919
+ if (pipeHandleTrunkKeys.has(refTrunkKey(outWire.to)))
920
+ continue;
921
+ // Follow chain backward to collect handle names (outermost-first)
922
+ const handleChain = [];
923
+ let currentTk = tk;
924
+ let actualSourceRef = null;
925
+ let chainForced = false;
926
+ for (;;) {
927
+ const handleName = handleMap.get(currentTk);
928
+ if (!handleName)
929
+ break;
930
+ // Token: "handle" when field is "in" (default), otherwise "handle.field"
931
+ const inWire = toInMap.get(currentTk);
932
+ const fieldName = inWire?.to.path[0] ?? "in";
933
+ const token = fieldName === "in" ? handleName : `${handleName}.${fieldName}`;
934
+ handleChain.push(token);
935
+ serializedPipeTrunks.add(currentTk);
936
+ if (inWire?.force)
937
+ chainForced = true;
938
+ if (!inWire)
939
+ break;
940
+ const fromTk = refTrunkKey(inWire.from);
941
+ // Inner source is another pipe fork root (empty path) → continue chain
942
+ if (inWire.from.path.length === 0 && pipeHandleTrunkKeys.has(fromTk)) {
943
+ currentTk = fromTk;
944
+ }
945
+ else {
946
+ actualSourceRef = inWire.from;
947
+ break;
948
+ }
949
+ }
950
+ if (actualSourceRef && handleChain.length > 0) {
951
+ const sourceStr = serializeRef(actualSourceRef, bridge, handleMap, inputHandle, true);
952
+ const destStr = serializeRef(outWire.to, bridge, handleMap, inputHandle, false);
953
+ const arrow = chainForced ? "<-!" : "<-";
954
+ const fb = outWire.fallback ? ` ?? ${outWire.fallback}` : "";
955
+ lines.push(`${destStr} ${arrow} ${handleChain.join("|")}|${sourceStr}${fb}`);
956
+ }
957
+ }
958
+ return lines.join("\n");
959
+ }
960
+ /**
961
+ * Build a reverse lookup: trunk key → handle name.
962
+ * Recomputes instance numbers from handle bindings in declaration order.
963
+ */
964
+ function buildHandleMap(bridge) {
965
+ const handleMap = new Map();
966
+ const instanceCounters = new Map();
967
+ let inputHandle;
968
+ for (const h of bridge.handles) {
969
+ switch (h.kind) {
970
+ case "tool": {
971
+ const lastDot = h.name.lastIndexOf(".");
972
+ if (lastDot !== -1) {
973
+ // Dotted name: module.field
974
+ const modulePart = h.name.substring(0, lastDot);
975
+ const fieldPart = h.name.substring(lastDot + 1);
976
+ const ik = `${modulePart}:${fieldPart}`;
977
+ const instance = (instanceCounters.get(ik) ?? 0) + 1;
978
+ instanceCounters.set(ik, instance);
979
+ handleMap.set(`${modulePart}:${bridge.type}:${fieldPart}:${instance}`, h.handle);
980
+ }
981
+ else {
982
+ // Simple name: inline tool
983
+ const ik = `Tools:${h.name}`;
984
+ const instance = (instanceCounters.get(ik) ?? 0) + 1;
985
+ instanceCounters.set(ik, instance);
986
+ handleMap.set(`${SELF_MODULE}:Tools:${h.name}:${instance}`, h.handle);
987
+ }
988
+ break;
989
+ }
990
+ case "input":
991
+ inputHandle = h.handle;
992
+ break;
993
+ case "context":
994
+ handleMap.set(`${SELF_MODULE}:Context:context`, h.handle);
995
+ break;
996
+ case "const":
997
+ handleMap.set(`${SELF_MODULE}:Const:const`, h.handle);
998
+ break;
999
+ }
1000
+ }
1001
+ return { handleMap, inputHandle };
1002
+ }
1003
+ function serializeRef(ref, bridge, handleMap, inputHandle, isFrom) {
1004
+ if (ref.element) {
1005
+ return "." + serPath(ref.path);
1006
+ }
1007
+ // Bridge's own trunk (no instance, no element)
1008
+ const isBridgeTrunk = ref.module === SELF_MODULE &&
1009
+ ref.type === bridge.type &&
1010
+ ref.field === bridge.field &&
1011
+ !ref.instance &&
1012
+ !ref.element;
1013
+ if (isBridgeTrunk) {
1014
+ if (isFrom && inputHandle) {
1015
+ // From side: use input handle (data comes from args)
1016
+ return inputHandle + "." + serPath(ref.path);
1017
+ }
1018
+ // To side: sub-fields of the bridge's own return type are prefixed with the
1019
+ // bridge field name so `path: ["a"]` serializes as `doubled.a` (not bare "a").
1020
+ // This is needed for bridges whose output type has named sub-fields
1021
+ // (e.g. `bridge Query.doubled` with `doubled.a <- ...`).
1022
+ if (!isFrom && ref.path.length > 0) {
1023
+ return bridge.field + "." + serPath(ref.path);
1024
+ }
1025
+ // Bare path (e.g. top-level scalar output, or no-path for the bridge trunk itself)
1026
+ return serPath(ref.path);
1027
+ }
1028
+ // Lookup by trunk key
1029
+ const trunkStr = ref.instance != null
1030
+ ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}`
1031
+ : `${ref.module}:${ref.type}:${ref.field}`;
1032
+ const handle = handleMap.get(trunkStr);
1033
+ if (handle) {
1034
+ // Empty path — just the handle name (e.g. pipe result = tool root)
1035
+ if (ref.path.length === 0)
1036
+ return handle;
1037
+ return handle + "." + serPath(ref.path);
1038
+ }
1039
+ // Fallback: bare path
1040
+ return serPath(ref.path);
1041
+ }
1042
+ /** Serialize a path array to dot notation with [n] for numeric indices */
1043
+ function serPath(path) {
1044
+ let result = "";
1045
+ for (const segment of path) {
1046
+ if (/^\d+$/.test(segment)) {
1047
+ result += `[${segment}]`;
1048
+ }
1049
+ else {
1050
+ if (result.length > 0)
1051
+ result += ".";
1052
+ result += segment;
1053
+ }
1054
+ }
1055
+ return result;
1056
+ }