CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
workflow-matrix.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.
| abfa9ad | 1 | /** |
| 2 | * workflow-matrix.ts | |
| 3 | * | |
| 4 | * Pure-function matrix expansion for workflow jobs. No DB, no I/O, no deps. | |
| 5 | * | |
| 6 | * Semantics mirror GitHub Actions `strategy.matrix`: | |
| 7 | * - Cartesian product of named axes. | |
| 8 | * - `exclude`: remove any cartesian combo whose keys all match an exclude entry. | |
| 9 | * - `include`: extend an existing combo if it matches on all of include's keys, | |
| 10 | * otherwise append as a standalone combo. | |
| 11 | * | |
| 12 | * Never throws. On bad input returns []. | |
| 13 | */ | |
| 14 | ||
| 15 | export type MatrixSpec = { | |
| 16 | axes: Record<string, unknown[]>; | |
| 17 | include?: Record<string, unknown>[]; | |
| 18 | exclude?: Record<string, unknown>[]; | |
| 19 | failFast?: boolean; | |
| 20 | maxParallel?: number; | |
| 21 | }; | |
| 22 | ||
| 23 | export type MatrixCombo = Record<string, unknown>; | |
| 24 | ||
| 25 | // --------------------------------------------------------------------------- | |
| 26 | // Deep-equality for primitive-holding matrix values. | |
| 27 | // Supports scalars, arrays, and plain objects nested arbitrarily. | |
| 28 | // --------------------------------------------------------------------------- | |
| 29 | ||
| 30 | function deepEqual(a: unknown, b: unknown): boolean { | |
| 31 | if (a === b) return true; | |
| 32 | if (a === null || b === null) return a === b; | |
| 33 | if (typeof a !== typeof b) return false; | |
| 34 | if (typeof a !== "object") return false; | |
| 35 | if (Array.isArray(a) !== Array.isArray(b)) return false; | |
| 36 | if (Array.isArray(a) && Array.isArray(b)) { | |
| 37 | if (a.length !== b.length) return false; | |
| 38 | for (let i = 0; i < a.length; i++) { | |
| 39 | if (!deepEqual(a[i], b[i])) return false; | |
| 40 | } | |
| 41 | return true; | |
| 42 | } | |
| 43 | const ao = a as Record<string, unknown>; | |
| 44 | const bo = b as Record<string, unknown>; | |
| 45 | const ak = Object.keys(ao).sort(); | |
| 46 | const bk = Object.keys(bo).sort(); | |
| 47 | if (ak.length !== bk.length) return false; | |
| 48 | for (let i = 0; i < ak.length; i++) { | |
| 49 | if (ak[i] !== bk[i]) return false; | |
| 50 | if (!deepEqual(ao[ak[i]!], bo[bk[i]!])) return false; | |
| 51 | } | |
| 52 | return true; | |
| 53 | } | |
| 54 | ||
| 55 | // A combo matches a partial entry iff every key in the entry deep-equals | |
| 56 | // the same key in the combo. Keys not in the entry are ignored. | |
| 57 | function matchesPartial(combo: MatrixCombo, partial: Record<string, unknown>): boolean { | |
| 58 | for (const k of Object.keys(partial)) { | |
| 59 | if (!(k in combo)) return false; | |
| 60 | if (!deepEqual(combo[k], partial[k])) return false; | |
| 61 | } | |
| 62 | return true; | |
| 63 | } | |
| 64 | ||
| 65 | // --------------------------------------------------------------------------- | |
| 66 | // Cartesian expansion. | |
| 67 | // Sort axis keys alphabetically so ordering is deterministic across runs. | |
| 68 | // --------------------------------------------------------------------------- | |
| 69 | ||
| 70 | function cartesian(axes: Record<string, unknown[]>): MatrixCombo[] { | |
| 71 | const keys = Object.keys(axes).sort(); | |
| 72 | if (keys.length === 0) return []; | |
| 73 | // If any axis has zero values, the product is empty — matches GitHub Actions. | |
| 74 | for (const k of keys) { | |
| 75 | const v = axes[k]; | |
| 76 | if (!Array.isArray(v) || v.length === 0) return []; | |
| 77 | } | |
| 78 | let combos: MatrixCombo[] = [{}]; | |
| 79 | for (const k of keys) { | |
| 80 | const values = axes[k]!; | |
| 81 | const next: MatrixCombo[] = []; | |
| 82 | for (const combo of combos) { | |
| 83 | for (const val of values) { | |
| 84 | next.push({ ...combo, [k]: val }); | |
| 85 | } | |
| 86 | } | |
| 87 | combos = next; | |
| 88 | } | |
| 89 | return combos; | |
| 90 | } | |
| 91 | ||
| 92 | export function expandMatrix(spec: MatrixSpec): MatrixCombo[] { | |
| 93 | if (!spec || typeof spec !== "object") return []; | |
| 94 | const axes = spec.axes; | |
| 95 | const include = Array.isArray(spec.include) ? spec.include : []; | |
| 96 | const exclude = Array.isArray(spec.exclude) ? spec.exclude : []; | |
| 97 | ||
| 98 | // Validate axes: must be a plain object mapping string -> array. | |
| 99 | let validAxes: Record<string, unknown[]> = {}; | |
| 100 | if (axes && typeof axes === "object" && !Array.isArray(axes)) { | |
| 101 | for (const k of Object.keys(axes)) { | |
| 102 | const v = (axes as Record<string, unknown>)[k]; | |
| 103 | if (!Array.isArray(v)) return []; | |
| 104 | validAxes[k] = v; | |
| 105 | } | |
| 106 | } else if (axes !== undefined && axes !== null) { | |
| 107 | return []; | |
| 108 | } | |
| 109 | ||
| 110 | // 1. Cartesian product (possibly empty if no axes). | |
| 111 | let combos: MatrixCombo[] = cartesian(validAxes); | |
| 112 | ||
| 113 | // 2. Apply exclude. An exclude entry removes any cartesian combo that | |
| 114 | // partially matches all of the exclude's keys. | |
| 115 | if (exclude.length > 0) { | |
| 116 | combos = combos.filter((combo) => { | |
| 117 | for (const ex of exclude) { | |
| 118 | if (ex && typeof ex === "object" && matchesPartial(combo, ex)) return false; | |
| 119 | } | |
| 120 | return true; | |
| 121 | }); | |
| 122 | } | |
| 123 | ||
| 124 | // 3. Apply include. For each include entry: | |
| 125 | // - If it fully matches an existing combo (partial match on include's | |
| 126 | // keys), merge extra keys into that combo. | |
| 127 | // - Otherwise append as a standalone combo. | |
| 128 | // "Matches an existing combo" means the include entry's keys that are | |
| 129 | // also axis keys deep-equal the combo's values for those keys. | |
| 130 | const axisKeySet = new Set(Object.keys(validAxes)); | |
| 131 | for (const inc of include) { | |
| 132 | if (!inc || typeof inc !== "object") continue; | |
| 133 | // Build the matcher: only axis-key fields count for matching. | |
| 134 | const matcher: Record<string, unknown> = {}; | |
| 135 | let hasAxisKeys = false; | |
| 136 | for (const k of Object.keys(inc)) { | |
| 137 | if (axisKeySet.has(k)) { | |
| 138 | matcher[k] = inc[k]; | |
| 139 | hasAxisKeys = true; | |
| 140 | } | |
| 141 | } | |
| 142 | let extended = false; | |
| 143 | if (hasAxisKeys && combos.length > 0) { | |
| 144 | for (const combo of combos) { | |
| 145 | if (matchesPartial(combo, matcher)) { | |
| 146 | // Extend with extra (non-axis or additive) keys that are not already | |
| 147 | // present in the combo. GitHub Actions semantics: include's extra | |
| 148 | // fields are added, but they never overwrite an axis value. | |
| 149 | for (const k of Object.keys(inc)) { | |
| 150 | if (!(k in combo)) combo[k] = inc[k]; | |
| 151 | } | |
| 152 | extended = true; | |
| 153 | } | |
| 154 | } | |
| 155 | } | |
| 156 | if (!extended) { | |
| 157 | // Append as a standalone combo (copy to avoid aliasing caller's object). | |
| 158 | combos.push({ ...inc }); | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | return combos; | |
| 163 | } | |
| 164 | ||
| 165 | // --------------------------------------------------------------------------- | |
| 166 | // validateMatrix — type-guard / sanitiser for untrusted input. | |
| 167 | // --------------------------------------------------------------------------- | |
| 168 | ||
| 169 | export function validateMatrix( | |
| 170 | spec: unknown, | |
| 171 | ): { ok: true; spec: MatrixSpec } | { ok: false; error: string } { | |
| 172 | if (spec === null || spec === undefined) { | |
| 173 | return { ok: false, error: "matrix: spec is null or undefined" }; | |
| 174 | } | |
| 175 | if (typeof spec !== "object" || Array.isArray(spec)) { | |
| 176 | return { ok: false, error: "matrix: spec must be an object" }; | |
| 177 | } | |
| 178 | const s = spec as Record<string, unknown>; | |
| 179 | ||
| 180 | const axes: Record<string, unknown[]> = {}; | |
| 181 | const rawAxes = s.axes; | |
| 182 | if (rawAxes !== undefined && rawAxes !== null) { | |
| 183 | if (typeof rawAxes !== "object" || Array.isArray(rawAxes)) { | |
| 184 | return { ok: false, error: "matrix: axes must be an object" }; | |
| 185 | } | |
| 186 | for (const k of Object.keys(rawAxes as object)) { | |
| 187 | const v = (rawAxes as Record<string, unknown>)[k]; | |
| 188 | if (!Array.isArray(v)) { | |
| 189 | return { ok: false, error: `matrix: axis "${k}" must be an array` }; | |
| 190 | } | |
| 191 | axes[k] = v; | |
| 192 | } | |
| 193 | } | |
| 194 | ||
| 195 | let include: Record<string, unknown>[] | undefined; | |
| 196 | if (s.include !== undefined && s.include !== null) { | |
| 197 | if (!Array.isArray(s.include)) { | |
| 198 | return { ok: false, error: "matrix: include must be an array" }; | |
| 199 | } | |
| 200 | include = []; | |
| 201 | for (let i = 0; i < s.include.length; i++) { | |
| 202 | const e = s.include[i]; | |
| 203 | if (!e || typeof e !== "object" || Array.isArray(e)) { | |
| 204 | return { ok: false, error: `matrix: include[${i}] must be an object` }; | |
| 205 | } | |
| 206 | include.push(e as Record<string, unknown>); | |
| 207 | } | |
| 208 | } | |
| 209 | ||
| 210 | let exclude: Record<string, unknown>[] | undefined; | |
| 211 | if (s.exclude !== undefined && s.exclude !== null) { | |
| 212 | if (!Array.isArray(s.exclude)) { | |
| 213 | return { ok: false, error: "matrix: exclude must be an array" }; | |
| 214 | } | |
| 215 | exclude = []; | |
| 216 | for (let i = 0; i < s.exclude.length; i++) { | |
| 217 | const e = s.exclude[i]; | |
| 218 | if (!e || typeof e !== "object" || Array.isArray(e)) { | |
| 219 | return { ok: false, error: `matrix: exclude[${i}] must be an object` }; | |
| 220 | } | |
| 221 | exclude.push(e as Record<string, unknown>); | |
| 222 | } | |
| 223 | } | |
| 224 | ||
| 225 | let failFast: boolean | undefined; | |
| 226 | if (s.failFast !== undefined) { | |
| 227 | if (typeof s.failFast !== "boolean") { | |
| 228 | return { ok: false, error: "matrix: failFast must be boolean" }; | |
| 229 | } | |
| 230 | failFast = s.failFast; | |
| 231 | } | |
| 232 | ||
| 233 | let maxParallel: number | undefined; | |
| 234 | if (s.maxParallel !== undefined) { | |
| 235 | if (typeof s.maxParallel !== "number" || !Number.isFinite(s.maxParallel) || s.maxParallel < 1) { | |
| 236 | return { ok: false, error: "matrix: maxParallel must be a positive number" }; | |
| 237 | } | |
| 238 | maxParallel = Math.floor(s.maxParallel); | |
| 239 | } | |
| 240 | ||
| 241 | return { | |
| 242 | ok: true, | |
| 243 | spec: { axes, include, exclude, failFast, maxParallel }, | |
| 244 | }; | |
| 245 | } |