Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

workflow-parser.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

workflow-parser.tsBlame598 lines · 1 contributor
eafe8c6Claude1/**
2 * Minimal GitHub-Actions-compatible workflow YAML parser.
3 *
4 * Block C1. Pure function — no DB, no file I/O, no external calls.
5 * Input: YAML text. Output: normalised workflow object, or error.
6 *
7 * Supported subset:
8 * name: <scalar>
9 * on: <scalar> | [list] | { mapping } (mapping is flattened to its top-level keys)
10 * jobs:
11 * <job-key>:
12 * runs-on: <scalar> (default "default")
13 * steps:
14 * - run: <scalar> (auto-name "Run command")
15 * - name: <scalar>
16 * run: <scalar>
17 *
18 * Quirks handled:
19 * - `#` comments (end-of-line and full-line)
20 * - Block literal strings (`|` and `>`) with indentation-stripped bodies
21 * - Inline flow arrays: [a, b, "c, still c"]
22 * - Inline flow mappings: { push: { branches: [main] } }
23 * - Single- and double-quoted scalars
24 * - Extra fields on jobs/steps (env, uses, with, matrix, …) are accepted and ignored
25 * - Job key order is preserved (Map-based accumulation)
26 * - Never throws — bad input returns { ok: false, error }
27 */
28
29export type WorkflowStep = {
30 name: string;
31 run: string;
32};
33
34export type WorkflowJob = {
35 name: string;
36 runsOn: string;
37 steps: WorkflowStep[];
38};
39
40export type ParsedWorkflow = {
41 name: string;
42 on: string[];
43 jobs: WorkflowJob[];
44};
45
46export type ParseResult =
47 | { ok: true; workflow: ParsedWorkflow }
48 | { ok: false; error: string };
49
50// ---------------------------------------------------------------------------
51// Tokeniser: split into logical lines, strip comments, record indent.
52// ---------------------------------------------------------------------------
53
54type Line = {
55 indent: number;
56 text: string; // comment-stripped, right-trimmed
57 raw: string; // original (for block-literal body preservation)
58 lineNo: number; // 1-based
59};
60
61function lex(source: string): Line[] {
62 const out: Line[] = [];
63 const rawLines = source.replace(/\r\n?/g, "\n").split("\n");
64 for (let i = 0; i < rawLines.length; i++) {
65 const raw = rawLines[i] ?? "";
66 // Compute indent (expand tabs as 1 space — we disallow tabs for YAML indent anyway)
67 let indent = 0;
68 while (indent < raw.length && (raw[indent] === " " || raw[indent] === "\t")) {
69 indent++;
70 }
71 const body = raw.slice(indent);
72 // Skip pure blank / pure comment lines
73 if (body.length === 0 || body.startsWith("#")) continue;
74 // Strip trailing comment (respecting quotes)
75 const stripped = stripTrailingComment(body).replace(/\s+$/, "");
76 if (stripped.length === 0) continue;
77 out.push({ indent, text: stripped, raw, lineNo: i + 1 });
78 }
79 return out;
80}
81
82function stripTrailingComment(s: string): string {
83 let inSingle = false;
84 let inDouble = false;
85 for (let i = 0; i < s.length; i++) {
86 const c = s[i];
87 if (c === "\\" && inDouble) {
88 i++;
89 continue;
90 }
91 if (c === "'" && !inDouble) inSingle = !inSingle;
92 else if (c === '"' && !inSingle) inDouble = !inDouble;
93 else if (c === "#" && !inSingle && !inDouble) {
94 // Must be preceded by whitespace (or start of line) to count as a comment
95 if (i === 0 || /\s/.test(s[i - 1]!)) return s.slice(0, i);
96 }
97 }
98 return s;
99}
100
101// ---------------------------------------------------------------------------
102// Scalar + flow-value parsing.
103// ---------------------------------------------------------------------------
104
105function unquote(s: string): string {
106 s = s.trim();
107 if (s.length >= 2) {
108 if (s.startsWith('"') && s.endsWith('"')) {
109 return s
110 .slice(1, -1)
111 .replace(/\\n/g, "\n")
112 .replace(/\\t/g, "\t")
113 .replace(/\\"/g, '"')
114 .replace(/\\\\/g, "\\");
115 }
116 if (s.startsWith("'") && s.endsWith("'")) {
117 return s.slice(1, -1).replace(/''/g, "'");
118 }
119 }
120 return s;
121}
122
123/**
124 * Parse a flow-style value starting at `s[i]`. Returns the parsed JS value
125 * and the index just after it. Supports nested [..] and {..}, quoted strings,
126 * plain scalars, and comma separators.
127 */
128function parseFlow(s: string, i: number): { value: unknown; next: number } {
129 i = skipWs(s, i);
130 if (i >= s.length) return { value: "", next: i };
131 const c = s[i];
132 if (c === "[") return parseFlowSeq(s, i);
133 if (c === "{") return parseFlowMap(s, i);
134 if (c === '"' || c === "'") {
135 const end = findQuoteEnd(s, i);
136 return { value: unquote(s.slice(i, end + 1)), next: end + 1 };
137 }
138 // plain scalar — read until , ] } or end
139 let j = i;
140 while (j < s.length && s[j] !== "," && s[j] !== "]" && s[j] !== "}") j++;
141 return { value: unquote(s.slice(i, j).trim()), next: j };
142}
143
144function parseFlowSeq(s: string, i: number): { value: unknown[]; next: number } {
145 // assumes s[i] === '['
146 const out: unknown[] = [];
147 i++;
148 i = skipWs(s, i);
149 if (s[i] === "]") return { value: out, next: i + 1 };
150 while (i < s.length) {
151 const { value, next } = parseFlow(s, i);
152 out.push(value);
153 i = skipWs(s, next);
154 if (s[i] === ",") {
155 i++;
156 i = skipWs(s, i);
157 continue;
158 }
159 if (s[i] === "]") return { value: out, next: i + 1 };
160 break; // malformed — bail out gracefully
161 }
162 return { value: out, next: i };
163}
164
165function parseFlowMap(
166 s: string,
167 i: number,
168): { value: Record<string, unknown>; next: number } {
169 // assumes s[i] === '{'
170 const out: Record<string, unknown> = {};
171 i++;
172 i = skipWs(s, i);
173 if (s[i] === "}") return { value: out, next: i + 1 };
174 while (i < s.length) {
175 // key
176 let keyEnd = i;
177 if (s[i] === '"' || s[i] === "'") keyEnd = findQuoteEnd(s, i) + 1;
178 else {
179 while (keyEnd < s.length && s[keyEnd] !== ":" && s[keyEnd] !== ",") keyEnd++;
180 }
181 const key = unquote(s.slice(i, keyEnd).trim());
182 i = skipWs(s, keyEnd);
183 if (s[i] === ":") {
184 i++;
185 i = skipWs(s, i);
186 const { value, next } = parseFlow(s, i);
187 out[key] = value;
188 i = skipWs(s, next);
189 } else {
190 // bare key with no value — treat as true (rare in our subset)
191 out[key] = true;
192 }
193 if (s[i] === ",") {
194 i++;
195 i = skipWs(s, i);
196 continue;
197 }
198 if (s[i] === "}") return { value: out, next: i + 1 };
199 break;
200 }
201 return { value: out, next: i };
202}
203
204function findQuoteEnd(s: string, i: number): number {
205 const q = s[i];
206 let j = i + 1;
207 while (j < s.length) {
208 if (q === '"' && s[j] === "\\") {
209 j += 2;
210 continue;
211 }
212 if (s[j] === q) {
213 if (q === "'" && s[j + 1] === "'") {
214 j += 2;
215 continue;
216 }
217 return j;
218 }
219 j++;
220 }
221 return s.length - 1;
222}
223
224function skipWs(s: string, i: number): number {
225 while (i < s.length && (s[i] === " " || s[i] === "\t")) i++;
226 return i;
227}
228
229// ---------------------------------------------------------------------------
230// Block-scalar (| and >) assembly: consumes continuation lines indented deeper
231// than `parentIndent` and joins them per the YAML block-scalar rules.
232// ---------------------------------------------------------------------------
233
234function readBlockScalar(
235 lines: Line[],
236 idx: number,
237 parentIndent: number,
238 style: "literal" | "folded",
239): { text: string; next: number } {
240 const parts: string[] = [];
241 let blockIndent = -1;
242 let i = idx;
243 while (i < lines.length) {
244 const line = lines[i]!;
245 if (line.indent <= parentIndent) break;
246 if (blockIndent < 0) blockIndent = line.indent;
247 // Use the raw line to preserve inner whitespace but strip the common indent.
248 const raw = line.raw;
249 const stripped = raw.slice(Math.min(blockIndent, raw.length));
250 parts.push(stripped);
251 i++;
252 }
253 const text =
254 style === "literal"
255 ? parts.join("\n")
256 : parts
257 .map((p) => p.trim())
258 .filter((p) => p.length > 0)
259 .join(" ");
260 return { text, next: i };
261}
262
263// ---------------------------------------------------------------------------
264// Block-style YAML parser. Recursive-descent over indented Line[] array.
265// Returns a plain JS value (object / array / string).
266// ---------------------------------------------------------------------------
267
268type Cursor = { i: number };
269
270function parseBlock(lines: Line[], cur: Cursor, indent: number): unknown {
271 if (cur.i >= lines.length) return null;
272 const first = lines[cur.i]!;
273 if (first.indent < indent) return null;
274 if (first.text.startsWith("- ") || first.text === "-") {
275 return parseBlockSeq(lines, cur, first.indent);
276 }
277 return parseBlockMap(lines, cur, first.indent);
278}
279
280function parseBlockMap(
281 lines: Line[],
282 cur: Cursor,
283 indent: number,
284): Record<string, unknown> {
285 const out: Record<string, unknown> = {};
286 while (cur.i < lines.length) {
287 const line = lines[cur.i]!;
288 if (line.indent < indent) break;
289 if (line.indent > indent) {
290 // Shouldn't happen in well-formed input — skip defensively.
291 cur.i++;
292 continue;
293 }
294 const { text } = line;
295 // Must be "key: …" at this indent.
296 const colon = findMapColon(text);
297 if (colon < 0) break;
298 const key = unquote(text.slice(0, colon).trim());
299 const rest = text.slice(colon + 1).trim();
300 cur.i++;
301 if (rest.length === 0) {
302 // value is on following deeper-indented lines (or nothing -> null)
303 const child = lines[cur.i];
304 if (!child || child.indent <= indent) {
305 out[key] = null;
306 } else {
307 out[key] = parseBlock(lines, cur, child.indent);
308 }
309 } else if (rest === "|" || rest === "|-" || rest === "|+") {
310 const { text: bs, next } = readBlockScalar(lines, cur.i, indent, "literal");
311 out[key] = rest === "|-" ? bs.replace(/\n+$/, "") : bs;
312 cur.i = next;
313 } else if (rest === ">" || rest === ">-" || rest === ">+") {
314 const { text: bs, next } = readBlockScalar(lines, cur.i, indent, "folded");
315 out[key] = bs;
316 cur.i = next;
317 } else if (rest.startsWith("[") || rest.startsWith("{")) {
318 out[key] = parseFlow(rest, 0).value;
319 } else {
320 out[key] = unquote(rest);
321 }
322 }
323 return out;
324}
325
326function parseBlockSeq(lines: Line[], cur: Cursor, indent: number): unknown[] {
327 const out: unknown[] = [];
328 while (cur.i < lines.length) {
329 const line = lines[cur.i]!;
330 if (line.indent < indent) break;
331 if (line.indent > indent) {
332 cur.i++;
333 continue;
334 }
335 if (!(line.text.startsWith("- ") || line.text === "-")) break;
336 const afterDash = line.text === "-" ? "" : line.text.slice(2);
337 cur.i++;
338
339 if (afterDash.length === 0) {
340 // element is on following deeper-indented lines
341 const child = lines[cur.i];
342 if (!child || child.indent <= indent) {
343 out.push(null);
344 } else {
345 out.push(parseBlock(lines, cur, child.indent));
346 }
347 continue;
348 }
349
350 // Could be "- key: value" (start of an inline map element) or "- scalar"
351 const colon = findMapColon(afterDash);
352 if (colon >= 0) {
353 // Build a virtual map: first pair from `afterDash`, further pairs from
354 // subsequent lines indented at (indent + 2 spaces past the dash).
355 const key = unquote(afterDash.slice(0, colon).trim());
356 const rest = afterDash.slice(colon + 1).trim();
357 const elem: Record<string, unknown> = {};
358 const childIndent = indent + 2;
359 if (rest.length === 0) {
360 const child = lines[cur.i];
361 if (child && child.indent > childIndent) {
362 elem[key] = parseBlock(lines, cur, child.indent);
363 } else {
364 elem[key] = null;
365 }
366 } else if (rest === "|" || rest === "|-" || rest === "|+") {
367 const { text: bs, next } = readBlockScalar(
368 lines,
369 cur.i,
370 childIndent - 1,
371 "literal",
372 );
373 elem[key] = rest === "|-" ? bs.replace(/\n+$/, "") : bs;
374 cur.i = next;
375 } else if (rest === ">" || rest === ">-" || rest === ">+") {
376 const { text: bs, next } = readBlockScalar(
377 lines,
378 cur.i,
379 childIndent - 1,
380 "folded",
381 );
382 elem[key] = bs;
383 cur.i = next;
384 } else if (rest.startsWith("[") || rest.startsWith("{")) {
385 elem[key] = parseFlow(rest, 0).value;
386 } else {
387 elem[key] = unquote(rest);
388 }
389 // Sibling map keys for the same element: same indent as childIndent.
390 while (cur.i < lines.length) {
391 const sib = lines[cur.i]!;
392 if (sib.indent < childIndent) break;
393 if (sib.indent > childIndent) {
394 cur.i++;
395 continue;
396 }
397 if (sib.text.startsWith("- ") || sib.text === "-") break;
398 const sc = findMapColon(sib.text);
399 if (sc < 0) break;
400 const sk = unquote(sib.text.slice(0, sc).trim());
401 const sr = sib.text.slice(sc + 1).trim();
402 cur.i++;
403 if (sr.length === 0) {
404 const child = lines[cur.i];
405 if (child && child.indent > childIndent) {
406 elem[sk] = parseBlock(lines, cur, child.indent);
407 } else {
408 elem[sk] = null;
409 }
410 } else if (sr === "|" || sr === "|-" || sr === "|+") {
411 const { text: bs, next } = readBlockScalar(
412 lines,
413 cur.i,
414 childIndent - 1,
415 "literal",
416 );
417 elem[sk] = sr === "|-" ? bs.replace(/\n+$/, "") : bs;
418 cur.i = next;
419 } else if (sr === ">" || sr === ">-" || sr === ">+") {
420 const { text: bs, next } = readBlockScalar(
421 lines,
422 cur.i,
423 childIndent - 1,
424 "folded",
425 );
426 elem[sk] = bs;
427 cur.i = next;
428 } else if (sr.startsWith("[") || sr.startsWith("{")) {
429 elem[sk] = parseFlow(sr, 0).value;
430 } else {
431 elem[sk] = unquote(sr);
432 }
433 }
434 out.push(elem);
435 } else {
436 // plain scalar element
437 if (afterDash.startsWith("[") || afterDash.startsWith("{")) {
438 out.push(parseFlow(afterDash, 0).value);
439 } else {
440 out.push(unquote(afterDash));
441 }
442 }
443 }
444 return out;
445}
446
447/** Locate the ':' that ends a mapping key, skipping quoted sections. */
448function findMapColon(text: string): number {
449 let inSingle = false;
450 let inDouble = false;
451 for (let i = 0; i < text.length; i++) {
452 const c = text[i];
453 if (c === "\\" && inDouble) {
454 i++;
455 continue;
456 }
457 if (c === "'" && !inDouble) inSingle = !inSingle;
458 else if (c === '"' && !inSingle) inDouble = !inDouble;
459 else if (c === ":" && !inSingle && !inDouble) {
460 // Must be followed by space, EOL, or be at the very end.
461 if (i + 1 >= text.length || text[i + 1] === " " || text[i + 1] === "\t") {
462 return i;
463 }
464 }
465 }
466 return -1;
467}
468
469// ---------------------------------------------------------------------------
470// Normalisation: raw YAML value → ParsedWorkflow
471// ---------------------------------------------------------------------------
472
473function asString(v: unknown): string | null {
474 if (typeof v === "string") return v;
475 if (typeof v === "number" || typeof v === "boolean") return String(v);
476 return null;
477}
478
479function normaliseOn(v: unknown): string[] | null {
480 if (v == null) return null;
481 if (typeof v === "string") {
482 const s = v.trim();
483 return s.length ? [s] : null;
484 }
485 if (Array.isArray(v)) {
486 const out: string[] = [];
487 for (const item of v) {
488 const s = asString(item);
489 if (s && s.trim().length) out.push(s.trim());
490 }
491 return out.length ? out : null;
492 }
493 if (typeof v === "object") {
494 const keys = Object.keys(v as Record<string, unknown>);
495 return keys.length ? keys : null;
496 }
497 return null;
498}
499
500function normaliseStep(
501 raw: unknown,
502 jobName: string,
503): { ok: true; step: WorkflowStep } | { ok: false; error: string } {
504 if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
505 return { ok: false, error: `step in job '${jobName}' must be a mapping` };
506 }
507 const r = raw as Record<string, unknown>;
508 const run = asString(r.run);
509 if (!run || !run.trim().length) {
510 return { ok: false, error: `step in job '${jobName}' missing 'run' command` };
511 }
512 const nameVal = asString(r.name);
513 const name = nameVal && nameVal.trim().length ? nameVal.trim() : "Run command";
514 return { ok: true, step: { name, run } };
515}
516
517function normaliseJob(
518 name: string,
519 raw: unknown,
520): { ok: true; job: WorkflowJob } | { ok: false; error: string } {
521 if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
522 return { ok: false, error: `job '${name}' is not a mapping` };
523 }
524 const r = raw as Record<string, unknown>;
525 const runsOnRaw = asString(r["runs-on"]);
526 const runsOn = runsOnRaw && runsOnRaw.trim().length ? runsOnRaw.trim() : "default";
527 const stepsRaw = r.steps;
528 if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) {
529 return { ok: false, error: `job '${name}' has no steps` };
530 }
531 const steps: WorkflowStep[] = [];
532 for (const s of stepsRaw) {
533 const res = normaliseStep(s, name);
534 if (!res.ok) return res;
535 steps.push(res.step);
536 }
537 return { ok: true, job: { name, runsOn, steps } };
538}
539
540// ---------------------------------------------------------------------------
541// Public entry point.
542// ---------------------------------------------------------------------------
543
544export function parseWorkflow(yaml: string): ParseResult {
545 if (typeof yaml !== "string") {
546 return { ok: false, error: "workflow input must be a string" };
547 }
548 let root: unknown;
549 try {
550 const lines = lex(yaml);
551 if (lines.length === 0) {
552 return { ok: false, error: "workflow is empty" };
553 }
554 const cur: Cursor = { i: 0 };
555 root = parseBlock(lines, cur, lines[0]!.indent);
556 } catch (err) {
557 return {
558 ok: false,
559 error: `failed to parse YAML: ${err instanceof Error ? err.message : String(err)}`,
560 };
561 }
562
563 if (!root || typeof root !== "object" || Array.isArray(root)) {
564 return { ok: false, error: "workflow root must be a mapping" };
565 }
566 const doc = root as Record<string, unknown>;
567
568 const nameRaw = asString(doc.name);
569 const name = nameRaw && nameRaw.trim().length ? nameRaw.trim() : "(unnamed)";
570
571 if (!("on" in doc) || doc.on == null) {
572 return { ok: false, error: "workflow missing 'on' trigger" };
573 }
574 const on = normaliseOn(doc.on);
575 if (!on || on.length === 0) {
576 return { ok: false, error: "workflow missing 'on' trigger" };
577 }
578
579 const jobsRaw = doc.jobs;
580 if (
581 !jobsRaw ||
582 typeof jobsRaw !== "object" ||
583 Array.isArray(jobsRaw) ||
584 Object.keys(jobsRaw as Record<string, unknown>).length === 0
585 ) {
586 return { ok: false, error: "workflow has no jobs" };
587 }
588
589 const jobs: WorkflowJob[] = [];
590 // Object.keys preserves insertion order for string keys in modern engines.
591 for (const key of Object.keys(jobsRaw as Record<string, unknown>)) {
592 const res = normaliseJob(key, (jobsRaw as Record<string, unknown>)[key]);
593 if (!res.ok) return res;
594 jobs.push(res.job);
595 }
596
597 return { ok: true, workflow: { name, on, jobs } };
598}