@nexusbloom/cli 0.3.4 → 0.8.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/src/pipe.js ADDED
@@ -0,0 +1,709 @@
1
+ /**
2
+ * pipe.js — Compose tools into a pipeline.
3
+ *
4
+ * nxb pipe frequency-analyzer text=@in type=word \
5
+ * '| data-generator count=$total_words format=json' \
6
+ * '| base64-tool text=$data|first.email action=$mode|upper'
7
+ *
8
+ * Syntax
9
+ * step `slug key=value key2=value`
10
+ * separator a leading `|` marks a new step; each step is one shell argument
11
+ * connection `key=$path` reads from the previous step's output
12
+ * whole value `key=$` passes the entire previous output
13
+ * transform `$path|len`, `$max|int`, `$data|first` — chained left to right
14
+ * stdin `key=@in` receives the pipeline's standard input
15
+ *
16
+ * Two phases, deliberately separated:
17
+ * 1. buildPlan() resolves every connection against the declared schemas and
18
+ * reports type problems *before* anything runs.
19
+ * 2. runPipeline() executes, re-checking types against real values, because
20
+ * declared schemas can be wrong or missing.
21
+ */
22
+
23
+ import chalk from "chalk";
24
+
25
+ import {
26
+ MISSING,
27
+ applyTransforms,
28
+ formatPath,
29
+ getPath,
30
+ parsePath,
31
+ preview,
32
+ TRANSFORMS,
33
+ TRANSFORM_ARITY,
34
+ } from "./values.js";
35
+ import { compatibility, inferType, schemaTypes, typeLabel, validateInput } from "./types.js";
36
+
37
+ // ─── Parsing ─────────────────────────────────────────────────────────────────
38
+
39
+ /** Parse one transform segment: `first`, `int(5)`, `slice(0,3)`, `default("x")`. */
40
+ function parseTransform(segment) {
41
+ const m = segment.match(/^([a-z_][a-z0-9_]*)\s*(?:\(([\s\S]*)\))?$/i);
42
+ if (!m) throw new Error(`Invalid transform "${segment}"`);
43
+ const name = m[1];
44
+ if (!TRANSFORMS[name]) {
45
+ throw new Error(`Unknown transform "${name}"`);
46
+ }
47
+ let args = [];
48
+ const rawArgs = (m[2] || "").trim();
49
+ if (rawArgs) {
50
+ args = splitArgs(rawArgs).map(coerceArg);
51
+ }
52
+ const needed = TRANSFORM_ARITY[name];
53
+ if (needed !== undefined && args.length < needed - 1) {
54
+ throw new Error(`Transform "${name}" needs ${needed - 1} argument(s)`);
55
+ }
56
+ return { name, args };
57
+ }
58
+
59
+ /** Split `a,"b,c",3` respecting quotes. */
60
+ function splitArgs(str) {
61
+ const out = [];
62
+ let cur = "";
63
+ let quote = null;
64
+ for (const ch of str) {
65
+ if (quote) {
66
+ if (ch === quote) quote = null;
67
+ else cur += ch;
68
+ continue;
69
+ }
70
+ if (ch === '"' || ch === "'") {
71
+ quote = ch;
72
+ continue;
73
+ }
74
+ if (ch === ",") {
75
+ out.push(cur.trim());
76
+ cur = "";
77
+ continue;
78
+ }
79
+ cur += ch;
80
+ }
81
+ if (cur.trim() !== "" || out.length) out.push(cur.trim());
82
+ return out;
83
+ }
84
+
85
+ function coerceArg(raw) {
86
+ const s = String(raw).trim();
87
+ if (s === "") return "";
88
+ if (s === "true") return true;
89
+ if (s === "false") return false;
90
+ if (s === "null") return null;
91
+ if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
92
+ return s;
93
+ }
94
+
95
+ /** Split a step's spec into slug + argument tokens, respecting quotes. */
96
+ function tokenize(spec) {
97
+ const tokens = [];
98
+ let cur = "";
99
+ let quote = null;
100
+ let depth = 0;
101
+ for (const ch of spec) {
102
+ if (quote) {
103
+ if (ch === quote) quote = null;
104
+ else cur += ch;
105
+ continue;
106
+ }
107
+ if (ch === '"' || ch === "'") {
108
+ quote = ch;
109
+ continue;
110
+ }
111
+ if (ch === "(") depth++;
112
+ if (ch === ")") depth = Math.max(0, depth - 1);
113
+ if (ch === " " && depth === 0) {
114
+ if (cur) tokens.push(cur);
115
+ cur = "";
116
+ continue;
117
+ }
118
+ cur += ch;
119
+ }
120
+ if (cur) tokens.push(cur);
121
+ return tokens;
122
+ }
123
+
124
+ /**
125
+ * Parse the expression on the right of `key=`.
126
+ *
127
+ * The `$` form interleaves path access and transforms so that both
128
+ * `$data[0].email` and `$data|first.email` work:
129
+ *
130
+ * accessors: [ {path:"data"}, {transform:"first"}, {path:".email"} ]
131
+ *
132
+ * A segment is treated as a transform when it parses as `name` or `name(args)`
133
+ * and that name is in the transform library; anything else continues the path.
134
+ *
135
+ * @returns {{kind:"stdin"|"connection"|"literal", headPath?: string,
136
+ * accessors?: Array, chain?: Array, literal?: any}}
137
+ */
138
+ function parseValueExpr(expr) {
139
+ const raw = String(expr ?? "");
140
+
141
+ if (raw === "@in" || raw === "@stdin") return { kind: "stdin" };
142
+
143
+ if (!raw.startsWith("$")) return { kind: "literal", literal: coerceArg(raw) };
144
+
145
+ if (/\s/.test(raw)) {
146
+ throw new Error(`"${raw}" — write transforms without spaces (${raw.replace(/\s+/g, "")})`);
147
+ }
148
+
149
+ // Split into `|`-delimited segments, ignoring pipes inside parentheses.
150
+ const segments = [];
151
+ let cur = "";
152
+ let depth = 0;
153
+ for (const ch of raw) {
154
+ if (ch === "(") depth++;
155
+ if (ch === ")") depth = Math.max(0, depth - 1);
156
+ if (ch === "|" && depth === 0) {
157
+ segments.push(cur);
158
+ cur = "";
159
+ continue;
160
+ }
161
+ cur += ch;
162
+ }
163
+ segments.push(cur);
164
+
165
+ const head = segments[0];
166
+ if (head !== "$" && !head.startsWith("$")) {
167
+ throw new Error(`Transform chain must start with $ (got "${raw}")`);
168
+ }
169
+ if (head.includes("|")) throw new Error(`Invalid path "${head}"`);
170
+
171
+ const accessors = [];
172
+ let headPath = "";
173
+
174
+ segments.forEach((seg, idx) => {
175
+ if (idx === 0) {
176
+ headPath = seg.slice(1);
177
+ accessors.push({ op: "path", path: headPath });
178
+ return;
179
+ }
180
+
181
+ // After the head, a segment is a path when it is explicitly written as one
182
+ // (a leading `.`, a `[index]`, or a bare index). Anything else must be a
183
+ // transform — guessing between the two would turn a typo like
184
+ // `$text|lenh` into a silent field lookup that fails much later.
185
+ if (seg.startsWith(".") || seg.startsWith("[") || /^\d+$/.test(seg)) {
186
+ accessors.push({ op: "path", path: seg });
187
+ return;
188
+ }
189
+
190
+ const m = seg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?/);
191
+ if (seg === "") {
192
+ throw new Error(
193
+ `Empty stage in "${raw}" — write transforms without spaces, e.g. $x|len not $x| len.`
194
+ );
195
+ }
196
+ const name = m?.[1];
197
+ if (!name || !Object.hasOwn(TRANSFORMS, name)) {
198
+ throw new Error(
199
+ `Unknown transform "${seg}". For a field, write it with a leading dot ($x.${seg}); for the list, run \`nxb pipe --transforms\`.`
200
+ );
201
+ }
202
+
203
+ // Anything glued to the call continues as a path, so `$data|first.email`
204
+ // is first(), then `.email`.
205
+ const rest = seg.slice(m[0].length);
206
+ accessors.push({ op: "transform", ...parseTransform(m[0]) });
207
+ if (rest) accessors.push({ op: "path", path: rest });
208
+ });
209
+
210
+ // Validate every path eagerly so errors surface while parsing, not mid-run.
211
+ for (const acc of accessors) {
212
+ if (acc.op === "path") parsePath(acc.path.replace(/^\./, ""));
213
+ }
214
+
215
+ return {
216
+ kind: "connection",
217
+ headPath,
218
+ accessors,
219
+ // Retained for display: the transform chain in source order.
220
+ chain: accessors.filter((a) => a.op === "transform"),
221
+ };
222
+ }
223
+
224
+
225
+ /**
226
+ * Resolve a connection against an upstream value.
227
+ * @returns {value|typeof MISSING}
228
+ */
229
+ export function resolveAccessors(upstream, accessors) {
230
+ let cur = upstream;
231
+ for (const acc of accessors) {
232
+ if (acc.op === "path") {
233
+ // A missing value keeps flowing, so a later |default(...) can rescue it.
234
+ if (cur === MISSING) continue;
235
+ cur = getPath(cur, acc.path.replace(/^\./, ""));
236
+ } else {
237
+ cur = applyTransforms(cur, [{ name: acc.name, args: acc.args }]);
238
+ }
239
+ }
240
+ return cur;
241
+ }
242
+
243
+ /**
244
+ * Split a pipeline into step chunks on `|`.
245
+ *
246
+ * `|` is ambiguous — it also separates transform stages (`$text|len`). The
247
+ * disambiguator is the token that follows: a known transform name means a
248
+ * transform pipe, anything else means a step separator. Write transforms
249
+ * without spaces around the pipe (`$text|len`, not `$text | len`).
250
+ */
251
+ function splitSteps(text) {
252
+ const chunks = [];
253
+ let cur = "";
254
+ let quote = null;
255
+ let depth = 0;
256
+ let inValue = false; // inside a `key=$...` token, where `|` always means transform
257
+
258
+ for (let i = 0; i < text.length; i++) {
259
+ const ch = text[i];
260
+
261
+ if (quote) {
262
+ if (ch === quote) quote = null;
263
+ cur += ch;
264
+ continue;
265
+ }
266
+ if (ch === '"' || ch === "'") {
267
+ quote = ch;
268
+ cur += ch;
269
+ continue;
270
+ }
271
+
272
+ // `key=$...` opens a connection token; whitespace closes it.
273
+ if (ch === "=" && text[i + 1] === "$") inValue = true;
274
+ else if (ch === " " || ch === "\t" || ch === "\n") inValue = false;
275
+
276
+ if (ch === "(") depth++;
277
+ if (ch === ")") depth = Math.max(0, depth - 1);
278
+
279
+ if (ch === "|" && depth === 0) {
280
+ // Inside a connection, or when a known transform follows, this pipe
281
+ // belongs to the transform chain rather than to the step list.
282
+ if (inValue || isKnownTransformWord(text.slice(i + 1))) {
283
+ cur += ch;
284
+ continue;
285
+ }
286
+ chunks.push(cur);
287
+ cur = "";
288
+ continue;
289
+ }
290
+
291
+ cur += ch;
292
+ }
293
+
294
+ if (cur.trim()) chunks.push(cur);
295
+ return chunks;
296
+ }
297
+
298
+ function isKnownTransformWord(rest) {
299
+ const word = (rest.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)/) || [])[1];
300
+ return word !== undefined && Object.hasOwn(TRANSFORMS, word);
301
+ }
302
+
303
+ /**
304
+ * Parse a full pipeline string (or argv array) into steps.
305
+ * Accepts both `a x=1 | b y=$x` as one string and as separate arguments.
306
+ *
307
+ * @returns {Array<{slug: string, inputs: Array<{field: string, expr: object}>}>}
308
+ */
309
+ export function parsePipeline(spec) {
310
+ const text = Array.isArray(spec) ? spec.join(" ") : String(spec);
311
+ const chunks = splitSteps(text);
312
+
313
+ const steps = [];
314
+ for (const chunk of chunks) {
315
+ const spec = chunk.trim();
316
+ if (!spec) continue;
317
+ const tokens = tokenize(spec);
318
+ if (!tokens.length) continue;
319
+
320
+ const slug = tokens[0];
321
+ if (!/^[a-zA-Z0-9._-]+$/.test(slug)) {
322
+ throw new Error(`Invalid tool slug "${slug}"`);
323
+ }
324
+
325
+ const inputs = [];
326
+ for (const token of tokens.slice(1)) {
327
+ const eq = token.indexOf("=");
328
+ if (eq === -1) {
329
+ throw new Error(`Expected key=value in "${spec}", got "${token}"`);
330
+ }
331
+ const field = token.slice(0, eq);
332
+ const valueExpr = token.slice(eq + 1);
333
+ if (!field) throw new Error(`Missing field name in "${token}"`);
334
+ const expr = parseValueExpr(valueExpr);
335
+ const exprText = expr.kind === "literal" ? "" : `$${expr.headPath ?? ""}`;
336
+ inputs.push({ field, expr, exprText });
337
+ }
338
+ steps.push({ slug, inputs, raw: spec });
339
+ }
340
+
341
+ if (!steps.length) throw new Error("Empty pipeline.");
342
+ return steps;
343
+ }
344
+
345
+ // ─── Plan ────────────────────────────────────────────────────────────────────
346
+
347
+ /**
348
+ * Build the wiring plan by resolving every connection against declared schemas.
349
+ *
350
+ * @param {Array} steps from parsePipeline()
351
+ * @param {(slug) => Promise<{manifest: object}|null>} getManifest
352
+ * @returns {Promise<{steps: Array, issues: Array, ok: boolean}>}
353
+ */
354
+ export async function buildPlan(steps, getManifest) {
355
+ const planned = [];
356
+ const issues = [];
357
+ const produced = new Map(); // slug -> output field names it declares
358
+
359
+ for (let i = 0; i < steps.length; i++) {
360
+ const step = steps[i];
361
+ const entry = { ...step, index: i, manifest: null, fields: [] };
362
+
363
+ let info = null;
364
+ try {
365
+ info = await getManifest(step.slug);
366
+ } catch (err) {
367
+ issues.push({ step: i, level: "error", message: `Could not load "${step.slug}": ${err.message}` });
368
+ }
369
+
370
+ if (!info?.manifest) {
371
+ issues.push({ step: i, level: "error", message: `Tool "${step.slug}" not found or has no manifest.` });
372
+ planned.push(entry);
373
+ continue;
374
+ }
375
+
376
+ entry.manifest = info.manifest;
377
+ const props = info.manifest.input_schema?.properties || {};
378
+ const outProps = info.manifest.output_schema?.properties || {};
379
+ entry.outputs = Object.keys(outProps);
380
+ produced.set(step.slug, outProps);
381
+
382
+ for (const input of step.inputs) {
383
+ const prop = props[input.field];
384
+ const field = {
385
+ ...input,
386
+ targetTypes: schemaTypes(prop),
387
+ targetEnum: Array.isArray(prop?.enum) ? prop.enum : null,
388
+ knownField: Boolean(prop),
389
+ verdict: "literal",
390
+ };
391
+
392
+ if (!prop) {
393
+ field.verdict = "unknown-field";
394
+ issues.push({
395
+ step: i,
396
+ level: "warn",
397
+ message: `"${step.slug}" has no input field "${input.field}" — it will still be sent.`,
398
+ });
399
+ }
400
+
401
+ if (input.expr.kind === "connection") {
402
+ const prev = i > 0 ? steps[i - 1] : null;
403
+ if (!prev) {
404
+ issues.push({ step: i, level: "error", message: `Nothing to read $${input.expr.headPath} from — it is the first step.` });
405
+ field.verdict = "error";
406
+ } else {
407
+ const prevOut = produced.get(prev.slug) || {};
408
+ const headSegments = parsePath(input.expr.headPath);
409
+ const leaf = headSegments[headSegments.length - 1];
410
+ const prevProp = headSegments.length === 1 ? prevOut[leaf] : undefined;
411
+ const prevDeclaresAny = Object.keys(prevOut).length > 0;
412
+ const known = headSegments.length === 0 || Boolean(prevProp) || !prevDeclaresAny;
413
+
414
+ field.source = {
415
+ slug: prev.slug,
416
+ path: input.expr.headPath,
417
+ pathLabel: formatPath(headSegments),
418
+ };
419
+ field.sourceTypes = schemaTypes(prevProp);
420
+ field.sourceKnown = known;
421
+
422
+ // Walk the accessors to work out the type that actually arrives.
423
+ field.resultTypes = simulateTypes(input.expr.accessors, field.sourceTypes);
424
+ // A path used *after* a transform cannot be typed from the schema.
425
+ if (!known || headSegments.length > 1) field.staticallyUnknown = true;
426
+
427
+ if (!known) {
428
+ field.verdict = "unknown-source";
429
+ issues.push({
430
+ step: i,
431
+ level: "warn",
432
+ message: `"${prev.slug}" does not declare output "${input.expr.headPath}" — checked at runtime.`,
433
+ });
434
+ } else if (field.targetTypes.length && field.resultTypes.length) {
435
+ const compat = compatibility(field.resultTypes[0], field.targetTypes);
436
+ field.compat = compat;
437
+ if (compat.level === "cast") {
438
+ field.verdict = "needs-cast";
439
+ issues.push({
440
+ step: i,
441
+ level: "error",
442
+ message: `${prev.slug}.${input.expr.headPath || "(root)"} is ${typeLabel(field.resultTypes)} but ${step.slug}.${input.field} wants ${typeLabel(field.targetTypes)} — add |${compat.suggestion}`,
443
+ });
444
+ } else if (compat.level === "lossy") {
445
+ field.verdict = "lossy";
446
+ issues.push({
447
+ step: i,
448
+ level: "warn",
449
+ message: `${prev.slug}.${input.expr.headPath || "(root)"} → ${step.slug}.${input.field} may lose precision (${compat.note}).`,
450
+ });
451
+ } else if (compat.level === "invalid") {
452
+ field.verdict = "error";
453
+ issues.push({ step: i, level: "error", message: `${prev.slug}.${input.expr.headPath || "(root)"} → ${step.slug}.${input.field}: ${compat.note}.` });
454
+ } else {
455
+ field.verdict = "ok";
456
+ }
457
+ } else {
458
+ field.verdict = "ok";
459
+ }
460
+ }
461
+ }
462
+
463
+ entry.fields.push(field);
464
+ }
465
+
466
+ planned.push(entry);
467
+ }
468
+
469
+ const ok = !issues.some((x) => x.level === "error");
470
+ return { steps: planned, issues, ok };
471
+ }
472
+
473
+ /** Representative values used to reason about a transform's return type. */
474
+ const TYPE_SAMPLE = {
475
+ string: "sample text",
476
+ integer: 42,
477
+ number: 4.2,
478
+ boolean: true,
479
+ array: ["a", "b"],
480
+ object: { a: 1 },
481
+ };
482
+
483
+ /**
484
+ * Walk accessors using representative values to infer the type that will
485
+ * arrive at the target field.
486
+ *
487
+ * A path used *after* a transform cannot be resolved from a schema, so it is
488
+ * reported as unknown and left to the runtime check — which is the honest
489
+ * answer rather than a confident guess.
490
+ */
491
+ function simulateTypes(accessors, sourceTypes) {
492
+ const source = sourceTypes?.[0];
493
+ if (!source || !TYPE_SAMPLE[source]) return [];
494
+
495
+ let cur = TYPE_SAMPLE[source];
496
+
497
+ for (let i = 0; i < accessors.length; i++) {
498
+ const acc = accessors[i];
499
+ if (acc.op === "path") {
500
+ // The head path *is* the declared source field, so it adds no
501
+ // uncertainty. Any later path step descends into a shape the schema
502
+ // does not describe, so we stop guessing and defer to runtime.
503
+ if (i === 0) continue;
504
+ return [];
505
+ }
506
+ try {
507
+ const next = applyTransforms(cur, [{ name: acc.name, args: acc.args }]);
508
+ if (next === MISSING) return [];
509
+ cur = next;
510
+ } catch {
511
+ return [];
512
+ }
513
+ }
514
+
515
+ const t = inferType(cur);
516
+ if (t === "integer") return ["integer", "number"];
517
+ return [t];
518
+ }
519
+
520
+ /**
521
+ * Run a planned pipeline.
522
+ *
523
+ * @param {object} opts
524
+ * @param {Array} opts.steps planned steps from buildPlan()
525
+ * @param {Function} opts.getTool (slug) => { manifest, coreLogicSource }
526
+ * @param {Function} opts.execute ({ slug, source, input, manifest, local }) => result
527
+ * @param {any} opts.stdin value for `@in`
528
+ * @param {number} [opts.from] 1-based step to start from (replay)
529
+ * @param {Function} [opts.onStep] progress callback
530
+ */
531
+ export async function runPipeline({
532
+ steps,
533
+ getTool,
534
+ execute,
535
+ stdin = null,
536
+ from = 1,
537
+ onStep = () => {},
538
+ }) {
539
+ const outputs = new Array(steps.length).fill(undefined);
540
+ const logs = [];
541
+
542
+ for (let i = 0; i < steps.length; i++) {
543
+ const step = steps[i];
544
+
545
+ // Replay: reuse a recorded output instead of re-running the step.
546
+ if (i + 1 < from && outputs[i] !== undefined) continue;
547
+
548
+ const input = {};
549
+ const resolvedInputs = {};
550
+
551
+ for (const field of step.fields) {
552
+ const { field: name, expr } = field;
553
+
554
+ if (expr.kind === "literal") {
555
+ input[name] = expr.literal;
556
+ resolvedInputs[name] = { value: expr.literal, from: "literal" };
557
+ continue;
558
+ }
559
+
560
+ if (expr.kind === "stdin") {
561
+ input[name] = stdin;
562
+ resolvedInputs[name] = { value: stdin, from: "@in" };
563
+ continue;
564
+ }
565
+
566
+ const upstream = outputs[i - 1];
567
+ if (upstream === undefined) {
568
+ throw new PipeError(`Step ${i + 1}: ${step.slug} reads $${expr.headPath} but step ${i} has not run.`);
569
+ }
570
+
571
+ let value = resolveAccessors(upstream, expr.accessors);
572
+ const fromLabel = `${field.source?.slug ?? `step ${i}`}.${expr.headPath || "(root)"}`;
573
+
574
+ // A connection that resolves to nothing is always an error: silently
575
+ // sending `undefined` hides the broken wire behind whatever the tool
576
+ // does with a missing field. `default(...)` is the explicit opt-in.
577
+ if (value === MISSING) {
578
+ const hasFallback = expr.accessors.some((a) => a.op === "transform" && a.name === "default");
579
+ if (!hasFallback) {
580
+ throw new PipeError(
581
+ `Step ${i + 1}: ${fromLabel} produced no value for ${step.slug}.${name}. ` +
582
+ `Check the field exists, or add |default(...) to continue anyway.`
583
+ );
584
+ }
585
+ }
586
+
587
+ input[name] = value === MISSING ? undefined : value;
588
+ resolvedInputs[name] = { value, from: fromLabel };
589
+ }
590
+
591
+ // Runtime type check against the real manifest — catches schema drift that
592
+ // the static plan cannot see.
593
+ const problems = validateInput(input, step.manifest?.input_schema);
594
+ if (problems.length) {
595
+ const detail = problems
596
+ .map((p) => `${p.field}: ${p.error}${p.suggestion ? ` (${p.suggestion})` : ""}`)
597
+ .join("; ");
598
+ throw new PipeError(`Step ${i + 1} (${step.slug}) input invalid — ${detail}`);
599
+ }
600
+
601
+ onStep({ phase: "start", index: i, step, input, resolvedInputs });
602
+
603
+ const tool = await getTool(step.slug);
604
+ const result = await execute({
605
+ slug: step.slug,
606
+ source: tool?.coreLogicSource,
607
+ manifest: step.manifest,
608
+ input,
609
+ stepIndex: i,
610
+ });
611
+
612
+ if (result?.ok === false) {
613
+ logs.push({ step: i, error: result.error });
614
+ throw new PipeError(
615
+ `Step ${i + 1} (${step.slug}) failed: ${result.error || "unknown error"}`
616
+ );
617
+ }
618
+
619
+ const value = result?.ok === true ? result.result : result;
620
+ outputs[i] = value;
621
+ onStep({ phase: "done", index: i, step, input, output: value, durationMs: result?.durationMs });
622
+ }
623
+
624
+ return { outputs, logs };
625
+ }
626
+
627
+ /** A pipeline failure with a message already written for a human. */
628
+ export class PipeError extends Error {
629
+ constructor(message) {
630
+ super(message);
631
+ this.name = "PipeError";
632
+ this.expected = true;
633
+ }
634
+ }
635
+
636
+ // ─── Rendering ───────────────────────────────────────────────────────────────
637
+
638
+ /** Render the wiring plan, the way the design called for. */
639
+ export function renderPlan(plan) {
640
+ const lines = [];
641
+ const { steps, issues } = plan;
642
+
643
+ for (const step of steps) {
644
+ if (!step.manifest) {
645
+ lines.push(chalk.red(` ✗ ${step.slug} — not found`));
646
+ continue;
647
+ }
648
+ lines.push(chalk.bold(` ${step.slug}`));
649
+
650
+ for (const f of step.fields) {
651
+ const target = chalk.cyan(f.field);
652
+ const targetType = typeLabel(f.targetTypes);
653
+
654
+ if (f.expr.kind === "literal") {
655
+ lines.push(` ${target} = ${chalk.dim(JSON.stringify(f.expr.literal))} ${chalk.gray(`(${targetType || "any"})`)}`);
656
+ } else if (f.expr.kind === "stdin") {
657
+ lines.push(` ${target} = ${chalk.magenta("@in")} ${chalk.gray(`(${targetType || "any"})`)}`);
658
+ } else {
659
+ const chain = (f.expr.accessors || [])
660
+ .map((a) => (a.op === "transform" ? `|${a.name}` : a.path))
661
+ .join("");
662
+ const arrow = chalk.gray("←");
663
+ const src = chalk.dim(`${f.source?.slug ?? "?"}${chain.startsWith(".") || chain.startsWith("|") ? chain : `.${chain}`}`);
664
+ const got = f.resultTypes?.length ? typeLabel(f.resultTypes) : "runtime";
665
+ const mark =
666
+ f.verdict === "needs-cast" || f.verdict === "error" ? chalk.red("✗")
667
+ : f.verdict === "lossy" ? chalk.yellow("~")
668
+ : chalk.green("✔");
669
+ lines.push(` ${mark} ${target} ${arrow} ${src} ${chalk.gray(`${got} → ${targetType || "any"}`)}`);
670
+ }
671
+ }
672
+ lines.push("");
673
+ }
674
+
675
+ const errors = issues.filter((i) => i.level === "error");
676
+ const warns = issues.filter((i) => i.level === "warn");
677
+ const lossy = issues.filter((i) => i.level === "warn" && /precision/.test(i.message));
678
+ const explicit = steps
679
+ .flatMap((s) => s.fields)
680
+ .filter((f) => f.expr?.accessors?.some((a) => a.op === "transform")).length;
681
+
682
+ if (issues.length) {
683
+ for (const issue of issues) {
684
+ const mark = issue.level === "error" ? chalk.red(" ✗") : chalk.yellow(" !");
685
+ lines.push(`${mark} step ${issue.step + 1}: ${issue.message}`);
686
+ }
687
+ lines.push("");
688
+ }
689
+
690
+ lines.push(
691
+ chalk.gray(
692
+ ` ${steps.length} step${steps.length === 1 ? "" : "s"} · ${errors.length} error${errors.length === 1 ? "" : "s"}` +
693
+ ` · ${lossy.length} lossy · ${explicit} explicit · runtime validation on`
694
+ )
695
+ );
696
+
697
+ return lines.join("\n");
698
+ }
699
+
700
+ /** One-line summary of a step's output for the run log. */
701
+ export function describeOutput(value) {
702
+ if (value === MISSING || value === undefined) return chalk.gray("—");
703
+ if (value === null) return chalk.dim("null");
704
+ if (typeof value !== "object") return preview(value, 60);
705
+ const keys = Object.keys(value);
706
+ if (!keys.length) return chalk.dim("{}");
707
+ const parts = keys.slice(0, 4).map((k) => `${k}=${preview(value[k], 18)}`);
708
+ return parts.join(" ") + (keys.length > 4 ? chalk.gray(` +${keys.length - 4}`) : "");
709
+ }