@secureci/cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,448 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.validatePipelineContract = validatePipelineContract;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const job_plan_1 = require("../runner/job-plan");
40
+ const workflow_parser_1 = require("./workflow-parser");
41
+ const repository_1 = require("../utils/repository");
42
+ const action_fetcher_1 = require("../runner/action-fetcher");
43
+ /** Maximum nesting of composite actions / reusable workflows. */
44
+ const MAX_DEPTH = 10;
45
+ /**
46
+ * Validate a workflow's job/step sequence against a central pipeline
47
+ * contract: a minimum set of required actions (optionally ordered) and a
48
+ * deny-list of forbidden actions.
49
+ *
50
+ * Composite actions (`uses: ./path`) and reusable workflows
51
+ * (`jobs.<id>.uses: ./path.yml`) are resolved recursively, so wrapping an
52
+ * action doesn't hide it: local ones by default, remote ones too with
53
+ * `recurse: 'all'`. Ordering follows
54
+ * what GitHub guarantees: steps of a job run in order, and a job runs after
55
+ * the jobs it (transitively) `needs:`; jobs with no such relationship may
56
+ * run in parallel, so neither is "before" the other.
57
+ */
58
+ async function validatePipelineContract(parsed, spec, options = {}) {
59
+ const graph = await buildGraph(parsed, options.recurse ?? 'local');
60
+ const { sequence, nodes } = graph;
61
+ const errors = [...graph.errors];
62
+ const ordering = spec.ordering ?? 'relative';
63
+ for (const entry of spec.forbidden ?? []) {
64
+ for (const usage of sequence) {
65
+ if (usage.name === normalizeActionName(entry.uses) &&
66
+ (entry.version === undefined || usage.version === entry.version)) {
67
+ errors.push({
68
+ path: usage.path,
69
+ message: entry.version
70
+ ? `Forbidden action "${entry.uses}@${entry.version}" is used`
71
+ : `Forbidden action "${entry.uses}" is used (any version)`,
72
+ severity: 'error'
73
+ });
74
+ }
75
+ }
76
+ }
77
+ const matchedPositions = [];
78
+ (spec.required ?? []).forEach((entry, idx) => {
79
+ const path = `required[${idx}]`;
80
+ const name = normalizeActionName(entry.uses);
81
+ const candidates = sequence.filter((u) => u.name === name);
82
+ if (candidates.length > 0 && candidates.every((c) => c.conditional)) {
83
+ errors.push({
84
+ path,
85
+ message: `Required action "${entry.uses}" only runs under an if: condition (on its step or job), so it may be skipped`,
86
+ severity: 'warning'
87
+ });
88
+ }
89
+ if (candidates.length === 0) {
90
+ errors.push({
91
+ path,
92
+ message: `Required action "${entry.uses}" was not found in the pipeline`,
93
+ severity: 'error'
94
+ });
95
+ return;
96
+ }
97
+ if (!entry['min-version']) {
98
+ matchedPositions.push({ entry, usage: candidates[0], index: idx });
99
+ return;
100
+ }
101
+ const satisfying = candidates.find((c) => c.version && isVersionAtLeast(c.version, entry['min-version']));
102
+ if (satisfying) {
103
+ matchedPositions.push({ entry, usage: satisfying, index: idx });
104
+ return;
105
+ }
106
+ // Present but too old (or not comparable): still check its ordering,
107
+ // using its first occurrence.
108
+ matchedPositions.push({ entry, usage: candidates[0], index: idx });
109
+ const comparable = candidates.find((c) => c.version &&
110
+ isVersionAtLeast(c.version, entry['min-version']) === false);
111
+ if (comparable) {
112
+ errors.push({
113
+ path,
114
+ message: `Required action "${entry.uses}" is used at version "${comparable.version}", which is below the required minimum "${entry['min-version']}"`,
115
+ severity: 'error'
116
+ });
117
+ }
118
+ else {
119
+ errors.push({
120
+ path,
121
+ message: `Required action "${entry.uses}" was found, but its version could not be compared against the required minimum "${entry['min-version']}"`,
122
+ severity: 'warning'
123
+ });
124
+ }
125
+ });
126
+ /** Whether `a` is guaranteed to have finished before `b` starts. */
127
+ const runsBefore = (a, b) => a.node === b.node
128
+ ? a.index < b.index
129
+ : needsTransitively(nodes, b.node, a.node);
130
+ for (let i = 1; i < matchedPositions.length; i++) {
131
+ const prev = matchedPositions[i - 1];
132
+ const curr = matchedPositions[i];
133
+ const path = `required[${curr.index}]`;
134
+ if (ordering === 'strict') {
135
+ const a = prev.usage;
136
+ const b = curr.usage;
137
+ const adjacent = a.node === b.node
138
+ ? b.index === a.index + 1
139
+ : a.index === (nodes.get(a.node)?.length ?? 0) - 1 &&
140
+ b.index === 0 &&
141
+ (nodes.get(b.node)?.needs.has(a.node) ?? false);
142
+ if (!adjacent) {
143
+ errors.push({
144
+ path,
145
+ message: `Required action "${curr.entry.uses}" must run immediately after "${prev.entry.uses}" (ordering: strict), but other steps appear in between`,
146
+ severity: 'error'
147
+ });
148
+ }
149
+ }
150
+ else if (!runsBefore(prev.usage, curr.usage)) {
151
+ const parallel = !runsBefore(curr.usage, prev.usage);
152
+ errors.push({
153
+ path,
154
+ message: `Required action "${curr.entry.uses}" must run after "${prev.entry.uses}", but it does not` +
155
+ (parallel
156
+ ? ` (jobs "${nodes.get(prev.usage.node)?.label}" and "${nodes.get(curr.usage.node)?.label}" have no needs: relationship, so they may run in parallel)`
157
+ : ''),
158
+ severity: 'error'
159
+ });
160
+ }
161
+ }
162
+ // Supply-chain rules over the same flattened sequence (recursion
163
+ // included), so a wrapper can't hide an unpinned or disallowed action.
164
+ const FULL_SHA = /^[0-9a-f]{40}$/i;
165
+ const isRemote = (u) => !u.name.startsWith('.') && !u.name.startsWith('docker://');
166
+ if (spec.pins?.required) {
167
+ const allow = new Set((spec.pins.allow ?? []).map((a) => normalizeActionName(a)));
168
+ for (const usage of sequence) {
169
+ if (!isRemote(usage))
170
+ continue;
171
+ const owner = usage.name.split('/')[0];
172
+ if (allow.has(usage.name) || allow.has(owner))
173
+ continue;
174
+ if (!usage.version || !FULL_SHA.test(usage.version)) {
175
+ errors.push({
176
+ path: usage.path,
177
+ message: `Action "${usage.name}${usage.version ? '@' + usage.version : ''}" must be pinned to a full commit SHA`,
178
+ severity: 'error'
179
+ });
180
+ }
181
+ }
182
+ }
183
+ if (spec.allowedOwners && spec.allowedOwners.length > 0) {
184
+ const allowed = new Set(spec.allowedOwners.map((o) => o.split('/')[0].toLowerCase()));
185
+ for (const usage of sequence) {
186
+ if (!isRemote(usage))
187
+ continue;
188
+ const owner = usage.name.split('/')[0];
189
+ if (!allowed.has(owner)) {
190
+ errors.push({
191
+ path: usage.path,
192
+ message: `Action owner "${owner}" is not allowed (allowed owners: ${[...allowed].join(', ')})`,
193
+ severity: 'error'
194
+ });
195
+ }
196
+ }
197
+ }
198
+ return {
199
+ valid: errors.every((e) => e.severity !== 'error'),
200
+ errors
201
+ };
202
+ }
203
+ /** Whether job `from` (transitively) needs job `to`. */
204
+ function needsTransitively(nodes, from, to) {
205
+ const seen = new Set();
206
+ const queue = [...(nodes.get(from)?.needs ?? [])];
207
+ while (queue.length > 0) {
208
+ const id = queue.shift();
209
+ if (id === to)
210
+ return true;
211
+ if (seen.has(id))
212
+ continue;
213
+ seen.add(id);
214
+ queue.push(...(nodes.get(id)?.needs ?? []));
215
+ }
216
+ return false;
217
+ }
218
+ async function buildGraph(parsed, recurse) {
219
+ const graph = { sequence: [], nodes: new Map(), errors: [] };
220
+ const root = (0, repository_1.repositoryRoot)(parsed.path);
221
+ const isLocal = (uses) => uses.startsWith('./') || uses.startsWith('../');
222
+ /**
223
+ * The file behind a composite action / reusable workflow reference, or
224
+ * null (with a warning) if it can't be found. Local actions resolve
225
+ * against the workspace, local workflows against their own repository.
226
+ * Returns undefined for references that aren't followed.
227
+ */
228
+ const resolveRef = async (uses, kind, workflowRoot, at) => {
229
+ if (isLocal(uses)) {
230
+ if (recurse === 'none')
231
+ return undefined;
232
+ const target = path.resolve(kind === 'action' ? root : workflowRoot, uses);
233
+ const names = kind === 'action' ? ['action.yml', 'action.yaml'] : [];
234
+ for (const candidate of [
235
+ target,
236
+ ...names.map((n) => path.join(target, n))
237
+ ]) {
238
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
239
+ return candidate;
240
+ }
241
+ }
242
+ graph.errors.push({
243
+ path: at,
244
+ message: kind === 'action'
245
+ ? `Local action "${uses}" could not be resolved, so its steps were not checked`
246
+ : `Local reusable workflow "${uses}" could not be resolved, so its jobs were not checked`,
247
+ severity: 'warning'
248
+ });
249
+ return null;
250
+ }
251
+ if (recurse !== 'all' || uses.startsWith('docker://'))
252
+ return undefined;
253
+ const file = kind === 'action' ? await (0, action_fetcher_1.fetchAction)(uses) : await (0, action_fetcher_1.fetchRepoFile)(uses);
254
+ if (!file) {
255
+ graph.errors.push({
256
+ path: at,
257
+ message: `Remote ${kind} "${uses}" could not be fetched, so its ${kind === 'action' ? 'steps' : 'jobs'} were not checked`,
258
+ severity: 'warning'
259
+ });
260
+ }
261
+ return file;
262
+ };
263
+ /** Parse an action/workflow file, recording an error if it's invalid. */
264
+ const parse = (file, at) => {
265
+ try {
266
+ return (0, workflow_parser_1.parseFile)(file);
267
+ }
268
+ catch (error) {
269
+ graph.errors.push({
270
+ path: at,
271
+ message: `Could not parse ${path.relative(root, file)}: ${error instanceof Error ? error.message : String(error)}`,
272
+ severity: 'error'
273
+ });
274
+ return null;
275
+ }
276
+ };
277
+ /** Check recursion limits; returns false (with an error) to stop. */
278
+ const canEnter = (file, stack, at) => {
279
+ if (stack.includes(file)) {
280
+ graph.errors.push({
281
+ path: at,
282
+ message: `Action/workflow cycle: ${[...stack, file]
283
+ .map((f) => path.relative(root, f))
284
+ .join(' -> ')}`,
285
+ severity: 'error'
286
+ });
287
+ return false;
288
+ }
289
+ if (stack.length >= MAX_DEPTH) {
290
+ graph.errors.push({
291
+ path: at,
292
+ message: `Actions/workflows exceed the maximum nesting depth (${MAX_DEPTH})`,
293
+ severity: 'error'
294
+ });
295
+ return false;
296
+ }
297
+ return true;
298
+ };
299
+ const addUsage = (node, index, at, uses, conditional) => {
300
+ graph.sequence.push({
301
+ node,
302
+ index,
303
+ path: at,
304
+ conditional,
305
+ ...splitActionRef(uses)
306
+ });
307
+ };
308
+ /** Flatten a job's steps (and composite action steps) into `node`. */
309
+ const addSteps = async (steps, node, at, counter, stack, workflowRoot, conditional) => {
310
+ for (const [i, step] of steps.entries()) {
311
+ const stepPath = `${at}steps[${i}]`;
312
+ if (!step.uses) {
313
+ counter.next++;
314
+ continue;
315
+ }
316
+ const stepConditional = conditional || hasCondition(step.if);
317
+ addUsage(node, counter.next++, stepPath, step.uses, stepConditional);
318
+ const file = await resolveRef(step.uses, 'action', workflowRoot, stepPath);
319
+ if (!file || !canEnter(file, stack, stepPath))
320
+ continue;
321
+ const action = parse(file, stepPath);
322
+ if (action?.type === 'action' && action.file.runs.using === 'composite') {
323
+ await addSteps(action.file.runs.steps ?? [], node, `${stepPath} > ${step.uses} > `, counter, [...stack, file], workflowRoot, stepConditional);
324
+ }
325
+ }
326
+ };
327
+ /**
328
+ * Add a workflow's jobs. Every job also runs after `after` (the jobs the
329
+ * reusable-workflow call itself needs, plus the call). Returns the ids
330
+ * of the added jobs.
331
+ */
332
+ const addWorkflow = async (workflow, workflowRoot, prefix, labelPrefix, at, after, stack, conditional) => {
333
+ const added = [];
334
+ // A job id -> the graph nodes standing for it (a reusable-workflow call
335
+ // stands for the call and all of its called jobs).
336
+ const alias = new Map();
337
+ for (const jobId of (0, job_plan_1.topologicalJobOrder)(workflow.jobs)) {
338
+ const job = workflow.jobs[jobId];
339
+ const node = `${prefix}${jobId}`;
340
+ const needs = new Set(after);
341
+ for (const dep of [job.needs ?? []].flat()) {
342
+ for (const id of alias.get(dep) ?? [])
343
+ needs.add(id);
344
+ }
345
+ graph.nodes.set(node, {
346
+ label: `${labelPrefix}${jobId}`,
347
+ needs,
348
+ length: 0
349
+ });
350
+ added.push(node);
351
+ const jobPath = `${at}jobs.${jobId}`;
352
+ const jobConditional = conditional || hasCondition(job.if);
353
+ if (job.uses) {
354
+ // The call itself is a one-step "job" its called jobs run after.
355
+ addUsage(node, 0, jobPath, job.uses, jobConditional);
356
+ graph.nodes.get(node).length = 1;
357
+ const ids = [node];
358
+ const file = await resolveRef(job.uses, 'workflow', workflowRoot, jobPath);
359
+ if (file && canEnter(file, stack, jobPath)) {
360
+ const called = parse(file, jobPath);
361
+ if (called?.type === 'workflow') {
362
+ ids.push(...(await addWorkflow(called.file, (0, repository_1.repositoryRoot)(file), `${node}>`, `${labelPrefix}${jobId} > ${job.uses} > `, `${jobPath} > ${job.uses} > `, [...needs, node], [...stack, file], jobConditional)));
363
+ }
364
+ }
365
+ alias.set(jobId, ids);
366
+ added.push(...ids.slice(1));
367
+ continue;
368
+ }
369
+ alias.set(jobId, [node]);
370
+ const counter = { next: 0 };
371
+ await addSteps(job.steps ?? [], node, `${jobPath}.`, counter, stack, workflowRoot, jobConditional);
372
+ graph.nodes.get(node).length = counter.next;
373
+ }
374
+ return added;
375
+ };
376
+ await addWorkflow(parsed.file, root, '', '', '', [], [path.resolve(parsed.path)], false);
377
+ return graph;
378
+ }
379
+ /**
380
+ * Whether an `if:` can skip the step/job: anything but a missing value,
381
+ * `true`, `success()` (the default) or `always()`.
382
+ */
383
+ function hasCondition(condition) {
384
+ if (condition === undefined || condition === null || condition === true) {
385
+ return false;
386
+ }
387
+ const text = String(condition)
388
+ .trim()
389
+ .replace(/^\$\{\{([\s\S]*)\}\}$/, '$1')
390
+ .trim();
391
+ return !['', 'true', 'success()', 'always()'].includes(text);
392
+ }
393
+ function splitActionRef(uses) {
394
+ const atIndex = uses.lastIndexOf('@');
395
+ if (atIndex === -1 || uses.startsWith('docker://')) {
396
+ return { name: normalizeActionName(uses) };
397
+ }
398
+ return {
399
+ name: normalizeActionName(uses.slice(0, atIndex)),
400
+ version: uses.slice(atIndex + 1)
401
+ };
402
+ }
403
+ /**
404
+ * Lower-case the `owner/repo` part of a marketplace-style reference, which
405
+ * GitHub resolves case-insensitively. A path inside the repo, local paths
406
+ * (`./...`) and `docker://` references (whose tags are case-sensitive) are
407
+ * kept as they are.
408
+ */
409
+ function normalizeActionName(name) {
410
+ if (name.startsWith('.') || name.startsWith('docker://'))
411
+ return name;
412
+ const parts = name.split('/');
413
+ return [
414
+ ...parts.slice(0, 2).map((p) => p.toLowerCase()),
415
+ ...parts.slice(2)
416
+ ].join('/');
417
+ }
418
+ /**
419
+ * Best-effort numeric semver-style comparison (ignoring a leading "v").
420
+ * Returns undefined when either version string isn't numeric dot-notation
421
+ * (e.g. a branch name or commit SHA), since it can't be meaningfully compared.
422
+ */
423
+ function isVersionAtLeast(actual, min) {
424
+ const a = parseVersionParts(actual);
425
+ const m = parseVersionParts(min);
426
+ if (!a || !m)
427
+ return undefined;
428
+ const len = Math.max(a.length, m.length);
429
+ for (let i = 0; i < len; i++) {
430
+ const av = a[i] ?? 0;
431
+ const mv = m[i] ?? 0;
432
+ if (av > mv)
433
+ return true;
434
+ if (av < mv)
435
+ return false;
436
+ }
437
+ return true;
438
+ }
439
+ function parseVersionParts(version) {
440
+ const cleaned = version.trim().replace(/^v/i, '');
441
+ if (!cleaned)
442
+ return null;
443
+ const parts = cleaned.split('.').map(Number);
444
+ if (parts.some((n) => Number.isNaN(n)))
445
+ return null;
446
+ return parts;
447
+ }
448
+ //# sourceMappingURL=pipeline-contract-validator.js.map
@@ -0,0 +1,232 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.workflowExpressionErrors = workflowExpressionErrors;
4
+ exports.actionExpressionErrors = actionExpressionErrors;
5
+ exports.validateWorkflow = validateWorkflow;
6
+ exports.validateAction = validateAction;
7
+ const workflow_parser_1 = require("./workflow-parser");
8
+ const expression_1 = require("../runner/expression");
9
+ /**
10
+ * Invalid `if:` expressions in a workflow: syntax errors, or contexts and
11
+ * functions not available where they're used. GitHub rejects such a
12
+ * workflow before running any job.
13
+ */
14
+ function workflowExpressionErrors(workflow) {
15
+ const errors = [];
16
+ const check = (condition, location, at) => {
17
+ const message = (0, expression_1.validateExpression)(condition, location);
18
+ if (message)
19
+ errors.push({ path: at, message, severity: 'error' });
20
+ };
21
+ for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) {
22
+ check(job.if, 'job-if', `jobs.${jobId}.if`);
23
+ (job.steps ?? []).forEach((step, i) => check(step.if, 'step-if', `jobs.${jobId}.steps[${i}].if`));
24
+ }
25
+ return errors;
26
+ }
27
+ /** Invalid `if:` expressions in a composite action's steps. */
28
+ function actionExpressionErrors(action) {
29
+ const errors = [];
30
+ (action.runs?.steps ?? []).forEach((step, i) => {
31
+ const message = (0, expression_1.validateExpression)(step.if, 'composite-step-if');
32
+ if (message) {
33
+ errors.push({ path: `runs.steps[${i}].if`, message, severity: 'error' });
34
+ }
35
+ });
36
+ return errors;
37
+ }
38
+ /**
39
+ * Validate a workflow file
40
+ */
41
+ function validateWorkflow(filePath) {
42
+ const errors = [];
43
+ try {
44
+ const parsed = (0, workflow_parser_1.parseWorkflow)(filePath);
45
+ const workflow = parsed.file;
46
+ // Validate name
47
+ if (!workflow.name || workflow.name.trim() === '') {
48
+ errors.push({
49
+ path: 'name',
50
+ message: 'Workflow name is required',
51
+ severity: 'error'
52
+ });
53
+ }
54
+ // Validate trigger
55
+ if (!workflow.on) {
56
+ errors.push({
57
+ path: 'on',
58
+ message: 'Workflow trigger (on) is required',
59
+ severity: 'error'
60
+ });
61
+ }
62
+ // Validate jobs
63
+ if (!workflow.jobs || Object.keys(workflow.jobs).length === 0) {
64
+ errors.push({
65
+ path: 'jobs',
66
+ message: 'At least one job is required',
67
+ severity: 'error'
68
+ });
69
+ }
70
+ else {
71
+ // Validate each job
72
+ Object.entries(workflow.jobs).forEach(([jobId, job]) => {
73
+ // Check if it's a reusable workflow call
74
+ if (job.uses) {
75
+ // Reusable workflow - only needs 'uses'
76
+ if (!job.uses.trim()) {
77
+ errors.push({
78
+ path: `jobs.${jobId}.uses`,
79
+ message: 'Reusable workflow path cannot be empty',
80
+ severity: 'error'
81
+ });
82
+ }
83
+ }
84
+ else {
85
+ // Regular job - needs runs-on and steps
86
+ if (!job['runs-on']) {
87
+ errors.push({
88
+ path: `jobs.${jobId}.runs-on`,
89
+ message: 'Job must specify runs-on',
90
+ severity: 'error'
91
+ });
92
+ }
93
+ if (!job.steps || job.steps.length === 0) {
94
+ errors.push({
95
+ path: `jobs.${jobId}.steps`,
96
+ message: 'Job must have at least one step',
97
+ severity: 'error'
98
+ });
99
+ }
100
+ else {
101
+ // Validate each step
102
+ job.steps.forEach((step, idx) => {
103
+ if (!step.uses && !step.run) {
104
+ errors.push({
105
+ path: `jobs.${jobId}.steps[${idx}]`,
106
+ message: 'Step must have either "uses" or "run"',
107
+ severity: 'error'
108
+ });
109
+ }
110
+ if (step.uses && step.run) {
111
+ errors.push({
112
+ path: `jobs.${jobId}.steps[${idx}]`,
113
+ message: 'Step cannot have both "uses" and "run"',
114
+ severity: 'error'
115
+ });
116
+ }
117
+ });
118
+ }
119
+ }
120
+ });
121
+ }
122
+ errors.push(...workflowExpressionErrors(workflow));
123
+ }
124
+ catch (error) {
125
+ errors.push({
126
+ path: 'root',
127
+ message: error instanceof Error ? error.message : 'Unknown error',
128
+ severity: 'error'
129
+ });
130
+ }
131
+ return {
132
+ valid: errors.length === 0,
133
+ errors
134
+ };
135
+ }
136
+ /**
137
+ * Validate an action file
138
+ */
139
+ function validateAction(filePath) {
140
+ const errors = [];
141
+ try {
142
+ const parsed = (0, workflow_parser_1.parseAction)(filePath);
143
+ const action = parsed.file;
144
+ // Validate name
145
+ if (!action.name || action.name.trim() === '') {
146
+ errors.push({
147
+ path: 'name',
148
+ message: 'Action name is required',
149
+ severity: 'error'
150
+ });
151
+ }
152
+ // Validate description
153
+ if (!action.description || action.description.trim() === '') {
154
+ errors.push({
155
+ path: 'description',
156
+ message: 'Action description is required',
157
+ severity: 'error'
158
+ });
159
+ }
160
+ // Validate runs
161
+ if (!action.runs) {
162
+ errors.push({
163
+ path: 'runs',
164
+ message: 'Action must have a runs section',
165
+ severity: 'error'
166
+ });
167
+ }
168
+ else {
169
+ if (!action.runs.using) {
170
+ errors.push({
171
+ path: 'runs.using',
172
+ message: 'Action must specify runs.using',
173
+ severity: 'error'
174
+ });
175
+ }
176
+ // Validate composite actions
177
+ if (action.runs.using === 'composite') {
178
+ if (!action.runs.steps || action.runs.steps.length === 0) {
179
+ errors.push({
180
+ path: 'runs.steps',
181
+ message: 'Composite action must have at least one step',
182
+ severity: 'error'
183
+ });
184
+ }
185
+ else {
186
+ // Validate each step
187
+ action.runs.steps.forEach((step, idx) => {
188
+ if (!step.uses && !step.run) {
189
+ errors.push({
190
+ path: `runs.steps[${idx}]`,
191
+ message: 'Step must have either "uses" or "run"',
192
+ severity: 'error'
193
+ });
194
+ }
195
+ if (step.run && !step.shell) {
196
+ errors.push({
197
+ path: `runs.steps[${idx}]`,
198
+ message: 'Composite action step with "run" must specify shell',
199
+ severity: 'error'
200
+ });
201
+ }
202
+ });
203
+ errors.push(...actionExpressionErrors(action));
204
+ }
205
+ }
206
+ }
207
+ // Validate inputs
208
+ if (action.inputs) {
209
+ Object.entries(action.inputs).forEach(([inputName, input]) => {
210
+ if (!input.description) {
211
+ errors.push({
212
+ path: `inputs.${inputName}.description`,
213
+ message: 'Input description is required',
214
+ severity: 'warning'
215
+ });
216
+ }
217
+ });
218
+ }
219
+ }
220
+ catch (error) {
221
+ errors.push({
222
+ path: 'root',
223
+ message: error instanceof Error ? error.message : 'Unknown error',
224
+ severity: 'error'
225
+ });
226
+ }
227
+ return {
228
+ valid: errors.length === 0 || errors.every((e) => e.severity === 'warning'),
229
+ errors
230
+ };
231
+ }
232
+ //# sourceMappingURL=validator.js.map