Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

120 lines · 4.9 KBRaw History
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 */
5import { evaluateValue, type ExprContext } from "./expr/evaluate.js";
6
7export type MatrixCombination = Record<string, unknown>;
8
9export class MatrixError extends Error {
10 constructor(message: string) {
11 super(message);
12 this.name = "MatrixError";
13 }
14}
15
16const isPlainObject = (v: unknown): v is Record<string, unknown> =>
17 typeof v === "object" && v !== null && !Array.isArray(v);
18
19function 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'`). */
27export 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). */
42function 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
49function resolve(value: unknown, ctx: ExprContext | undefined): unknown {
50 return typeof value === "string" && value.includes("${{") ? evaluateValue(value, ctx ?? {}) : value;
51}
52
53function 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 */
74export 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}