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.

WIP: workflow parser and runner agent in progress

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Claude authored signed1 parent 8cbc69ecommit e0ffa64cecd9c291aae2b18523ec63049e5e18b8Browse files
3 files changed+633−0
addedcontainers/runner/src/util.ts+440−0View file
@@ -1,0 +1,440 @@
1+import { spawn, type ChildProcess } from "node:child_process";
2+import { createHash } from "node:crypto";
3+import { existsSync, statSync, accessSync, readdirSync, readFileSync, constants as fsc } from "node:fs";
4+import path from "node:path";
5+
6+export const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
7+
8+export function nowIso(): string {
9+ return new Date().toISOString();
10+}
11+
12+/** Splits a command line into words, honouring single and double quotes. */
13+export function shellWords(s: string): string[] {
14+ const out: string[] = [];
15+ let cur = "";
16+ let has = false;
17+ let q: string | null = null;
18+ for (let i = 0; i < s.length; i++) {
19+ const ch = s[i]!;
20+ if (q) {
21+ if (ch === q) q = null;
22+ else if (ch === "\\" && q === '"' && (s[i + 1] === '"' || s[i + 1] === "\\")) cur += s[++i];
23+ else cur += ch;
24+ continue;
25+ }
26+ if (ch === '"' || ch === "'") {
27+ q = ch;
28+ has = true;
29+ } else if (/\s/.test(ch)) {
30+ if (has || cur) out.push(cur);
31+ cur = "";
32+ has = false;
33+ } else {
34+ cur += ch;
35+ has = true;
36+ }
37+ }
38+ if (has || cur) out.push(cur);
39+ return out;
40+}
41+
42+/** Locates an executable in a PATH string. */
43+export function which(cmd: string, pathEnv: string | undefined = process.env.PATH): string | null {
44+ if (cmd.includes("/")) return existsSync(cmd) ? cmd : null;
45+ for (const dir of (pathEnv ?? "").split(path.delimiter)) {
46+ if (!dir) continue;
47+ const p = path.join(dir, cmd);
48+ try {
49+ accessSync(p, fsc.X_OK);
50+ if (statSync(p).isFile()) return p;
51+ } catch {
52+ /* next */
53+ }
54+ }
55+ return null;
56+}
57+
58+export interface ExecOptions {
59+ cwd?: string;
60+ env?: NodeJS.ProcessEnv;
61+ onLine?: (line: string, stream: "stdout" | "stderr") => void;
62+ input?: string;
63+ timeoutMs?: number;
64+ /** Called with the child so callers can kill it on cancellation. */
65+ onSpawn?: (child: ChildProcess) => void;
66+ /** Collect stdout/stderr (bounded). */
67+ capture?: boolean;
68+ maxCapture?: number;
69+}
70+
71+export interface ExecResult {
72+ exitCode: number;
73+ signal: NodeJS.Signals | null;
74+ stdout: string;
75+ stderr: string;
76+ timedOut: boolean;
77+ stdoutTruncated: boolean;
78+ stderrTruncated: boolean;
79+}
80+
81+/** Kills a detached child's whole process group. */
82+export function killTree(child: ChildProcess, graceMs = 5000): void {
83+ if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
84+ const pid = child.pid;
85+ const k = (sig: NodeJS.Signals) => {
86+ try {
87+ process.kill(-pid, sig);
88+ } catch {
89+ try {
90+ child.kill(sig);
91+ } catch {
92+ /* gone */
93+ }
94+ }
95+ };
96+ k("SIGTERM");
97+ const t = setTimeout(() => k("SIGKILL"), graceMs);
98+ t.unref();
99+ child.once("exit", () => clearTimeout(t));
100+}
101+
102+/** Splits a stream into lines, invoking `cb` per line (partial last line flushed at end). */
103+export class LineSplitter {
104+ private buf = "";
105+ constructor(private cb: (line: string) => void) {}
106+ write(chunk: string): void {
107+ this.buf += chunk;
108+ let i: number;
109+ while ((i = this.buf.indexOf("\n")) >= 0) {
110+ const line = this.buf.slice(0, i).replace(/\r$/, "");
111+ this.buf = this.buf.slice(i + 1);
112+ this.cb(line);
113+ }
114+ }
115+ end(): void {
116+ if (this.buf.length) {
117+ const l = this.buf.replace(/\r$/, "");
118+ this.buf = "";
119+ this.cb(l);
120+ }
121+ }
122+}
123+
124+/** Spawns a process in its own process group and streams its output by line. */
125+export function exec(cmd: string, args: string[], o: ExecOptions = {}): Promise<ExecResult> {
126+ return new Promise((resolve) => {
127+ const max = o.maxCapture ?? 1024 * 1024;
128+ let stdout = "";
129+ let stderr = "";
130+ let stdoutTruncated = false;
131+ let stderrTruncated = false;
132+ let timedOut = false;
133+ let child: ChildProcess;
134+ try {
135+ child = spawn(cmd, args, {
136+ cwd: o.cwd,
137+ env: o.env,
138+ detached: true,
139+ stdio: [o.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
140+ });
141+ } catch (e) {
142+ resolve({ exitCode: 127, signal: null, stdout: "", stderr: String(e), timedOut: false, stdoutTruncated, stderrTruncated });
143+ return;
144+ }
145+ o.onSpawn?.(child);
146+ const so = new LineSplitter((l) => o.onLine?.(l, "stdout"));
147+ const se = new LineSplitter((l) => o.onLine?.(l, "stderr"));
148+ child.stdout!.setEncoding("utf8");
149+ child.stderr!.setEncoding("utf8");
150+ child.stdout!.on("data", (d: string) => {
151+ if (o.capture) {
152+ if (stdout.length < max) stdout += d;
153+ else stdoutTruncated = true;
154+ }
155+ so.write(d);
156+ });
157+ child.stderr!.on("data", (d: string) => {
158+ if (o.capture) {
159+ if (stderr.length < max) stderr += d;
160+ else stderrTruncated = true;
161+ }
162+ se.write(d);
163+ });
164+ if (o.input !== undefined) {
165+ child.stdin!.on("error", () => {});
166+ child.stdin!.end(o.input);
167+ }
168+ let timer: NodeJS.Timeout | undefined;
169+ if (o.timeoutMs && o.timeoutMs > 0) {
170+ timer = setTimeout(() => {
171+ timedOut = true;
172+ killTree(child);
173+ }, o.timeoutMs);
174+ }
175+ let spawnError: Error | null = null;
176+ child.on("error", (e) => {
177+ spawnError = e;
178+ });
179+ child.on("close", (code, signal) => {
180+ if (timer) clearTimeout(timer);
181+ so.end();
182+ se.end();
183+ if (stdout.length > max) {
184+ stdout = stdout.slice(0, max);
185+ stdoutTruncated = true;
186+ }
187+ if (stderr.length > max) {
188+ stderr = stderr.slice(0, max);
189+ stderrTruncated = true;
190+ }
191+ if (spawnError && code === null) {
192+ const msg = (spawnError as NodeJS.ErrnoException).code === "ENOENT" ? `${cmd}: command not found` : String(spawnError);
193+ o.onLine?.(msg, "stderr");
194+ resolve({ exitCode: 127, signal: null, stdout, stderr: stderr + msg, timedOut, stdoutTruncated, stderrTruncated });
195+ return;
196+ }
197+ resolve({ exitCode: code ?? (signal ? 128 : 1), signal, stdout, stderr, timedOut, stdoutTruncated, stderrTruncated });
198+ });
199+ });
200+}
201+
202+// ---------------------------------------------------------------- glob
203+
204+function globToRegex(pattern: string): RegExp {
205+ let re = "";
206+ for (let i = 0; i < pattern.length; i++) {
207+ const c = pattern[i]!;
208+ if (c === "*") {
209+ if (pattern[i + 1] === "*") {
210+ // `**/` matches zero or more directories
211+ if (pattern[i + 2] === "/") {
212+ re += "(?:.*/)?";
213+ i += 2;
214+ } else {
215+ re += ".*";
216+ i++;
217+ }
218+ } else re += "[^/]*";
219+ } else if (c === "?") re += "[^/]";
220+ else if (c === "[") {
221+ const j = pattern.indexOf("]", i);
222+ if (j > i) {
223+ re += pattern.slice(i, j + 1).replace(/^\[!/, "[^");
224+ i = j;
225+ } else re += "\\[";
226+ } else re += c.replace(/[.+^${}()|\\]/g, "\\$&");
227+ }
228+ return new RegExp(`^${re}$`);
229+}
230+
231+function walk(root: string, rel: string, out: string[], skip: (rel: string) => boolean, includeDirs: boolean): void {
232+ let entries;
233+ try {
234+ entries = readdirSync(path.join(root, rel), { withFileTypes: true });
235+ } catch {
236+ return;
237+ }
238+ for (const e of entries) {
239+ const r = rel ? `${rel}/${e.name}` : e.name;
240+ if (skip(r)) continue;
241+ if (e.isDirectory()) {
242+ if (includeDirs) out.push(r + "/");
243+ walk(root, r, out, skip, includeDirs);
244+ } else out.push(r);
245+ }
246+}
247+
248+/**
249+ * Expands glob patterns (relative to `root`, or absolute) into absolute file
250+ * paths. Supports `*`, `**`, `?`, `[...]` and `!negation`. Literal paths
251+ * (no glob chars) are returned as-is when they exist (files or directories).
252+ */
253+export function globPaths(root: string, patterns: string[], opts: { filesOnly?: boolean } = {}): string[] {
254+ const include: Array<{ base: string; re: RegExp | null; literal: string | null }> = [];
255+ const exclude: RegExp[] = [];
256+ const results = new Set<string>();
257+ for (let p of patterns) {
258+ p = p.trim();
259+ if (!p || p.startsWith("#")) continue;
260+ const neg = p.startsWith("!");
261+ if (neg) p = p.slice(1);
262+ if (p.startsWith("~/")) p = path.join(process.env.HOME ?? "/root", p.slice(2));
263+ const abs = path.isAbsolute(p) ? p : path.join(root, p);
264+ if (neg) {
265+ exclude.push(globToRegex(path.normalize(abs).replace(/\/$/, "")));
266+ exclude.push(globToRegex(path.normalize(abs).replace(/\/$/, "") + "/**"));
267+ continue;
268+ }
269+ if (!/[*?[]/.test(abs)) {
270+ include.push({ base: abs, re: null, literal: path.normalize(abs) });
271+ continue;
272+ }
273+ // base dir = longest prefix without glob chars
274+ const parts = path.normalize(abs).split("/");
275+ const baseParts: string[] = [];
276+ for (const part of parts) {
277+ if (/[*?[]/.test(part)) break;
278+ baseParts.push(part);
279+ }
280+ include.push({ base: baseParts.join("/") || "/", re: globToRegex(path.normalize(abs)), literal: null });
281+ }
282+ for (const inc of include) {
283+ if (inc.literal) {
284+ if (existsSync(inc.literal)) {
285+ if (opts.filesOnly && statSync(inc.literal).isDirectory()) {
286+ const files: string[] = [];
287+ walk(inc.literal, "", files, () => false, false);
288+ for (const f of files) results.add(path.join(inc.literal, f));
289+ } else results.add(inc.literal);
290+ }
291+ continue;
292+ }
293+ const files: string[] = [];
294+ walk(inc.base, "", files, (r) => r === ".git" || r.endsWith("/.git"), !opts.filesOnly);
295+ for (const f of files) {
296+ const full = path.join(inc.base, f.replace(/\/$/, ""));
297+ if (inc.re!.test(full)) results.add(full);
298+ }
299+ }
300+ let list = [...results];
301+ if (exclude.length) list = list.filter((f) => !exclude.some((re) => re.test(f)));
302+ // drop entries that are inside another included directory (avoid duplicates in tarballs)
303+ if (!opts.filesOnly) {
304+ const set = new Set(list);
305+ list = list.filter((f) => {
306+ let d = path.dirname(f);
307+ while (d !== "/" && d !== ".") {
308+ if (set.has(d)) return false;
309+ d = path.dirname(d);
310+ }
311+ return true;
312+ });
313+ }
314+ return list.sort();
315+}
316+
317+/** `hashFiles()` as on GitHub: sha256 over the sha256 of each matched file (workspace-relative). */
318+export function hashFiles(workspace: string, patterns: string[]): string {
319+ const files = globPaths(workspace, patterns, { filesOnly: true }).filter((f) => f.startsWith(workspace + "/"));
320+ if (files.length === 0) return "";
321+ const outer = createHash("sha256");
322+ for (const f of files) {
323+ try {
324+ outer.update(createHash("sha256").update(readFileSync(f)).digest());
325+ } catch {
326+ /* unreadable: skip */
327+ }
328+ }
329+ return outer.digest("hex");
330+}
331+
332+// ---------------------------------------------------------------- semver-ish
333+
334+export type Ver = [number, number, number];
335+
336+export function parseVer(v: string): Ver | null {
337+ const m = /^\s*[vV]?(?:go)?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(v);
338+ if (!m) return null;
339+ return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)];
340+}
341+
342+export function cmpVer(a: Ver, b: Ver): number {
343+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
344+}
345+
346+/** Matches a version against a range like `22`, `22.x`, `^20.1`, `>=18 <21`, `20 || 22`, `*`. */
347+export function satisfies(version: string, range: string): boolean {
348+ const v = parseVer(version);
349+ if (!v) return false;
350+ const r = range.trim();
351+ if (r === "" || r === "*" || r === "x" || /^(latest|current|node|stable)$/i.test(r)) return true;
352+ return r.split("||").some((alt) => {
353+ const comps = alt.trim().replace(/([<>=~^]+)\s+/g, "$1").split(/\s+/).filter(Boolean);
354+ // hyphen range "a - b"
355+ const hy = /^(\S+)\s+-\s+(\S+)$/.exec(alt.trim());
356+ if (hy) {
357+ const lo = parseVer(hy[1]!);
358+ const hi = parseVer(hy[2]!);
359+ return !!lo && !!hi && cmpVer(v, lo) >= 0 && cmpVer(v, hi) <= 0;
360+ }
361+ return comps.every((c) => matchComparator(v, c));
362+ });
363+}
364+
365+function matchComparator(v: Ver, c: string): boolean {
366+ const m = /^(>=|<=|>|<|=|\^|~)?\s*[vV]?(.*)$/.exec(c)!;
367+ const op = m[1] ?? "";
368+ const body = m[2]!;
369+ const parts = body.split(".");
370+ const nums: Array<number | null> = [0, 1, 2].map((i) => {
371+ const p = parts[i];
372+ if (p === undefined || p === "x" || p === "X" || p === "*" || p === "") return null;
373+ const n = parseInt(p, 10);
374+ return Number.isNaN(n) ? null : n;
375+ });
376+ if (nums[0] === null) return true;
377+ const full: Ver = [nums[0]!, nums[1] ?? 0, nums[2] ?? 0];
378+ const specified = nums[1] === null ? 1 : nums[2] === null ? 2 : 3;
379+ const cmp = cmpVer(v, full);
380+ switch (op) {
381+ case ">=": return cmp >= 0;
382+ case "<=": return specified === 3 ? cmp <= 0 : cmpVer(v, bump(full, specified)) < 0;
383+ case ">": return specified === 3 ? cmp > 0 : cmpVer(v, bump(full, specified)) >= 0;
384+ case "<": return cmp < 0;
385+ case "^": {
386+ const hi: Ver = full[0] > 0 || specified === 1 ? [full[0] + 1, 0, 0] : full[1] > 0 || specified === 2 ? [0, full[1] + 1, 0] : [0, 0, full[2] + 1];
387+ return cmp >= 0 && cmpVer(v, hi) < 0;
388+ }
389+ case "~": {
390+ const hi: Ver = specified === 1 ? [full[0] + 1, 0, 0] : [full[0], full[1] + 1, 0];
391+ return cmp >= 0 && cmpVer(v, hi) < 0;
392+ }
393+ default:
394+ return cmp >= 0 && cmpVer(v, bump(full, specified)) < 0;
395+ }
396+}
397+
398+function bump(v: Ver, specified: number): Ver {
399+ if (specified === 1) return [v[0] + 1, 0, 0];
400+ if (specified === 2) return [v[0], v[1] + 1, 0];
401+ return [v[0], v[1], v[2] + 1];
402+}
403+
404+/** Picks the highest version from `versions` that satisfies `range`. */
405+export function maxSatisfying(versions: string[], range: string): string | null {
406+ let best: string | null = null;
407+ let bestV: Ver | null = null;
408+ for (const s of versions) {
409+ if (!satisfies(s, range)) continue;
410+ const v = parseVer(s)!;
411+ if (!bestV || cmpVer(v, bestV) > 0) {
412+ best = s;
413+ bestV = v;
414+ }
415+ }
416+ return best;
417+}
418+
419+export function runnerArch(): "X64" | "ARM64" | "ARM" | "X86" {
420+ switch (process.arch) {
421+ case "arm64": return "ARM64";
422+ case "arm": return "ARM";
423+ case "ia32": return "X86";
424+ default: return "X64";
425+ }
426+}
427+
428+/** Splits a multi-line / comma-ish input into entries. */
429+export function inputList(v: string | undefined, splitComma = false): string[] {
430+ if (!v) return [];
431+ return v
432+ .split(splitComma ? /[\n,]/ : /\n/)
433+ .map((s) => s.trim())
434+ .filter(Boolean);
435+}
436+
437+export function truthyInput(v: string | undefined, dflt: boolean): boolean {
438+ if (v === undefined || v === "") return dflt;
439+ return /^(true|yes|1|on)$/i.test(v.trim());
440+}
addedpackages/workflow-yaml/src/graph.ts+73−0View file
@@ -1,0 +1,73 @@
1+import type { Workflow, WorkflowError } from "./model.js";
2+
3+/**
4+ * Groups jobs into topological levels from `needs`: every job in level N only
5+ * needs jobs in levels < N. Jobs keep file order within a level. Unknown
6+ * `needs` and dependency cycles are reported as errors; jobs involved in a
7+ * cycle (or depending on one) are left out of `order`.
8+ */
9+export function jobOrder(wf: Workflow): { order: string[][]; errors: WorkflowError[] } {
10+ const errors: WorkflowError[] = [];
11+ const keys = Object.keys(wf.jobs);
12+ const known = new Set(keys);
13+ const deps = new Map<string, string[]>();
14+ for (const key of keys) {
15+ const needs = wf.jobs[key]!.needs;
16+ for (const n of needs) {
17+ if (!known.has(n)) {
18+ errors.push({ path: `jobs.${key}.needs`, message: `Job '${key}' depends on unknown job '${n}'` });
19+ }
20+ }
21+ deps.set(key, needs.filter((n) => known.has(n)));
22+ }
23+
24+ const order: string[][] = [];
25+ const placed = new Set<string>();
26+ let remaining = keys.slice();
27+ while (remaining.length > 0) {
28+ const level = remaining.filter((k) => deps.get(k)!.every((d) => placed.has(d)));
29+ if (level.length === 0) break;
30+ order.push(level);
31+ for (const k of level) placed.add(k);
32+ remaining = remaining.filter((k) => !placed.has(k));
33+ }
34+
35+ if (remaining.length > 0) {
36+ const cycle = findCycle(remaining, deps);
37+ const where = cycle ? cycle[0]! : remaining[0]!;
38+ errors.push({
39+ path: `jobs.${where}.needs`,
40+ message: cycle
41+ ? `Dependency cycle between jobs: ${cycle.join(" -> ")}`
42+ : `Jobs ${remaining.map((r) => `'${r}'`).join(", ")} can never run because of a dependency cycle`,
43+ });
44+ }
45+ return { order, errors };
46+}
47+
48+function findCycle(nodes: string[], deps: Map<string, string[]>): string[] | undefined {
49+ const state = new Map<string, 1 | 2>();
50+ const stack: string[] = [];
51+ const visit = (n: string): string[] | undefined => {
52+ state.set(n, 1);
53+ stack.push(n);
54+ for (const d of deps.get(n) ?? []) {
55+ const s = state.get(d);
56+ if (s === 1) return [...stack.slice(stack.indexOf(d)), d];
57+ if (s === undefined) {
58+ const found = visit(d);
59+ if (found) return found;
60+ }
61+ }
62+ stack.pop();
63+ state.set(n, 2);
64+ return undefined;
65+ };
66+ for (const n of nodes) {
67+ if (!state.has(n)) {
68+ const found = visit(n);
69+ if (found) return found;
70+ }
71+ }
72+ return undefined;
73+}
addedpackages/workflow-yaml/src/matrix.ts+120−0View file
@@ -1,0 +1,120 @@
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+}