@juicesharp/rpiv-workflow 1.14.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/api.ts +557 -0
  4. package/audit.ts +217 -0
  5. package/built-ins.ts +65 -0
  6. package/command.ts +137 -0
  7. package/docs/cover.png +0 -0
  8. package/docs/cover.svg +120 -0
  9. package/docs/workflow-authoring.md +629 -0
  10. package/docs/workflow-basics.md +122 -0
  11. package/docs-protocol.ts +106 -0
  12. package/fanout.ts +96 -0
  13. package/host.ts +97 -0
  14. package/index.ts +230 -0
  15. package/internal-utils.ts +69 -0
  16. package/internal.ts +27 -0
  17. package/layers.ts +33 -0
  18. package/lifecycle.ts +274 -0
  19. package/load/cache.test.ts +82 -0
  20. package/load/cache.ts +40 -0
  21. package/load/index.ts +159 -0
  22. package/load/merge.ts +136 -0
  23. package/load/normalize.ts +73 -0
  24. package/load/paths.ts +32 -0
  25. package/load/resolve-default.ts +43 -0
  26. package/load/shape-guards.test.ts +74 -0
  27. package/load/shape-guards.ts +42 -0
  28. package/messages.ts +185 -0
  29. package/outcomes/collectors/directory-path.test.ts +64 -0
  30. package/outcomes/collectors/directory-path.ts +40 -0
  31. package/outcomes/collectors/index.ts +21 -0
  32. package/outcomes/collectors/tool-call.test.ts +110 -0
  33. package/outcomes/collectors/tool-call.ts +63 -0
  34. package/outcomes/collectors/transcript-path.test.ts +70 -0
  35. package/outcomes/collectors/transcript-path.ts +53 -0
  36. package/outcomes/collectors/union.test.ts +59 -0
  37. package/outcomes/collectors/union.ts +55 -0
  38. package/outcomes/collectors/url.test.ts +67 -0
  39. package/outcomes/collectors/url.ts +45 -0
  40. package/outcomes/collectors/workspace-diff.test.ts +107 -0
  41. package/outcomes/collectors/workspace-diff.ts +123 -0
  42. package/outcomes/git-commit.test.ts +194 -0
  43. package/outcomes/git-commit.ts +192 -0
  44. package/outcomes/index.ts +22 -0
  45. package/outcomes/parsers/index.ts +11 -0
  46. package/outcomes/parsers/json-body.test.ts +80 -0
  47. package/outcomes/parsers/json-body.ts +50 -0
  48. package/outcomes/side-effect.ts +26 -0
  49. package/output-spec.ts +170 -0
  50. package/output.ts +98 -0
  51. package/package.json +83 -0
  52. package/preview.ts +120 -0
  53. package/routing.ts +79 -0
  54. package/runner/chain-advance.ts +185 -0
  55. package/runner/index.ts +7 -0
  56. package/runner/runner.ts +356 -0
  57. package/runner/script-stage.ts +240 -0
  58. package/runner/stage-lifecycle.ts +447 -0
  59. package/sessions/extraction.ts +297 -0
  60. package/sessions/index.ts +7 -0
  61. package/sessions/sessions.ts +269 -0
  62. package/sessions/spawn.ts +135 -0
  63. package/state/index.ts +27 -0
  64. package/state/paths.ts +46 -0
  65. package/state/reads.ts +190 -0
  66. package/state/state.ts +115 -0
  67. package/state/writes.ts +58 -0
  68. package/transcript.ts +156 -0
  69. package/triggers.ts +27 -0
  70. package/typebox-adapter.ts +48 -0
  71. package/types.ts +237 -0
  72. package/validate-output.ts +120 -0
  73. package/validate-workflow.ts +491 -0
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Load-time graph validation for `Workflow` objects.
3
+ *
4
+ * Catches the wiring mistakes a TS type system can't reach on its own:
5
+ * unknown edge sources/targets, unreachable stages, missing terminals,
6
+ * predicate functions that return targets outside the stage set.
7
+ *
8
+ * `validateWorkflow` returns a flat array of `WorkflowValidationIssue`s — errors
9
+ * for problems that would crash the runner, warnings for shapes that
10
+ * work but probably aren't what the author intended (unreachable stages,
11
+ * implicit terminals via missing edges). The load pipeline can choose
12
+ * to halt on any error and surface warnings non-fatally.
13
+ *
14
+ * No I/O, no throws — purely a graph walk + predicate probe.
15
+ */
16
+
17
+ import {
18
+ type EdgeTarget,
19
+ marksReadsData,
20
+ ON_INVALID_VALUES,
21
+ SESSION_POLICIES,
22
+ STAGE_KINDS,
23
+ STOP,
24
+ type StageDef,
25
+ type Workflow,
26
+ } from "./api.js";
27
+ import type { ConfigLayer } from "./layers.js";
28
+ import {
29
+ MAX_VALIDATION_RETRIES,
30
+ MAX_VALIDATION_RETRY_TIMEOUT_MS,
31
+ MIN_VALIDATION_RETRIES,
32
+ MIN_VALIDATION_RETRY_TIMEOUT_MS,
33
+ } from "./validate-output.js";
34
+
35
+ // ===========================================================================
36
+ // Issue shape
37
+ // ===========================================================================
38
+
39
+ export interface WorkflowValidationIssue {
40
+ workflow: string;
41
+ stage?: string;
42
+ severity: "error" | "warning";
43
+ message: string;
44
+ /**
45
+ * Populated by `load.ts` after aggregation — the layer the workflow came
46
+ * from. `validateWorkflow` itself doesn't know about layers; the loader
47
+ * is the seam that has both `workflowSources` and the issue list in scope.
48
+ */
49
+ layer?: ConfigLayer;
50
+ /** Source path (rpiv.config.ts) when the layer is user or project. */
51
+ path?: string;
52
+ }
53
+
54
+ // ===========================================================================
55
+ // Public — validateWorkflow
56
+ // ===========================================================================
57
+
58
+ /**
59
+ * Validate one workflow. Aggregates all issues; never short-circuits. Caller
60
+ * decides what's fatal — `severity === "error"` is the runner-blocking set.
61
+ */
62
+ export function validateWorkflow(workflow: Workflow): WorkflowValidationIssue[] {
63
+ const issues: WorkflowValidationIssue[] = [];
64
+
65
+ checkWorkflowName(workflow, issues);
66
+
67
+ if (!workflow.stages[workflow.start]) {
68
+ issues.push(error(workflow.name, undefined, `start stage "${workflow.start}" is not declared in stages`));
69
+ }
70
+
71
+ checkEdgeKeys(workflow, issues);
72
+ checkEdgeTargets(workflow, issues);
73
+ checkMissingEdges(workflow, issues);
74
+ // Skip reachability when an EdgeFn lacks `.targets` — the BFS would emit
75
+ // "unreachable from start" cascades whose root cause is the upstream error
76
+ // already reported by checkEdgeTargets.
77
+ const hasUnenumerableEdge = issues.some((i) => /\.targets` metadata/.test(i.message));
78
+ if (!hasUnenumerableEdge) checkReachability(workflow, issues);
79
+ checkStageSemantics(workflow, issues);
80
+ checkPredicateSchemas(workflow, issues);
81
+ checkReadsReferences(workflow, issues);
82
+
83
+ return issues;
84
+ }
85
+
86
+ // ===========================================================================
87
+ // Individual checks
88
+ // ===========================================================================
89
+
90
+ /** `name` is what users type as `/wf <name>` — empty string makes the workflow unreachable. */
91
+ function checkWorkflowName(w: Workflow, issues: WorkflowValidationIssue[]): void {
92
+ if (typeof w.name !== "string" || w.name.length === 0) {
93
+ issues.push(error("(anonymous)", undefined, "workflow name must be a non-empty string"));
94
+ }
95
+ }
96
+
97
+ /** Every key in `edges` must be a declared stage. */
98
+ function checkEdgeKeys(w: Workflow, issues: WorkflowValidationIssue[]): void {
99
+ for (const from of Object.keys(w.edges)) {
100
+ if (!w.stages[from]) {
101
+ issues.push(error(w.name, from, `edges["${from}"] references a stage that's not declared in stages`));
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Every edge target must resolve to a declared stage or the `"stop"` sentinel.
108
+ * String targets are checked directly. `EdgeFn` targets are checked via the
109
+ * paired `checkEdgeFnTargets` (emits the no-`.targets` error) and enumerated
110
+ * via the pure `enumerateTargets`.
111
+ */
112
+ function checkEdgeTargets(w: Workflow, issues: WorkflowValidationIssue[]): void {
113
+ for (const [from, target] of Object.entries(w.edges)) {
114
+ checkEdgeFnTargets(target, { workflow: w.name, from }, issues);
115
+ for (const candidate of enumerateTargets(target)) {
116
+ if (candidate === STOP) continue;
117
+ if (!w.stages[candidate]) {
118
+ issues.push(
119
+ error(w.name, from, `edges["${from}"] resolves to "${candidate}" which is not declared in stages`),
120
+ );
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ /** Stages with no outgoing edge are implicit terminals — usually a missing connection. */
127
+ function checkMissingEdges(w: Workflow, issues: WorkflowValidationIssue[]): void {
128
+ for (const name of Object.keys(w.stages)) {
129
+ if (!(name in w.edges)) {
130
+ issues.push(
131
+ warning(
132
+ w.name,
133
+ name,
134
+ `stage "${name}" has no edge — treated as terminal; declare \`${name}: "stop"\` to be explicit`,
135
+ ),
136
+ );
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * BFS from `start`; every declared stage should be reachable. Orphans aren't
143
+ * a runner error (they can't fire) but they're almost always a mistake worth
144
+ * surfacing.
145
+ */
146
+ function checkReachability(w: Workflow, issues: WorkflowValidationIssue[]): void {
147
+ if (!w.stages[w.start]) return; // already reported by start-check
148
+
149
+ const reachable = new Set<string>();
150
+ const frontier: string[] = [w.start];
151
+ while (frontier.length > 0) {
152
+ const cur = frontier.shift()!;
153
+ if (reachable.has(cur)) continue;
154
+ reachable.add(cur);
155
+
156
+ const target = w.edges[cur];
157
+ if (target === undefined || target === STOP) continue;
158
+
159
+ for (const next of enumerateTargets(target)) {
160
+ if (next !== STOP && w.stages[next] && !reachable.has(next)) frontier.push(next);
161
+ }
162
+ }
163
+
164
+ for (const name of Object.keys(w.stages)) {
165
+ if (!reachable.has(name)) {
166
+ issues.push(warning(w.name, name, `stage "${name}" is unreachable from start "${w.start}"`));
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Per-stage semantic checks — bounds and enums that the TS type system narrows
173
+ * at edit time but jiti erases at runtime. A user-authored config can ship any
174
+ * numeric `maxRetries` or any string for `onInvalid`; this
175
+ * pass catches them at load time. Each check is a focused helper so the
176
+ * orchestrator reads top-down and individual rules can be exercised in
177
+ * isolation.
178
+ */
179
+ function checkStageSemantics(w: Workflow, issues: WorkflowValidationIssue[]): void {
180
+ for (const [name, stage] of Object.entries(w.stages)) {
181
+ checkRetryBounds(w, name, stage, issues);
182
+ checkTimeoutBounds(w, name, stage, issues);
183
+ checkStageEnums(w, name, stage, issues);
184
+ checkFanoutContinueInvariant(w, name, stage, issues);
185
+ checkInheritsArtifactsKind(w, name, stage, issues);
186
+ checkScriptStageInvariants(w, name, stage, issues);
187
+ }
188
+ }
189
+
190
+ function checkRetryBounds(w: Workflow, name: string, stage: StageDef, issues: WorkflowValidationIssue[]): void {
191
+ if (stage.maxRetries === undefined) return;
192
+ if (stage.maxRetries < MIN_VALIDATION_RETRIES || stage.maxRetries > MAX_VALIDATION_RETRIES) {
193
+ issues.push(
194
+ error(
195
+ w.name,
196
+ name,
197
+ `maxRetries: ${stage.maxRetries} — must be in [${MIN_VALIDATION_RETRIES}, ${MAX_VALIDATION_RETRIES}]`,
198
+ ),
199
+ );
200
+ }
201
+ }
202
+
203
+ function checkTimeoutBounds(w: Workflow, name: string, stage: StageDef, issues: WorkflowValidationIssue[]): void {
204
+ if (stage.validateTimeoutMs === undefined) return;
205
+ if (
206
+ stage.validateTimeoutMs < MIN_VALIDATION_RETRY_TIMEOUT_MS ||
207
+ stage.validateTimeoutMs > MAX_VALIDATION_RETRY_TIMEOUT_MS
208
+ ) {
209
+ issues.push(
210
+ error(
211
+ w.name,
212
+ name,
213
+ `validateTimeoutMs: ${stage.validateTimeoutMs} — must be in [${MIN_VALIDATION_RETRY_TIMEOUT_MS}, ${MAX_VALIDATION_RETRY_TIMEOUT_MS}]`,
214
+ ),
215
+ );
216
+ }
217
+ }
218
+
219
+ function checkStageEnums(w: Workflow, name: string, stage: StageDef, issues: WorkflowValidationIssue[]): void {
220
+ if (stage.onInvalid !== undefined && !(ON_INVALID_VALUES as readonly string[]).includes(stage.onInvalid)) {
221
+ issues.push(
222
+ error(w.name, name, `onInvalid: "${stage.onInvalid}" — must be one of ${ON_INVALID_VALUES.join(", ")}`),
223
+ );
224
+ }
225
+ if (!(STAGE_KINDS as readonly string[]).includes(stage.kind)) {
226
+ issues.push(error(w.name, name, `kind: "${stage.kind}" — must be one of ${STAGE_KINDS.join(", ")}`));
227
+ }
228
+ if (!(SESSION_POLICIES as readonly string[]).includes(stage.sessionPolicy)) {
229
+ issues.push(
230
+ error(w.name, name, `sessionPolicy: "${stage.sessionPolicy}" — must be one of ${SESSION_POLICIES.join(", ")}`),
231
+ );
232
+ }
233
+ if (stage.kind === "produces" && !stage.outcome && !stage.run) {
234
+ issues.push(
235
+ error(
236
+ w.name,
237
+ name,
238
+ `stage "${name}" has kind "produces" but no \`outcome\` — ` +
239
+ "there is no framework default for produces stages. Wire `outcome: rpivArtifactMdOutcome` " +
240
+ "(from @juicesharp/rpiv-pi) or supply your own `{ collector, parser? }`.",
241
+ ),
242
+ );
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Fanout requires per-unit session isolation — `continue` would replay the
248
+ * prior unit's branch into the next unit's session. The runner enforces
249
+ * this at dispatch (`enforceSessionInvariants`); surfacing it at load
250
+ * time gives user-authored configs a targeted error instead of a generic
251
+ * chain-advance failure on first invocation.
252
+ *
253
+ * The invariant is keyed on the stage's `fanout` field — not on a skill
254
+ * name — keeping the package skill-agnostic: any stage opting into
255
+ * fanout must use `sessionPolicy: "fresh"` regardless of what skill it
256
+ * dispatches.
257
+ */
258
+ function checkFanoutContinueInvariant(
259
+ w: Workflow,
260
+ name: string,
261
+ stage: StageDef,
262
+ issues: WorkflowValidationIssue[],
263
+ ): void {
264
+ if (stage.fanout && stage.sessionPolicy === "continue") {
265
+ issues.push(
266
+ error(
267
+ w.name,
268
+ name,
269
+ `stage "${name}" cannot combine fanout with sessionPolicy "continue" — fanout requires per-unit session isolation`,
270
+ ),
271
+ );
272
+ }
273
+ }
274
+
275
+ /**
276
+ * `inheritsArtifacts: false` is the `terminal()` factory's mechanism — it
277
+ * tells the runner to bypass upstream-artifact inheritance for a
278
+ * side-effect stage. Setting it on a `produces` stage is meaningless: a
279
+ * `produces` stage emits its own outcome and never consumes the upstream
280
+ * primary artifact in `inputForStage` (the first stage always uses
281
+ * originalInput, and inheritance only affects the prompt arg). Surface
282
+ * the redundancy as a warning so users don't author "off" flags they
283
+ * think are doing something.
284
+ */
285
+ function checkInheritsArtifactsKind(
286
+ w: Workflow,
287
+ name: string,
288
+ stage: StageDef,
289
+ issues: WorkflowValidationIssue[],
290
+ ): void {
291
+ if (stage.inheritsArtifacts === false && stage.kind === "produces") {
292
+ issues.push(
293
+ warning(
294
+ w.name,
295
+ name,
296
+ `stage "${name}" sets \`inheritsArtifacts: false\` on a \`produces\` stage — the flag is the \`terminal()\` factory's mechanism and is only meaningful on side-effect stages`,
297
+ ),
298
+ );
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Skillless script stages: presence of `stage.run` declares "the runner
304
+ * calls this TS function instead of dispatching a Pi skill." Four fields
305
+ * are categorically incompatible with that contract — fail loudly at
306
+ * load time so the runner branch can assume the invariant.
307
+ *
308
+ * Mutual-exclusion rules:
309
+ *
310
+ * - `skill` — the function IS the work; a skill body would never
311
+ * be dispatched.
312
+ * - `outcome` — the function returns the `Output` envelope directly;
313
+ * there is no transcript / tool-use stream for a
314
+ * collector to scan.
315
+ * - `fanout` — a TS function can write its own loop; the runner's
316
+ * per-unit session machinery doesn't apply.
317
+ * - `sessionPolicy: "continue"` — there is no Pi session at all on a
318
+ * script stage; nothing to continue.
319
+ *
320
+ * Side-effect script stages with an `outputSchema` get a warning — the
321
+ * function returns `void`, so no data ever flows through the validator.
322
+ *
323
+ * The existing `produces` + `inheritsArtifacts: false` warning
324
+ * (`checkInheritsArtifactsKind`) fires uniformly for both skill and
325
+ * script variants — same author error, same message.
326
+ */
327
+ function checkScriptStageInvariants(
328
+ w: Workflow,
329
+ name: string,
330
+ stage: StageDef,
331
+ issues: WorkflowValidationIssue[],
332
+ ): void {
333
+ if (!stage.run) return;
334
+
335
+ if (stage.skill !== undefined) {
336
+ issues.push(
337
+ error(w.name, name, `stage "${name}": script stages cannot set "skill" (the run function IS the work)`),
338
+ );
339
+ }
340
+ if (stage.outcome) {
341
+ issues.push(
342
+ error(
343
+ w.name,
344
+ name,
345
+ `stage "${name}": script stages cannot set "outcome" (the run function IS the OutputSpec)`,
346
+ ),
347
+ );
348
+ }
349
+ if (stage.fanout) {
350
+ issues.push(
351
+ error(w.name, name, `stage "${name}": script stages cannot fanout — write a loop inside run() instead`),
352
+ );
353
+ }
354
+ if (stage.sessionPolicy === "continue") {
355
+ issues.push(
356
+ error(
357
+ w.name,
358
+ name,
359
+ `stage "${name}": script stages cannot use sessionPolicy "continue" (no session to continue)`,
360
+ ),
361
+ );
362
+ }
363
+ if (stage.kind === "side-effect" && stage.outputSchema) {
364
+ issues.push(
365
+ warning(
366
+ w.name,
367
+ name,
368
+ `stage "${name}": outputSchema is meaningless on side-effect script stages — no data to validate`,
369
+ ),
370
+ );
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Every name in a stage's `reads:` must be filled by some `produces` stage
376
+ * in the workflow. The publish key is `stage.outcome?.name ?? <record-key>`
377
+ * — same rule the runner enforces at write time (see
378
+ * `resolvePublishName`). Reachability isn't checked here: validating that
379
+ * the producer can actually reach the consumer in the edge graph is a
380
+ * larger graph problem and the static check is intentionally narrow —
381
+ * it answers "does this name correspond to something in the workflow at
382
+ * all?", catching typos and renames; the runtime `ensureNamedReads`
383
+ * preflight handles the "haven't fired yet" case.
384
+ */
385
+ function checkReadsReferences(w: Workflow, issues: WorkflowValidationIssue[]): void {
386
+ const publishedNames = new Set<string>();
387
+ for (const [name, stage] of Object.entries(w.stages)) {
388
+ if (stage.kind !== "produces") continue;
389
+ publishedNames.add(stage.outcome?.name ?? name);
390
+ }
391
+ for (const [name, stage] of Object.entries(w.stages)) {
392
+ if (!stage.reads?.length) continue;
393
+ for (const read of stage.reads) {
394
+ if (publishedNames.has(read)) continue;
395
+ issues.push(
396
+ error(
397
+ w.name,
398
+ name,
399
+ `stage "${name}" reads "${read}" but no produces stage in this workflow publishes it (check outcome.name or stage record key)`,
400
+ ),
401
+ );
402
+ }
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Route edges that read `output.data[field]` (i.e. `defineRoute(...)` with
408
+ * the default `readsData: true`, `gate(...)`, and any future factory that
409
+ * auto-attaches the `READS_DATA` marker) should fire on data the source
410
+ * stage has validated against its `outputSchema`. If the schema is absent,
411
+ * the validation-retry loop never runs and the route may read an undefined
412
+ * field — routing decisions silently default.
413
+ *
414
+ * Routes authored via `defineRoute(targets, fn, { readsData: false })`
415
+ * consult only `state` or `output.meta` and carry no marker — exempt from
416
+ * this lint.
417
+ */
418
+ function checkPredicateSchemas(w: Workflow, issues: WorkflowValidationIssue[]): void {
419
+ for (const [from, target] of Object.entries(w.edges)) {
420
+ if (typeof target === "string") continue;
421
+ if (!marksReadsData(target)) continue;
422
+ const stage = w.stages[from];
423
+ if (stage && !stage.outputSchema) {
424
+ issues.push(
425
+ warning(
426
+ w.name,
427
+ from,
428
+ `route edge from "${from}" reads output.data but the stage has no outputSchema — routing may fire on un-validated data`,
429
+ ),
430
+ );
431
+ }
432
+ }
433
+ }
434
+
435
+ // ===========================================================================
436
+ // Edge-target enumeration
437
+ // ===========================================================================
438
+
439
+ /**
440
+ * Returns the set of possible string targets an `EdgeTarget` could resolve to.
441
+ * Pure — no issue emission, no caller-supplied discard buffer.
442
+ *
443
+ * - String → singleton.
444
+ * - `EdgeFn` with `.targets` metadata → declared targets.
445
+ * - `EdgeFn` without `.targets` → empty list. The missing-metadata error is
446
+ * the responsibility of `checkEdgeFnTargets` (paired emit-only function);
447
+ * call it alongside `enumerateTargets` only at sites that lint edges
448
+ * (currently `checkEdgeTargets`). Reachability traversal calls only the
449
+ * pure form.
450
+ */
451
+ function enumerateTargets(target: EdgeTarget): string[] {
452
+ if (typeof target === "string") return [target];
453
+ if (Array.isArray(target.targets) && target.targets.length > 0) return [...target.targets];
454
+ return [];
455
+ }
456
+
457
+ /**
458
+ * Emits the "EdgeFn without `.targets` metadata" error for an `EdgeTarget`
459
+ * that's a hand-rolled `EdgeFn` lacking the marker. Pairs with
460
+ * `enumerateTargets`: lint sites call both; reachability calls only the
461
+ * enumerator. Users authoring routes by hand MUST go through
462
+ * `defineRoute(targets, fn)` so the `.targets` metadata is structurally
463
+ * attached.
464
+ */
465
+ function checkEdgeFnTargets(
466
+ target: EdgeTarget,
467
+ ctx: { workflow: string; from: string },
468
+ issues: WorkflowValidationIssue[],
469
+ ): void {
470
+ if (typeof target === "string") return;
471
+ if (Array.isArray(target.targets) && target.targets.length > 0) return;
472
+ issues.push(
473
+ error(
474
+ ctx.workflow,
475
+ ctx.from,
476
+ `edges["${ctx.from}"] is an EdgeFn without \`.targets\` metadata — use defineRoute([...], fn) or gate() so reachability can enumerate branches`,
477
+ ),
478
+ );
479
+ }
480
+
481
+ // ===========================================================================
482
+ // Issue constructors
483
+ // ===========================================================================
484
+
485
+ function error(workflow: string, stage: string | undefined, message: string): WorkflowValidationIssue {
486
+ return { workflow, stage, severity: "error", message };
487
+ }
488
+
489
+ function warning(workflow: string, stage: string | undefined, message: string): WorkflowValidationIssue {
490
+ return { workflow, stage, severity: "warning", message };
491
+ }