stageflow 0.13.0 → 0.16.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 (113) hide show
  1. package/dist/agent/activity.d.ts +3 -0
  2. package/dist/agent/claudeAdapter.js +84 -7
  3. package/dist/agent/claudeSession.d.ts +3 -0
  4. package/dist/agent/piAdapter.d.ts +2 -0
  5. package/dist/agent/piAdapter.js +34 -7
  6. package/dist/agent/port.d.ts +5 -2
  7. package/dist/cli/initTemplates.d.ts +1 -1
  8. package/dist/cli/initTemplates.js +1 -1
  9. package/dist/cli/migrateYamlCommand.d.ts +18 -0
  10. package/dist/cli/migrateYamlCommand.js +180 -0
  11. package/dist/cli/runOutput.js +58 -26
  12. package/dist/cli/validateCommand.d.ts +1 -0
  13. package/dist/cli/validateCommand.js +2 -0
  14. package/dist/cli.js +15 -1
  15. package/dist/config/browseCatalog.js +1 -1
  16. package/dist/config/createPipeline.d.ts +1 -1
  17. package/dist/config/createPipeline.js +23 -11
  18. package/dist/config/createStage.d.ts +1 -1
  19. package/dist/config/createStage.js +10 -5
  20. package/dist/config/legacyYaml.d.ts +28 -0
  21. package/dist/config/legacyYaml.js +98 -0
  22. package/dist/config/loadOutcome.d.ts +2 -1
  23. package/dist/config/loadOutcome.js +4 -1
  24. package/dist/config/loadPipeline.d.ts +2 -0
  25. package/dist/config/loadPipeline.js +150 -8
  26. package/dist/config/loadStage.d.ts +8 -1
  27. package/dist/config/loadStage.js +138 -29
  28. package/dist/config/loadStageflowManifest.js +10 -0
  29. package/dist/config/loadTask.js +14 -0
  30. package/dist/config/materializeStageModels.d.ts +7 -0
  31. package/dist/config/materializeStageModels.js +35 -0
  32. package/dist/config/mergePipelineIncludes.d.ts +5 -1
  33. package/dist/config/mergePipelineIncludes.js +82 -4
  34. package/dist/config/migrateYaml.d.ts +19 -0
  35. package/dist/config/migrateYaml.js +627 -0
  36. package/dist/config/modelField.d.ts +13 -0
  37. package/dist/config/modelField.js +16 -0
  38. package/dist/config/normalizePipelineStageEntry.js +41 -2
  39. package/dist/config/parseCompletionContract.d.ts +5 -2
  40. package/dist/config/parseCompletionContract.js +16 -3
  41. package/dist/config/parsePreEmitChecks.js +4 -0
  42. package/dist/config/pipelineNeeds.js +3 -3
  43. package/dist/config/pipelineStageKeys.d.ts +11 -0
  44. package/dist/config/pipelineStageKeys.js +14 -0
  45. package/dist/config/printTargetYaml.d.ts +28 -0
  46. package/dist/config/printTargetYaml.js +243 -0
  47. package/dist/config/resolveModel.d.ts +20 -0
  48. package/dist/config/resolveModel.js +35 -0
  49. package/dist/config/validateCatalog.d.ts +5 -1
  50. package/dist/config/validateCatalog.js +36 -6
  51. package/dist/config/validateCompletionContract.js +5 -0
  52. package/dist/config/yamlDialect.d.ts +29 -0
  53. package/dist/config/yamlDialect.js +327 -0
  54. package/dist/envelope/payloadSchema.d.ts +37 -5
  55. package/dist/envelope/payloadSchema.js +272 -22
  56. package/dist/mcp/catalogTools.js +1 -0
  57. package/dist/package-meta.d.ts +1 -1
  58. package/dist/package-meta.js +1 -1
  59. package/dist/runstore/pipelineDagSnapshot.js +2 -0
  60. package/dist/runstore/port.d.ts +11 -0
  61. package/dist/runstore/runProjection.js +9 -0
  62. package/dist/runstore/sqlite/SqliteRunStore.js +27 -4
  63. package/dist/runstore/sqlite/schema.d.ts +1 -1
  64. package/dist/runstore/sqlite/schema.js +2 -0
  65. package/dist/runstore/stageExecution.d.ts +3 -0
  66. package/dist/runstore/stageExecution.js +6 -2
  67. package/dist/runstore/stageSnapshot.js +11 -0
  68. package/dist/runtime/completionCheckRunner.d.ts +3 -2
  69. package/dist/runtime/completionCheckRunner.js +3 -2
  70. package/dist/runtime/envelopeRouting.d.ts +2 -2
  71. package/dist/runtime/pipelineRunner.d.ts +3 -0
  72. package/dist/runtime/pipelineRunner.js +23 -3
  73. package/dist/runtime/runManager.js +3 -2
  74. package/dist/runtime/stageAttemptBootstrap.d.ts +2 -2
  75. package/dist/runtime/stageRunner.d.ts +2 -2
  76. package/dist/runtime/stageRunner.js +2 -2
  77. package/dist/runtime/taskInput.d.ts +7 -0
  78. package/dist/runtime/taskInput.js +60 -0
  79. package/dist/runtime/verifiedStageExecution.js +3 -0
  80. package/dist/types/completion.d.ts +6 -0
  81. package/dist/types/pipeline.d.ts +21 -2
  82. package/dist/types/preEmitCheck.d.ts +4 -6
  83. package/dist/types/stage.d.ts +22 -3
  84. package/dist/types/stageflowManifest.d.ts +2 -0
  85. package/dist/types/task.d.ts +1 -0
  86. package/dist/types/usage.d.ts +16 -0
  87. package/dist/types/usage.js +24 -0
  88. package/dist/ui/assets/{index-uVHiae8s.css → index-D2Wt8hwH.css} +1 -1
  89. package/dist/ui/assets/index-XWJovnp7.js +118 -0
  90. package/dist/ui/index.html +2 -2
  91. package/package.json +1 -1
  92. package/skills/stageflow-author/SKILL.md +7 -7
  93. package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +9 -1
  94. package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +6 -2
  95. package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +9 -1
  96. package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +16 -1
  97. package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +12 -2
  98. package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +16 -1
  99. package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +11 -2
  100. package/skills/stageflow-author/assets/examples/linear-review/review.yaml +19 -1
  101. package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +9 -1
  102. package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +6 -2
  103. package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +16 -1
  104. package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +9 -1
  105. package/skills/stageflow-author/references/catalog-mapping.md +30 -28
  106. package/skills/stageflow-author/references/catalog-write-conventions.md +2 -2
  107. package/skills/stageflow-author/references/stage-prompt-template.md +5 -5
  108. package/skills/stageflow-session-capture/SKILL.md +1 -1
  109. package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +2 -1
  110. package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +13 -2
  111. package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +18 -2
  112. package/skills/stageflow-session-capture/references/catalog-authoring.md +2 -2
  113. package/dist/ui/assets/index-Yzh42vhf.js +0 -118
@@ -0,0 +1,627 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { loadPipelineOutcome } from "./loadPipeline.js";
6
+ import { afterCompletionForStage, loadStageOutcome } from "./loadStage.js";
7
+ import { withLegacyYamlAllowedAsync } from "./legacyYaml.js";
8
+ import { readYamlObject } from "./readYamlObject.js";
9
+ import { relPath } from "./validateCatalog.js";
10
+ import { buildVerifyItems, ioFromRawDocument, rewriteCatalogDocument, rewritePipelineStageEntry, rewriteStageDocument, stringifyTargetYaml, } from "./printTargetYaml.js";
11
+ import { classifyYamlDocument, } from "./yamlDialect.js";
12
+ function isPlainObject(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ function normalizeAbs(filePath) {
16
+ return path.normalize(path.resolve(filePath));
17
+ }
18
+ function isStageflowPath(absPath) {
19
+ return absPath.split(path.sep).includes(".stageflow");
20
+ }
21
+ const SKIP_DIRS = new Set([".git", ".stageflow", "node_modules", "dist"]);
22
+ async function walkFiles(dir, predicate) {
23
+ const found = [];
24
+ let entries;
25
+ try {
26
+ entries = await readdir(dir, { withFileTypes: true });
27
+ }
28
+ catch {
29
+ return found;
30
+ }
31
+ for (const entry of entries) {
32
+ const abs = path.join(dir, entry.name);
33
+ if (entry.isDirectory()) {
34
+ if (SKIP_DIRS.has(entry.name))
35
+ continue;
36
+ found.push(...(await walkFiles(abs, predicate)));
37
+ continue;
38
+ }
39
+ if (entry.isFile() && predicate(entry.name))
40
+ found.push(abs);
41
+ }
42
+ return found;
43
+ }
44
+ async function collectIncludes(filePath, seen) {
45
+ const abs = normalizeAbs(filePath);
46
+ if (seen.has(abs))
47
+ return [];
48
+ seen.add(abs);
49
+ let raw;
50
+ try {
51
+ raw = await readYamlObject(abs);
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ const files = [];
57
+ if (!Array.isArray(raw.include))
58
+ return files;
59
+ for (const item of raw.include) {
60
+ if (!isPlainObject(item) || typeof item.local !== "string" || item.local.length === 0) {
61
+ continue;
62
+ }
63
+ const includePath = normalizeAbs(path.resolve(path.dirname(abs), item.local));
64
+ files.push(includePath);
65
+ files.push(...(await collectIncludes(includePath, seen)));
66
+ }
67
+ return files;
68
+ }
69
+ function usesFromDocument(raw, declaringPath) {
70
+ if (!Array.isArray(raw.stages))
71
+ return [];
72
+ const files = [];
73
+ for (const entry of raw.stages) {
74
+ if (isPlainObject(entry) && typeof entry.uses === "string" && entry.uses.length > 0) {
75
+ files.push(normalizeAbs(path.resolve(path.dirname(declaringPath), entry.uses)));
76
+ }
77
+ }
78
+ return files;
79
+ }
80
+ export function catalogKind(raw, filePath) {
81
+ const base = path.basename(filePath);
82
+ if (base.endsWith(".task.yaml"))
83
+ return "task";
84
+ if (typeof raw.goal === "string" && raw.stages === undefined && raw.system_prompt === undefined) {
85
+ return "task";
86
+ }
87
+ if (Array.isArray(raw.stages) || raw.include !== undefined)
88
+ return "pipeline";
89
+ if (typeof raw.system_prompt === "string" || base.endsWith(".stage.yaml"))
90
+ return "stage";
91
+ return "unknown";
92
+ }
93
+ async function dialectOf(filePath) {
94
+ try {
95
+ const raw = await readYamlObject(filePath);
96
+ return classifyYamlDocument(raw);
97
+ }
98
+ catch {
99
+ return "unreadable";
100
+ }
101
+ }
102
+ function verifyFingerprint(items) {
103
+ return JSON.stringify(items);
104
+ }
105
+ function gitToplevel(cwd) {
106
+ try {
107
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
108
+ cwd,
109
+ encoding: "utf8",
110
+ stdio: ["ignore", "pipe", "ignore"],
111
+ }).trim();
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ export function fileDirtyVsHead(absPath) {
118
+ const top = gitToplevel(path.dirname(absPath));
119
+ if (top === null)
120
+ return true;
121
+ try {
122
+ const status = execFileSync("git", ["status", "--porcelain", "--", absPath], {
123
+ cwd: top,
124
+ encoding: "utf8",
125
+ stdio: ["ignore", "pipe", "ignore"],
126
+ });
127
+ return status.trim().length > 0;
128
+ }
129
+ catch {
130
+ return true;
131
+ }
132
+ }
133
+ async function discoverScope(targetPath) {
134
+ const abs = normalizeAbs(targetPath);
135
+ let info;
136
+ try {
137
+ info = await stat(abs);
138
+ }
139
+ catch {
140
+ throw new Error(`Path not found: ${abs}`);
141
+ }
142
+ const pipelines = [];
143
+ const stages = [];
144
+ const tasks = [];
145
+ const extras = [];
146
+ if (info.isDirectory()) {
147
+ const yamlFiles = await walkFiles(abs, (name) => name.endsWith(".yaml") || name.endsWith(".yml"));
148
+ for (const file of yamlFiles) {
149
+ if (isStageflowPath(file))
150
+ continue;
151
+ try {
152
+ const raw = await readYamlObject(file);
153
+ const kind = catalogKind(raw, file);
154
+ if (kind === "pipeline")
155
+ pipelines.push(file);
156
+ else if (kind === "stage")
157
+ stages.push(file);
158
+ else if (kind === "task")
159
+ tasks.push(file);
160
+ else
161
+ extras.push(file);
162
+ }
163
+ catch {
164
+ extras.push(file);
165
+ }
166
+ }
167
+ }
168
+ else if (info.isFile()) {
169
+ if (isStageflowPath(abs)) {
170
+ throw new Error("sf migrate-yaml does not rewrite .stageflow run snapshots");
171
+ }
172
+ const raw = await readYamlObject(abs);
173
+ const kind = catalogKind(raw, abs);
174
+ if (kind === "pipeline")
175
+ pipelines.push(abs);
176
+ else if (kind === "stage")
177
+ stages.push(abs);
178
+ else if (kind === "task")
179
+ tasks.push(abs);
180
+ else
181
+ extras.push(abs);
182
+ }
183
+ const includeSeen = new Set();
184
+ for (const pipelinePath of [...pipelines]) {
185
+ const includes = await collectIncludes(pipelinePath, includeSeen);
186
+ extras.push(...includes);
187
+ try {
188
+ const raw = await readYamlObject(pipelinePath);
189
+ stages.push(...usesFromDocument(raw, pipelinePath));
190
+ }
191
+ catch {
192
+ // load errors reported later
193
+ }
194
+ for (const includePath of includes) {
195
+ try {
196
+ const raw = await readYamlObject(includePath);
197
+ stages.push(...usesFromDocument(raw, includePath));
198
+ }
199
+ catch {
200
+ // load errors reported later
201
+ }
202
+ }
203
+ }
204
+ const uniq = (files) => [...new Set(files.map(normalizeAbs))].filter((file) => !isStageflowPath(file));
205
+ return {
206
+ pipelines: uniq(pipelines).sort(),
207
+ stages: uniq(stages).sort(),
208
+ tasks: uniq(tasks).sort(),
209
+ extras: uniq(extras).sort(),
210
+ };
211
+ }
212
+ function nodeForStage(loaded, stageId) {
213
+ return loaded.dag.nodes.find((node) => node.id === stageId);
214
+ }
215
+ function stageForId(loaded, stageId) {
216
+ return loaded.stages.find((stage) => stage.id === stageId);
217
+ }
218
+ function compileVerify(preEmit, afterChecks) {
219
+ return buildVerifyItems(preEmit, afterChecks);
220
+ }
221
+ async function rewriteUsesFile(absPath, compiled) {
222
+ const raw = await readYamlObject(absPath);
223
+ const next = rewriteStageDocument(raw, {
224
+ io: ioFromRawDocument(raw),
225
+ verify: compiled.verify,
226
+ });
227
+ return stringifyTargetYaml(next);
228
+ }
229
+ async function rewriteDeclaringFile(absPath, loaded) {
230
+ const raw = await readYamlObject(absPath);
231
+ const next = rewriteCatalogDocument(raw, (entry) => {
232
+ const id = typeof entry.id === "string" ? entry.id : undefined;
233
+ if (!id)
234
+ return entry;
235
+ const node = nodeForStage(loaded, id);
236
+ const stage = stageForId(loaded, id);
237
+ if (!node || !stage)
238
+ return dropLegacyOnly(entry);
239
+ const uses = typeof entry.uses === "string" ? entry.uses : undefined;
240
+ if (uses) {
241
+ return rewritePipelineStageEntry(entry, {
242
+ uses,
243
+ on_verify_fail: node.recovery,
244
+ });
245
+ }
246
+ const verify = compileVerify(stage.pre_emit_checks, node.completion?.checks);
247
+ if (!verify.ok) {
248
+ throw new Error(verify.error);
249
+ }
250
+ return rewritePipelineStageEntry(entry, {
251
+ io: ioFromRawDocument(entry),
252
+ verify: verify.items,
253
+ on_verify_fail: node.recovery,
254
+ });
255
+ });
256
+ return { content: stringifyTargetYaml(next) };
257
+ }
258
+ function dropLegacyOnly(entry) {
259
+ return rewritePipelineStageEntry(entry, {
260
+ uses: typeof entry.uses === "string" ? entry.uses : undefined,
261
+ });
262
+ }
263
+ async function collectUsesCompile(loaded, cwd, byPath, errors) {
264
+ for (const stage of loaded.stages) {
265
+ const source = loaded.stageSources?.[stage.id];
266
+ if (source?.kind !== "file")
267
+ continue;
268
+ const abs = normalizeAbs(source.path);
269
+ const node = nodeForStage(loaded, stage.id);
270
+ const afterChecks = node?.completion?.checks;
271
+ const verify = compileVerify(stage.pre_emit_checks, afterChecks);
272
+ if (!verify.ok) {
273
+ errors.push(`${relPath(cwd, abs)}: ${verify.error}`);
274
+ continue;
275
+ }
276
+ const existing = byPath.get(abs);
277
+ if (!existing) {
278
+ byPath.set(abs, {
279
+ verify: verify.items,
280
+ pipelineIds: [loaded.pipeline.id],
281
+ });
282
+ continue;
283
+ }
284
+ if (verifyFingerprint(existing.verify) !== verifyFingerprint(verify.items)) {
285
+ const first = existing.pipelineIds[0] ?? loaded.pipeline.id;
286
+ const second = loaded.pipeline.id;
287
+ errors.push(`uses file ${relPath(cwd, abs)} has different compiled verify lists from pipelines "${first}" and "${second}"`);
288
+ existing.pipelineIds.push(loaded.pipeline.id);
289
+ continue;
290
+ }
291
+ existing.pipelineIds.push(loaded.pipeline.id);
292
+ }
293
+ }
294
+ function verifyHasAfter(items) {
295
+ return items.some((item) => Array.isArray(item.when) && item.when.includes("after"));
296
+ }
297
+ async function catalogPipelinePaths(root) {
298
+ return (await walkFiles(root, (name) => name.endsWith(".pipeline.yaml")))
299
+ .map(normalizeAbs)
300
+ .filter((file) => !isStageflowPath(file));
301
+ }
302
+ async function pipelineUsesPath(pipelinePath, usesAbs) {
303
+ const declaring = [pipelinePath, ...(await collectIncludes(pipelinePath, new Set()))];
304
+ for (const file of declaring) {
305
+ let raw;
306
+ try {
307
+ raw = await readYamlObject(file);
308
+ }
309
+ catch {
310
+ continue;
311
+ }
312
+ if (usesFromDocument(raw, file).includes(usesAbs))
313
+ return true;
314
+ }
315
+ return false;
316
+ }
317
+ async function refuseOutOfScopeUsesFolds(usesCompile, scopePipelines, catalogRoot, cwd, projectRoot, errors) {
318
+ const inScope = new Set(scopePipelines.map(normalizeAbs));
319
+ const catalogPipelines = await catalogPipelinePaths(catalogRoot);
320
+ for (const [usesAbs, compiled] of usesCompile) {
321
+ if (!verifyHasAfter(compiled.verify))
322
+ continue;
323
+ for (const pipelinePath of catalogPipelines) {
324
+ if (inScope.has(pipelinePath))
325
+ continue;
326
+ if (!(await pipelineUsesPath(pipelinePath, usesAbs)))
327
+ continue;
328
+ let outsideId;
329
+ try {
330
+ const raw = await readYamlObject(pipelinePath);
331
+ outsideId = typeof raw.id === "string" && raw.id.length > 0 ? raw.id : undefined;
332
+ }
333
+ catch {
334
+ outsideId = undefined;
335
+ }
336
+ const outcome = await loadPipelineOutcome(pipelinePath, { cwd, projectRoot });
337
+ if (outcome.ok) {
338
+ const other = new Map();
339
+ const otherErrors = [];
340
+ await collectUsesCompile(outcome.value, cwd, other, otherErrors);
341
+ const otherCompiled = other.get(usesAbs);
342
+ if (otherCompiled &&
343
+ verifyFingerprint(compiled.verify) !== verifyFingerprint(otherCompiled.verify)) {
344
+ const first = compiled.pipelineIds[0] ?? "unknown";
345
+ const second = outsideId ?? outcome.value.pipeline.id;
346
+ errors.push(`uses file ${relPath(cwd, usesAbs)} has different compiled verify lists from pipelines "${first}" and "${second}"`);
347
+ continue;
348
+ }
349
+ }
350
+ errors.push(`uses file ${relPath(cwd, usesAbs)} is also used by pipeline "${outsideId ?? relPath(cwd, pipelinePath)}" outside the migrate target`);
351
+ }
352
+ }
353
+ }
354
+ function shouldRewriteFile(dialect, kind, foldingWrapper) {
355
+ if (dialect === "unreadable")
356
+ return "skip-unreadable";
357
+ if (dialect === "invalid")
358
+ return "skip-mixed";
359
+ if (kind === "task" || kind === "unknown")
360
+ return "skip";
361
+ if (dialect === "legacy")
362
+ return "rewrite";
363
+ if (foldingWrapper)
364
+ return "rewrite";
365
+ return "skip";
366
+ }
367
+ async function usesPathHasLegacyWrapper(usesAbs, pipelinePaths) {
368
+ for (const pipelinePath of pipelinePaths) {
369
+ const declaring = [pipelinePath, ...(await collectIncludes(pipelinePath, new Set()))];
370
+ for (const file of declaring) {
371
+ let raw;
372
+ try {
373
+ raw = await readYamlObject(file);
374
+ }
375
+ catch {
376
+ continue;
377
+ }
378
+ if (!Array.isArray(raw.stages))
379
+ continue;
380
+ for (const entry of raw.stages) {
381
+ if (!isPlainObject(entry) || typeof entry.uses !== "string")
382
+ continue;
383
+ const abs = normalizeAbs(path.resolve(path.dirname(file), entry.uses));
384
+ if (abs !== usesAbs)
385
+ continue;
386
+ if (entry.completion !== undefined || entry.recovery !== undefined)
387
+ return true;
388
+ }
389
+ }
390
+ }
391
+ return false;
392
+ }
393
+ export async function planMigrateYaml(targetPath, options = { cwd: process.cwd() }) {
394
+ return withLegacyYamlAllowedAsync(() => planMigrateYamlInner(targetPath, options));
395
+ }
396
+ async function planMigrateYamlInner(targetPath, options) {
397
+ const cwd = options.cwd;
398
+ const projectRoot = options.projectRoot ?? cwd;
399
+ const writesByPath = new Map();
400
+ const skipped = new Set();
401
+ const errors = [];
402
+ let scope;
403
+ try {
404
+ scope = await discoverScope(targetPath);
405
+ }
406
+ catch (err) {
407
+ const message = err instanceof Error ? err.message : String(err);
408
+ return { writes: [], skipped: [], errors: [message] };
409
+ }
410
+ const usesCompile = new Map();
411
+ const declaringFiles = new Map();
412
+ for (const pipelinePath of scope.pipelines) {
413
+ const dialect = await dialectOf(pipelinePath);
414
+ if (dialect === "invalid") {
415
+ skipped.add(relPath(cwd, pipelinePath));
416
+ continue;
417
+ }
418
+ const outcome = await loadPipelineOutcome(pipelinePath, { cwd, projectRoot });
419
+ if (!outcome.ok) {
420
+ if (outcome.issues.some((issue) => issue.code === "catalog.mixed_yaml_dialect")) {
421
+ skipped.add(relPath(cwd, pipelinePath));
422
+ continue;
423
+ }
424
+ errors.push(...outcome.issues.map((issue) => `${relPath(cwd, pipelinePath)}: ${issue.message}`));
425
+ continue;
426
+ }
427
+ declaringFiles.set(normalizeAbs(outcome.value.pipelinePath), outcome.value);
428
+ await collectUsesCompile(outcome.value, cwd, usesCompile, errors);
429
+ for (const includePath of await collectIncludes(pipelinePath, new Set())) {
430
+ declaringFiles.set(normalizeAbs(includePath), outcome.value);
431
+ }
432
+ }
433
+ await refuseOutOfScopeUsesFolds(usesCompile, scope.pipelines, projectRoot, cwd, projectRoot, errors);
434
+ if (errors.length > 0) {
435
+ return { writes: [], skipped: [...skipped].sort(), errors };
436
+ }
437
+ for (const [abs, compiled] of usesCompile) {
438
+ const dialect = await dialectOf(abs);
439
+ let kind = "stage";
440
+ try {
441
+ kind = catalogKind(await readYamlObject(abs), abs);
442
+ }
443
+ catch {
444
+ errors.push(`${relPath(cwd, abs)}: unreadable YAML`);
445
+ continue;
446
+ }
447
+ const folding = await usesPathHasLegacyWrapper(abs, scope.pipelines);
448
+ const action = shouldRewriteFile(dialect, kind, folding);
449
+ if (action === "skip-mixed") {
450
+ skipped.add(relPath(cwd, abs));
451
+ continue;
452
+ }
453
+ if (action !== "rewrite") {
454
+ skipped.add(relPath(cwd, abs));
455
+ continue;
456
+ }
457
+ const content = await rewriteUsesFile(abs, compiled);
458
+ const current = await readFile(abs, "utf8");
459
+ if (current === content) {
460
+ skipped.add(relPath(cwd, abs));
461
+ continue;
462
+ }
463
+ writesByPath.set(abs, { file: relPath(cwd, abs), absPath: abs, content });
464
+ }
465
+ for (const [abs, loaded] of declaringFiles) {
466
+ const dialect = await dialectOf(abs);
467
+ let raw;
468
+ try {
469
+ raw = await readYamlObject(abs);
470
+ }
471
+ catch (err) {
472
+ const message = err instanceof Error ? err.message : String(err);
473
+ errors.push(`${relPath(cwd, abs)}: ${message}`);
474
+ continue;
475
+ }
476
+ const kind = catalogKind(raw, abs);
477
+ const action = shouldRewriteFile(dialect, kind, false);
478
+ if (action === "skip-mixed") {
479
+ skipped.add(relPath(cwd, abs));
480
+ continue;
481
+ }
482
+ if (action !== "rewrite") {
483
+ skipped.add(relPath(cwd, abs));
484
+ continue;
485
+ }
486
+ try {
487
+ const rewritten = await rewriteDeclaringFile(abs, loaded);
488
+ if ("error" in rewritten) {
489
+ errors.push(`${relPath(cwd, abs)}: ${rewritten.error}`);
490
+ continue;
491
+ }
492
+ const current = await readFile(abs, "utf8");
493
+ if (current === rewritten.content) {
494
+ skipped.add(relPath(cwd, abs));
495
+ continue;
496
+ }
497
+ writesByPath.set(abs, {
498
+ file: relPath(cwd, abs),
499
+ absPath: abs,
500
+ content: rewritten.content,
501
+ });
502
+ }
503
+ catch (err) {
504
+ const message = err instanceof Error ? err.message : String(err);
505
+ errors.push(`${relPath(cwd, abs)}: ${message}`);
506
+ }
507
+ }
508
+ for (const stagePath of scope.stages) {
509
+ if (writesByPath.has(normalizeAbs(stagePath)) || usesCompile.has(normalizeAbs(stagePath))) {
510
+ continue;
511
+ }
512
+ const dialect = await dialectOf(stagePath);
513
+ if (dialect === "invalid") {
514
+ skipped.add(relPath(cwd, stagePath));
515
+ continue;
516
+ }
517
+ if (dialect !== "legacy") {
518
+ skipped.add(relPath(cwd, stagePath));
519
+ continue;
520
+ }
521
+ const outcome = await loadStageOutcome(stagePath, { deferSchemaRefs: true });
522
+ if (!outcome.ok) {
523
+ if (outcome.issues.some((issue) => issue.code === "catalog.mixed_yaml_dialect")) {
524
+ skipped.add(relPath(cwd, stagePath));
525
+ continue;
526
+ }
527
+ errors.push(...outcome.issues.map((issue) => `${relPath(cwd, stagePath)}: ${issue.message}`));
528
+ continue;
529
+ }
530
+ const verify = compileVerify(outcome.value.pre_emit_checks, afterCompletionForStage(outcome.value)?.checks);
531
+ if (!verify.ok) {
532
+ errors.push(`${relPath(cwd, stagePath)}: ${verify.error}`);
533
+ continue;
534
+ }
535
+ const raw = await readYamlObject(stagePath);
536
+ const content = stringifyTargetYaml(rewriteStageDocument(raw, {
537
+ io: ioFromRawDocument(raw),
538
+ verify: verify.items,
539
+ }));
540
+ const current = await readFile(stagePath, "utf8");
541
+ if (current === content) {
542
+ skipped.add(relPath(cwd, stagePath));
543
+ continue;
544
+ }
545
+ writesByPath.set(normalizeAbs(stagePath), {
546
+ file: relPath(cwd, stagePath),
547
+ absPath: normalizeAbs(stagePath),
548
+ content,
549
+ });
550
+ }
551
+ for (const taskPath of scope.tasks) {
552
+ skipped.add(relPath(cwd, taskPath));
553
+ }
554
+ for (const extra of scope.extras) {
555
+ if (writesByPath.has(normalizeAbs(extra)))
556
+ continue;
557
+ const dialect = await dialectOf(extra);
558
+ if (dialect === "legacy")
559
+ continue;
560
+ skipped.add(relPath(cwd, extra));
561
+ }
562
+ if (errors.length > 0) {
563
+ return { writes: [], skipped: [...skipped].sort(), errors };
564
+ }
565
+ const writes = [...writesByPath.values()].sort((a, b) => a.file.localeCompare(b.file));
566
+ const skippedSorted = [...skipped].filter((file) => !writes.some((write) => write.file === file)).sort();
567
+ return { writes, skipped: skippedSorted, errors: [] };
568
+ }
569
+ function siblingTempPath(absPath) {
570
+ return path.join(path.dirname(absPath), `${path.basename(absPath)}.${randomBytes(8).toString("hex")}.tmp`);
571
+ }
572
+ async function restoreSnapshots(snapshots) {
573
+ const restoreErrors = [];
574
+ const restoreTemps = [];
575
+ for (const [absPath, bytes] of snapshots) {
576
+ const tmp = siblingTempPath(absPath);
577
+ try {
578
+ await writeFile(tmp, bytes);
579
+ restoreTemps.push(tmp);
580
+ await rename(tmp, absPath);
581
+ }
582
+ catch {
583
+ restoreErrors.push(absPath);
584
+ }
585
+ }
586
+ await deleteTemps(restoreTemps);
587
+ return restoreErrors;
588
+ }
589
+ async function deleteTemps(temps) {
590
+ for (const tmp of temps) {
591
+ try {
592
+ await unlink(tmp);
593
+ }
594
+ catch {
595
+ // leftover cleanup
596
+ }
597
+ }
598
+ }
599
+ export async function applyMigrateYamlPlan(plan, testOnly) {
600
+ if (plan.writes.length === 0)
601
+ return;
602
+ const snapshots = new Map();
603
+ const temps = [];
604
+ try {
605
+ for (const write of plan.writes) {
606
+ snapshots.set(write.absPath, await readFile(write.absPath));
607
+ }
608
+ for (const write of plan.writes) {
609
+ const tmp = siblingTempPath(write.absPath);
610
+ await writeFile(tmp, write.content);
611
+ temps.push(tmp);
612
+ }
613
+ await testOnly?.beforeRename?.();
614
+ for (let i = 0; i < plan.writes.length; i++) {
615
+ await rename(temps[i], plan.writes[i].absPath);
616
+ }
617
+ }
618
+ catch (err) {
619
+ const restoreErrors = await restoreSnapshots(snapshots);
620
+ await deleteTemps(temps);
621
+ if (restoreErrors.length > 0) {
622
+ const original = err instanceof Error ? err.message : String(err);
623
+ throw new Error(`Failed to restore ${restoreErrors.join(", ")} after apply error: ${original}`);
624
+ }
625
+ throw err;
626
+ }
627
+ }
@@ -0,0 +1,13 @@
1
+ export type ParsedModelField = {
2
+ ok: true;
3
+ value: string | undefined;
4
+ } | {
5
+ ok: false;
6
+ message: string;
7
+ };
8
+ /**
9
+ * Validates a raw `model:` config value (stage, pipeline, or manifest YAML).
10
+ * Callers own their own error shape — this only owns the shared rule: absent
11
+ * is fine, present must be a non-empty trimmed string.
12
+ */
13
+ export declare function parseModelField(raw: unknown): ParsedModelField;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Validates a raw `model:` config value (stage, pipeline, or manifest YAML).
3
+ * Callers own their own error shape — this only owns the shared rule: absent
4
+ * is fine, present must be a non-empty trimmed string.
5
+ */
6
+ export function parseModelField(raw) {
7
+ if (raw === undefined)
8
+ return { ok: true, value: undefined };
9
+ if (typeof raw !== "string" || raw.trim() === "") {
10
+ return {
11
+ ok: false,
12
+ message: "model must be a non-empty string",
13
+ };
14
+ }
15
+ return { ok: true, value: raw.trim() };
16
+ }