@boboddy/sdk 0.5.1 → 0.5.3
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/dist/definitions/pipelines/chain-graph.d.ts +29 -0
- package/dist/definitions/pipelines/compile-node-definitions.d.ts +0 -8
- package/dist/definitions/pipelines/define-pipeline.d.ts +2 -0
- package/dist/definitions/pipelines/index.js +2 -21
- package/dist/definitions/steps/define-code-step.d.ts +17 -5
- package/dist/definitions/steps/define-step.d.ts +1 -1
- package/dist/definitions/steps/index.js +97 -32
- package/dist/definitions/steps/step-features.d.ts +92 -31
- package/dist/definitions/validation/index.d.ts +2 -0
- package/dist/definitions/validation/index.js +394 -32
- package/dist/definitions/validation/json-schema-paths.d.ts +29 -0
- package/dist/definitions/validation/validate-definition-specs.d.ts +12 -23
- package/dist/definitions/validation/validate-input-bindings.d.ts +7 -0
- package/dist/definitions/validation/validation-issue.d.ts +52 -0
- package/dist/generated/types.gen.d.ts +2 -0
- package/dist/index.js +99 -53
- package/dist/push/index.js +484 -64
- package/package.json +1 -1
|
@@ -207,22 +207,22 @@ function enumeratePaths(node, root, maxDepth = 3, limit = 40) {
|
|
|
207
207
|
visit(node, "", 1);
|
|
208
208
|
return [...new Set(out)].sort();
|
|
209
209
|
}
|
|
210
|
-
function
|
|
210
|
+
function resolvePathToNode(schema, sourcePath) {
|
|
211
211
|
const segments = parseSourcePath(sourcePath);
|
|
212
212
|
if (segments.length === 0)
|
|
213
|
-
return { kind: "resolved" };
|
|
213
|
+
return { kind: "resolved", node: schema };
|
|
214
214
|
let candidates = [schema];
|
|
215
215
|
let resolvedPrefix = "";
|
|
216
216
|
for (const segment of segments) {
|
|
217
|
-
const expanded = candidates.flatMap((
|
|
217
|
+
const expanded = candidates.flatMap((node2) => flatten(node2, schema) ?? []);
|
|
218
218
|
if (expanded.length === 0)
|
|
219
219
|
return { kind: "indeterminate" };
|
|
220
|
-
const outcome = combine(expanded.map((
|
|
220
|
+
const outcome = combine(expanded.map((node2) => stepInto(node2, segment)));
|
|
221
221
|
if (outcome.kind === "indeterminate")
|
|
222
222
|
return { kind: "indeterminate" };
|
|
223
223
|
if (outcome.kind === "invalid") {
|
|
224
224
|
const availablePaths = [
|
|
225
|
-
...new Set(expanded.flatMap((
|
|
225
|
+
...new Set(expanded.flatMap((node2) => enumeratePaths(node2, schema)))
|
|
226
226
|
].sort();
|
|
227
227
|
return {
|
|
228
228
|
kind: "invalid",
|
|
@@ -235,7 +235,60 @@ function resolveSourcePath(schema, sourcePath) {
|
|
|
235
235
|
candidates = [outcome.node];
|
|
236
236
|
resolvedPrefix = resolvedPrefix ? `${resolvedPrefix}.${segment}` : segment;
|
|
237
237
|
}
|
|
238
|
-
|
|
238
|
+
const [node] = candidates;
|
|
239
|
+
return node ? { kind: "resolved", node } : { kind: "indeterminate" };
|
|
240
|
+
}
|
|
241
|
+
function resolveSourcePath(schema, sourcePath) {
|
|
242
|
+
const result = resolvePathToNode(schema, sourcePath);
|
|
243
|
+
if (result.kind === "resolved")
|
|
244
|
+
return { kind: "resolved" };
|
|
245
|
+
if (result.kind === "indeterminate")
|
|
246
|
+
return { kind: "indeterminate" };
|
|
247
|
+
return {
|
|
248
|
+
kind: "invalid",
|
|
249
|
+
resolvedPrefix: result.resolvedPrefix,
|
|
250
|
+
segment: result.segment,
|
|
251
|
+
reason: result.reason,
|
|
252
|
+
availablePaths: result.availablePaths
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
var KNOWN_TYPES = new Set([
|
|
256
|
+
"string",
|
|
257
|
+
"number",
|
|
258
|
+
"boolean",
|
|
259
|
+
"object",
|
|
260
|
+
"array",
|
|
261
|
+
"null"
|
|
262
|
+
]);
|
|
263
|
+
function normalizeTypeName(name) {
|
|
264
|
+
return name === "integer" ? "number" : name;
|
|
265
|
+
}
|
|
266
|
+
function resolveSchemaType(node, root = node) {
|
|
267
|
+
const branches = flatten(node, root);
|
|
268
|
+
if (!branches || branches.length === 0)
|
|
269
|
+
return "unknown";
|
|
270
|
+
const types = new Set;
|
|
271
|
+
for (const branch of branches) {
|
|
272
|
+
const record = asRecord(branch);
|
|
273
|
+
if (!record)
|
|
274
|
+
return "unknown";
|
|
275
|
+
const names = [...typeNames(record)].map(normalizeTypeName);
|
|
276
|
+
if (names.length !== 1)
|
|
277
|
+
return "unknown";
|
|
278
|
+
const [name] = names;
|
|
279
|
+
if (!name || !KNOWN_TYPES.has(name))
|
|
280
|
+
return "unknown";
|
|
281
|
+
types.add(name);
|
|
282
|
+
}
|
|
283
|
+
if (types.size !== 1)
|
|
284
|
+
return "unknown";
|
|
285
|
+
return [...types][0];
|
|
286
|
+
}
|
|
287
|
+
function resolvePathType(schema, sourcePath) {
|
|
288
|
+
const result = resolvePathToNode(schema, sourcePath);
|
|
289
|
+
if (result.kind !== "resolved")
|
|
290
|
+
return "unknown";
|
|
291
|
+
return resolveSchemaType(result.node, schema);
|
|
239
292
|
}
|
|
240
293
|
// src/definitions/pipelines/chain-graph.ts
|
|
241
294
|
function tryComputeTopoRanks(nodeDefinitions, dependencyEdges) {
|
|
@@ -288,6 +341,75 @@ function tryOrderNodeDefinitionsByTopoRank(nodeDefinitions, dependencyEdges) {
|
|
|
288
341
|
return rankDiff !== 0 ? rankDiff : left.declarationIndex - right.declarationIndex;
|
|
289
342
|
}).map(({ node }) => node);
|
|
290
343
|
}
|
|
344
|
+
function tryComputeDominators(nodeDefinitions, dependencyEdges, entryNodeKey) {
|
|
345
|
+
const nodeKeys = new Set(nodeDefinitions.map((node) => node.nodeKey));
|
|
346
|
+
if (!nodeKeys.has(entryNodeKey))
|
|
347
|
+
return null;
|
|
348
|
+
if (tryComputeTopoRanks(nodeDefinitions, dependencyEdges) === null) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
const outgoing = new Map;
|
|
352
|
+
const incoming = new Map;
|
|
353
|
+
for (const key of nodeKeys) {
|
|
354
|
+
outgoing.set(key, []);
|
|
355
|
+
incoming.set(key, []);
|
|
356
|
+
}
|
|
357
|
+
for (const edge of dependencyEdges) {
|
|
358
|
+
outgoing.get(edge.fromNodeKey)?.push(edge.toNodeKey);
|
|
359
|
+
incoming.get(edge.toNodeKey)?.push(edge.fromNodeKey);
|
|
360
|
+
}
|
|
361
|
+
const reachable = new Set([entryNodeKey]);
|
|
362
|
+
const queue = [entryNodeKey];
|
|
363
|
+
while (queue.length > 0) {
|
|
364
|
+
const current = queue.shift();
|
|
365
|
+
if (current === undefined)
|
|
366
|
+
break;
|
|
367
|
+
for (const next of outgoing.get(current) ?? []) {
|
|
368
|
+
if (reachable.has(next))
|
|
369
|
+
continue;
|
|
370
|
+
reachable.add(next);
|
|
371
|
+
queue.push(next);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const dom = new Map;
|
|
375
|
+
dom.set(entryNodeKey, new Set([entryNodeKey]));
|
|
376
|
+
for (const key of reachable) {
|
|
377
|
+
if (key !== entryNodeKey)
|
|
378
|
+
dom.set(key, new Set(reachable));
|
|
379
|
+
}
|
|
380
|
+
let changed = true;
|
|
381
|
+
while (changed) {
|
|
382
|
+
changed = false;
|
|
383
|
+
for (const key of reachable) {
|
|
384
|
+
if (key === entryNodeKey)
|
|
385
|
+
continue;
|
|
386
|
+
let intersection = null;
|
|
387
|
+
for (const predecessor of incoming.get(key) ?? []) {
|
|
388
|
+
if (!reachable.has(predecessor))
|
|
389
|
+
continue;
|
|
390
|
+
const predecessorDom = dom.get(predecessor);
|
|
391
|
+
if (!predecessorDom)
|
|
392
|
+
continue;
|
|
393
|
+
if (intersection === null) {
|
|
394
|
+
intersection = new Set(predecessorDom);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
for (const candidate of intersection) {
|
|
398
|
+
if (!predecessorDom.has(candidate))
|
|
399
|
+
intersection.delete(candidate);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const nextDom = intersection ?? new Set;
|
|
403
|
+
nextDom.add(key);
|
|
404
|
+
const currentDom = dom.get(key);
|
|
405
|
+
if (!currentDom || currentDom.size !== nextDom.size || [...currentDom].some((item) => !nextDom.has(item))) {
|
|
406
|
+
dom.set(key, nextDom);
|
|
407
|
+
changed = true;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return dom;
|
|
412
|
+
}
|
|
291
413
|
|
|
292
414
|
// ../../../node_modules/.bun/zod@4.4.3/node_modules/zod/v4/classic/external.js
|
|
293
415
|
var exports_external = {};
|
|
@@ -15259,25 +15381,6 @@ function compileLoopState(stateKey, state, ctx) {
|
|
|
15259
15381
|
function compileTerminalState(stateKey, kind) {
|
|
15260
15382
|
return { nodeDefinitions: [{ nodeKey: stateKey, kind }], edges: [] };
|
|
15261
15383
|
}
|
|
15262
|
-
function assertNoIllegalConvergentEdges(pipelineKey, nodeKindByKey, edges) {
|
|
15263
|
-
const incoming = new Map;
|
|
15264
|
-
for (const edge of edges) {
|
|
15265
|
-
const list = incoming.get(edge.toNodeKey) ?? [];
|
|
15266
|
-
list.push(edge);
|
|
15267
|
-
incoming.set(edge.toNodeKey, list);
|
|
15268
|
-
}
|
|
15269
|
-
for (const [targetKey, incomingEdges] of incoming) {
|
|
15270
|
-
if (incomingEdges.length <= 1)
|
|
15271
|
-
continue;
|
|
15272
|
-
const hasInvalidSource = incomingEdges.some((edge) => {
|
|
15273
|
-
const kind = nodeKindByKey.get(edge.fromNodeKey);
|
|
15274
|
-
return kind !== "choice" && kind !== "loop";
|
|
15275
|
-
});
|
|
15276
|
-
if (hasInvalidSource) {
|
|
15277
|
-
throw new Error(`Pipeline "${pipelineKey}": state "${targetKey}" has more than one incoming edge, but not every source is a 'choice'/'loop' state (unconditional convergent edges are not allowed \u2014 see docs/research/flat-pipeline-sdk-and-visual-designer.md \xA76).`);
|
|
15278
|
-
}
|
|
15279
|
-
}
|
|
15280
|
-
}
|
|
15281
15384
|
|
|
15282
15385
|
// src/definitions/pipelines/define-pipeline.ts
|
|
15283
15386
|
function isWorkingNodeDefinition(node) {
|
|
@@ -15314,8 +15417,6 @@ function definePipeline(config2) {
|
|
|
15314
15417
|
nodeDefinitions.push(...compiled.nodeDefinitions);
|
|
15315
15418
|
dependencyEdges.push(...compiled.edges);
|
|
15316
15419
|
}
|
|
15317
|
-
const nodeKindByKey = new Map(nodeDefinitions.map((node) => [node.nodeKey, node.kind]));
|
|
15318
|
-
assertNoIllegalConvergentEdges(config2.key, nodeKindByKey, dependencyEdges);
|
|
15319
15420
|
let inputSchemaJson = null;
|
|
15320
15421
|
if (config2.input) {
|
|
15321
15422
|
try {
|
|
@@ -15331,13 +15432,14 @@ function definePipeline(config2) {
|
|
|
15331
15432
|
version: config2.version ?? 1,
|
|
15332
15433
|
status: config2.status ?? "active",
|
|
15333
15434
|
inputSchemaJson,
|
|
15435
|
+
entryNodeKey: config2.startAt,
|
|
15334
15436
|
_stepDefinitions: [...stepDefMap.values()],
|
|
15335
15437
|
nodeDefinitions,
|
|
15336
15438
|
dependencyEdges
|
|
15337
15439
|
};
|
|
15338
15440
|
}
|
|
15339
15441
|
|
|
15340
|
-
// src/definitions/validation/
|
|
15442
|
+
// src/definitions/validation/validation-issue.ts
|
|
15341
15443
|
function listPaths(paths, limit = 24) {
|
|
15342
15444
|
if (paths.length === 0)
|
|
15343
15445
|
return "";
|
|
@@ -15345,6 +15447,250 @@ function listPaths(paths, limit = 24) {
|
|
|
15345
15447
|
return paths.join(", ");
|
|
15346
15448
|
return `${paths.slice(0, limit).join(", ")}, \u2026 (${String(paths.length - limit)} more)`;
|
|
15347
15449
|
}
|
|
15450
|
+
|
|
15451
|
+
// src/definitions/validation/validate-input-bindings.ts
|
|
15452
|
+
var WORK_ITEM_TOP_LEVEL_FIELD_SET = new Set(WORK_ITEM_TOP_LEVEL_FIELDS);
|
|
15453
|
+
function isAutoBoundWorkItemField(field) {
|
|
15454
|
+
return field === "workItemTitle" || field === "workItemDescription";
|
|
15455
|
+
}
|
|
15456
|
+
function bindingContexts(pipeline) {
|
|
15457
|
+
const contexts = [];
|
|
15458
|
+
for (const node of pipeline.nodeDefinitions) {
|
|
15459
|
+
if (isWorkingNodeDefinition(node)) {
|
|
15460
|
+
contexts.push({
|
|
15461
|
+
nodeKey: node.nodeKey,
|
|
15462
|
+
branchKey: null,
|
|
15463
|
+
stepKey: node.stepKey,
|
|
15464
|
+
inputBindingsJson: node.inputBindingsJson ?? {}
|
|
15465
|
+
});
|
|
15466
|
+
continue;
|
|
15467
|
+
}
|
|
15468
|
+
if (node.kind === "parallel" && node.branches) {
|
|
15469
|
+
for (const [branchKey, branch] of Object.entries(node.branches)) {
|
|
15470
|
+
contexts.push({
|
|
15471
|
+
nodeKey: node.nodeKey,
|
|
15472
|
+
branchKey,
|
|
15473
|
+
stepKey: branch.stepKey,
|
|
15474
|
+
inputBindingsJson: branch.inputBindingsJson ?? {}
|
|
15475
|
+
});
|
|
15476
|
+
}
|
|
15477
|
+
}
|
|
15478
|
+
}
|
|
15479
|
+
return contexts;
|
|
15480
|
+
}
|
|
15481
|
+
function bindingContextLabel(pipelineKey, ctx) {
|
|
15482
|
+
return ctx.branchKey ? `Pipeline "${pipelineKey}" node "${ctx.nodeKey}" branch "${ctx.branchKey}"` : `Pipeline "${pipelineKey}" node "${ctx.nodeKey}"`;
|
|
15483
|
+
}
|
|
15484
|
+
function knownInputFields(specs) {
|
|
15485
|
+
const fields = new Set;
|
|
15486
|
+
for (const spec of specs) {
|
|
15487
|
+
const schema = spec.inputSchemaJson;
|
|
15488
|
+
if (!schema)
|
|
15489
|
+
continue;
|
|
15490
|
+
const properties = schema["properties"];
|
|
15491
|
+
if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
|
|
15492
|
+
continue;
|
|
15493
|
+
}
|
|
15494
|
+
for (const key of Object.keys(properties))
|
|
15495
|
+
fields.add(key);
|
|
15496
|
+
}
|
|
15497
|
+
return fields;
|
|
15498
|
+
}
|
|
15499
|
+
function requiredInputFields(specs) {
|
|
15500
|
+
const fields = new Set;
|
|
15501
|
+
for (const spec of specs) {
|
|
15502
|
+
const schema = spec.inputSchemaJson;
|
|
15503
|
+
if (!schema)
|
|
15504
|
+
continue;
|
|
15505
|
+
const required2 = schema["required"];
|
|
15506
|
+
if (!Array.isArray(required2))
|
|
15507
|
+
continue;
|
|
15508
|
+
for (const entry of required2) {
|
|
15509
|
+
if (typeof entry === "string")
|
|
15510
|
+
fields.add(entry);
|
|
15511
|
+
}
|
|
15512
|
+
}
|
|
15513
|
+
return fields;
|
|
15514
|
+
}
|
|
15515
|
+
function isJsonSchemaNode(value) {
|
|
15516
|
+
return typeof value === "boolean" || typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15517
|
+
}
|
|
15518
|
+
function findPropertyNode(specs, field) {
|
|
15519
|
+
for (const spec of specs) {
|
|
15520
|
+
const schema = spec.inputSchemaJson;
|
|
15521
|
+
if (!schema)
|
|
15522
|
+
continue;
|
|
15523
|
+
const properties = schema["properties"];
|
|
15524
|
+
if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
|
|
15525
|
+
continue;
|
|
15526
|
+
}
|
|
15527
|
+
const node = properties[field];
|
|
15528
|
+
if (isJsonSchemaNode(node))
|
|
15529
|
+
return { node, root: schema };
|
|
15530
|
+
}
|
|
15531
|
+
return null;
|
|
15532
|
+
}
|
|
15533
|
+
function checkUnboundRequiredInputs(pipelines, stepsByKey) {
|
|
15534
|
+
const issues = [];
|
|
15535
|
+
for (const pipeline of pipelines) {
|
|
15536
|
+
for (const ctx of bindingContexts(pipeline)) {
|
|
15537
|
+
const specs = stepsByKey.get(ctx.stepKey);
|
|
15538
|
+
if (!specs)
|
|
15539
|
+
continue;
|
|
15540
|
+
const required2 = requiredInputFields(specs);
|
|
15541
|
+
if (required2.size === 0)
|
|
15542
|
+
continue;
|
|
15543
|
+
const bound = new Set(Object.keys(ctx.inputBindingsJson));
|
|
15544
|
+
const missing = [...required2].filter((field) => !bound.has(field) && !isAutoBoundWorkItemField(field)).sort();
|
|
15545
|
+
if (missing.length === 0)
|
|
15546
|
+
continue;
|
|
15547
|
+
const where = bindingContextLabel(pipeline.key, ctx);
|
|
15548
|
+
const boundList = [
|
|
15549
|
+
...bound,
|
|
15550
|
+
"workItemTitle",
|
|
15551
|
+
"workItemDescription"
|
|
15552
|
+
].sort();
|
|
15553
|
+
for (const field of missing) {
|
|
15554
|
+
issues.push({
|
|
15555
|
+
check: "unbound-required-input",
|
|
15556
|
+
severity: "error",
|
|
15557
|
+
pipelineKey: pipeline.key,
|
|
15558
|
+
nodeKey: ctx.nodeKey,
|
|
15559
|
+
branchKey: ctx.branchKey ?? undefined,
|
|
15560
|
+
message: `${where} runs step "${ctx.stepKey}", which requires input "${field}", ` + `but no binding provides it. Bound inputs: ${listPaths(boundList)}.`
|
|
15561
|
+
});
|
|
15562
|
+
}
|
|
15563
|
+
}
|
|
15564
|
+
}
|
|
15565
|
+
return issues;
|
|
15566
|
+
}
|
|
15567
|
+
function checkBindingTargetFields(pipelines, stepsByKey) {
|
|
15568
|
+
const issues = [];
|
|
15569
|
+
for (const pipeline of pipelines) {
|
|
15570
|
+
for (const ctx of bindingContexts(pipeline)) {
|
|
15571
|
+
const specs = stepsByKey.get(ctx.stepKey);
|
|
15572
|
+
const knownFields = specs ? knownInputFields(specs) : null;
|
|
15573
|
+
const where = bindingContextLabel(pipeline.key, ctx);
|
|
15574
|
+
for (const [field, binding] of Object.entries(ctx.inputBindingsJson)) {
|
|
15575
|
+
if (knownFields && !isAutoBoundWorkItemField(field) && !knownFields.has(field)) {
|
|
15576
|
+
issues.push({
|
|
15577
|
+
check: "binding-target-field",
|
|
15578
|
+
severity: "info",
|
|
15579
|
+
pipelineKey: pipeline.key,
|
|
15580
|
+
nodeKey: ctx.nodeKey,
|
|
15581
|
+
branchKey: ctx.branchKey ?? undefined,
|
|
15582
|
+
message: `${where} is passing information ("${field}") to step "${ctx.stepKey}" ` + `that it isn't explicitly asking for \u2014 the step declares no such ` + `additionalInput field, so the value is dropped. Declared fields: ` + `${knownFields.size > 0 ? listPaths([...knownFields].sort()) : "(none)"}.`
|
|
15583
|
+
});
|
|
15584
|
+
}
|
|
15585
|
+
if (binding.source === "work_item" && !binding.field.startsWith(WORK_ITEM_FIELDS_PATH_PREFIX) && !WORK_ITEM_TOP_LEVEL_FIELD_SET.has(binding.field)) {
|
|
15586
|
+
issues.push({
|
|
15587
|
+
check: "binding-target-field",
|
|
15588
|
+
severity: "error",
|
|
15589
|
+
pipelineKey: pipeline.key,
|
|
15590
|
+
nodeKey: ctx.nodeKey,
|
|
15591
|
+
branchKey: ctx.branchKey ?? undefined,
|
|
15592
|
+
message: `${where} binds input "${field}" to work_item field "${binding.field}", ` + `which is not a known top-level work-item field and does not start ` + `with "${WORK_ITEM_FIELDS_PATH_PREFIX}". Known top-level fields: ` + `${listPaths(WORK_ITEM_TOP_LEVEL_FIELDS)}.`
|
|
15593
|
+
});
|
|
15594
|
+
}
|
|
15595
|
+
}
|
|
15596
|
+
}
|
|
15597
|
+
}
|
|
15598
|
+
return issues;
|
|
15599
|
+
}
|
|
15600
|
+
function describeBindingSource(binding) {
|
|
15601
|
+
switch (binding.source) {
|
|
15602
|
+
case "step_signal":
|
|
15603
|
+
return `signal "${binding.signalKey}" of node "${binding.stepKey}"`;
|
|
15604
|
+
case "step_output":
|
|
15605
|
+
return `the output of node "${binding.stepKey}"`;
|
|
15606
|
+
case "signals_list":
|
|
15607
|
+
return `the signals list of fan-out node "${binding.stepKey}"`;
|
|
15608
|
+
case "pipeline_input":
|
|
15609
|
+
return `pipeline input "${binding.path}"`;
|
|
15610
|
+
case "work_item":
|
|
15611
|
+
return `work_item field "${binding.field}"`;
|
|
15612
|
+
case "literal":
|
|
15613
|
+
return "a literal value";
|
|
15614
|
+
case "fan_out_item":
|
|
15615
|
+
return "the fan-out item";
|
|
15616
|
+
}
|
|
15617
|
+
}
|
|
15618
|
+
function bindingSourceType(binding, pipeline, nodeByKey, stepsByKey) {
|
|
15619
|
+
if (binding.source === "step_signal") {
|
|
15620
|
+
const producerNode = nodeByKey.get(binding.stepKey);
|
|
15621
|
+
if (!producerNode || !isWorkingNodeDefinition(producerNode))
|
|
15622
|
+
return "unknown";
|
|
15623
|
+
const specs = stepsByKey.get(producerNode.stepKey) ?? [];
|
|
15624
|
+
for (const spec of specs) {
|
|
15625
|
+
const signal2 = spec.signalExtractorDefinitions.find((candidate) => candidate.key === binding.signalKey);
|
|
15626
|
+
if (signal2)
|
|
15627
|
+
return signal2.type;
|
|
15628
|
+
}
|
|
15629
|
+
return "unknown";
|
|
15630
|
+
}
|
|
15631
|
+
if (binding.source === "step_output") {
|
|
15632
|
+
const producerNode = nodeByKey.get(binding.stepKey);
|
|
15633
|
+
if (!producerNode || !isWorkingNodeDefinition(producerNode))
|
|
15634
|
+
return "unknown";
|
|
15635
|
+
const specs = stepsByKey.get(producerNode.stepKey) ?? [];
|
|
15636
|
+
for (const spec of specs) {
|
|
15637
|
+
if (spec.resultSchemaJson)
|
|
15638
|
+
return resolveSchemaType(spec.resultSchemaJson);
|
|
15639
|
+
}
|
|
15640
|
+
return "unknown";
|
|
15641
|
+
}
|
|
15642
|
+
if (binding.source === "signals_list")
|
|
15643
|
+
return "array";
|
|
15644
|
+
if (binding.source === "pipeline_input") {
|
|
15645
|
+
return pipeline.inputSchemaJson ? resolvePathType(pipeline.inputSchemaJson, binding.path) : "unknown";
|
|
15646
|
+
}
|
|
15647
|
+
if (binding.source === "work_item") {
|
|
15648
|
+
if (binding.field.startsWith(WORK_ITEM_FIELDS_PATH_PREFIX))
|
|
15649
|
+
return "unknown";
|
|
15650
|
+
return WORK_ITEM_TOP_LEVEL_FIELD_SET.has(binding.field) ? "string" : "unknown";
|
|
15651
|
+
}
|
|
15652
|
+
return "unknown";
|
|
15653
|
+
}
|
|
15654
|
+
function checkBindingTypeCompatibility(pipelines, stepsByKey) {
|
|
15655
|
+
const issues = [];
|
|
15656
|
+
for (const pipeline of pipelines) {
|
|
15657
|
+
const nodeByKey = new Map(pipeline.nodeDefinitions.map((node) => [node.nodeKey, node]));
|
|
15658
|
+
for (const ctx of bindingContexts(pipeline)) {
|
|
15659
|
+
const specs = stepsByKey.get(ctx.stepKey);
|
|
15660
|
+
const where = bindingContextLabel(pipeline.key, ctx);
|
|
15661
|
+
for (const [field, binding] of Object.entries(ctx.inputBindingsJson)) {
|
|
15662
|
+
if (binding.source === "fan_out_item")
|
|
15663
|
+
continue;
|
|
15664
|
+
if (isAutoBoundWorkItemField(field))
|
|
15665
|
+
continue;
|
|
15666
|
+
if (!specs)
|
|
15667
|
+
continue;
|
|
15668
|
+
const target = findPropertyNode(specs, field);
|
|
15669
|
+
if (!target)
|
|
15670
|
+
continue;
|
|
15671
|
+
const targetType = resolveSchemaType(target.node, target.root);
|
|
15672
|
+
if (targetType === "unknown")
|
|
15673
|
+
continue;
|
|
15674
|
+
const sourceType = bindingSourceType(binding, pipeline, nodeByKey, stepsByKey);
|
|
15675
|
+
if (sourceType === "unknown")
|
|
15676
|
+
continue;
|
|
15677
|
+
if (sourceType === targetType)
|
|
15678
|
+
continue;
|
|
15679
|
+
issues.push({
|
|
15680
|
+
check: "binding-type-mismatch",
|
|
15681
|
+
severity: "warning",
|
|
15682
|
+
pipelineKey: pipeline.key,
|
|
15683
|
+
nodeKey: ctx.nodeKey,
|
|
15684
|
+
branchKey: ctx.branchKey ?? undefined,
|
|
15685
|
+
message: `${where} binds input "${field}" (declared type "${targetType}") to ` + `${describeBindingSource(binding)}, which resolves to type ` + `"${sourceType}" \u2014 the types disagree.`
|
|
15686
|
+
});
|
|
15687
|
+
}
|
|
15688
|
+
}
|
|
15689
|
+
}
|
|
15690
|
+
return issues;
|
|
15691
|
+
}
|
|
15692
|
+
|
|
15693
|
+
// src/definitions/validation/validate-definition-specs.ts
|
|
15348
15694
|
function quotedOrRoot(prefix) {
|
|
15349
15695
|
return prefix ? `"${prefix}"` : "the result root";
|
|
15350
15696
|
}
|
|
@@ -15363,6 +15709,7 @@ function checkSignalSourcePaths(steps) {
|
|
|
15363
15709
|
const suffix = availablePaths.length > 0 ? ` Valid sourcePaths ${resolvedPrefix ? `under "${resolvedPrefix}"` : "for this step"}: ${listPaths(availablePaths)}.` : "";
|
|
15364
15710
|
issues.push({
|
|
15365
15711
|
check: "signal-source-path",
|
|
15712
|
+
severity: "error",
|
|
15366
15713
|
message: `Step "${step.key}" declares signal "${signal2.key}" with sourcePath ` + `"${signal2.sourcePath}", which can never resolve against the step's ` + `result schema: ${cause}.${suffix}`
|
|
15367
15714
|
});
|
|
15368
15715
|
}
|
|
@@ -15385,6 +15732,7 @@ function checkHealthChecks(steps) {
|
|
|
15385
15732
|
if (!mcpServerKeySet.has(check2.mcp)) {
|
|
15386
15733
|
issues.push({
|
|
15387
15734
|
check: "health-check-mcp-server",
|
|
15735
|
+
severity: "error",
|
|
15388
15736
|
message: `${where} names MCP server "${check2.mcp}", but the step declares no ` + `such server in mcpServers. Declared servers: ${mcpServerKeys.length > 0 ? listPaths([...mcpServerKeys].sort()) : "(none)"}.`
|
|
15389
15737
|
});
|
|
15390
15738
|
}
|
|
@@ -15392,6 +15740,7 @@ function checkHealthChecks(steps) {
|
|
|
15392
15740
|
if (check2.tool.startsWith(prefix)) {
|
|
15393
15741
|
issues.push({
|
|
15394
15742
|
check: "health-check-double-qualified",
|
|
15743
|
+
severity: "error",
|
|
15395
15744
|
message: `${where} sets mcp "${check2.mcp}" and tool "${check2.tool}", which already ` + `starts with "${prefix}". When "mcp" is set, "tool" should be the bare tool ` + `name \u2014 OpenCode resolves it to "${prefix}${check2.tool}". Did you mean ` + `tool: "${check2.tool.slice(prefix.length)}"?`
|
|
15396
15745
|
});
|
|
15397
15746
|
}
|
|
@@ -15428,6 +15777,7 @@ function checkRouteTargets(pipelines, knownPipelineKeys) {
|
|
|
15428
15777
|
continue;
|
|
15429
15778
|
issues.push({
|
|
15430
15779
|
check: "route-target",
|
|
15780
|
+
severity: "error",
|
|
15431
15781
|
pipelineKey: pipeline.key,
|
|
15432
15782
|
nodeKey: node.nodeKey,
|
|
15433
15783
|
message: `Pipeline "${pipeline.key}" step "${stepLabel}" routes to pipeline ` + `"${target}", but no pipeline with that key was found on the server or ` + `in the current push batch. Push the target pipeline first.`
|
|
@@ -15483,6 +15833,7 @@ function checkSignalBindings(pipelines, stepsByKey) {
|
|
|
15483
15833
|
const order = [...pipeline.nodeDefinitions.keys()].sort((left, right) => (ranks.get(left) ?? 0) - (ranks.get(right) ?? 0)).map((index) => pipeline.nodeDefinitions[index]?.nodeKey ?? "");
|
|
15484
15834
|
const orderHint = `Nodes in "${pipeline.key}", in order: ${order.join(" \u2192 ")}.`;
|
|
15485
15835
|
const nodeByKey = new Map(pipeline.nodeDefinitions.map((node) => [node.nodeKey, node]));
|
|
15836
|
+
const dominatorSets = tryComputeDominators(pipeline.nodeDefinitions, pipeline.dependencyEdges, pipeline.entryNodeKey);
|
|
15486
15837
|
pipeline.nodeDefinitions.forEach((node, index) => {
|
|
15487
15838
|
const consumerRank = ranks.get(index) ?? index;
|
|
15488
15839
|
const where = `Pipeline "${pipeline.key}" node "${node.nodeKey}"`;
|
|
@@ -15493,6 +15844,7 @@ function checkSignalBindings(pipelines, stepsByKey) {
|
|
|
15493
15844
|
continue;
|
|
15494
15845
|
const what = source.kind === "signal" ? `binds input "${field}" to signal "${source.signalKey ?? ""}" of node "${source.nodeKey}"` : source.kind === "signals_list" ? `binds input "${field}" to the signals list of fan-out node "${source.nodeKey}"` : `binds input "${field}" to the output of node "${source.nodeKey}"`;
|
|
15495
15846
|
const issueBase = {
|
|
15847
|
+
severity: "error",
|
|
15496
15848
|
pipelineKey: pipeline.key,
|
|
15497
15849
|
nodeKey: node.nodeKey,
|
|
15498
15850
|
targetNodeKey: source.nodeKey
|
|
@@ -15507,11 +15859,12 @@ function checkSignalBindings(pipelines, stepsByKey) {
|
|
|
15507
15859
|
});
|
|
15508
15860
|
continue;
|
|
15509
15861
|
}
|
|
15510
|
-
|
|
15862
|
+
const producesBeforeConsumer = dominatorSets !== null ? source.nodeKey !== node.nodeKey && (dominatorSets.get(node.nodeKey)?.has(source.nodeKey) ?? false) : producerRank < consumerRank;
|
|
15863
|
+
if (!producesBeforeConsumer) {
|
|
15511
15864
|
issues.push({
|
|
15512
15865
|
check: "signal-binding",
|
|
15513
15866
|
...issueBase,
|
|
15514
|
-
message: `${where} ${what}, but that node does not run
|
|
15867
|
+
message: `${where} ${what}, but that node does not run on every path ` + `leading to this node, so the value may not exist. ${orderHint}`
|
|
15515
15868
|
});
|
|
15516
15869
|
continue;
|
|
15517
15870
|
}
|
|
@@ -15543,11 +15896,14 @@ function validateDefinitionSpecs(specs, options = {}) {
|
|
|
15543
15896
|
...checkSignalSourcePaths(specs.steps),
|
|
15544
15897
|
...checkHealthChecks(specs.steps),
|
|
15545
15898
|
...checkRouteTargets(specs.pipelines, options.knownPipelineKeys ?? []),
|
|
15546
|
-
...checkSignalBindings(specs.pipelines, stepsByKey)
|
|
15899
|
+
...checkSignalBindings(specs.pipelines, stepsByKey),
|
|
15900
|
+
...checkUnboundRequiredInputs(specs.pipelines, stepsByKey),
|
|
15901
|
+
...checkBindingTargetFields(specs.pipelines, stepsByKey),
|
|
15902
|
+
...checkBindingTypeCompatibility(specs.pipelines, stepsByKey)
|
|
15547
15903
|
];
|
|
15548
15904
|
}
|
|
15549
15905
|
function assertValidDefinitionSpecs(specs, options = {}) {
|
|
15550
|
-
const issues = validateDefinitionSpecs(specs, options);
|
|
15906
|
+
const issues = validateDefinitionSpecs(specs, options).filter((issue2) => issue2.severity === "error");
|
|
15551
15907
|
if (issues.length === 0)
|
|
15552
15908
|
return;
|
|
15553
15909
|
const header = issues.length === 1 ? "Definition validation failed:" : `Definition validation failed with ${String(issues.length)} problems:`;
|
|
@@ -15556,8 +15912,14 @@ function assertValidDefinitionSpecs(specs, options = {}) {
|
|
|
15556
15912
|
}
|
|
15557
15913
|
export {
|
|
15558
15914
|
assertValidDefinitionSpecs,
|
|
15915
|
+
checkBindingTargetFields,
|
|
15916
|
+
checkBindingTypeCompatibility,
|
|
15917
|
+
checkUnboundRequiredInputs,
|
|
15559
15918
|
enumeratePaths,
|
|
15919
|
+
listPaths,
|
|
15560
15920
|
parseSourcePath,
|
|
15921
|
+
resolvePathType,
|
|
15922
|
+
resolveSchemaType,
|
|
15561
15923
|
resolveSourcePath,
|
|
15562
15924
|
validateDefinitionSpecs
|
|
15563
15925
|
};
|
|
@@ -43,3 +43,32 @@ export declare function enumeratePaths(node: JsonSchemaNode, root: JsonSchemaNod
|
|
|
43
43
|
* `$ref`s are resolved against the root.
|
|
44
44
|
*/
|
|
45
45
|
export declare function resolveSourcePath(schema: JsonSchemaNode, sourcePath: string): PathResolution;
|
|
46
|
+
/**
|
|
47
|
+
* The type vocabulary a schema node is reduced to. `"integer"` (a JSON
|
|
48
|
+
* Schema refinement Zod emits for `z.number().int()`) collapses into
|
|
49
|
+
* `"number"`; anything the schema doesn't pin to exactly one of these is
|
|
50
|
+
* `"unknown"`.
|
|
51
|
+
*/
|
|
52
|
+
export type SchemaType = "string" | "number" | "boolean" | "object" | "array" | "null" | "unknown";
|
|
53
|
+
/**
|
|
54
|
+
* Reduces a JSON Schema node to its top-level type, per `SchemaType`.
|
|
55
|
+
*
|
|
56
|
+
* Follows the same `$ref` / `anyOf` / `oneOf` / `allOf` flattening
|
|
57
|
+
* `resolveSourcePath` uses, so a `$ref` is resolved against `root` (default:
|
|
58
|
+
* `node` itself — pass the document root explicitly when `node` came from
|
|
59
|
+
* deeper inside it). Returns `"unknown"` — never a guess — when: the schema
|
|
60
|
+
* is absent (`{}`, e.g. `z.unknown()`) or a boolean schema (`true` / `false`);
|
|
61
|
+
* a `$ref` fails to resolve; or the branches disagree on type (a union of
|
|
62
|
+
* a string and a number, say). A false "this is definitely a string" is
|
|
63
|
+
* worse than an honest "unknown" here, same bias as path resolution.
|
|
64
|
+
*/
|
|
65
|
+
export declare function resolveSchemaType(node: JsonSchemaNode, root?: JsonSchemaNode): SchemaType;
|
|
66
|
+
/**
|
|
67
|
+
* The type `sourcePath` resolves to within `schema` — `"unknown"` whenever
|
|
68
|
+
* the path doesn't provably resolve to a single node (indeterminate or
|
|
69
|
+
* invalid) or the node it resolves to doesn't pin down a single type.
|
|
70
|
+
*
|
|
71
|
+
* Pass the step's `resultSchemaJson` as `schema`; `$ref`s are resolved
|
|
72
|
+
* against it as the document root, matching `resolveSourcePath`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolvePathType(schema: JsonSchemaNode, sourcePath: string): SchemaType;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type PipelineDefinitionSpec } from "../pipelines/define-pipeline";
|
|
2
2
|
import type { StepDefinitionSpec } from "../steps/define-step";
|
|
3
|
+
import { type DefinitionValidationIssue } from "./validation-issue";
|
|
3
4
|
export type DefinitionSpecSet = {
|
|
4
5
|
readonly pipelines: readonly PipelineDefinitionSpec[];
|
|
5
6
|
readonly steps: readonly StepDefinitionSpec[];
|
|
@@ -12,32 +13,20 @@ export type ValidateDefinitionSpecsOptions = {
|
|
|
12
13
|
*/
|
|
13
14
|
readonly knownPipelineKeys?: readonly string[];
|
|
14
15
|
};
|
|
15
|
-
export type DefinitionValidationIssue = {
|
|
16
|
-
readonly check: "signal-source-path" | "route-target" | "signal-binding" | "health-check-mcp-server" | "health-check-double-qualified";
|
|
17
|
-
readonly message: string;
|
|
18
|
-
/**
|
|
19
|
-
* The pipeline this issue belongs to, when the check is pipeline-scoped
|
|
20
|
-
* (`route-target`/`signal-binding`) — absent for step-only checks
|
|
21
|
-
* (`signal-source-path`/`health-check-*`), which have no pipeline
|
|
22
|
-
* context of their own. Required before the designer (Phase 5) can
|
|
23
|
-
* attach an error to the right graph node.
|
|
24
|
-
*/
|
|
25
|
-
readonly pipelineKey?: string;
|
|
26
|
-
/** The node this issue is about, when pipeline-scoped. */
|
|
27
|
-
readonly nodeKey?: string;
|
|
28
|
-
/**
|
|
29
|
-
* A second, related node this issue is about — e.g. `signal-binding`'s
|
|
30
|
-
* producer node, when different from `nodeKey`'s consumer. Absent when
|
|
31
|
-
* the issue is about a single node, or when the "other end" isn't a
|
|
32
|
-
* node in this pipeline at all (`route-target`'s target is a different
|
|
33
|
-
* *pipeline*, not a node).
|
|
34
|
-
*/
|
|
35
|
-
readonly targetNodeKey?: string;
|
|
36
|
-
};
|
|
37
16
|
/**
|
|
38
17
|
* Runs every offline check over a batch of definitions and returns the issues
|
|
39
18
|
* found, in check order. An empty array means the batch is clean.
|
|
40
19
|
*/
|
|
41
20
|
export declare function validateDefinitionSpecs(specs: DefinitionSpecSet, options?: ValidateDefinitionSpecsOptions): DefinitionValidationIssue[];
|
|
42
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* `validateDefinitionSpecs`, but throws a single aggregated error.
|
|
23
|
+
*
|
|
24
|
+
* Only counts and lists `severity: "error"` issues — `"warning"`-tier issues
|
|
25
|
+
* (`binding-type-mismatch`) and `"info"`-tier issues (`binding-target-field`'s
|
|
26
|
+
* unbound-field-name sub-check) never block a push and are silently dropped
|
|
27
|
+
* from both the header count and the body here. They're still present in
|
|
28
|
+
* `validateDefinitionSpecs`'s own return value for any caller that wants to
|
|
29
|
+
* surface them (e.g. the designer, in a later phase); this is the one entry
|
|
30
|
+
* point whose whole job is "should this push be blocked."
|
|
31
|
+
*/
|
|
43
32
|
export declare function assertValidDefinitionSpecs(specs: DefinitionSpecSet, options?: ValidateDefinitionSpecsOptions): void;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type PipelineDefinitionSpec } from "../pipelines/define-pipeline";
|
|
2
|
+
import type { StepDefinitionSpec } from "../steps/define-step";
|
|
3
|
+
import { type DefinitionValidationIssue } from "./validation-issue";
|
|
4
|
+
declare function checkUnboundRequiredInputs(pipelines: readonly PipelineDefinitionSpec[], stepsByKey: Map<string, readonly StepDefinitionSpec[]>): DefinitionValidationIssue[];
|
|
5
|
+
declare function checkBindingTargetFields(pipelines: readonly PipelineDefinitionSpec[], stepsByKey: Map<string, readonly StepDefinitionSpec[]>): DefinitionValidationIssue[];
|
|
6
|
+
declare function checkBindingTypeCompatibility(pipelines: readonly PipelineDefinitionSpec[], stepsByKey: Map<string, readonly StepDefinitionSpec[]>): DefinitionValidationIssue[];
|
|
7
|
+
export { checkBindingTargetFields, checkBindingTypeCompatibility, checkUnboundRequiredInputs, };
|