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-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.

workflow-matrix.tsBlame245 lines · 1 contributor
abfa9adClaude1/**
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
15export 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
23export 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
30function 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.
57function 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
70function 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
92export 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
169export 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}