@akanjs/devkit 3.0.2 → 3.1.0-rc.1

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 (71) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.ts +3 -2
  3. package/akanConfig/akanConfig.test.ts +76 -6
  4. package/akanConfig/akanConfig.ts +111 -13
  5. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  6. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  7. package/akanConfig/index.ts +1 -0
  8. package/akanConfig/types.ts +23 -1
  9. package/akanContext.ts +1 -1
  10. package/applicationBuildRunner.test.ts +1 -1
  11. package/applicationBuildRunner.ts +73 -84
  12. package/artifact/implicitRootLayout.test.ts +6 -2
  13. package/artifact/implicitRootLayout.ts +12 -7
  14. package/biome.base.json +7 -2
  15. package/cloud/cloudApi.ts +87 -0
  16. package/cloud/globalConfig.ts +4 -0
  17. package/cloud/index.ts +1 -0
  18. package/cloud/updatePackUpload.test.ts +250 -0
  19. package/cloud/updatePackUpload.ts +132 -0
  20. package/commandDecorators/command.ts +8 -2
  21. package/commandDecorators/targetMeta.ts +1 -0
  22. package/executors.test.ts +21 -0
  23. package/executors.ts +9 -2
  24. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  25. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  26. package/frontendBuild/cssCandidateCache.ts +4 -2
  27. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  28. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  29. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  30. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  31. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  32. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  33. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  34. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  35. package/lint/no-import-external-library-in-test.grit +24 -0
  36. package/mobile/desktopServerStage.test.ts +6 -0
  37. package/mobile/desktopServerStage.ts +25 -3
  38. package/mobile/index.ts +1 -0
  39. package/mobile/nativeApp.test.ts +34 -0
  40. package/mobile/nativeApp.ts +106 -34
  41. package/mobile/nativeConfig.test.ts +59 -1
  42. package/mobile/nativeConfig.ts +13 -3
  43. package/mobile/serverBundleSecrets.test.ts +61 -0
  44. package/mobile/serverBundleSecrets.ts +85 -0
  45. package/mobile/updateUploader.test.ts +49 -0
  46. package/mobile/updateUploader.ts +71 -0
  47. package/package.json +3 -2
  48. package/pipeline/PipelineCompiler.test.ts +631 -0
  49. package/pipeline/PipelineCompiler.ts +646 -0
  50. package/pipeline/PipelineDefinition.test.ts +89 -0
  51. package/pipeline/PipelineDefinition.ts +117 -0
  52. package/pipeline/PipelineDryRun.test.ts +93 -0
  53. package/pipeline/PipelineDryRun.ts +89 -0
  54. package/pipeline/PipelineInputReader.ts +739 -0
  55. package/pipeline/index.ts +4 -0
  56. package/qualityScanner.test.ts +9 -3
  57. package/qualityScanner.ts +5 -2
  58. package/scanInfo.ts +13 -6
  59. package/subspace.test.ts +108 -4
  60. package/subspace.ts +162 -10
  61. package/subspaceConfig.ts +11 -14
  62. package/testHelpers.ts +4 -0
  63. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  64. package/transforms/serverEnvPlugin.test.ts +93 -2
  65. package/transforms/serverEnvPlugin.ts +71 -18
  66. package/transforms/serverEnvProfile.test.ts +82 -0
  67. package/transforms/serverEnvProfile.ts +114 -0
  68. package/typecheck/projectTypecheck.test.ts +32 -0
  69. package/typecheck/projectTypecheck.ts +71 -0
  70. package/workspaceLayout.test.ts +26 -3
  71. package/workspaceLayout.ts +15 -4
@@ -0,0 +1,739 @@
1
+ import {
2
+ type PipelineActionName,
3
+ type PipelineDiagnostic,
4
+ type PipelineMilestone,
5
+ PipelineNames,
6
+ type PipelineOtaPlatform,
7
+ PipelinePattern,
8
+ type PipelinePlanRetry,
9
+ type PipelineTrigger,
10
+ pipelineActionNames,
11
+ pipelineMilestones,
12
+ pipelineOtaPlatforms,
13
+ pipelinePlanLimits,
14
+ pipelineTriggers,
15
+ } from "akanjs/common";
16
+
17
+ export interface PipelineWhenRule {
18
+ branch: string[] | null;
19
+ trigger: PipelineTrigger[] | null;
20
+ changed: string[] | null;
21
+ /** A workspace `matrix: "apps"` stage's narrowing; never set elsewhere. */
22
+ app: string[] | null;
23
+ }
24
+
25
+ export interface PipelineStepRule {
26
+ localId: string;
27
+ name: string;
28
+ timeoutSeconds: number | null;
29
+ retry: PipelinePlanRetry | null;
30
+ allowFailure: boolean;
31
+ run: string;
32
+ cwd: string;
33
+ /** The stage's env under the step's own. */
34
+ env: Record<string, string>;
35
+ secrets: string[];
36
+ skipExitCodes: number[];
37
+ }
38
+
39
+ interface PipelineStageRuleBase {
40
+ id: string;
41
+ name: string;
42
+ description: string | null;
43
+ milestone: PipelineMilestone | null;
44
+ when: PipelineWhenRule;
45
+ timeoutSeconds: number | null;
46
+ allowFailure: boolean;
47
+ path: string;
48
+ }
49
+
50
+ export interface PipelineShellStageRule extends PipelineStageRuleBase {
51
+ kind: "sh";
52
+ steps: PipelineStepRule[];
53
+ }
54
+
55
+ export interface PipelineActionStageRule extends PipelineStageRuleBase {
56
+ kind: "action";
57
+ action: PipelineActionName;
58
+ named: boolean;
59
+ retry: PipelinePlanRetry | null;
60
+ /** `ota` only; `null` takes the default. */
61
+ targets: string[] | null;
62
+ platforms: PipelineOtaPlatform[] | null;
63
+ }
64
+
65
+ export interface PipelineParallelStageRule extends PipelineStageRuleBase {
66
+ kind: "parallel";
67
+ maxParallel: number | null;
68
+ failFast: boolean;
69
+ branches: PipelineShellStageRule[];
70
+ }
71
+
72
+ export interface PipelineMatrixStageRule extends PipelineStageRuleBase {
73
+ kind: "matrix";
74
+ maxParallel: number | null;
75
+ steps: PipelineStepRule[];
76
+ }
77
+
78
+ export type PipelineWorkspaceStageRule = PipelineShellStageRule | PipelineParallelStageRule | PipelineMatrixStageRule;
79
+ export type PipelineAppStageRule = PipelineShellStageRule | PipelineActionStageRule;
80
+
81
+ export interface PipelinePostRule {
82
+ success: PipelineStepRule[];
83
+ failure: PipelineStepRule[];
84
+ canceled: PipelineStepRule[];
85
+ always: PipelineStepRule[];
86
+ }
87
+
88
+ export interface PipelineWorkspaceRule {
89
+ env: Record<string, string>;
90
+ timeoutSeconds: number | null;
91
+ before: PipelineWorkspaceStageRule[];
92
+ after: PipelineWorkspaceStageRule[];
93
+ post: PipelinePostRule;
94
+ }
95
+
96
+ export interface PipelineAppRule {
97
+ env: Record<string, string>;
98
+ after: string[];
99
+ stages: PipelineAppStageRule[];
100
+ }
101
+
102
+ type Scope = "workspace" | "app";
103
+ type JsonObject = Record<string, unknown>;
104
+
105
+ /** Turns one file's `pipeline` value into rules, reporting what is wrong with it rather than throwing. */
106
+ export class PipelineInputReader {
107
+ static readonly #futureKeys = ["cache", "artifacts", "input", "image", "services", "notify"];
108
+ static readonly #durationPattern = /^(\d+)(s|m|h)$/;
109
+ static readonly #stepNameChars = 80;
110
+
111
+ readonly diagnostics: PipelineDiagnostic[] = [];
112
+ readonly #file: string;
113
+
114
+ constructor(file: string) {
115
+ this.#file = file;
116
+ }
117
+
118
+ workspace(value: unknown): PipelineWorkspaceRule | null {
119
+ const input = this.#object(value, "pipeline", ["schemaVersion", "env", "timeout", "before", "after", "post"]);
120
+ if (!input) return null;
121
+ this.#schemaVersion(input.schemaVersion, "pipeline.schemaVersion");
122
+ const ids = new Set<string>();
123
+ const stages = (key: "before" | "after") =>
124
+ this.#list(input[key], `pipeline.${key}`).flatMap((stage, idx) => {
125
+ const rule = this.#workspaceStage(stage, `pipeline.${key}[${idx}]`);
126
+ if (rule) this.#claimId(ids, rule.id, rule.path);
127
+ return rule ? [rule] : [];
128
+ });
129
+ return {
130
+ env: this.#env(input.env, "pipeline.env"),
131
+ timeoutSeconds: this.#duration(input.timeout, "pipeline.timeout"),
132
+ before: stages("before"),
133
+ after: stages("after"),
134
+ post: this.#post(input.post, "pipeline.post"),
135
+ };
136
+ }
137
+
138
+ app(value: unknown): PipelineAppRule | null {
139
+ const input = this.#object(value, "pipeline", ["schemaVersion", "stages", "env", "after"]);
140
+ if (!input) return null;
141
+ this.#schemaVersion(input.schemaVersion, "pipeline.schemaVersion");
142
+ if (input.stages === undefined) this.#report("pipeline.schema.invalid", "lists no `stages`", "pipeline");
143
+ const ids = new Set<string>();
144
+ const stages = this.#list(input.stages, "pipeline.stages").flatMap((stage, idx) => {
145
+ const rule = this.#appStage(stage, `pipeline.stages[${idx}]`);
146
+ if (rule) this.#claimId(ids, rule.id, rule.path);
147
+ return rule ? [rule] : [];
148
+ });
149
+ return {
150
+ env: this.#env(input.env, "pipeline.env"),
151
+ after: this.#strings(input.after, "pipeline.after"),
152
+ stages,
153
+ };
154
+ }
155
+
156
+ #workspaceStage(value: unknown, path: string): PipelineWorkspaceStageRule | null {
157
+ const raw = this.#object(value, path);
158
+ if (!raw) return null;
159
+ if ("use" in raw) {
160
+ this.#report(
161
+ "pipeline.schema.invalid",
162
+ "built-in actions are declared per app, in apps/<app>/akan.config.ts",
163
+ `${path}.use`,
164
+ );
165
+ return null;
166
+ }
167
+ if ("parallel" in raw) return this.#parallelStage(raw, path);
168
+ if ("matrix" in raw) return this.#matrixStage(raw, path);
169
+ return this.#shellStage(raw, path, "workspace");
170
+ }
171
+
172
+ #appStage(value: unknown, path: string): PipelineAppStageRule | null {
173
+ const raw = this.#object(value, path);
174
+ if (!raw) return null;
175
+ if ("parallel" in raw || "matrix" in raw) {
176
+ this.#report(
177
+ "pipeline.schema.invalid",
178
+ `an app's own stage takes no \`${"parallel" in raw ? "parallel" : "matrix"}\`: it is already that app's, and parallel or per-app copies belong to the workspace root akan.config.ts`,
179
+ path,
180
+ );
181
+ return null;
182
+ }
183
+ if ("use" in raw) return this.#actionStage(raw, path);
184
+ return this.#shellStage(raw, path, "app");
185
+ }
186
+
187
+ #actionStage(raw: JsonObject, path: string): PipelineActionStageRule | null {
188
+ if (
189
+ !this.#keys(raw, path, [
190
+ "use",
191
+ "id",
192
+ "name",
193
+ "description",
194
+ "milestone",
195
+ "when",
196
+ "timeout",
197
+ "allowFailure",
198
+ "retry",
199
+ ...(raw.use === "ota" ? ["targets", "platforms"] : []),
200
+ ])
201
+ )
202
+ return null;
203
+ const action = this.#oneOf(raw.use, `${path}.use`, pipelineActionNames);
204
+ if (!action) return null;
205
+ const name = raw.name === undefined ? null : this.#text(raw.name, `${path}.name`, pipelinePlanLimits.nameChars);
206
+ const id = raw.id === undefined ? action : this.#id(raw.id, `${path}.id`);
207
+ if (!id) return null;
208
+ const attempts =
209
+ raw.retry === undefined ? null : this.#int(raw.retry, `${path}.retry`, 1, pipelinePlanLimits.retryAttempts);
210
+ return {
211
+ kind: "action",
212
+ action,
213
+ id,
214
+ name: name ?? action,
215
+ named: name !== null,
216
+ description: this.#description(raw.description, `${path}.description`),
217
+ milestone: this.#milestone(raw.milestone, `${path}.milestone`),
218
+ when: this.#when(raw.when, `${path}.when`, false),
219
+ timeoutSeconds: this.#duration(raw.timeout, `${path}.timeout`),
220
+ allowFailure: this.#boolean(raw.allowFailure, `${path}.allowFailure`),
221
+ retry: attempts === null ? null : { attempts, delaySeconds: 0 },
222
+ targets:
223
+ raw.targets === undefined
224
+ ? null
225
+ : this.#nonEmptySet(this.#strings(raw.targets, `${path}.targets`), `${path}.targets`),
226
+ platforms:
227
+ raw.platforms === undefined
228
+ ? null
229
+ : this.#nonEmptySet(
230
+ this.#list(raw.platforms, `${path}.platforms`).flatMap((platform, idx) => {
231
+ const parsed = this.#oneOf(platform, `${path}.platforms[${idx}]`, pipelineOtaPlatforms);
232
+ return parsed ? [parsed] : [];
233
+ }),
234
+ `${path}.platforms`,
235
+ ),
236
+ path,
237
+ };
238
+ }
239
+
240
+ #nonEmptySet<T>(items: T[], path: string): T[] | null {
241
+ if (!items.length) {
242
+ this.#report("pipeline.schema.invalid", "lists none: leave it out to take the default", path);
243
+ return null;
244
+ }
245
+ return [...new Set(items)];
246
+ }
247
+
248
+ #shellStage(raw: JsonObject, path: string, scope: Scope): PipelineShellStageRule | null {
249
+ const keys = [
250
+ "id",
251
+ "name",
252
+ "description",
253
+ "milestone",
254
+ "when",
255
+ "timeout",
256
+ "allowFailure",
257
+ "sh",
258
+ "steps",
259
+ "env",
260
+ "secrets",
261
+ "retry",
262
+ ];
263
+ if (!this.#keys(raw, path, keys)) return null;
264
+ const head = this.#stageHead(raw, path, scope === "workspace" ? "workspace" : "app");
265
+ if (!head) return null;
266
+ const steps = this.#stageSteps(raw, path);
267
+ return steps ? { kind: "sh", ...head, steps } : null;
268
+ }
269
+
270
+ #matrixStage(raw: JsonObject, path: string): PipelineMatrixStageRule | null {
271
+ const keys = [
272
+ "id",
273
+ "name",
274
+ "description",
275
+ "milestone",
276
+ "when",
277
+ "timeout",
278
+ "allowFailure",
279
+ "sh",
280
+ "steps",
281
+ "env",
282
+ "secrets",
283
+ "retry",
284
+ "matrix",
285
+ "maxParallel",
286
+ ];
287
+ if (!this.#keys(raw, path, keys)) return null;
288
+ if (raw.matrix !== "apps") {
289
+ this.#report(
290
+ "pipeline.schema.invalid",
291
+ `${JSON.stringify(raw.matrix)} is not a matrix axis: only "apps"`,
292
+ `${path}.matrix`,
293
+ );
294
+ return null;
295
+ }
296
+ const head = this.#stageHead(raw, path, "matrix");
297
+ if (!head) return null;
298
+ const steps = this.#stageSteps(raw, path);
299
+ if (!steps) return null;
300
+ return { kind: "matrix", ...head, maxParallel: this.#maxParallel(raw.maxParallel, `${path}.maxParallel`), steps };
301
+ }
302
+
303
+ #parallelStage(raw: JsonObject, path: string): PipelineParallelStageRule | null {
304
+ const keys = [
305
+ "id",
306
+ "name",
307
+ "description",
308
+ "milestone",
309
+ "when",
310
+ "timeout",
311
+ "allowFailure",
312
+ "parallel",
313
+ "maxParallel",
314
+ "failFast",
315
+ "env",
316
+ "secrets",
317
+ ];
318
+ if (!this.#keys(raw, path, keys)) return null;
319
+ const head = this.#stageHead(raw, path, "workspace");
320
+ if (!head) return null;
321
+ const env = this.#env(raw.env, `${path}.env`);
322
+ const secrets = this.#secrets(raw.secrets, `${path}.secrets`);
323
+ const ids = new Set<string>();
324
+ const branches = this.#list(raw.parallel, `${path}.parallel`).flatMap((branch, idx) => {
325
+ const branchPath = `${path}.parallel[${idx}]`;
326
+ const branchRaw = this.#object(branch, branchPath);
327
+ if (!branchRaw) return [];
328
+ if ("use" in branchRaw || "parallel" in branchRaw || "matrix" in branchRaw) {
329
+ this.#report("pipeline.schema.invalid", "a parallel branch is a shell stage", branchPath);
330
+ return [];
331
+ }
332
+ const rule = this.#shellStage(branchRaw, branchPath, "workspace");
333
+ if (!rule) return [];
334
+ if (rule.when.changed && head.when.changed) {
335
+ this.#report(
336
+ "pipeline.schema.invalid",
337
+ "`when.changed` on both a parallel stage and its branch cannot be combined; keep one",
338
+ `${branchPath}.when.changed`,
339
+ );
340
+ return [];
341
+ }
342
+ this.#claimId(ids, rule.id, rule.path);
343
+ const steps = rule.steps.map((step) => ({
344
+ ...step,
345
+ env: { ...env, ...step.env },
346
+ secrets: [...new Set([...secrets, ...step.secrets])],
347
+ }));
348
+ return [{ ...rule, steps }];
349
+ });
350
+ if (!branches.length && Array.isArray(raw.parallel))
351
+ this.#report("pipeline.schema.invalid", "a parallel stage holds at least one branch", `${path}.parallel`);
352
+ return {
353
+ kind: "parallel",
354
+ ...head,
355
+ maxParallel: this.#maxParallel(raw.maxParallel, `${path}.maxParallel`),
356
+ failFast: this.#boolean(raw.failFast, `${path}.failFast`),
357
+ branches,
358
+ };
359
+ }
360
+
361
+ #stageHead(raw: JsonObject, path: string, whenScope: "workspace" | "app" | "matrix") {
362
+ const name = this.#text(raw.name, `${path}.name`, pipelinePlanLimits.nameChars);
363
+ if (name === null) return null;
364
+ const id = raw.id === undefined ? this.#slugId(name, `${path}.name`) : this.#id(raw.id, `${path}.id`);
365
+ if (!id) return null;
366
+ return {
367
+ id,
368
+ name,
369
+ description: this.#description(raw.description, `${path}.description`),
370
+ milestone: this.#milestone(raw.milestone, `${path}.milestone`),
371
+ when: this.#when(raw.when, `${path}.when`, whenScope === "matrix"),
372
+ timeoutSeconds: this.#duration(raw.timeout, `${path}.timeout`),
373
+ allowFailure: this.#boolean(raw.allowFailure, `${path}.allowFailure`),
374
+ path,
375
+ };
376
+ }
377
+
378
+ #stageSteps(raw: JsonObject, path: string): PipelineStepRule[] | null {
379
+ if ((raw.sh === undefined) === (raw.steps === undefined)) {
380
+ this.#report("pipeline.schema.invalid", "takes exactly one of `sh` and `steps`", path);
381
+ return null;
382
+ }
383
+ const env = this.#env(raw.env, `${path}.env`);
384
+ const secrets = this.#secrets(raw.secrets, `${path}.secrets`);
385
+ const retry = this.#retry(raw.retry, `${path}.retry`);
386
+ const steps = raw.sh === undefined ? this.#list(raw.steps, `${path}.steps`) : [raw.sh];
387
+ const stepsPath = raw.sh === undefined ? `${path}.steps` : path;
388
+ if (!steps.length) {
389
+ this.#report("pipeline.schema.invalid", "a stage runs at least one step", stepsPath);
390
+ return null;
391
+ }
392
+ const ids = new Set<string>();
393
+ return steps.flatMap((step, idx) => {
394
+ const stepPath = raw.sh === undefined ? `${stepsPath}[${idx}]` : `${path}.sh`;
395
+ const rule = this.#step(step, stepPath, idx);
396
+ if (!rule) return [];
397
+ this.#claimId(ids, rule.localId, stepPath);
398
+ return [
399
+ {
400
+ ...rule,
401
+ retry: rule.retry ?? retry,
402
+ env: { ...env, ...rule.env },
403
+ secrets: [...new Set([...secrets, ...rule.secrets])],
404
+ },
405
+ ];
406
+ });
407
+ }
408
+
409
+ #step(value: unknown, path: string, idx: number): PipelineStepRule | null {
410
+ const raw = typeof value === "string" ? { sh: value } : this.#object(value, path);
411
+ if (!raw) return null;
412
+ const keys = ["sh", "id", "name", "cwd", "env", "secrets", "timeout", "retry", "allowFailure", "skipExitCodes"];
413
+ if (!this.#keys(raw, path, keys)) return null;
414
+ const run = this.#text(raw.sh, typeof value === "string" ? path : `${path}.sh`, Number.POSITIVE_INFINITY);
415
+ if (run === null) return null;
416
+ if (new TextEncoder().encode(run).length > pipelinePlanLimits.shBytes) {
417
+ this.#report("pipeline.limit.exceeded", `a script is at most ${pipelinePlanLimits.shBytes} bytes`, path);
418
+ return null;
419
+ }
420
+ const name =
421
+ raw.name === undefined
422
+ ? PipelineInputReader.#nameOf(run)
423
+ : this.#text(raw.name, `${path}.name`, pipelinePlanLimits.nameChars);
424
+ if (name === null) return null;
425
+ const localId =
426
+ raw.id !== undefined
427
+ ? this.#id(raw.id, `${path}.id`)
428
+ : raw.name !== undefined
429
+ ? this.#slugId(name, `${path}.name`)
430
+ : String(idx + 1);
431
+ if (!localId) return null;
432
+ return {
433
+ localId,
434
+ name,
435
+ timeoutSeconds: this.#duration(raw.timeout, `${path}.timeout`),
436
+ retry: this.#retry(raw.retry, `${path}.retry`),
437
+ allowFailure: this.#boolean(raw.allowFailure, `${path}.allowFailure`),
438
+ run,
439
+ cwd: this.#cwd(raw.cwd, `${path}.cwd`),
440
+ env: this.#env(raw.env, `${path}.env`),
441
+ secrets: this.#secrets(raw.secrets, `${path}.secrets`),
442
+ skipExitCodes: this.#exitCodes(raw.skipExitCodes, `${path}.skipExitCodes`),
443
+ };
444
+ }
445
+
446
+ #post(value: unknown, path: string): PipelinePostRule {
447
+ const empty: PipelinePostRule = { success: [], failure: [], canceled: [], always: [] };
448
+ if (value === undefined) return empty;
449
+ const raw = this.#object(value, path);
450
+ if (!raw) return empty;
451
+ for (const key of Object.keys(raw))
452
+ if (!["success", "failure", "canceled", "always"].includes(key))
453
+ this.#report(
454
+ "pipeline.schema.unknownKey",
455
+ key === "unstable"
456
+ ? "`unstable` is not a result the framework knows: the executor decides which list an allowed failure runs"
457
+ : `"${key}" is not a post result: success, failure, canceled or always`,
458
+ `${path}.${key}`,
459
+ );
460
+ const steps = (key: keyof PipelinePostRule) => {
461
+ const ids = new Set<string>();
462
+ return this.#list(raw[key], `${path}.${key}`).flatMap((step, idx) => {
463
+ const rule = this.#step(step, `${path}.${key}[${idx}]`, idx);
464
+ if (rule) this.#claimId(ids, rule.localId, `${path}.${key}[${idx}]`);
465
+ return rule ? [rule] : [];
466
+ });
467
+ };
468
+ return {
469
+ success: steps("success"),
470
+ failure: steps("failure"),
471
+ canceled: steps("canceled"),
472
+ always: steps("always"),
473
+ };
474
+ }
475
+
476
+ #when(value: unknown, path: string, allowApp: boolean): PipelineWhenRule {
477
+ const none: PipelineWhenRule = { branch: null, trigger: null, changed: null, app: null };
478
+ if (value === undefined) return none;
479
+ const raw = this.#object(value, path);
480
+ if (!raw) return none;
481
+ for (const key of Object.keys(raw)) {
482
+ if (["branch", "trigger", "changed"].includes(key) || (allowApp && key === "app")) continue;
483
+ this.#report(
484
+ "pipeline.schema.unknownKey",
485
+ key === "app"
486
+ ? "`when.app` narrows a workspace matrix stage only; an app's own stages are already that app's"
487
+ : `"${key}" is not a condition: branch, trigger, changed${allowApp ? " or app" : ""}`,
488
+ `${path}.${key}`,
489
+ );
490
+ }
491
+ const triggers =
492
+ raw.trigger === undefined
493
+ ? null
494
+ : (typeof raw.trigger === "string" ? [raw.trigger] : this.#list(raw.trigger, `${path}.trigger`)).flatMap(
495
+ (trigger, idx) => {
496
+ const parsed = this.#oneOf(trigger, `${path}.trigger[${idx}]`, pipelineTriggers);
497
+ return parsed ? [parsed] : [];
498
+ },
499
+ );
500
+ return {
501
+ branch: this.#patterns(raw.branch, `${path}.branch`),
502
+ trigger: triggers,
503
+ changed: this.#patterns(raw.changed, `${path}.changed`),
504
+ app: allowApp ? this.#patterns(raw.app, `${path}.app`) : null,
505
+ };
506
+ }
507
+
508
+ #patterns(value: unknown, path: string): string[] | null {
509
+ if (value === undefined) return null;
510
+ const patterns = typeof value === "string" ? [value] : this.#strings(value, path);
511
+ const problem = PipelinePattern.validate(patterns);
512
+ if (problem) {
513
+ this.#report("pipeline.when.pattern", problem, path);
514
+ return null;
515
+ }
516
+ return patterns;
517
+ }
518
+
519
+ #schemaVersion(value: unknown, path: string) {
520
+ if (value !== undefined && value !== 1)
521
+ this.#report(
522
+ "pipeline.schema.unsupported",
523
+ `${JSON.stringify(value)} is not a schemaVersion this CLI reads (1)`,
524
+ path,
525
+ );
526
+ }
527
+
528
+ #env(value: unknown, path: string): Record<string, string> {
529
+ if (value === undefined) return {};
530
+ const raw = this.#object(value, path);
531
+ if (!raw) return {};
532
+ const env: Record<string, string> = {};
533
+ for (const [name, entry] of Object.entries(raw)) {
534
+ const problem = PipelineNames.envNameError(name);
535
+ if (problem) this.#report("pipeline.env.name", problem, `${path}.${name}`);
536
+ else if (typeof entry !== "string")
537
+ this.#report("pipeline.schema.invalid", "must be a string", `${path}.${name}`);
538
+ else env[name] = entry;
539
+ }
540
+ return env;
541
+ }
542
+
543
+ #secrets(value: unknown, path: string): string[] {
544
+ const names = value === undefined ? [] : this.#strings(value, path);
545
+ const valid = names.filter((name) => {
546
+ const problem = PipelineNames.secretNameError(name);
547
+ if (problem) this.#report("pipeline.secret.name", problem, path);
548
+ return !problem;
549
+ });
550
+ return [...new Set(valid)];
551
+ }
552
+
553
+ #cwd(value: unknown, path: string) {
554
+ if (value === undefined) return ".";
555
+ const cwd = this.#text(value, path, 1024);
556
+ if (cwd === null) return ".";
557
+ if (cwd.startsWith("/") || cwd.startsWith("~") || cwd.includes("\\") || cwd.split("/").includes("..")) {
558
+ this.#report("pipeline.cwd.outside", `"${cwd}" is not a path inside the workspace`, path);
559
+ return ".";
560
+ }
561
+ return cwd;
562
+ }
563
+
564
+ #exitCodes(value: unknown, path: string): number[] {
565
+ if (value === undefined) return [];
566
+ const codes = this.#list(value, path).flatMap((code, idx) => {
567
+ const parsed = this.#int(code, `${path}[${idx}]`, 1, 255);
568
+ return parsed === null ? [] : [parsed];
569
+ });
570
+ return [...new Set(codes)];
571
+ }
572
+
573
+ #retry(value: unknown, path: string): PipelinePlanRetry | null {
574
+ if (value === undefined) return null;
575
+ if (typeof value === "number") {
576
+ const attempts = this.#int(value, path, 1, pipelinePlanLimits.retryAttempts);
577
+ return attempts === null ? null : { attempts, delaySeconds: 0 };
578
+ }
579
+ const raw = this.#object(value, path);
580
+ if (!raw || !this.#keys(raw, path, ["attempts", "delay"])) return null;
581
+ const attempts = this.#int(raw.attempts, `${path}.attempts`, 1, pipelinePlanLimits.retryAttempts);
582
+ const delaySeconds = this.#duration(raw.delay, `${path}.delay`, {
583
+ min: 0,
584
+ max: pipelinePlanLimits.retryDelaySeconds,
585
+ });
586
+ return attempts === null ? null : { attempts, delaySeconds: delaySeconds ?? 0 };
587
+ }
588
+
589
+ #duration(value: unknown, path: string, { min = 1, max = 86_400 }: { min?: number; max?: number } = {}) {
590
+ if (value === undefined) return null;
591
+ const match = typeof value === "string" ? PipelineInputReader.#durationPattern.exec(value) : null;
592
+ const seconds =
593
+ typeof value === "number" && Number.isInteger(value)
594
+ ? value
595
+ : match
596
+ ? Number(match[1]) * { s: 1, m: 60, h: 3600 }[match[2] as "s" | "m" | "h"]
597
+ : Number.NaN;
598
+ if (!Number.isInteger(seconds) || seconds < min || seconds > max) {
599
+ this.#report(
600
+ "pipeline.duration.invalid",
601
+ `${JSON.stringify(value)} is not a duration between ${min} and ${max} seconds: whole seconds, or "30s", "20m", "1h"`,
602
+ path,
603
+ );
604
+ return null;
605
+ }
606
+ return seconds;
607
+ }
608
+
609
+ #maxParallel(value: unknown, path: string) {
610
+ return value === undefined ? null : this.#int(value, path, 1, pipelinePlanLimits.stages);
611
+ }
612
+
613
+ #milestone(value: unknown, path: string) {
614
+ return value === undefined ? null : this.#oneOf(value, path, pipelineMilestones);
615
+ }
616
+
617
+ #description(value: unknown, path: string) {
618
+ return value === undefined ? null : this.#text(value, path, pipelinePlanLimits.descriptionChars);
619
+ }
620
+
621
+ #id(value: unknown, path: string) {
622
+ const id = this.#text(value, path, 64);
623
+ if (id === null) return null;
624
+ const problem = PipelineNames.idError(id);
625
+ if (problem) {
626
+ this.#report("pipeline.id.invalid", problem, path);
627
+ return null;
628
+ }
629
+ return id;
630
+ }
631
+
632
+ #slugId(name: string, path: string) {
633
+ const slug = name
634
+ .toLowerCase()
635
+ .replace(/[^a-z0-9]+/g, "-")
636
+ .replace(/^-+/, "")
637
+ .slice(0, 64)
638
+ .replace(/-+$/, "");
639
+ if (!slug) {
640
+ this.#report(
641
+ "pipeline.id.invalid",
642
+ `"${name}" has no letter or digit to make an id from: give it an \`id\``,
643
+ path,
644
+ );
645
+ return null;
646
+ }
647
+ return slug;
648
+ }
649
+
650
+ #claimId(ids: Set<string>, id: string, path: string) {
651
+ if (ids.has(id)) this.#report("pipeline.id.duplicate", `"${id}" is already the id of another one here`, path);
652
+ ids.add(id);
653
+ }
654
+
655
+ static #nameOf(run: string) {
656
+ const line = run
657
+ .split("\n")
658
+ .map((text) => text.trim())
659
+ .find(Boolean);
660
+ const name = line ?? run.trim();
661
+ return name.length > PipelineInputReader.#stepNameChars
662
+ ? `${name.slice(0, PipelineInputReader.#stepNameChars - 1)}…`
663
+ : name;
664
+ }
665
+
666
+ #keys(raw: JsonObject, path: string, keys: readonly string[]) {
667
+ let ok = true;
668
+ for (const key of Object.keys(raw)) {
669
+ if (keys.includes(key)) continue;
670
+ ok = false;
671
+ if (PipelineInputReader.#futureKeys.includes(key))
672
+ this.#report("pipeline.schema.unsupported", `\`${key}\` is not supported in schemaVersion 1`, `${path}.${key}`);
673
+ else
674
+ this.#report("pipeline.schema.unknownKey", `"${key}" is not a key here: ${keys.join(", ")}`, `${path}.${key}`);
675
+ }
676
+ return ok;
677
+ }
678
+
679
+ #object(value: unknown, path: string, keys?: readonly string[]): JsonObject | null {
680
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
681
+ this.#report("pipeline.schema.invalid", "must be an object", path);
682
+ return null;
683
+ }
684
+ const raw = value as JsonObject;
685
+ if (keys && !this.#keys(raw, path, keys)) return null;
686
+ return raw;
687
+ }
688
+
689
+ #list(value: unknown, path: string): unknown[] {
690
+ if (value === undefined) return [];
691
+ if (!Array.isArray(value)) {
692
+ this.#report("pipeline.schema.invalid", "must be a list", path);
693
+ return [];
694
+ }
695
+ return value;
696
+ }
697
+
698
+ #strings(value: unknown, path: string): string[] {
699
+ return this.#list(value, path).flatMap((item, idx) => {
700
+ const text = this.#text(item, `${path}[${idx}]`, 1024);
701
+ return text === null ? [] : [text];
702
+ });
703
+ }
704
+
705
+ #text(value: unknown, path: string, max: number) {
706
+ if (typeof value !== "string" || !value.trim()) {
707
+ this.#report("pipeline.schema.invalid", "must be a non-empty string", path);
708
+ return null;
709
+ }
710
+ if (value.length > max) {
711
+ this.#report("pipeline.schema.invalid", `is longer than ${max} characters`, path);
712
+ return null;
713
+ }
714
+ return value;
715
+ }
716
+
717
+ #boolean(value: unknown, path: string) {
718
+ if (value === undefined) return false;
719
+ if (typeof value === "boolean") return value;
720
+ this.#report("pipeline.schema.invalid", "must be true or false", path);
721
+ return false;
722
+ }
723
+
724
+ #int(value: unknown, path: string, min: number, max: number) {
725
+ if (typeof value === "number" && Number.isInteger(value) && value >= min && value <= max) return value;
726
+ this.#report("pipeline.schema.invalid", `must be a whole number between ${min} and ${max}`, path);
727
+ return null;
728
+ }
729
+
730
+ #oneOf<T extends string>(value: unknown, path: string, options: readonly T[]): T | null {
731
+ if (typeof value === "string" && (options as readonly string[]).includes(value)) return value as T;
732
+ this.#report("pipeline.schema.invalid", `${JSON.stringify(value)} is not one of ${options.join(", ")}`, path);
733
+ return null;
734
+ }
735
+
736
+ #report(code: string, message: string, path: string) {
737
+ this.diagnostics.push({ severity: "error", code, message, file: this.#file, path });
738
+ }
739
+ }