@elmundi/ship-cli 0.8.1 → 0.12.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 (78) hide show
  1. package/README.md +651 -25
  2. package/bin/shipctl.mjs +168 -0
  3. package/lib/adapters/_fs.mjs +165 -0
  4. package/lib/adapters/agents/index.mjs +26 -0
  5. package/lib/adapters/ci/azure-pipelines.mjs +23 -0
  6. package/lib/adapters/ci/buildkite.mjs +24 -0
  7. package/lib/adapters/ci/circleci.mjs +23 -0
  8. package/lib/adapters/ci/gh-actions.mjs +29 -0
  9. package/lib/adapters/ci/gitlab-ci.mjs +23 -0
  10. package/lib/adapters/ci/jenkins.mjs +23 -0
  11. package/lib/adapters/ci/manual.mjs +18 -0
  12. package/lib/adapters/index.mjs +122 -0
  13. package/lib/adapters/language/dart.mjs +23 -0
  14. package/lib/adapters/language/go.mjs +23 -0
  15. package/lib/adapters/language/java.mjs +27 -0
  16. package/lib/adapters/language/js.mjs +32 -0
  17. package/lib/adapters/language/kotlin.mjs +48 -0
  18. package/lib/adapters/language/py.mjs +34 -0
  19. package/lib/adapters/language/rust.mjs +23 -0
  20. package/lib/adapters/language/swift.mjs +37 -0
  21. package/lib/adapters/language/ts.mjs +35 -0
  22. package/lib/adapters/trackers/azure-boards.mjs +49 -0
  23. package/lib/adapters/trackers/clickup.mjs +43 -0
  24. package/lib/adapters/trackers/github-issues.mjs +52 -0
  25. package/lib/adapters/trackers/jira.mjs +72 -0
  26. package/lib/adapters/trackers/linear.mjs +62 -0
  27. package/lib/adapters/trackers/none.mjs +18 -0
  28. package/lib/adapters/trackers/spreadsheet.mjs +28 -0
  29. package/lib/artifacts/fs-index.mjs +230 -0
  30. package/lib/bootstrap/render.mjs +422 -0
  31. package/lib/cache/store.mjs +422 -0
  32. package/lib/commands/bootstrap.mjs +4 -0
  33. package/lib/commands/callback.mjs +742 -0
  34. package/lib/commands/config.mjs +257 -0
  35. package/lib/commands/docs.mjs +4 -4
  36. package/lib/commands/doctor.mjs +583 -0
  37. package/lib/commands/feedback.mjs +355 -0
  38. package/lib/commands/help.mjs +159 -24
  39. package/lib/commands/init.mjs +830 -158
  40. package/lib/commands/kickoff.mjs +192 -0
  41. package/lib/commands/knowledge.mjs +562 -0
  42. package/lib/commands/lanes.mjs +527 -0
  43. package/lib/commands/manifest-catalog.mjs +106 -42
  44. package/lib/commands/migrate.mjs +204 -0
  45. package/lib/commands/new.mjs +452 -0
  46. package/lib/commands/patterns.mjs +14 -48
  47. package/lib/commands/run.mjs +857 -0
  48. package/lib/commands/search.mjs +2 -2
  49. package/lib/commands/sync.mjs +824 -0
  50. package/lib/commands/telemetry.mjs +390 -0
  51. package/lib/commands/trigger.mjs +196 -0
  52. package/lib/commands/verify.mjs +187 -0
  53. package/lib/config/io.mjs +232 -0
  54. package/lib/config/migrate.mjs +223 -0
  55. package/lib/config/schema.mjs +901 -0
  56. package/lib/detect.mjs +162 -19
  57. package/lib/feedback/drafts.mjs +129 -0
  58. package/lib/find-ship-root.mjs +16 -10
  59. package/lib/http.mjs +237 -11
  60. package/lib/state/idempotency.mjs +183 -0
  61. package/lib/state/lockfile.mjs +180 -0
  62. package/lib/telemetry/outbox.mjs +224 -0
  63. package/lib/templates.mjs +53 -65
  64. package/lib/verify/checks/agents-on-disk.mjs +58 -0
  65. package/lib/verify/checks/api-reachable.mjs +39 -0
  66. package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
  67. package/lib/verify/checks/bootstrap-files.mjs +67 -0
  68. package/lib/verify/checks/cache-integrity.mjs +51 -0
  69. package/lib/verify/checks/ci-secrets.mjs +86 -0
  70. package/lib/verify/checks/config-present.mjs +39 -0
  71. package/lib/verify/checks/gitignore-cache.mjs +51 -0
  72. package/lib/verify/checks/rules-markers.mjs +135 -0
  73. package/lib/verify/checks/stack-enums.mjs +33 -0
  74. package/lib/verify/checks/tracker-labels.mjs +91 -0
  75. package/lib/verify/registry.mjs +120 -0
  76. package/lib/version.mjs +34 -0
  77. package/package.json +10 -3
  78. package/bin/ship.mjs +0 -68
@@ -0,0 +1,901 @@
1
+ import { KNOWN_AGENTS } from "../detect.mjs";
2
+
3
+ /* Historical schema used by every released shipctl through 0.11.x. We
4
+ * keep validating it in parallel with v2 so customers who haven't run
5
+ * `shipctl migrate` see clear warnings instead of silent failures. */
6
+ export const LEGACY_CONFIG_SCHEMA_VERSION = 1;
7
+
8
+ /* RFC-0007 lanes-as-config. Introduced alongside `shipctl run`. Clients
9
+ * that understand only v1 will refuse to read v2 and print a shipctl
10
+ * upgrade hint; clients that understand v2 accept v1 with a deprecation
11
+ * warning and suggest `shipctl migrate`. */
12
+ export const CONFIG_SCHEMA_VERSION = 2;
13
+
14
+ export const SUPPORTED_CONFIG_VERSIONS = Object.freeze([
15
+ LEGACY_CONFIG_SCHEMA_VERSION,
16
+ CONFIG_SCHEMA_VERSION,
17
+ ]);
18
+
19
+ /* Lane kinds accepted in v2. `once` ships today end-to-end; `event` and
20
+ * `schedule` are parsed + validated but `shipctl run` emits a "not-yet
21
+ * implemented" exit-0 no-op for them until Phase 3 wires the reusable
22
+ * workflow. Keep this union tight — any new kind requires an RFC
23
+ * amendment. */
24
+ export const LANE_KINDS = Object.freeze(["once", "event", "schedule"]);
25
+
26
+ export const LANE_EVENT_TYPES = Object.freeze([
27
+ "pull_request",
28
+ "push",
29
+ "workflow_run",
30
+ "deployment_status",
31
+ ]);
32
+
33
+ export const LANE_IDEMPOTENCY_STORES = Object.freeze(["file", "backend"]);
34
+ export const LANE_IDEMPOTENCY_RESET_ON = Object.freeze(["version-change", "manual"]);
35
+
36
+ /* RFC-0008 C3.2 — fan-out strategy for multi-pattern lanes.
37
+ *
38
+ * matrix — GitHub Actions matrix: one runner per pattern, parallel.
39
+ * sequential — Single runner, `shipctl run` iterates patterns in order.
40
+ * concurrent — Single runner, `shipctl run` spawns subprocesses in parallel.
41
+ *
42
+ * Meaningful only when ``patterns.length > 1``; single-pattern lanes ignore
43
+ * it (the linter warns if it's set on a single-pattern lane). Default:
44
+ * ``matrix``. */
45
+ export const LANE_FANOUT_MODES = Object.freeze([
46
+ "matrix",
47
+ "sequential",
48
+ "concurrent",
49
+ ]);
50
+ export const LANE_FANOUT_DEFAULT = "matrix";
51
+
52
+ /* Lane ids travel into file paths (`.ship/state/<key>.json`), workflow
53
+ * file names (`.github/workflows/ship-<lane>.yml`), and env vars, so
54
+ * restrict them conservatively: ASCII lowercase, digits, dash, underscore. */
55
+ export const LANE_ID_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
56
+ export const IDEMPOTENCY_KEY_REGEX = /^[a-z0-9][a-z0-9_.-]{0,127}$/;
57
+ /* Coarse sanity check for 5-field crons: we're not a cron parser, but
58
+ * anything that isn't whitespace-separated 5 tokens is almost certainly
59
+ * a typo and worth rejecting up front. */
60
+ const CRON_5_FIELD_REGEX = /^\s*(\S+\s+){4}\S+\s*$/;
61
+
62
+ export const TRACKERS = Object.freeze([
63
+ "linear",
64
+ "jira",
65
+ "github-issues",
66
+ "azure-boards",
67
+ "clickup",
68
+ "spreadsheet",
69
+ "none",
70
+ ]);
71
+
72
+ export const CIS = Object.freeze([
73
+ "gh-actions",
74
+ "gitlab-ci",
75
+ "buildkite",
76
+ "circleci",
77
+ "azure-pipelines",
78
+ "jenkins",
79
+ "manual",
80
+ ]);
81
+
82
+ export const LANGUAGES = Object.freeze([
83
+ "ts",
84
+ "js",
85
+ "py",
86
+ "go",
87
+ "rust",
88
+ "java",
89
+ "kotlin",
90
+ "swift",
91
+ "dart",
92
+ "multi",
93
+ ]);
94
+
95
+ export const PRESETS = Object.freeze([
96
+ "web-app",
97
+ "api-backend",
98
+ "mobile-app",
99
+ "cli",
100
+ "monorepo",
101
+ "adoption-minimum",
102
+ ]);
103
+
104
+ export const CHANNELS = Object.freeze(["stable", "edge"]);
105
+
106
+ export const KINDS = Object.freeze(["pattern", "tool", "collection", "doc"]);
107
+
108
+ export const AGENT_IDS = Object.freeze(Object.keys(KNOWN_AGENTS));
109
+
110
+ export const PIN_KEY_REGEX = /^(pattern|tool|collection|doc)\/[a-zA-Z0-9_\-\.\/]+$/;
111
+
112
+ export const UUID_V4_REGEX =
113
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
114
+
115
+ const SEMVER_OR_RANGE_REGEX =
116
+ /^(\^|~|>=|<=|>|<|=)?\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
117
+
118
+ /**
119
+ * Produce a fresh, independent default v1 config (all nested objects are new).
120
+ *
121
+ * Kept for the legacy `shipctl config init` path and any callers that
122
+ * still dogfood v1. New installs go through DEFAULT_CONFIG_V2 below.
123
+ */
124
+ export function DEFAULT_CONFIG_V1() {
125
+ return {
126
+ version: LEGACY_CONFIG_SCHEMA_VERSION,
127
+ shipctl_min: "0.11.2",
128
+ api: {
129
+ base_url: "https://ship.elmundi.com",
130
+ channel: "stable",
131
+ ttl_hours: 24,
132
+ offline_ok: true,
133
+ },
134
+ stack: {
135
+ tracker: "none",
136
+ ci: "manual",
137
+ agents: [],
138
+ language: "multi",
139
+ preset: "adoption-minimum",
140
+ },
141
+ artifacts: {
142
+ pins: {},
143
+ auto_update: true,
144
+ },
145
+ cache: {
146
+ vcs_tracked: false,
147
+ },
148
+ telemetry: {
149
+ share: false,
150
+ anonymous_id: null,
151
+ scope: {
152
+ artifact_usage: true,
153
+ improvement_drafts: true,
154
+ errors: false,
155
+ },
156
+ },
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Produce a fresh, independent default v2 config. `lanes` is empty — the
162
+ * install flow (`shipctl init`, presets) seeds it with per-preset lanes.
163
+ */
164
+ export function DEFAULT_CONFIG_V2() {
165
+ const v1 = DEFAULT_CONFIG_V1();
166
+ return {
167
+ version: CONFIG_SCHEMA_VERSION,
168
+ shipctl_min: "0.12.0",
169
+ api: v1.api,
170
+ stack: v1.stack,
171
+ agent: {
172
+ default: { provider: null },
173
+ overrides: {},
174
+ },
175
+ process: DEFAULT_PROCESS_CONFIG(),
176
+ lanes: {},
177
+ artifacts: v1.artifacts,
178
+ cache: v1.cache,
179
+ telemetry: v1.telemetry,
180
+ };
181
+ }
182
+
183
+ export function DEFAULT_PROCESS_CONFIG() {
184
+ return {
185
+ id: "development",
186
+ name: "Development Process",
187
+ primary: true,
188
+ states: [
189
+ { id: "task_intake", name: "Intake", specialist: { id: "intake", name: "Intake specialist" }, layout: { x: 72, y: 170 } },
190
+ { id: "ba_requirements", name: "Requirements", specialist: { id: "business_analyst", name: "Business analyst" }, layout: { x: 338, y: 170 } },
191
+ { id: "dev_implementation", name: "Implementation", specialist: { id: "developer", name: "Developer" }, layout: { x: 604, y: 170 } },
192
+ { id: "qa_manual", name: "Quality Review", specialist: { id: "qa_engineer", name: "QA engineer" }, layout: { x: 870, y: 170 } },
193
+ { id: "pr_review", name: "Final Review", specialist: { id: "review_owner", name: "Review owner" }, layout: { x: 1136, y: 170 } },
194
+ ],
195
+ transitions: [
196
+ { from: "task_intake", to: "ba_requirements" },
197
+ { from: "ba_requirements", to: "dev_implementation" },
198
+ { from: "dev_implementation", to: "qa_manual" },
199
+ { from: "qa_manual", to: "pr_review" },
200
+ ],
201
+ routines: [],
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Back-compat alias. Pre-existing callers expect DEFAULT_CONFIG() to
207
+ * return the current schema's shape; default to v2 so new installs
208
+ * benefit from lanes.
209
+ */
210
+ export function DEFAULT_CONFIG() {
211
+ return DEFAULT_CONFIG_V2();
212
+ }
213
+
214
+ /** @typedef {{ok:true,config:object,warnings:string[]}|{ok:false,errors:string[],warnings:string[]}} ValidationResult */
215
+
216
+ const KNOWN_TOP_LEVEL_V1 = new Set([
217
+ "version",
218
+ "shipctl_min",
219
+ "api",
220
+ "stack",
221
+ "artifacts",
222
+ "cache",
223
+ "telemetry",
224
+ ]);
225
+
226
+ const KNOWN_TOP_LEVEL_V2 = new Set([
227
+ "version",
228
+ "shipctl_min",
229
+ "api",
230
+ "stack",
231
+ "agent",
232
+ "process",
233
+ "lanes",
234
+ "artifacts",
235
+ "cache",
236
+ "telemetry",
237
+ ]);
238
+
239
+ /* Back-compat export — keep the old symbol alive so external importers
240
+ * (if any) don't break silently. Point it at the v2 set since new
241
+ * callers should assume v2. */
242
+ const KNOWN_TOP_LEVEL = KNOWN_TOP_LEVEL_V2;
243
+
244
+ const KNOWN_API = new Set(["base_url", "channel", "ttl_hours", "offline_ok"]);
245
+ const KNOWN_STACK = new Set(["tracker", "ci", "agents", "agent", "language", "preset"]);
246
+ const KNOWN_STACK_AGENT = new Set(["provider"]);
247
+ const KNOWN_ARTIFACTS = new Set(["pins", "auto_update"]);
248
+ const KNOWN_CACHE = new Set(["vcs_tracked"]);
249
+ const KNOWN_TELEMETRY = new Set(["share", "anonymous_id", "scope"]);
250
+ const KNOWN_TELEMETRY_SCOPE = new Set(["artifact_usage", "improvement_drafts", "errors"]);
251
+
252
+ function isPlainObject(v) {
253
+ return v !== null && typeof v === "object" && !Array.isArray(v);
254
+ }
255
+
256
+ function pushUnknownKeyWarnings(obj, allowed, prefix, warnings) {
257
+ if (!isPlainObject(obj)) return;
258
+ for (const k of Object.keys(obj)) {
259
+ if (!allowed.has(k)) {
260
+ warnings.push(`${prefix}.${k}: unknown key (ignored, preserved on write)`);
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Validate the common shared portion of v1 and v2. Mutates `errors` and
267
+ * `warnings` in place. The per-version wrappers below layer on
268
+ * schema-specific validators on top of this foundation.
269
+ *
270
+ * @param {object} obj
271
+ * @param {string[]} errors
272
+ * @param {string[]} warnings
273
+ */
274
+ function validateSharedSections(obj, errors, warnings) {
275
+ const api = obj.api;
276
+ if (!isPlainObject(api)) {
277
+ errors.push("api: must be an object");
278
+ } else {
279
+ pushUnknownKeyWarnings(api, KNOWN_API, "api", warnings);
280
+ if (typeof api.base_url !== "string") {
281
+ errors.push("api.base_url: must be a string URL");
282
+ } else {
283
+ try {
284
+ new URL(api.base_url);
285
+ } catch {
286
+ errors.push(`api.base_url: not a valid URL (${api.base_url})`);
287
+ }
288
+ }
289
+ if (api.channel !== undefined && !CHANNELS.includes(api.channel)) {
290
+ errors.push(
291
+ `api.channel: ${JSON.stringify(api.channel)} is not valid. Expected one of: ${CHANNELS.join(", ")}`,
292
+ );
293
+ }
294
+ if (api.ttl_hours !== undefined) {
295
+ if (typeof api.ttl_hours !== "number" || !Number.isFinite(api.ttl_hours) || api.ttl_hours < 0) {
296
+ errors.push("api.ttl_hours: must be a number ≥ 0");
297
+ }
298
+ }
299
+ if (api.offline_ok !== undefined && typeof api.offline_ok !== "boolean") {
300
+ errors.push("api.offline_ok: must be boolean");
301
+ }
302
+ }
303
+ }
304
+
305
+ /**
306
+ * v2-specific validator for the `agent` and `lanes` blocks.
307
+ *
308
+ * @param {object} obj
309
+ * @param {string[]} errors
310
+ * @param {string[]} warnings
311
+ */
312
+ function validateV2Process(obj, errors, warnings) {
313
+ const process = obj.process;
314
+ if (process === undefined) return;
315
+ if (!isPlainObject(process)) {
316
+ errors.push("process: must be an object");
317
+ return;
318
+ }
319
+ pushUnknownKeyWarnings(
320
+ process,
321
+ new Set(["id", "name", "primary", "states", "transitions", "routines"]),
322
+ "process",
323
+ warnings,
324
+ );
325
+ if (typeof process.id !== "string" || !process.id.trim()) {
326
+ errors.push("process.id: must be a non-empty string");
327
+ }
328
+ if (process.name !== undefined && (typeof process.name !== "string" || !process.name.trim())) {
329
+ errors.push("process.name: must be a non-empty string when set");
330
+ }
331
+ if (process.primary !== undefined && typeof process.primary !== "boolean") {
332
+ errors.push("process.primary: must be boolean when set");
333
+ }
334
+
335
+ if (!Array.isArray(process.states) || process.states.length < 1) {
336
+ errors.push("process.states: must contain at least one state");
337
+ return;
338
+ }
339
+ const stateIds = new Set();
340
+ for (let i = 0; i < process.states.length; i += 1) {
341
+ const state = process.states[i];
342
+ const prefix = `process.states[${i}]`;
343
+ if (!isPlainObject(state)) {
344
+ errors.push(`${prefix}: must be an object`);
345
+ continue;
346
+ }
347
+ pushUnknownKeyWarnings(
348
+ state,
349
+ new Set(["id", "name", "specialist", "layout", "instructions", "triggers", "exit_conditions", "block_conditions"]),
350
+ prefix,
351
+ warnings,
352
+ );
353
+ if (typeof state.id !== "string" || !state.id.trim()) {
354
+ errors.push(`${prefix}.id: must be a non-empty string`);
355
+ } else if (stateIds.has(state.id)) {
356
+ errors.push(`${prefix}.id: duplicate state id ${JSON.stringify(state.id)}`);
357
+ } else {
358
+ stateIds.add(state.id);
359
+ }
360
+ if (state.name !== undefined && (typeof state.name !== "string" || !state.name.trim())) {
361
+ errors.push(`${prefix}.name: must be a non-empty string when set`);
362
+ }
363
+ if (state.instructions !== undefined && typeof state.instructions !== "string") {
364
+ errors.push(`${prefix}.instructions: must be a string when set`);
365
+ }
366
+ if (state.specialist !== undefined && !isPlainObject(state.specialist) && typeof state.specialist !== "string") {
367
+ errors.push(`${prefix}.specialist: must be an object or string when set`);
368
+ }
369
+ if (state.layout !== undefined) {
370
+ if (!isPlainObject(state.layout)) {
371
+ errors.push(`${prefix}.layout: must be an object when set`);
372
+ } else {
373
+ pushUnknownKeyWarnings(state.layout, new Set(["x", "y"]), `${prefix}.layout`, warnings);
374
+ if (typeof state.layout.x !== "number" || !Number.isFinite(state.layout.x)) {
375
+ errors.push(`${prefix}.layout.x: must be a finite number`);
376
+ }
377
+ if (typeof state.layout.y !== "number" || !Number.isFinite(state.layout.y)) {
378
+ errors.push(`${prefix}.layout.y: must be a finite number`);
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ if (process.transitions !== undefined) {
385
+ if (!Array.isArray(process.transitions)) {
386
+ errors.push("process.transitions: must be a list when set");
387
+ } else {
388
+ for (let i = 0; i < process.transitions.length; i += 1) {
389
+ const transition = process.transitions[i];
390
+ const prefix = `process.transitions[${i}]`;
391
+ if (!isPlainObject(transition)) {
392
+ errors.push(`${prefix}: must be an object`);
393
+ continue;
394
+ }
395
+ pushUnknownKeyWarnings(transition, new Set(["from", "to", "condition"]), prefix, warnings);
396
+ if (!stateIds.has(transition.from)) {
397
+ errors.push(`${prefix}.from: must reference an existing state id`);
398
+ }
399
+ if (!stateIds.has(transition.to)) {
400
+ errors.push(`${prefix}.to: must reference an existing state id`);
401
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ if (process.routines !== undefined && !Array.isArray(process.routines)) {
407
+ errors.push("process.routines: must be a list when set");
408
+ }
409
+ }
410
+
411
+ function validateV2Lanes(obj, errors, warnings) {
412
+ const agent = obj.agent;
413
+ if (agent !== undefined) {
414
+ if (!isPlainObject(agent)) {
415
+ errors.push("agent: must be an object");
416
+ } else {
417
+ pushUnknownKeyWarnings(agent, new Set(["default", "overrides"]), "agent", warnings);
418
+ if (agent.default !== undefined) {
419
+ if (!isPlainObject(agent.default)) {
420
+ errors.push("agent.default: must be an object");
421
+ } else {
422
+ pushUnknownKeyWarnings(agent.default, new Set(["provider"]), "agent.default", warnings);
423
+ const p = agent.default.provider;
424
+ if (p !== undefined && p !== null) {
425
+ if (typeof p !== "string" || p.length < 1 || p.length > 64) {
426
+ errors.push("agent.default.provider: must be a non-empty string (≤64 chars)");
427
+ }
428
+ }
429
+ }
430
+ }
431
+ if (agent.overrides !== undefined) {
432
+ if (!isPlainObject(agent.overrides)) {
433
+ errors.push("agent.overrides: must be a map");
434
+ } else {
435
+ for (const [laneId, override] of Object.entries(agent.overrides)) {
436
+ if (!LANE_ID_REGEX.test(laneId)) {
437
+ errors.push(`agent.overrides[${JSON.stringify(laneId)}]: invalid lane id`);
438
+ continue;
439
+ }
440
+ if (!isPlainObject(override)) {
441
+ errors.push(`agent.overrides.${laneId}: must be an object`);
442
+ continue;
443
+ }
444
+ const p = override.provider;
445
+ if (p !== undefined && p !== null && (typeof p !== "string" || p.length < 1 || p.length > 64)) {
446
+ errors.push(`agent.overrides.${laneId}.provider: must be a non-empty string (≤64 chars)`);
447
+ }
448
+ }
449
+ }
450
+ }
451
+ }
452
+ }
453
+
454
+ const lanes = obj.lanes;
455
+ if (lanes === undefined) {
456
+ /* An empty lanes map is legal — a fresh repo that has only gone
457
+ * through `shipctl migrate` has no automation wired yet, and that's
458
+ * the right default for onboarding. */
459
+ return;
460
+ }
461
+ if (!isPlainObject(lanes)) {
462
+ errors.push("lanes: must be a map of lane-id → lane");
463
+ return;
464
+ }
465
+ for (const [laneId, lane] of Object.entries(lanes)) {
466
+ validateLane(laneId, lane, errors, warnings);
467
+ }
468
+ }
469
+
470
+ const KNOWN_LANE_COMMON = new Set([
471
+ "kind",
472
+ "pattern",
473
+ "patterns",
474
+ "pattern_version",
475
+ "fanout",
476
+ "permissions",
477
+ "runner",
478
+ "timeout_minutes",
479
+ "concurrency",
480
+ ]);
481
+ const KNOWN_LANE_ONCE = new Set([...KNOWN_LANE_COMMON, "idempotency"]);
482
+ const KNOWN_LANE_EVENT = new Set([...KNOWN_LANE_COMMON, "on", "when"]);
483
+ const KNOWN_LANE_SCHEDULE = new Set([...KNOWN_LANE_COMMON, "cron", "cron_tz"]);
484
+
485
+ /**
486
+ * @param {string} laneId
487
+ * @param {any} lane
488
+ * @param {string[]} errors
489
+ * @param {string[]} warnings
490
+ */
491
+ function validateLane(laneId, lane, errors, warnings) {
492
+ if (!LANE_ID_REGEX.test(laneId)) {
493
+ errors.push(
494
+ `lanes[${JSON.stringify(laneId)}]: invalid id; expected /^[a-z0-9][a-z0-9_-]{0,63}$/`,
495
+ );
496
+ return;
497
+ }
498
+ if (!isPlainObject(lane)) {
499
+ errors.push(`lanes.${laneId}: must be an object`);
500
+ return;
501
+ }
502
+
503
+ const prefix = `lanes.${laneId}`;
504
+
505
+ if (typeof lane.kind !== "string" || !LANE_KINDS.includes(lane.kind)) {
506
+ errors.push(
507
+ `${prefix}.kind: must be one of ${LANE_KINDS.join("|")}; got ${JSON.stringify(lane.kind)}`,
508
+ );
509
+ }
510
+ // ``patterns: [ids]`` is the canonical multi-pattern form (RFC-0008 C3);
511
+ // ``pattern: <id>`` is kept as the single-pattern alias so existing
512
+ // configs keep working. Exactly one of the two must be present.
513
+ const hasPatterns = Array.isArray(lane.patterns);
514
+ const hasPattern = typeof lane.pattern === "string";
515
+ if (hasPatterns && hasPattern) {
516
+ errors.push(
517
+ `${prefix}: use either 'pattern' (single) or 'patterns' (list), not both`,
518
+ );
519
+ } else if (hasPatterns) {
520
+ if (lane.patterns.length < 1) {
521
+ errors.push(`${prefix}.patterns: must contain at least one pattern id`);
522
+ } else {
523
+ for (let i = 0; i < lane.patterns.length; i += 1) {
524
+ const p = lane.patterns[i];
525
+ if (typeof p !== "string" || !p.trim()) {
526
+ errors.push(
527
+ `${prefix}.patterns[${i}]: must be a non-empty pattern id string`,
528
+ );
529
+ }
530
+ }
531
+ }
532
+ } else if (hasPattern) {
533
+ if (!lane.pattern.trim()) {
534
+ errors.push(`${prefix}.pattern: must be a non-empty pattern id`);
535
+ }
536
+ } else {
537
+ errors.push(
538
+ `${prefix}: must declare 'pattern' (single) or 'patterns' (list)`,
539
+ );
540
+ }
541
+ if (
542
+ lane.pattern_version !== undefined &&
543
+ (typeof lane.pattern_version !== "string" || !lane.pattern_version.trim())
544
+ ) {
545
+ errors.push(`${prefix}.pattern_version: must be a non-empty semver string when set`);
546
+ }
547
+ // RFC-0008 C3.2 — `fanout` picks how multi-pattern lanes execute.
548
+ // Single-pattern lanes ignore it (it's a no-op for them); we emit a
549
+ // warning rather than an error so schedule templates that set it
550
+ // blindly remain portable across single/multi-pattern use.
551
+ if (lane.fanout !== undefined) {
552
+ if (typeof lane.fanout !== "string" || !LANE_FANOUT_MODES.includes(lane.fanout)) {
553
+ errors.push(
554
+ `${prefix}.fanout: must be one of ${LANE_FANOUT_MODES.join("|")}; got ${JSON.stringify(lane.fanout)}`,
555
+ );
556
+ } else if (hasPattern || (hasPatterns && lane.patterns.length < 2)) {
557
+ warnings.push(
558
+ `${prefix}.fanout: ignored for single-pattern lanes (has no effect unless 'patterns' has ≥2 entries)`,
559
+ );
560
+ }
561
+ }
562
+ if (lane.permissions !== undefined && !isPlainObject(lane.permissions)) {
563
+ errors.push(`${prefix}.permissions: must be an object when set`);
564
+ }
565
+ if (lane.runner !== undefined && (typeof lane.runner !== "string" || !lane.runner.trim())) {
566
+ errors.push(`${prefix}.runner: must be a non-empty string when set`);
567
+ }
568
+ if (lane.timeout_minutes !== undefined) {
569
+ const n = lane.timeout_minutes;
570
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 1 || n > 6 * 60) {
571
+ errors.push(`${prefix}.timeout_minutes: must be an integer between 1 and 360`);
572
+ }
573
+ }
574
+ if (lane.concurrency !== undefined) {
575
+ if (!isPlainObject(lane.concurrency)) {
576
+ errors.push(`${prefix}.concurrency: must be an object`);
577
+ } else {
578
+ pushUnknownKeyWarnings(
579
+ lane.concurrency,
580
+ new Set(["group", "cancel_in_progress"]),
581
+ `${prefix}.concurrency`,
582
+ warnings,
583
+ );
584
+ if (typeof lane.concurrency.group !== "string" || !lane.concurrency.group.trim()) {
585
+ errors.push(`${prefix}.concurrency.group: must be a non-empty string`);
586
+ }
587
+ if (
588
+ lane.concurrency.cancel_in_progress !== undefined &&
589
+ typeof lane.concurrency.cancel_in_progress !== "boolean"
590
+ ) {
591
+ errors.push(`${prefix}.concurrency.cancel_in_progress: must be boolean`);
592
+ }
593
+ }
594
+ }
595
+
596
+ switch (lane.kind) {
597
+ case "once":
598
+ pushUnknownKeyWarnings(lane, KNOWN_LANE_ONCE, prefix, warnings);
599
+ validateLaneIdempotency(lane, prefix, errors, warnings);
600
+ break;
601
+ case "event":
602
+ pushUnknownKeyWarnings(lane, KNOWN_LANE_EVENT, prefix, warnings);
603
+ if (typeof lane.on !== "string" || !LANE_EVENT_TYPES.includes(lane.on)) {
604
+ errors.push(
605
+ `${prefix}.on: must be one of ${LANE_EVENT_TYPES.join("|")}; got ${JSON.stringify(lane.on)}`,
606
+ );
607
+ }
608
+ if (lane.when !== undefined && !isPlainObject(lane.when)) {
609
+ errors.push(`${prefix}.when: must be an object when set`);
610
+ }
611
+ break;
612
+ case "schedule":
613
+ pushUnknownKeyWarnings(lane, KNOWN_LANE_SCHEDULE, prefix, warnings);
614
+ if (typeof lane.cron !== "string" || !CRON_5_FIELD_REGEX.test(lane.cron)) {
615
+ errors.push(
616
+ `${prefix}.cron: must be a 5-field cron expression; got ${JSON.stringify(lane.cron)}`,
617
+ );
618
+ }
619
+ if (lane.cron_tz !== undefined && (typeof lane.cron_tz !== "string" || !lane.cron_tz.trim())) {
620
+ errors.push(`${prefix}.cron_tz: must be a non-empty IANA tz string when set`);
621
+ }
622
+ break;
623
+ }
624
+ }
625
+
626
+ function validateLaneIdempotency(lane, prefix, errors, warnings) {
627
+ const idem = lane.idempotency;
628
+ if (!isPlainObject(idem)) {
629
+ errors.push(`${prefix}.idempotency: must be an object (kind=once requires it)`);
630
+ return;
631
+ }
632
+ pushUnknownKeyWarnings(
633
+ idem,
634
+ new Set(["key", "store", "reset_on"]),
635
+ `${prefix}.idempotency`,
636
+ warnings,
637
+ );
638
+ if (typeof idem.key !== "string" || !IDEMPOTENCY_KEY_REGEX.test(idem.key)) {
639
+ errors.push(
640
+ `${prefix}.idempotency.key: must match /^[a-z0-9][a-z0-9_.-]{0,127}$/; got ${JSON.stringify(idem.key)}`,
641
+ );
642
+ }
643
+ if (idem.store !== undefined && !LANE_IDEMPOTENCY_STORES.includes(idem.store)) {
644
+ errors.push(
645
+ `${prefix}.idempotency.store: must be one of ${LANE_IDEMPOTENCY_STORES.join("|")}`,
646
+ );
647
+ }
648
+ if (idem.reset_on !== undefined && !LANE_IDEMPOTENCY_RESET_ON.includes(idem.reset_on)) {
649
+ errors.push(
650
+ `${prefix}.idempotency.reset_on: must be one of ${LANE_IDEMPOTENCY_RESET_ON.join("|")}`,
651
+ );
652
+ }
653
+ }
654
+
655
+ /**
656
+ * @param {any} obj
657
+ * @returns {ValidationResult}
658
+ */
659
+ export function validateConfig(obj) {
660
+ const errors = [];
661
+ const warnings = [];
662
+
663
+ if (!isPlainObject(obj)) {
664
+ return { ok: false, errors: ["config must be a YAML mapping"], warnings };
665
+ }
666
+
667
+ if (!SUPPORTED_CONFIG_VERSIONS.includes(obj.version)) {
668
+ errors.push(
669
+ `version: unsupported; expected one of ${SUPPORTED_CONFIG_VERSIONS.join(", ")}, got ${JSON.stringify(obj.version)}`,
670
+ );
671
+ return { ok: false, errors, warnings };
672
+ }
673
+
674
+ const isV2 = obj.version === CONFIG_SCHEMA_VERSION;
675
+ const topLevel = isV2 ? KNOWN_TOP_LEVEL_V2 : KNOWN_TOP_LEVEL_V1;
676
+ pushUnknownKeyWarnings(obj, topLevel, "", warnings);
677
+
678
+ if (!isV2) {
679
+ warnings.push(
680
+ `version: config is at v${obj.version}; run \`shipctl migrate\` to upgrade to v${CONFIG_SCHEMA_VERSION}`,
681
+ );
682
+ }
683
+
684
+ validateSharedSections(obj, errors, warnings);
685
+ if (isV2) {
686
+ validateV2Process(obj, errors, warnings);
687
+ validateV2Lanes(obj, errors, warnings);
688
+ }
689
+
690
+ /* stack / artifacts / telemetry / cache share the same shape between
691
+ * v1 and v2; api is already covered by validateSharedSections above. */
692
+
693
+ const stack = obj.stack;
694
+ if (!isPlainObject(stack)) {
695
+ errors.push("stack: must be an object");
696
+ } else {
697
+ pushUnknownKeyWarnings(stack, KNOWN_STACK, "stack", warnings);
698
+ if (stack.tracker !== undefined && !TRACKERS.includes(stack.tracker)) {
699
+ errors.push(
700
+ `stack.tracker: ${JSON.stringify(stack.tracker)} is not valid. Expected one of: ${TRACKERS.join(", ")}`,
701
+ );
702
+ }
703
+ if (stack.ci !== undefined && !CIS.includes(stack.ci)) {
704
+ errors.push(
705
+ `stack.ci: ${JSON.stringify(stack.ci)} is not valid. Expected one of: ${CIS.join(", ")}`,
706
+ );
707
+ }
708
+ if (stack.language !== undefined && !LANGUAGES.includes(stack.language)) {
709
+ errors.push(
710
+ `stack.language: ${JSON.stringify(stack.language)} is not valid. Expected one of: ${LANGUAGES.join(", ")}`,
711
+ );
712
+ }
713
+ if (stack.preset !== undefined && !PRESETS.includes(stack.preset)) {
714
+ errors.push(
715
+ `stack.preset: ${JSON.stringify(stack.preset)} is not valid. Expected one of: ${PRESETS.join(", ")}`,
716
+ );
717
+ }
718
+ if (stack.agents !== undefined) {
719
+ if (!Array.isArray(stack.agents)) {
720
+ errors.push("stack.agents: must be an array");
721
+ } else {
722
+ for (const a of stack.agents) {
723
+ if (typeof a !== "string" || !AGENT_IDS.includes(a)) {
724
+ errors.push(
725
+ `stack.agents: ${JSON.stringify(a)} is not valid. Expected one of: ${AGENT_IDS.join(", ")}`,
726
+ );
727
+ }
728
+ }
729
+ }
730
+ }
731
+ if (stack.agent !== undefined) {
732
+ if (!isPlainObject(stack.agent)) {
733
+ errors.push("stack.agent: must be an object");
734
+ } else {
735
+ pushUnknownKeyWarnings(stack.agent, KNOWN_STACK_AGENT, "stack.agent", warnings);
736
+ const p = stack.agent.provider;
737
+ if (p !== undefined && p !== null) {
738
+ if (typeof p !== "string" || p.length < 1 || p.length > 64) {
739
+ errors.push(
740
+ "stack.agent.provider: must be a non-empty string (≤64 chars), e.g. claude-code or cursor-cloud",
741
+ );
742
+ }
743
+ }
744
+ }
745
+ }
746
+ }
747
+
748
+ const artifacts = obj.artifacts;
749
+ if (artifacts !== undefined) {
750
+ if (!isPlainObject(artifacts)) {
751
+ errors.push("artifacts: must be an object");
752
+ } else {
753
+ pushUnknownKeyWarnings(artifacts, KNOWN_ARTIFACTS, "artifacts", warnings);
754
+ if (artifacts.pins !== undefined) {
755
+ if (!isPlainObject(artifacts.pins)) {
756
+ errors.push("artifacts.pins: must be a map");
757
+ } else {
758
+ for (const [k, v] of Object.entries(artifacts.pins)) {
759
+ if (!PIN_KEY_REGEX.test(k)) {
760
+ errors.push(
761
+ `artifacts.pins[${JSON.stringify(k)}]: invalid key; expected <kind>/<id> where kind∈{pattern,tool,collection,doc}`,
762
+ );
763
+ }
764
+ if (typeof v !== "string" || !SEMVER_OR_RANGE_REGEX.test(v.trim())) {
765
+ errors.push(
766
+ `artifacts.pins[${JSON.stringify(k)}]: value must be a semver or range (got ${JSON.stringify(v)})`,
767
+ );
768
+ }
769
+ }
770
+ }
771
+ }
772
+ if (artifacts.auto_update !== undefined && typeof artifacts.auto_update !== "boolean") {
773
+ errors.push("artifacts.auto_update: must be boolean");
774
+ }
775
+ }
776
+ }
777
+
778
+ const cache = obj.cache;
779
+ if (cache !== undefined) {
780
+ if (!isPlainObject(cache)) {
781
+ errors.push("cache: must be an object");
782
+ } else {
783
+ pushUnknownKeyWarnings(cache, KNOWN_CACHE, "cache", warnings);
784
+ if (cache.vcs_tracked !== undefined && typeof cache.vcs_tracked !== "boolean") {
785
+ errors.push("cache.vcs_tracked: must be boolean");
786
+ }
787
+ }
788
+ }
789
+
790
+ const telemetry = obj.telemetry;
791
+ if (telemetry !== undefined) {
792
+ if (!isPlainObject(telemetry)) {
793
+ errors.push("telemetry: must be an object");
794
+ } else {
795
+ pushUnknownKeyWarnings(telemetry, KNOWN_TELEMETRY, "telemetry", warnings);
796
+ if (telemetry.share !== undefined && typeof telemetry.share !== "boolean") {
797
+ errors.push("telemetry.share: must be boolean");
798
+ }
799
+ if (telemetry.share === true) {
800
+ if (typeof telemetry.anonymous_id !== "string" || !UUID_V4_REGEX.test(telemetry.anonymous_id)) {
801
+ errors.push(
802
+ "telemetry.anonymous_id: required UUID v4 when telemetry.share=true",
803
+ );
804
+ }
805
+ } else if (
806
+ telemetry.anonymous_id !== undefined &&
807
+ telemetry.anonymous_id !== null &&
808
+ (typeof telemetry.anonymous_id !== "string" || !UUID_V4_REGEX.test(telemetry.anonymous_id))
809
+ ) {
810
+ errors.push(
811
+ `telemetry.anonymous_id: ${JSON.stringify(telemetry.anonymous_id)} is not a valid UUID v4`,
812
+ );
813
+ }
814
+ if (telemetry.scope !== undefined) {
815
+ if (!isPlainObject(telemetry.scope)) {
816
+ errors.push("telemetry.scope: must be an object");
817
+ } else {
818
+ pushUnknownKeyWarnings(telemetry.scope, KNOWN_TELEMETRY_SCOPE, "telemetry.scope", warnings);
819
+ for (const k of KNOWN_TELEMETRY_SCOPE) {
820
+ if (telemetry.scope[k] !== undefined && typeof telemetry.scope[k] !== "boolean") {
821
+ errors.push(`telemetry.scope.${k}: must be boolean`);
822
+ }
823
+ }
824
+ }
825
+ }
826
+ }
827
+ }
828
+
829
+ if (typeof obj.shipctl_min !== "undefined" && typeof obj.shipctl_min !== "string") {
830
+ errors.push("shipctl_min: must be a semver string");
831
+ }
832
+
833
+ if (errors.length) return { ok: false, errors, warnings };
834
+ return { ok: true, config: obj, warnings };
835
+ }
836
+
837
+ /**
838
+ * Return the canonical list of pattern ids for a lane.
839
+ *
840
+ * Accepts both the canonical ``patterns: [ids]`` (RFC-0008) and the
841
+ * legacy ``pattern: <id>`` single-string alias. Use this helper
842
+ * everywhere that reads ``lane.pattern`` / ``lane.patterns`` so the
843
+ * call-site never has to branch on the two shapes.
844
+ *
845
+ * For lanes that declared neither key (e.g. malformed config that
846
+ * slipped past validateConfig), returns an empty list rather than
847
+ * throwing — the caller already surfaced the validation error.
848
+ *
849
+ * @param {any} lane
850
+ * @returns {string[]}
851
+ */
852
+ export function lanePatterns(lane) {
853
+ if (!lane || typeof lane !== "object") return [];
854
+ if (Array.isArray(lane.patterns)) {
855
+ return lane.patterns
856
+ .map((p) => (typeof p === "string" ? p.trim() : ""))
857
+ .filter(Boolean);
858
+ }
859
+ if (typeof lane.pattern === "string" && lane.pattern.trim()) {
860
+ return [lane.pattern.trim()];
861
+ }
862
+ return [];
863
+ }
864
+
865
+ /**
866
+ * The "primary" pattern id for a lane. Shorthand for ``lanePatterns(lane)[0]``
867
+ * with an explicit null for lanes that have no pattern.
868
+ *
869
+ * Intended for call-sites that currently only consume a single pattern
870
+ * (shipctl run, the renderer, the dashboard). They read this and will
871
+ * continue to work until multi-pattern execution lands (C3.2); until
872
+ * then, a lane with ``patterns.length > 1`` is rejected upstream by
873
+ * the executor with a clear error.
874
+ *
875
+ * @param {any} lane
876
+ * @returns {string | null}
877
+ */
878
+ export function lanePrimaryPattern(lane) {
879
+ const list = lanePatterns(lane);
880
+ return list.length ? list[0] : null;
881
+ }
882
+
883
+ /**
884
+ * Resolve the effective fan-out mode for a lane.
885
+ *
886
+ * Returns ``LANE_FANOUT_DEFAULT`` (``matrix``) when the lane doesn't
887
+ * declare one or has at most one pattern (in which case the concept
888
+ * doesn't apply but we still want a deterministic value for downstream
889
+ * consumers). Unknown / invalid values fall back to the default; the
890
+ * validator already flags them as errors at config-load time.
891
+ *
892
+ * @param {any} lane
893
+ * @returns {"matrix" | "sequential" | "concurrent"}
894
+ */
895
+ export function laneFanout(lane) {
896
+ if (!lane || typeof lane !== "object") return LANE_FANOUT_DEFAULT;
897
+ if (typeof lane.fanout === "string" && LANE_FANOUT_MODES.includes(lane.fanout)) {
898
+ return lane.fanout;
899
+ }
900
+ return LANE_FANOUT_DEFAULT;
901
+ }