CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
workflow-parser-ext.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.
| 5ff9cc2 | 1 | /** |
| 2 | * Extended workflow parser — adds matrix / if / needs / uses / with / env / | |
| 3 | * outputs / workflow_dispatch inputs on top of the locked base parser in | |
| 4 | * `workflow-parser.ts`. | |
| 5 | * | |
| 6 | * The base parser is shipped and locked (BUILD_BIBLE §4.3). It silently | |
| 7 | * ignores the extended fields. We never modify it. Instead: | |
| 8 | * 1. Call `parseWorkflow()` for the validated base shape. | |
| 9 | * 2. Walk the same YAML with a line-based indent-aware scanner that | |
| 10 | * specifically looks for the extended keys under known job/step | |
| 11 | * blocks. | |
| 12 | * 3. Merge the extensions onto the base jobs/steps by name. | |
| 13 | * | |
| 14 | * Any extension-field failure is captured as a `warnings[]` entry without | |
| 15 | * failing the whole parse — the base workflow is still usable. | |
| 16 | * | |
| 17 | * Pure: no DB, no I/O, no external calls. Never throws. | |
| 18 | */ | |
| 19 | ||
| 20 | import { parseWorkflow, type ParsedWorkflow } from "./workflow-parser"; | |
| 21 | ||
| 22 | // --------------------------------------------------------------------------- | |
| 23 | // Public types | |
| 24 | // --------------------------------------------------------------------------- | |
| 25 | ||
| 26 | export type MatrixSpec = { | |
| 27 | axes: Record<string, unknown[]>; | |
| 28 | include?: Record<string, unknown>[]; | |
| 29 | exclude?: Record<string, unknown>[]; | |
| 30 | failFast?: boolean; | |
| 31 | maxParallel?: number; | |
| 32 | }; | |
| 33 | ||
| 34 | export type ExtendedStep = { | |
| 35 | name?: string; | |
| 36 | id?: string; | |
| 37 | if?: string; | |
| 38 | run?: string; | |
| 39 | uses?: string; | |
| 40 | with?: Record<string, unknown>; | |
| 41 | env?: Record<string, string>; | |
| 42 | parallel?: boolean; | |
| 43 | continueOnError?: boolean; | |
| 44 | }; | |
| 45 | ||
| 46 | export type ExtendedJob = { | |
| 47 | name: string; | |
| 48 | runsOn: string; | |
| 49 | if?: string; | |
| 50 | needs?: string[]; | |
| 51 | strategy?: { matrix?: MatrixSpec }; | |
| 52 | env?: Record<string, string>; | |
| 53 | outputs?: Record<string, string>; | |
| 54 | steps: ExtendedStep[]; | |
| 55 | }; | |
| 56 | ||
| 57 | export type DispatchInput = { | |
| 58 | type: "string" | "boolean" | "choice" | "number"; | |
| 59 | required?: boolean; | |
| 60 | default?: string | boolean | number; | |
| 61 | options?: string[]; | |
| 62 | description?: string; | |
| 63 | }; | |
| 64 | ||
| 65 | export type ExtendedWorkflow = { | |
| 66 | name: string; | |
| 67 | on: string[]; | |
| 68 | /** Keyed by input name — shape mirrors GitHub Actions' `on.workflow_dispatch.inputs`. */ | |
| 69 | dispatchInputs?: Record<string, DispatchInput>; | |
| 70 | env?: Record<string, string>; | |
| 71 | jobs: ExtendedJob[]; | |
| 72 | warnings: string[]; | |
| 73 | }; | |
| 74 | ||
| 75 | export type ExtendedParseResult = | |
| 76 | | { ok: true; workflow: ExtendedWorkflow } | |
| 77 | | { ok: false; error: string }; | |
| 78 | ||
| 79 | // --------------------------------------------------------------------------- | |
| 80 | // Line-based YAML walker (tolerant — only finds blocks we care about) | |
| 81 | // --------------------------------------------------------------------------- | |
| 82 | ||
| 83 | type Line = { indent: number; text: string; raw: string }; | |
| 84 | ||
| 85 | function lexLines(source: string): Line[] { | |
| 86 | const out: Line[] = []; | |
| 87 | const raw = source.replace(/\r\n?/g, "\n").split("\n"); | |
| 88 | for (const r of raw) { | |
| 89 | let indent = 0; | |
| 90 | while (indent < r.length && (r[indent] === " " || r[indent] === "\t")) indent++; | |
| 91 | const body = r.slice(indent); | |
| 92 | if (body.length === 0 || body.startsWith("#")) continue; | |
| 93 | out.push({ indent, text: body, raw: r }); | |
| 94 | } | |
| 95 | return out; | |
| 96 | } | |
| 97 | ||
| 98 | /** Parse a scalar that may be quoted, true/false, numeric, or plain. */ | |
| 99 | function parseScalar(raw: string): string | number | boolean | null { | |
| 100 | const s = raw.trim(); | |
| 101 | if (s.length === 0) return null; | |
| 102 | if (s === "true") return true; | |
| 103 | if (s === "false") return false; | |
| 104 | if (s === "null" || s === "~") return null; | |
| 105 | if (/^-?\d+$/.test(s)) return parseInt(s, 10); | |
| 106 | if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s); | |
| 107 | if ( | |
| 108 | (s.startsWith('"') && s.endsWith('"')) || | |
| 109 | (s.startsWith("'") && s.endsWith("'")) | |
| 110 | ) { | |
| 111 | return s.slice(1, -1); | |
| 112 | } | |
| 113 | return s; | |
| 114 | } | |
| 115 | ||
| 116 | /** Parse a flow-style array literal like `[a, b, "c"]` or `[16, 18, 20]`. */ | |
| 117 | function parseFlowArray(raw: string): unknown[] { | |
| 118 | const trimmed = raw.trim(); | |
| 119 | if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return []; | |
| 120 | const body = trimmed.slice(1, -1).trim(); | |
| 121 | if (body.length === 0) return []; | |
| 122 | const items: string[] = []; | |
| 123 | let depth = 0; | |
| 124 | let cur = ""; | |
| 125 | let inQuote: string | null = null; | |
| 126 | for (const ch of body) { | |
| 127 | if (inQuote) { | |
| 128 | cur += ch; | |
| 129 | if (ch === inQuote) inQuote = null; | |
| 130 | continue; | |
| 131 | } | |
| 132 | if (ch === '"' || ch === "'") { | |
| 133 | inQuote = ch; | |
| 134 | cur += ch; | |
| 135 | continue; | |
| 136 | } | |
| 137 | if (ch === "[" || ch === "{") depth++; | |
| 138 | else if (ch === "]" || ch === "}") depth--; | |
| 139 | if (ch === "," && depth === 0) { | |
| 140 | items.push(cur); | |
| 141 | cur = ""; | |
| 142 | } else { | |
| 143 | cur += ch; | |
| 144 | } | |
| 145 | } | |
| 146 | if (cur.trim().length > 0) items.push(cur); | |
| 147 | return items.map((s) => parseScalar(s)); | |
| 148 | } | |
| 149 | ||
| 150 | /** Given the full line list + start index pointing at "key:", collect every | |
| 151 | * child line (indent > startIndent). Returns the child sub-array and the | |
| 152 | * index of the line immediately after the block. */ | |
| 153 | function collectBlock( | |
| 154 | lines: Line[], | |
| 155 | startIdx: number | |
| 156 | ): { children: Line[]; nextIdx: number } { | |
| 157 | const startIndent = lines[startIdx].indent; | |
| 158 | const children: Line[] = []; | |
| 159 | let i = startIdx + 1; | |
| 160 | while (i < lines.length && lines[i].indent > startIndent) { | |
| 161 | children.push(lines[i]); | |
| 162 | i++; | |
| 163 | } | |
| 164 | return { children, nextIdx: i }; | |
| 165 | } | |
| 166 | ||
| 167 | /** Split "key: value" at the first unquoted colon. Returns [key, value] or | |
| 168 | * null if the line has no colon (value-only — used for list items). */ | |
| 169 | function splitKV(text: string): [string, string] | null { | |
| 170 | let inQuote: string | null = null; | |
| 171 | for (let i = 0; i < text.length; i++) { | |
| 172 | const ch = text[i]; | |
| 173 | if (inQuote) { | |
| 174 | if (ch === inQuote) inQuote = null; | |
| 175 | continue; | |
| 176 | } | |
| 177 | if (ch === '"' || ch === "'") inQuote = ch; | |
| 178 | else if (ch === ":" && (i + 1 === text.length || /\s|$/.test(text[i + 1] ?? ""))) { | |
| 179 | return [text.slice(0, i).trim(), text.slice(i + 1).trim()]; | |
| 180 | } | |
| 181 | } | |
| 182 | return null; | |
| 183 | } | |
| 184 | ||
| 185 | /** Parse a block-style mapping (each line is `key: value` at the same | |
| 186 | * indent). Returns a flat Record<string, string>. */ | |
| 187 | function parseBlockMap(children: Line[]): Record<string, string> { | |
| 188 | const out: Record<string, string> = {}; | |
| 189 | if (children.length === 0) return out; | |
| 190 | const baseIndent = children[0].indent; | |
| 191 | for (const ln of children) { | |
| 192 | if (ln.indent !== baseIndent) continue; | |
| 193 | const kv = splitKV(ln.text); | |
| 194 | if (!kv) continue; | |
| 195 | const scalar = parseScalar(kv[1]); | |
| 196 | out[kv[0]] = String(scalar ?? ""); | |
| 197 | } | |
| 198 | return out; | |
| 199 | } | |
| 200 | ||
| 201 | /** Parse `needs:` which can be a scalar, flow-array, or block list. */ | |
| 202 | function parseNeeds(valueAfterColon: string, children: Line[]): string[] | undefined { | |
| 203 | const v = valueAfterColon.trim(); | |
| 204 | if (v.length > 0) { | |
| 205 | if (v.startsWith("[")) { | |
| 206 | return parseFlowArray(v).map(String); | |
| 207 | } | |
| 208 | return [String(parseScalar(v))]; | |
| 209 | } | |
| 210 | const items: string[] = []; | |
| 211 | if (children.length === 0) return undefined; | |
| 212 | const baseIndent = children[0].indent; | |
| 213 | for (const ln of children) { | |
| 214 | if (ln.indent !== baseIndent) continue; | |
| 215 | if (ln.text.startsWith("- ")) { | |
| 216 | items.push(String(parseScalar(ln.text.slice(2)))); | |
| 217 | } | |
| 218 | } | |
| 219 | return items.length > 0 ? items : undefined; | |
| 220 | } | |
| 221 | ||
| 222 | /** Parse a matrix block. `axes` are any key/value where value is a flow | |
| 223 | * array or block list. `include`/`exclude` are lists of combo maps. */ | |
| 224 | function parseMatrix(children: Line[], warnings: string[]): MatrixSpec | undefined { | |
| 225 | if (children.length === 0) return undefined; | |
| 226 | const baseIndent = children[0].indent; | |
| 227 | const spec: MatrixSpec = { axes: {} }; | |
| 228 | let i = 0; | |
| 229 | while (i < children.length) { | |
| 230 | const ln = children[i]; | |
| 231 | if (ln.indent !== baseIndent) { | |
| 232 | i++; | |
| 233 | continue; | |
| 234 | } | |
| 235 | const kv = splitKV(ln.text); | |
| 236 | if (!kv) { | |
| 237 | i++; | |
| 238 | continue; | |
| 239 | } | |
| 240 | const [key, val] = kv; | |
| 241 | if (key === "include" || key === "exclude") { | |
| 242 | // Collect child list-of-map entries | |
| 243 | const slice: Record<string, unknown>[] = []; | |
| 244 | let j = i + 1; | |
| 245 | let current: Record<string, unknown> | null = null; | |
| 246 | while (j < children.length && children[j].indent > baseIndent) { | |
| 247 | const c = children[j]; | |
| 248 | if (c.text.startsWith("- ")) { | |
| 249 | if (current) slice.push(current); | |
| 250 | current = {}; | |
| 251 | const inner = c.text.slice(2); | |
| 252 | const innerKv = splitKV(inner); | |
| 253 | if (innerKv) current[innerKv[0]] = parseScalar(innerKv[1]); | |
| 254 | } else if (current) { | |
| 255 | const innerKv = splitKV(c.text); | |
| 256 | if (innerKv) current[innerKv[0]] = parseScalar(innerKv[1]); | |
| 257 | } | |
| 258 | j++; | |
| 259 | } | |
| 260 | if (current) slice.push(current); | |
| 261 | if (key === "include") spec.include = slice; | |
| 262 | else spec.exclude = slice; | |
| 263 | i = j; | |
| 264 | continue; | |
| 265 | } | |
| 266 | if (key === "fail-fast") { | |
| 267 | spec.failFast = val.trim() === "true"; | |
| 268 | i++; | |
| 269 | continue; | |
| 270 | } | |
| 271 | if (key === "max-parallel") { | |
| 272 | const n = parseInt(val.trim(), 10); | |
| 273 | if (Number.isFinite(n)) spec.maxParallel = n; | |
| 274 | i++; | |
| 275 | continue; | |
| 276 | } | |
| 277 | // Treat as axis | |
| 278 | if (val.trim().length > 0 && val.trim().startsWith("[")) { | |
| 279 | spec.axes[key] = parseFlowArray(val); | |
| 280 | i++; | |
| 281 | } else if (val.trim().length === 0) { | |
| 282 | // Block list under this axis | |
| 283 | const items: unknown[] = []; | |
| 284 | let j = i + 1; | |
| 285 | while (j < children.length && children[j].indent > baseIndent) { | |
| 286 | const c = children[j]; | |
| 287 | if (c.text.startsWith("- ")) items.push(parseScalar(c.text.slice(2))); | |
| 288 | j++; | |
| 289 | } | |
| 290 | spec.axes[key] = items; | |
| 291 | i = j; | |
| 292 | } else { | |
| 293 | warnings.push(`matrix axis '${key}' has unsupported shape`); | |
| 294 | i++; | |
| 295 | } | |
| 296 | } | |
| 297 | return spec; | |
| 298 | } | |
| 299 | ||
| 300 | /** Parse `on:` block for workflow_dispatch inputs. */ | |
| 301 | function extractDispatchInputs( | |
| 302 | lines: Line[], | |
| 303 | warnings: string[] | |
| 304 | ): { onArray: string[]; dispatchInputs?: Record<string, DispatchInput> } { | |
| 305 | const onArray: string[] = []; | |
| 306 | let dispatchInputs: Record<string, DispatchInput> | undefined; | |
| 307 | for (let i = 0; i < lines.length; i++) { | |
| 308 | const ln = lines[i]; | |
| 309 | if (ln.indent !== 0) continue; | |
| 310 | const kv = splitKV(ln.text); | |
| 311 | if (!kv || kv[0] !== "on") continue; | |
| 312 | const val = kv[1].trim(); | |
| 313 | // Scalar/flow form — base parser already handles these; just populate | |
| 314 | if (val.length > 0) { | |
| 315 | if (val.startsWith("[")) { | |
| 316 | onArray.push(...parseFlowArray(val).map(String)); | |
| 317 | } else { | |
| 318 | onArray.push(String(parseScalar(val))); | |
| 319 | } | |
| 320 | break; | |
| 321 | } | |
| 322 | // Block form — children can be scalar events or mappings | |
| 323 | const { children } = collectBlock(lines, i); | |
| 324 | if (children.length === 0) break; | |
| 325 | const baseIndent = children[0].indent; | |
| 326 | for (let j = 0; j < children.length; j++) { | |
| 327 | const c = children[j]; | |
| 328 | if (c.indent !== baseIndent) continue; | |
| 329 | if (c.text.startsWith("- ")) { | |
| 330 | onArray.push(String(parseScalar(c.text.slice(2)))); | |
| 331 | continue; | |
| 332 | } | |
| 333 | const eventKv = splitKV(c.text); | |
| 334 | if (!eventKv) continue; | |
| 335 | const event = eventKv[0]; | |
| 336 | onArray.push(event); | |
| 337 | if (event === "workflow_dispatch" && eventKv[1].trim().length === 0) { | |
| 338 | // Look for nested `inputs:` block | |
| 339 | let k = j + 1; | |
| 340 | while (k < children.length && children[k].indent > baseIndent) { | |
| 341 | const inputsLine = children[k]; | |
| 342 | const inputsKv = splitKV(inputsLine.text); | |
| 343 | if (inputsKv && inputsKv[0] === "inputs") { | |
| 344 | dispatchInputs = {}; | |
| 345 | let m = k + 1; | |
| 346 | const inputBaseIndent = inputsLine.indent + 2; | |
| 347 | while (m < children.length && children[m].indent >= inputBaseIndent) { | |
| 348 | const nameLine = children[m]; | |
| 349 | if (nameLine.indent === inputBaseIndent) { | |
| 350 | const nameKv = splitKV(nameLine.text); | |
| 351 | if (nameKv) { | |
| 352 | const inp: DispatchInput = { type: "string" }; | |
| 353 | let n = m + 1; | |
| 354 | while (n < children.length && children[n].indent > nameLine.indent) { | |
| 355 | const fieldKv = splitKV(children[n].text); | |
| 356 | if (fieldKv) { | |
| 357 | const [fk, fv] = fieldKv; | |
| 358 | if (fk === "type") { | |
| 359 | const t = String(parseScalar(fv)); | |
| 360 | if (t === "string" || t === "boolean" || t === "choice" || t === "number") { | |
| 361 | inp.type = t; | |
| 362 | } | |
| 363 | } else if (fk === "required") { | |
| 364 | inp.required = parseScalar(fv) === true; | |
| 365 | } else if (fk === "default") { | |
| 366 | const s = parseScalar(fv); | |
| 367 | if (s !== null) inp.default = s as string | boolean | number; | |
| 368 | } else if (fk === "description") { | |
| 369 | inp.description = String(parseScalar(fv)); | |
| 370 | } else if (fk === "options" && fv.trim().startsWith("[")) { | |
| 371 | inp.options = parseFlowArray(fv).map(String); | |
| 372 | } | |
| 373 | } | |
| 374 | n++; | |
| 375 | } | |
| 376 | dispatchInputs[nameKv[0]] = inp; | |
| 377 | m = n; | |
| 378 | continue; | |
| 379 | } | |
| 380 | } | |
| 381 | m++; | |
| 382 | } | |
| 383 | k = m; | |
| 384 | } else { | |
| 385 | k++; | |
| 386 | } | |
| 387 | } | |
| 388 | } | |
| 389 | } | |
| 390 | break; | |
| 391 | } | |
| 392 | return { onArray, dispatchInputs }; | |
| 393 | } | |
| 394 | ||
| 395 | /** Walk job + step blocks and enrich them. Returns a map of jobName → | |
| 396 | * extension data, which callers merge onto the base workflow. */ | |
| 397 | function extractJobExtensions( | |
| 398 | lines: Line[], | |
| 399 | warnings: string[] | |
| 400 | ): { | |
| 401 | workflowEnv?: Record<string, string>; | |
| 402 | jobExts: Map<string, Partial<ExtendedJob>>; | |
| 403 | stepExts: Map<string, ExtendedStep[]>; // jobName → step[] aligned by order | |
| 404 | } { | |
| 405 | const jobExts = new Map<string, Partial<ExtendedJob>>(); | |
| 406 | const stepExts = new Map<string, ExtendedStep[]>(); | |
| 407 | let workflowEnv: Record<string, string> | undefined; | |
| 408 | ||
| 409 | for (let i = 0; i < lines.length; i++) { | |
| 410 | const ln = lines[i]; | |
| 411 | if (ln.indent !== 0) continue; | |
| 412 | const kv = splitKV(ln.text); | |
| 413 | if (!kv) continue; | |
| 414 | ||
| 415 | if (kv[0] === "env" && kv[1].trim().length === 0) { | |
| 416 | const { children, nextIdx } = collectBlock(lines, i); | |
| 417 | workflowEnv = parseBlockMap(children); | |
| 418 | i = nextIdx - 1; | |
| 419 | continue; | |
| 420 | } | |
| 421 | ||
| 422 | if (kv[0] !== "jobs" || kv[1].trim().length > 0) continue; | |
| 423 | ||
| 424 | // Walk each job block | |
| 425 | const { children: jobChildren } = collectBlock(lines, i); | |
| 426 | if (jobChildren.length === 0) continue; | |
| 427 | const jobIndent = jobChildren[0].indent; | |
| 428 | let j = 0; | |
| 429 | while (j < jobChildren.length) { | |
| 430 | const jline = jobChildren[j]; | |
| 431 | if (jline.indent !== jobIndent) { | |
| 432 | j++; | |
| 433 | continue; | |
| 434 | } | |
| 435 | const jkv = splitKV(jline.text); | |
| 436 | if (!jkv) { | |
| 437 | j++; | |
| 438 | continue; | |
| 439 | } | |
| 440 | const jobName = jkv[0]; | |
| 441 | // Absolute index in lines[] for this job header | |
| 442 | const absIdx = lines.indexOf(jline); | |
| 443 | const { children: jobBody, nextIdx: jobNextIdx } = collectBlock(lines, absIdx); | |
| 444 | const ext: Partial<ExtendedJob> = {}; | |
| 445 | const steps: ExtendedStep[] = []; | |
| 446 | const jobBodyIndent = jobBody.length > 0 ? jobBody[0].indent : 0; | |
| 447 | ||
| 448 | let k = 0; | |
| 449 | while (k < jobBody.length) { | |
| 450 | const bline = jobBody[k]; | |
| 451 | if (bline.indent !== jobBodyIndent) { | |
| 452 | k++; | |
| 453 | continue; | |
| 454 | } | |
| 455 | const bkv = splitKV(bline.text); | |
| 456 | if (!bkv) { | |
| 457 | k++; | |
| 458 | continue; | |
| 459 | } | |
| 460 | const [bkey, bval] = bkv; | |
| 461 | const bodyAbsIdx = lines.indexOf(bline); | |
| 462 | const { children: bodyChildren, nextIdx: bodyNextIdx } = collectBlock( | |
| 463 | lines, | |
| 464 | bodyAbsIdx | |
| 465 | ); | |
| 466 | ||
| 467 | if (bkey === "if") { | |
| 468 | ext.if = bval.trim().length > 0 ? bval.trim() : undefined; | |
| 469 | k++; | |
| 470 | } else if (bkey === "needs") { | |
| 471 | ext.needs = parseNeeds(bval, bodyChildren); | |
| 472 | k = bodyNextIdx - bodyAbsIdx - 1 + k + 1; | |
| 473 | } else if (bkey === "env" && bval.trim().length === 0) { | |
| 474 | ext.env = parseBlockMap(bodyChildren); | |
| 475 | k += bodyChildren.length + 1; | |
| 476 | } else if (bkey === "outputs" && bval.trim().length === 0) { | |
| 477 | ext.outputs = parseBlockMap(bodyChildren); | |
| 478 | k += bodyChildren.length + 1; | |
| 479 | } else if (bkey === "strategy" && bval.trim().length === 0) { | |
| 480 | for (let s = 0; s < bodyChildren.length; s++) { | |
| 481 | const sline = bodyChildren[s]; | |
| 482 | if (sline.indent !== bodyChildren[0].indent) continue; | |
| 483 | const skv = splitKV(sline.text); | |
| 484 | if (skv && skv[0] === "matrix" && skv[1].trim().length === 0) { | |
| 485 | const sAbsIdx = lines.indexOf(sline); | |
| 486 | const { children: matrixChildren } = collectBlock(lines, sAbsIdx); | |
| 487 | const matrix = parseMatrix(matrixChildren, warnings); | |
| 488 | if (matrix) ext.strategy = { matrix }; | |
| 489 | } | |
| 490 | } | |
| 491 | k += bodyChildren.length + 1; | |
| 492 | } else if (bkey === "steps" && bval.trim().length === 0) { | |
| 493 | // Walk each step | |
| 494 | let stepIdx = 0; | |
| 495 | let s = 0; | |
| 496 | while (s < bodyChildren.length) { | |
| 497 | const sline = bodyChildren[s]; | |
| 498 | if (sline.text.startsWith("- ")) { | |
| 499 | const stepBody: Line[] = []; | |
| 500 | const stepIndent = sline.indent; | |
| 501 | const stepFirst: Line = { | |
| 502 | indent: stepIndent + 2, | |
| 503 | text: sline.text.slice(2), | |
| 504 | raw: sline.raw, | |
| 505 | }; | |
| 506 | stepBody.push(stepFirst); | |
| 507 | let t = s + 1; | |
| 508 | while (t < bodyChildren.length && bodyChildren[t].indent > stepIndent) { | |
| 509 | stepBody.push(bodyChildren[t]); | |
| 510 | t++; | |
| 511 | } | |
| 512 | const stepExt: ExtendedStep = {}; | |
| 513 | for (let u = 0; u < stepBody.length; u++) { | |
| 514 | const stepLine = stepBody[u]; | |
| 515 | const sKv = splitKV(stepLine.text); | |
| 516 | if (!sKv) continue; | |
| 517 | const [sk, sv] = sKv; | |
| 518 | if (sk === "id") stepExt.id = String(parseScalar(sv)); | |
| 519 | else if (sk === "name") stepExt.name = String(parseScalar(sv)); | |
| 520 | else if (sk === "if") stepExt.if = sv.trim(); | |
| 521 | else if (sk === "run") stepExt.run = String(parseScalar(sv)); | |
| 522 | else if (sk === "uses") stepExt.uses = String(parseScalar(sv)); | |
| 523 | else if (sk === "parallel") stepExt.parallel = parseScalar(sv) === true; | |
| 524 | else if (sk === "continue-on-error") | |
| 525 | stepExt.continueOnError = parseScalar(sv) === true; | |
| 526 | else if (sk === "with" && sv.trim().length === 0) { | |
| 527 | // Sub-block at deeper indent | |
| 528 | const withBody: Line[] = []; | |
| 529 | let v = u + 1; | |
| 530 | while (v < stepBody.length && stepBody[v].indent > stepLine.indent) { | |
| 531 | withBody.push(stepBody[v]); | |
| 532 | v++; | |
| 533 | } | |
| 534 | stepExt.with = parseBlockMap(withBody); | |
| 535 | u = v - 1; | |
| 536 | } else if (sk === "env" && sv.trim().length === 0) { | |
| 537 | const envBody: Line[] = []; | |
| 538 | let v = u + 1; | |
| 539 | while (v < stepBody.length && stepBody[v].indent > stepLine.indent) { | |
| 540 | envBody.push(stepBody[v]); | |
| 541 | v++; | |
| 542 | } | |
| 543 | stepExt.env = parseBlockMap(envBody); | |
| 544 | u = v - 1; | |
| 545 | } | |
| 546 | } | |
| 547 | steps.push(stepExt); | |
| 548 | stepIdx++; | |
| 549 | s = t; | |
| 550 | } else { | |
| 551 | s++; | |
| 552 | } | |
| 553 | } | |
| 554 | k += bodyChildren.length + 1; | |
| 555 | } else { | |
| 556 | k++; | |
| 557 | } | |
| 558 | } | |
| 559 | ||
| 560 | if (Object.keys(ext).length > 0) jobExts.set(jobName, ext); | |
| 561 | if (steps.length > 0) stepExts.set(jobName, steps); | |
| 562 | j = jobNextIdx - absIdx - 1 + j + 1; | |
| 563 | } | |
| 564 | break; | |
| 565 | } | |
| 566 | ||
| 567 | return { workflowEnv, jobExts, stepExts }; | |
| 568 | } | |
| 569 | ||
| 570 | // --------------------------------------------------------------------------- | |
| 571 | // Public API | |
| 572 | // --------------------------------------------------------------------------- | |
| 573 | ||
| 574 | /** The base parser requires every step to have a `run:` field. For `uses:`-only | |
| 575 | * steps (which are legal in GitHub Actions) we inject a no-op `run` so base | |
| 576 | * parse succeeds; the extended parser then picks up the real `uses:` value. */ | |
| 577 | function preprocessForBaseParser(yaml: string): string { | |
| 578 | const lines = yaml.replace(/\r\n?/g, "\n").split("\n"); | |
| 579 | const out: string[] = []; | |
| 580 | let inStepsBlock = false; | |
| 581 | let stepsIndent = -1; | |
| 582 | for (let i = 0; i < lines.length; i++) { | |
| 583 | const line = lines[i]; | |
| 584 | const trimmed = line.replace(/^\s+/, ""); | |
| 585 | const indent = line.length - trimmed.length; | |
| 586 | ||
| 587 | if (/^steps\s*:\s*$/.test(trimmed)) { | |
| 588 | inStepsBlock = true; | |
| 589 | stepsIndent = indent; | |
| 590 | out.push(line); | |
| 591 | continue; | |
| 592 | } | |
| 593 | if (inStepsBlock && trimmed.length > 0 && indent <= stepsIndent && !trimmed.startsWith("-")) { | |
| 594 | inStepsBlock = false; | |
| 595 | } | |
| 596 | ||
| 597 | out.push(line); | |
| 598 | ||
| 599 | if (inStepsBlock && /^-\s+uses\s*:/.test(trimmed)) { | |
| 600 | // Look ahead to see if this step block already has a run: line | |
| 601 | const stepItemIndent = indent; | |
| 602 | let hasRun = false; | |
| 603 | for (let j = i + 1; j < lines.length; j++) { | |
| 604 | const jline = lines[j]; | |
| 605 | const jtrim = jline.replace(/^\s+/, ""); | |
| 606 | const jindent = jline.length - jtrim.length; | |
| 607 | if (jtrim.length === 0) continue; | |
| 608 | if (jtrim.startsWith("-") && jindent === stepItemIndent) break; | |
| 609 | if (jindent <= stepItemIndent) break; | |
| 610 | if (/^run\s*:/.test(jtrim)) { | |
| 611 | hasRun = true; | |
| 612 | break; | |
| 613 | } | |
| 614 | } | |
| 615 | if (!hasRun) { | |
| 616 | out.push(`${" ".repeat(indent + 2)}run: ':'`); | |
| 617 | } | |
| 618 | } | |
| 619 | } | |
| 620 | return out.join("\n"); | |
| 621 | } | |
| 622 | ||
| 623 | export function parseExtended(yaml: string): ExtendedParseResult { | |
| 624 | const base = parseWorkflow(preprocessForBaseParser(yaml)); | |
| 625 | if (!base.ok) return base; | |
| 626 | ||
| 627 | const warnings: string[] = []; | |
| 628 | let extended: ExtendedWorkflow; | |
| 629 | try { | |
| 630 | const lines = lexLines(yaml); | |
| 631 | const { onArray, dispatchInputs } = extractDispatchInputs(lines, warnings); | |
| 632 | const { workflowEnv, jobExts, stepExts } = extractJobExtensions(lines, warnings); | |
| 633 | ||
| 634 | // Merge onto base — keep base structure but enrich each job with extras | |
| 635 | const mergedOn = onArray.length > 0 ? Array.from(new Set(onArray)) : base.workflow.on; | |
| 636 | ||
| 637 | const jobs: ExtendedJob[] = base.workflow.jobs.map((baseJob) => { | |
| 638 | const ext = jobExts.get(baseJob.name) ?? {}; | |
| 639 | const extendedStepsForJob = stepExts.get(baseJob.name) ?? []; | |
| 640 | const steps: ExtendedStep[] = baseJob.steps.map((bs, idx) => { | |
| 641 | const ext = extendedStepsForJob[idx] ?? {}; | |
| 642 | return { name: bs.name, run: bs.run, ...ext }; | |
| 643 | }); | |
| 644 | return { | |
| 645 | name: baseJob.name, | |
| 646 | runsOn: baseJob.runsOn, | |
| 647 | steps, | |
| 648 | if: ext.if, | |
| 649 | needs: ext.needs, | |
| 650 | strategy: ext.strategy, | |
| 651 | env: ext.env, | |
| 652 | outputs: ext.outputs, | |
| 653 | }; | |
| 654 | }); | |
| 655 | ||
| 656 | extended = { | |
| 657 | name: base.workflow.name, | |
| 658 | on: mergedOn, | |
| 659 | dispatchInputs, | |
| 660 | env: workflowEnv, | |
| 661 | jobs, | |
| 662 | warnings, | |
| 663 | }; | |
| 664 | } catch (err) { | |
| 665 | // Defensive: if enrichment throws for any reason, return the base as | |
| 666 | // an extended workflow with a single warning — never fail the parse. | |
| 667 | warnings.push( | |
| 668 | `extension-pass error: ${err instanceof Error ? err.message : String(err)}` | |
| 669 | ); | |
| 670 | extended = { | |
| 671 | name: base.workflow.name, | |
| 672 | on: base.workflow.on, | |
| 673 | jobs: base.workflow.jobs.map((j) => ({ | |
| 674 | name: j.name, | |
| 675 | runsOn: j.runsOn, | |
| 676 | steps: j.steps.map((s) => ({ name: s.name, run: s.run })), | |
| 677 | })), | |
| 678 | warnings, | |
| 679 | }; | |
| 680 | } | |
| 681 | ||
| 682 | return { ok: true, workflow: extended }; | |
| 683 | } |