@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.
- package/LICENSE +21 -0
- package/README.md +291 -0
- package/build/ExecutionTree.d.ts +50 -0
- package/build/ExecutionTree.js +403 -0
- package/build/bridge-format.d.ts +24 -0
- package/build/bridge-format.js +1056 -0
- package/build/bridge-transform.d.ts +15 -0
- package/build/bridge-transform.js +57 -0
- package/build/index.d.ts +5 -0
- package/build/index.js +3 -0
- package/build/tools/find-object.d.ts +4 -0
- package/build/tools/find-object.js +11 -0
- package/build/tools/http-call.d.ts +34 -0
- package/build/tools/http-call.js +116 -0
- package/build/tools/index.d.ts +43 -0
- package/build/tools/index.js +43 -0
- package/build/tools/lower-case.d.ts +3 -0
- package/build/tools/lower-case.js +3 -0
- package/build/tools/pick-first.d.ts +11 -0
- package/build/tools/pick-first.js +20 -0
- package/build/tools/to-array.d.ts +8 -0
- package/build/tools/to-array.js +8 -0
- package/build/tools/upper-case.d.ts +3 -0
- package/build/tools/upper-case.js +3 -0
- package/build/types.d.ts +218 -0
- package/build/types.js +2 -0
- package/package.json +44 -0
|
@@ -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
|
+
}
|