| 1 | /** |
| 2 | * `strategy.matrix` expansion with GitHub's include/exclude semantics |
| 3 | * (https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow). |
| 4 | */ |
| 5 | import { evaluateValue, type ExprContext } from "./expr/evaluate.js"; |
| 6 | |
| 7 | export type MatrixCombination = Record<string, unknown>; |
| 8 | |
| 9 | export class MatrixError extends Error { |
| 10 | constructor(message: string) { |
| 11 | super(message); |
| 12 | this.name = "MatrixError"; |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | const isPlainObject = (v: unknown): v is Record<string, unknown> => |
| 17 | typeof v === "object" && v !== null && !Array.isArray(v); |
| 18 | |
| 19 | function scalarEqual(a: unknown, b: unknown): boolean { |
| 20 | if (a === b) return true; |
| 21 | if (a === null || b === null || a === undefined || b === undefined) return false; |
| 22 | if (typeof a === "object" || typeof b === "object") return false; |
| 23 | return String(a) === String(b); |
| 24 | } |
| 25 | |
| 26 | /** Deep equality (scalars compared by string form so `18` equals `'18'`). */ |
| 27 | export function deepEqual(a: unknown, b: unknown): boolean { |
| 28 | if (Array.isArray(a) || Array.isArray(b)) { |
| 29 | if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; |
| 30 | return a.every((v, i) => deepEqual(v, b[i])); |
| 31 | } |
| 32 | if (isPlainObject(a) || isPlainObject(b)) { |
| 33 | if (!isPlainObject(a) || !isPlainObject(b)) return false; |
| 34 | const ka = Object.keys(a); |
| 35 | if (ka.length !== Object.keys(b).length) return false; |
| 36 | return ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k])); |
| 37 | } |
| 38 | return scalarEqual(a, b); |
| 39 | } |
| 40 | |
| 41 | /** `pattern` partially matches `value`: every key of a pattern object matches (recursively). */ |
| 42 | function partialMatch(pattern: unknown, value: unknown): boolean { |
| 43 | if (isPlainObject(pattern) && isPlainObject(value)) { |
| 44 | return Object.keys(pattern).every((k) => Object.prototype.hasOwnProperty.call(value, k) && partialMatch(pattern[k], value[k])); |
| 45 | } |
| 46 | return deepEqual(pattern, value); |
| 47 | } |
| 48 | |
| 49 | function resolve(value: unknown, ctx: ExprContext | undefined): unknown { |
| 50 | return typeof value === "string" && value.includes("${{") ? evaluateValue(value, ctx ?? {}) : value; |
| 51 | } |
| 52 | |
| 53 | function asObjectList(value: unknown, what: string): Array<Record<string, unknown>> { |
| 54 | if (value === undefined || value === null) return []; |
| 55 | if (isPlainObject(value)) return [value]; |
| 56 | if (!Array.isArray(value)) throw new MatrixError(`matrix.${what} must be a list of mappings`); |
| 57 | return value.map((entry, i) => { |
| 58 | if (!isPlainObject(entry)) throw new MatrixError(`matrix.${what}[${i}] must be a mapping`); |
| 59 | return entry; |
| 60 | }); |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * Expands a matrix into its job combinations. |
| 65 | * |
| 66 | * - `undefined`/`null` (no matrix) yields a single empty combination `[{}]`. |
| 67 | * - The matrix (or any of its vectors, `include` or `exclude`) may be a |
| 68 | * `${{ }}` expression string, evaluated with `ctx` (e.g. `fromJSON(needs.x.outputs.m)`). |
| 69 | * - Exclusions are applied first (partial matches exclude). |
| 70 | * - Each `include` entry is merged into every original combination whose |
| 71 | * original matrix values it does not overwrite (values added by earlier |
| 72 | * includes may be overwritten); if it fits none, it becomes a new combination. |
| 73 | */ |
| 74 | export function expandMatrix(matrix: unknown, ctx?: ExprContext): MatrixCombination[] { |
| 75 | if (matrix === undefined || matrix === null) return [{}]; |
| 76 | const resolved = resolve(matrix, ctx); |
| 77 | if (resolved === undefined || resolved === null || resolved === "") return [{}]; |
| 78 | if (!isPlainObject(resolved)) throw new MatrixError("matrix must be a mapping"); |
| 79 | |
| 80 | const dims: Array<[string, unknown[]]> = []; |
| 81 | let include: Array<Record<string, unknown>> = []; |
| 82 | let exclude: Array<Record<string, unknown>> = []; |
| 83 | for (const [key, raw] of Object.entries(resolved)) { |
| 84 | const value = resolve(raw, ctx); |
| 85 | if (key === "include") include = asObjectList(value, "include"); |
| 86 | else if (key === "exclude") exclude = asObjectList(value, "exclude"); |
| 87 | else { |
| 88 | if (!Array.isArray(value)) throw new MatrixError(`matrix.${key} must be a list (got ${value === null ? "null" : typeof value})`); |
| 89 | dims.push([key, value]); |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | let combos: MatrixCombination[] = []; |
| 94 | if (dims.length > 0) { |
| 95 | combos = [{}]; |
| 96 | for (const [key, values] of dims) { |
| 97 | const next: MatrixCombination[] = []; |
| 98 | for (const combo of combos) for (const v of values) next.push({ ...combo, [key]: v }); |
| 99 | combos = next; |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | combos = combos.filter((combo) => !exclude.some((ex) => Object.keys(ex).every((k) => k in combo && partialMatch(ex[k], combo[k])))); |
| 104 | |
| 105 | const originalKeys = new Set(dims.map(([k]) => k)); |
| 106 | const extra: MatrixCombination[] = []; |
| 107 | for (const entry of include) { |
| 108 | let added = false; |
| 109 | for (const combo of combos) { |
| 110 | // Original vector values are never overwritten, so checking `combo` is enough. |
| 111 | const fits = Object.keys(entry).every((k) => !originalKeys.has(k) || deepEqual(combo[k], entry[k])); |
| 112 | if (fits) { |
| 113 | Object.assign(combo, entry); |
| 114 | added = true; |
| 115 | } |
| 116 | } |
| 117 | if (!added) extra.push({ ...entry }); |
| 118 | } |
| 119 | return [...combos, ...extra]; |
| 120 | } |