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