@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.
- package/README.md +651 -25
- package/bin/shipctl.mjs +168 -0
- package/lib/adapters/_fs.mjs +165 -0
- package/lib/adapters/agents/index.mjs +26 -0
- package/lib/adapters/ci/azure-pipelines.mjs +23 -0
- package/lib/adapters/ci/buildkite.mjs +24 -0
- package/lib/adapters/ci/circleci.mjs +23 -0
- package/lib/adapters/ci/gh-actions.mjs +29 -0
- package/lib/adapters/ci/gitlab-ci.mjs +23 -0
- package/lib/adapters/ci/jenkins.mjs +23 -0
- package/lib/adapters/ci/manual.mjs +18 -0
- package/lib/adapters/index.mjs +122 -0
- package/lib/adapters/language/dart.mjs +23 -0
- package/lib/adapters/language/go.mjs +23 -0
- package/lib/adapters/language/java.mjs +27 -0
- package/lib/adapters/language/js.mjs +32 -0
- package/lib/adapters/language/kotlin.mjs +48 -0
- package/lib/adapters/language/py.mjs +34 -0
- package/lib/adapters/language/rust.mjs +23 -0
- package/lib/adapters/language/swift.mjs +37 -0
- package/lib/adapters/language/ts.mjs +35 -0
- package/lib/adapters/trackers/azure-boards.mjs +49 -0
- package/lib/adapters/trackers/clickup.mjs +43 -0
- package/lib/adapters/trackers/github-issues.mjs +52 -0
- package/lib/adapters/trackers/jira.mjs +72 -0
- package/lib/adapters/trackers/linear.mjs +62 -0
- package/lib/adapters/trackers/none.mjs +18 -0
- package/lib/adapters/trackers/spreadsheet.mjs +28 -0
- package/lib/artifacts/fs-index.mjs +230 -0
- package/lib/bootstrap/render.mjs +422 -0
- package/lib/cache/store.mjs +422 -0
- package/lib/commands/bootstrap.mjs +4 -0
- package/lib/commands/callback.mjs +742 -0
- package/lib/commands/config.mjs +257 -0
- package/lib/commands/docs.mjs +4 -4
- package/lib/commands/doctor.mjs +583 -0
- package/lib/commands/feedback.mjs +355 -0
- package/lib/commands/help.mjs +159 -24
- package/lib/commands/init.mjs +830 -158
- package/lib/commands/kickoff.mjs +192 -0
- package/lib/commands/knowledge.mjs +562 -0
- package/lib/commands/lanes.mjs +527 -0
- package/lib/commands/manifest-catalog.mjs +106 -42
- package/lib/commands/migrate.mjs +204 -0
- package/lib/commands/new.mjs +452 -0
- package/lib/commands/patterns.mjs +14 -48
- package/lib/commands/run.mjs +857 -0
- package/lib/commands/search.mjs +2 -2
- package/lib/commands/sync.mjs +824 -0
- package/lib/commands/telemetry.mjs +390 -0
- package/lib/commands/trigger.mjs +196 -0
- package/lib/commands/verify.mjs +187 -0
- package/lib/config/io.mjs +232 -0
- package/lib/config/migrate.mjs +223 -0
- package/lib/config/schema.mjs +901 -0
- package/lib/detect.mjs +162 -19
- package/lib/feedback/drafts.mjs +129 -0
- package/lib/find-ship-root.mjs +16 -10
- package/lib/http.mjs +237 -11
- package/lib/state/idempotency.mjs +183 -0
- package/lib/state/lockfile.mjs +180 -0
- package/lib/telemetry/outbox.mjs +224 -0
- package/lib/templates.mjs +53 -65
- package/lib/verify/checks/agents-on-disk.mjs +58 -0
- package/lib/verify/checks/api-reachable.mjs +39 -0
- package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
- package/lib/verify/checks/bootstrap-files.mjs +67 -0
- package/lib/verify/checks/cache-integrity.mjs +51 -0
- package/lib/verify/checks/ci-secrets.mjs +86 -0
- package/lib/verify/checks/config-present.mjs +39 -0
- package/lib/verify/checks/gitignore-cache.mjs +51 -0
- package/lib/verify/checks/rules-markers.mjs +135 -0
- package/lib/verify/checks/stack-enums.mjs +33 -0
- package/lib/verify/checks/tracker-labels.mjs +91 -0
- package/lib/verify/registry.mjs +120 -0
- package/lib/version.mjs +34 -0
- package/package.json +10 -3
- 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
|
+
}
|