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.

Scaffold monorepo and Git core (packfiles, objects, diffs, merges)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Claude authored signed1 parent 3555f7dcommit 62327dc04b0e6679be439b576d901b56e9f5dcf2Browse files
41 files changed+4748−0
Files (41)
added.gitignore+9−0View file
@@ -1,0 +1,9 @@
1+node_modules/
2+dist/
3+.wrangler/
4+.turbo/
5+.dev.vars
6+*.log
7+coverage/
8+.DS_Store
9+wrangler.deploy.jsonc
addedcontainers/runner/package.json+13−0View file
@@ -1,0 +1,13 @@
1+{
2+ "name": "@diffinery/runner",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "build": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/agent.mjs",
8+ "typecheck": "tsc --noEmit",
9+ "test": "vitest run"
10+ },
11+ "dependencies": { "@diffinery/shared": "workspace:*", "@diffinery/workflow-yaml": "workspace:*" },
12+ "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0", "esbuild": "^0.25.0", "@types/node": "^22.0.0" }
13+}
addedcontainers/runner/src/api.ts+222−0View file
@@ -1,0 +1,222 @@
1+import { createReadStream } from "node:fs";
2+import { stat } from "node:fs/promises";
3+import { Readable } from "node:stream";
4+import type { JobCompletion, JobSpec, LogLine, StepReport } from "@diffinery/shared";
5+
6+export class HttpError extends Error {
7+ constructor(
8+ public status: number,
9+ message: string,
10+ public body?: string,
11+ ) {
12+ super(message);
13+ }
14+}
15+
16+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
17+
18+export interface RequestOptions {
19+ method?: string;
20+ /** JSON body, or a factory producing a raw body (re-invoked on retry). */
21+ json?: unknown;
22+ body?: () => Promise<{ body: BodyInit; length?: number; contentType?: string }>;
23+ retries?: number;
24+ /** Return the response for these statuses instead of throwing. */
25+ allow?: number[];
26+ headers?: Record<string, string>;
27+}
28+
29+/**
30+ * Client for `${apiUrl}/runner/jobs/${jobId}` endpoints. When `base` is null
31+ * (offline `--spec` mode) all calls are no-ops.
32+ */
33+export class RunnerApi {
34+ constructor(
35+ public readonly base: string | null,
36+ private readonly token: string,
37+ private readonly opts: { minBackoffMs?: number; maxBackoffMs?: number } = {},
38+ ) {}
39+
40+ get online(): boolean {
41+ return this.base !== null;
42+ }
43+
44+ url(path: string): string {
45+ return `${this.base}${path}`;
46+ }
47+
48+ authHeaders(): Record<string, string> {
49+ return { authorization: `Bearer ${this.token}` };
50+ }
51+
52+ async request(path: string, o: RequestOptions = {}): Promise<Response> {
53+ if (!this.base) throw new Error("runner API not configured");
54+ return this.requestUrl(this.url(path), o);
55+ }
56+
57+ async requestUrl(url: string, o: RequestOptions = {}, withAuth = true): Promise<Response> {
58+ const retries = o.retries ?? 6;
59+ const min = this.opts.minBackoffMs ?? 500;
60+ const max = this.opts.maxBackoffMs ?? 15000;
61+ let lastErr: unknown;
62+ for (let attempt = 0; attempt <= retries; attempt++) {
63+ if (attempt > 0) await sleep(Math.min(max, min * 2 ** (attempt - 1)));
64+ try {
65+ const headers: Record<string, string> = { ...(withAuth ? this.authHeaders() : {}), ...(o.headers ?? {}) };
66+ const init: RequestInit & { duplex?: string } = { method: o.method ?? "GET", headers };
67+ if (o.json !== undefined) {
68+ headers["content-type"] = "application/json";
69+ init.body = JSON.stringify(o.json);
70+ } else if (o.body) {
71+ const b = await o.body();
72+ init.body = b.body;
73+ if (b.contentType) headers["content-type"] = b.contentType;
74+ if (b.length !== undefined) headers["content-length"] = String(b.length);
75+ init.duplex = "half";
76+ }
77+ const res = await fetch(url, init);
78+ if (res.ok || o.allow?.includes(res.status)) return res;
79+ const text = await res.text().catch(() => "");
80+ const err = new HttpError(res.status, `${init.method} ${redactUrl(url)} -> ${res.status} ${text.slice(0, 300)}`, text);
81+ if (res.status === 408 || res.status === 429 || res.status >= 500) {
82+ lastErr = err;
83+ continue;
84+ }
85+ throw err;
86+ } catch (e) {
87+ if (e instanceof HttpError && !(e.status === 408 || e.status === 429 || e.status >= 500)) throw e;
88+ lastErr = e;
89+ }
90+ }
91+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
92+ }
93+
94+ async getSpec(): Promise<JobSpec> {
95+ const res = await this.request("", { retries: 8 });
96+ return (await res.json()) as JobSpec;
97+ }
98+
99+ async postLogs(lines: LogLine[]): Promise<void> {
100+ if (!this.base) return;
101+ await this.request("/logs", { method: "POST", json: { lines }, retries: 0 });
102+ }
103+
104+ async postStep(index: number, report: StepReport): Promise<void> {
105+ if (!this.base) return;
106+ await this.request(`/steps/${index}`, { method: "POST", json: report });
107+ }
108+
109+ async complete(c: JobCompletion): Promise<void> {
110+ if (!this.base) return;
111+ await this.request("/complete", { method: "POST", json: c, retries: 10 });
112+ }
113+
114+ /** Looks up a cache entry. Returns null on miss. */
115+ async cacheLookup(key: string, restoreKeys: string[]): Promise<{ key: string; url: string } | null> {
116+ const q = new URLSearchParams({ key });
117+ if (restoreKeys.length) q.set("restore", restoreKeys.join(","));
118+ const res = await this.request(`/cache?${q}`, { allow: [404, 204] });
119+ if (res.status === 404 || res.status === 204) return null;
120+ const j = (await res.json()) as { key?: string; url?: string } | null;
121+ if (!j || !j.key) return null;
122+ return { key: j.key, url: j.url ?? `${this.base}/cache/${encodeURIComponent(j.key)}` };
123+ }
124+
125+ /** Downloads a URL (relative URLs resolve against the API; auth sent only to the API origin). */
126+ async download(url: string): Promise<Response> {
127+ const abs = new URL(url, this.base ?? undefined).toString();
128+ const sameOrigin = this.base ? new URL(abs).origin === new URL(this.base).origin : false;
129+ return this.requestUrl(abs, {}, sameOrigin);
130+ }
131+
132+ async putFile(path: string, file: string, contentType = "application/gzip"): Promise<Response> {
133+ return this.request(path, {
134+ method: "PUT",
135+ body: async () => {
136+ const s = await stat(file);
137+ return { body: Readable.toWeb(createReadStream(file)) as unknown as BodyInit, length: s.size, contentType };
138+ },
139+ allow: [409],
140+ });
141+ }
142+}
143+
144+function redactUrl(u: string): string {
145+ return u.replace(/([?&](?:token|sig|signature)=)[^&]+/gi, "$1***");
146+}
147+
148+/**
149+ * Batches log lines and ships them every `intervalMs` or when `maxLines`
150+ * accumulate. Lines are never dropped: failed batches are retried with
151+ * exponential backoff and kept in order.
152+ */
153+export class LogBatcher {
154+ private queue: LogLine[] = [];
155+ private timer: NodeJS.Timeout | null = null;
156+ private inflight: Promise<void> | null = null;
157+ private failures = 0;
158+ private nextAttemptAt = 0;
159+ private closed = false;
160+
161+ constructor(
162+ private readonly sink: (lines: LogLine[]) => Promise<void>,
163+ private readonly opts: { intervalMs?: number; maxLines?: number; maxBatch?: number; maxBackoffMs?: number; onError?: (e: unknown) => void } = {},
164+ ) {
165+ this.timer = setInterval(() => void this.tick(), this.opts.intervalMs ?? 500);
166+ this.timer.unref?.();
167+ }
168+
169+ push(line: LogLine): void {
170+ this.queue.push(line);
171+ if (this.queue.length >= (this.opts.maxLines ?? 200)) void this.tick();
172+ }
173+
174+ get pending(): number {
175+ return this.queue.length;
176+ }
177+
178+ private tick(): Promise<void> {
179+ if (this.inflight) return this.inflight;
180+ if (this.queue.length === 0) return Promise.resolve();
181+ if (Date.now() < this.nextAttemptAt) return Promise.resolve();
182+ const batch = this.queue.slice(0, this.opts.maxBatch ?? 1000);
183+ this.inflight = this.sink(batch)
184+ .then(() => {
185+ this.queue.splice(0, batch.length);
186+ this.failures = 0;
187+ this.nextAttemptAt = 0;
188+ })
189+ .catch((e) => {
190+ this.failures++;
191+ this.nextAttemptAt = Date.now() + Math.min(this.opts.maxBackoffMs ?? 30000, 250 * 2 ** this.failures);
192+ this.opts.onError?.(e);
193+ })
194+ .finally(() => {
195+ this.inflight = null;
196+ });
197+ return this.inflight;
198+ }
199+
200+ /** Flushes everything, retrying until `deadlineMs` elapses. */
201+ async flush(deadlineMs = 120000): Promise<boolean> {
202+ const until = Date.now() + deadlineMs;
203+ while (this.queue.length > 0 || this.inflight) {
204+ if (Date.now() > until) return false;
205+ if (this.inflight) {
206+ await this.inflight;
207+ continue;
208+ }
209+ const wait = this.nextAttemptAt - Date.now();
210+ if (wait > 0) await sleep(Math.min(wait, Math.max(0, until - Date.now())));
211+ await this.tick();
212+ }
213+ return true;
214+ }
215+
216+ async close(deadlineMs?: number): Promise<boolean> {
217+ if (this.closed) return true;
218+ this.closed = true;
219+ if (this.timer) clearInterval(this.timer);
220+ return this.flush(deadlineMs);
221+ }
222+}
addedcontainers/runner/src/commands.ts+89−0View file
@@ -1,0 +1,89 @@
1+/**
2+ * GitHub Actions workflow commands (`::name k=v::message` on stdout) and file
3+ * commands (`$GITHUB_ENV`, `$GITHUB_OUTPUT`, ... files).
4+ */
5+
6+export interface WorkflowCommand {
7+ command: string;
8+ properties: Record<string, string>;
9+ message: string;
10+}
11+
12+export function unescapeData(s: string): string {
13+ return s.replace(/%0D/gi, "\r").replace(/%0A/gi, "\n").replace(/%25/g, "%");
14+}
15+
16+export function unescapeProperty(s: string): string {
17+ return s
18+ .replace(/%0D/gi, "\r")
19+ .replace(/%0A/gi, "\n")
20+ .replace(/%3A/gi, ":")
21+ .replace(/%2C/gi, ",")
22+ .replace(/%25/g, "%");
23+}
24+
25+/** Parses a `::command props::message` line. Returns null if not a command. */
26+export function parseCommand(line: string): WorkflowCommand | null {
27+ const trimmed = line.replace(/^\s+/, "");
28+ if (!trimmed.startsWith("::")) return null;
29+ const end = trimmed.indexOf("::", 2);
30+ if (end < 0) return null;
31+ const head = trimmed.slice(2, end);
32+ const message = trimmed.slice(end + 2);
33+ const sp = head.indexOf(" ");
34+ const command = (sp < 0 ? head : head.slice(0, sp)).trim();
35+ if (!command || !/^[A-Za-z0-9_.-]+$/.test(command)) return null;
36+ const properties: Record<string, string> = {};
37+ if (sp >= 0) {
38+ for (const part of head.slice(sp + 1).split(",")) {
39+ const eq = part.indexOf("=");
40+ if (eq <= 0) continue;
41+ properties[part.slice(0, eq).trim()] = unescapeProperty(part.slice(eq + 1));
42+ }
43+ }
44+ return { command, properties, message: unescapeData(message) };
45+}
46+
47+/**
48+ * Parses an env-file style file command (GITHUB_ENV / GITHUB_OUTPUT /
49+ * GITHUB_STATE): `name=value` lines and `name<<DELIM` heredocs.
50+ */
51+export function parseKeyValueFile(content: string): Array<[string, string]> {
52+ const out: Array<[string, string]> = [];
53+ if (!content) return out;
54+ const lines = content.split(/\r?\n/);
55+ for (let i = 0; i < lines.length; i++) {
56+ const line = lines[i]!;
57+ if (line === "") continue;
58+ const eq = line.indexOf("=");
59+ const hd = line.indexOf("<<");
60+ if (hd > 0 && (eq < 0 || hd < eq)) {
61+ const name = line.slice(0, hd);
62+ const delim = line.slice(hd + 2);
63+ if (!name || !delim) throw new Error(`Invalid file command format: ${line}`);
64+ const buf: string[] = [];
65+ let found = false;
66+ for (i++; i < lines.length; i++) {
67+ if (lines[i] === delim) {
68+ found = true;
69+ break;
70+ }
71+ buf.push(lines[i]!);
72+ }
73+ if (!found) throw new Error(`Matching delimiter not found '${delim}'`);
74+ out.push([name, buf.join("\n")]);
75+ continue;
76+ }
77+ if (eq <= 0) throw new Error(`Invalid file command format: ${line}`);
78+ out.push([line.slice(0, eq), line.slice(eq + 1)]);
79+ }
80+ return out;
81+}
82+
83+/** GITHUB_PATH: one directory per line. */
84+export function parsePathFile(content: string): string[] {
85+ return content
86+ .split(/\r?\n/)
87+ .map((l) => l.trim())
88+ .filter(Boolean);
89+}
addedcontainers/runner/src/mask.ts+43−0View file
@@ -1,0 +1,43 @@
1+/** Replaces registered secret values with `***` in log output. */
2+export class Masker {
3+ private values = new Set<string>();
4+ private sorted: string[] = [];
5+
6+ add(value: string | undefined | null): void {
7+ if (value === undefined || value === null) return;
8+ const v = String(value);
9+ if (v.trim() === "") return;
10+ const before = this.values.size;
11+ this.values.add(v);
12+ this.values.add(v.trim());
13+ // multi-line secrets: mask each line individually, since logs are line based
14+ if (/\r?\n/.test(v)) {
15+ for (const line of v.split(/\r?\n/)) {
16+ const t = line.trim();
17+ if (t.length >= 2) this.values.add(t);
18+ }
19+ }
20+ if (this.values.size !== before) {
21+ this.sorted = [...this.values].sort((a, b) => b.length - a.length);
22+ }
23+ }
24+
25+ has(value: string): boolean {
26+ return this.values.has(value);
27+ }
28+
29+ /** True when the text contains any masked value. */
30+ containsSecret(text: string): boolean {
31+ for (const v of this.sorted) if (text.includes(v)) return true;
32+ return false;
33+ }
34+
35+ mask(text: string): string {
36+ if (this.sorted.length === 0 || text === "") return text;
37+ let out = text;
38+ for (const v of this.sorted) {
39+ if (out.includes(v)) out = out.split(v).join("***");
40+ }
41+ return out;
42+ }
43+}
addedcontainers/runner/src/yaml.ts+393−0View file
@@ -1,0 +1,393 @@
1+/**
2+ * Minimal YAML parser covering what `action.yml` files use in practice:
3+ * block mappings/sequences, block scalars (| > with chomping), quoted and
4+ * plain scalars (incl. multi-line plain), flow collections and comments.
5+ * Anchors/aliases/tags are not supported (tags and anchors are ignored).
6+ */
7+
8+interface Line {
9+ indent: number;
10+ text: string; // without leading indentation
11+ raw: string;
12+}
13+
14+const KEY_RE = /^(?:"(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s#'"\-?:,\[\]{}][^#]*?|-[^\s][^#]*?|\?[^\s][^#]*?)\s*:(?=\s|$)/;
15+
16+export class YamlError extends Error {}
17+
18+export function parseYaml(src: string): unknown {
19+ const lines: Line[] = src
20+ .replace(/^/, "")
21+ .split(/\r?\n/)
22+ .map((raw) => {
23+ const m = /^( *)/.exec(raw)!;
24+ return { indent: m[1]!.length, text: raw.slice(m[1]!.length), raw };
25+ });
26+ const p = new Parser(lines);
27+ return p.document();
28+}
29+
30+function isBlank(l: Line): boolean {
31+ const t = l.text.trim();
32+ return t === "" || t.startsWith("#");
33+}
34+
35+function isSeqItem(text: string): boolean {
36+ return text === "-" || text.startsWith("- ") || text.startsWith("-\t");
37+}
38+
39+class Parser {
40+ pos = 0;
41+ constructor(private lines: Line[]) {}
42+
43+ document(): unknown {
44+ // skip directives / document start
45+ this.skipBlank();
46+ while (this.pos < this.lines.length && (this.lines[this.pos]!.text.startsWith("%") || this.lines[this.pos]!.text.trim() === "---")) {
47+ this.pos++;
48+ this.skipBlank();
49+ }
50+ if (this.pos >= this.lines.length) return null;
51+ const first = this.lines[this.pos]!;
52+ const v = this.node(first.indent);
53+ return v;
54+ }
55+
56+ skipBlank(): void {
57+ while (this.pos < this.lines.length && isBlank(this.lines[this.pos]!)) this.pos++;
58+ }
59+
60+ peek(): Line | undefined {
61+ this.skipBlank();
62+ const l = this.lines[this.pos];
63+ if (l && (l.text.trim() === "---" || l.text.trim() === "...") && l.indent === 0) return undefined;
64+ return l;
65+ }
66+
67+ node(indent: number): unknown {
68+ const l = this.peek();
69+ if (!l || l.indent < indent) return null;
70+ if (isSeqItem(l.text)) return this.sequence(l.indent);
71+ if (KEY_RE.test(l.text)) return this.mapping(l.indent);
72+ // a bare scalar (possibly multi-line)
73+ this.pos++;
74+ return this.inlineValue(l.text, l.indent - 1);
75+ }
76+
77+ mapping(indent: number): Record<string, unknown> {
78+ const out: Record<string, unknown> = {};
79+ for (;;) {
80+ const l = this.peek();
81+ if (!l || l.indent !== indent) break;
82+ const m = KEY_RE.exec(l.text);
83+ if (!m) break;
84+ const keyRaw = m[0].replace(/\s*:$/, "");
85+ const key = String(scalarFromToken(keyRaw.replace(/^\?\s*/, "")));
86+ const rest = l.text.slice(m[0].length);
87+ this.pos++;
88+ out[key] = this.valueAfterIndicator(rest, indent, true);
89+ }
90+ return out;
91+ }
92+
93+ sequence(indent: number): unknown[] {
94+ const out: unknown[] = [];
95+ for (;;) {
96+ const l = this.peek();
97+ if (!l || l.indent !== indent || !isSeqItem(l.text)) break;
98+ const afterDash = l.text.slice(1);
99+ const spaces = /^[ \t]*/.exec(afterDash)![0].length;
100+ const rest = afterDash.slice(spaces);
101+ if (rest === "" || rest.startsWith("#")) {
102+ this.pos++;
103+ const n = this.peek();
104+ out.push(n && n.indent > indent ? this.node(n.indent) : null);
105+ continue;
106+ }
107+ if (KEY_RE.test(rest) || isSeqItem(rest)) {
108+ // rewrite this line as a nested node at a deeper indent
109+ const newIndent = indent + 1 + spaces;
110+ this.lines[this.pos] = { indent: newIndent, text: rest, raw: l.raw };
111+ out.push(this.node(newIndent));
112+ continue;
113+ }
114+ this.pos++;
115+ out.push(this.valueAfterIndicator(rest, indent, false));
116+ }
117+ return out;
118+ }
119+
120+ /** Parses what follows `key:` or `- `. */
121+ valueAfterIndicator(rest: string, indent: number, inMapping: boolean): unknown {
122+ let r = rest.replace(/^[ \t]+/, "");
123+ // strip tags / anchors
124+ r = r.replace(/^(?:![^\s]*|&[^\s]+)\s*/, "");
125+ if (r === "" || r.startsWith("#")) {
126+ const n = this.peek();
127+ if (!n) return null;
128+ if (n.indent > indent) return this.node(n.indent);
129+ if (inMapping && n.indent === indent && isSeqItem(n.text)) return this.sequence(indent);
130+ return null;
131+ }
132+ if (r[0] === "|" || r[0] === ">") return this.blockScalar(r, indent);
133+ return this.inlineValue(r, indent);
134+ }
135+
136+ blockScalar(header: string, parentIndent: number): string {
137+ const h = /^([|>])([+-]?)(\d?)([+-]?)/.exec(header)!;
138+ const folded = h[1] === ">";
139+ const chomp = h[2] || h[4] || "";
140+ const explicit = h[3] ? Number(h[3]) : 0;
141+ const body: string[] = [];
142+ let contentIndent = explicit ? parentIndent + explicit : -1;
143+ while (this.pos < this.lines.length) {
144+ const l = this.lines[this.pos]!;
145+ if (l.raw.trim() === "") {
146+ body.push("");
147+ this.pos++;
148+ continue;
149+ }
150+ if (contentIndent < 0) {
151+ if (l.indent <= parentIndent) break;
152+ contentIndent = l.indent;
153+ }
154+ if (l.indent < contentIndent) break;
155+ body.push(l.raw.slice(contentIndent));
156+ this.pos++;
157+ }
158+ // trailing blank lines
159+ let trailing = 0;
160+ while (body.length && body[body.length - 1] === "") {
161+ body.pop();
162+ trailing++;
163+ }
164+ let text: string;
165+ if (folded) {
166+ text = "";
167+ for (let i = 0; i < body.length; i++) {
168+ const cur = body[i]!;
169+ if (i === 0) {
170+ text = cur;
171+ continue;
172+ }
173+ const prev = body[i - 1]!;
174+ const moreIndented = /^\s/.test(cur) || /^\s/.test(prev);
175+ if (cur === "") text += "\n";
176+ else if (prev === "" || moreIndented) text += (prev === "" ? "" : "\n") + cur;
177+ else text += " " + cur;
178+ }
179+ } else {
180+ text = body.join("\n");
181+ }
182+ if (body.length === 0) return chomp === "+" ? "\n".repeat(trailing) : "";
183+ if (chomp === "-") return text;
184+ if (chomp === "+") return text + "\n" + "\n".repeat(trailing);
185+ return text + "\n";
186+ }
187+
188+ inlineValue(first: string, indent: number): unknown {
189+ let text = first;
190+ const c = text[0];
191+ if (c === "[" || c === "{") {
192+ // gather until balanced
193+ while (!flowBalanced(text) && this.pos < this.lines.length) {
194+ text += " " + this.lines[this.pos]!.text.trim();
195+ this.pos++;
196+ }
197+ const fp = new FlowParser(text);
198+ return fp.value();
199+ }
200+ if (c === '"' || c === "'") {
201+ // quoted scalar, may span lines
202+ while (!quoteClosed(text) && this.pos < this.lines.length) {
203+ const nxt = this.lines[this.pos]!.text.trim();
204+ text += nxt === "" ? "\n" : " " + nxt;
205+ this.pos++;
206+ }
207+ return parseQuoted(text).value;
208+ }
209+ // plain scalar, may continue on more-indented lines
210+ let value = stripComment(text).trim();
211+ for (;;) {
212+ const l = this.lines[this.pos];
213+ if (!l) break;
214+ if (l.text.trim() === "") {
215+ // a blank line in a multi-line plain scalar becomes a newline, only if followed by continuation
216+ let j = this.pos;
217+ while (j < this.lines.length && this.lines[j]!.text.trim() === "") j++;
218+ const nx = this.lines[j];
219+ if (nx && nx.indent > indent && !nx.text.startsWith("#") && !KEY_RE.test(nx.text)) {
220+ value += "\n".repeat(j - this.pos);
221+ this.pos = j;
222+ continue;
223+ }
224+ break;
225+ }
226+ if (l.indent <= indent || l.text.startsWith("#")) break;
227+ if (KEY_RE.test(l.text) || isSeqItem(l.text)) break;
228+ const add = stripComment(l.text).trim();
229+ value += value.endsWith("\n") ? add : " " + add;
230+ this.pos++;
231+ }
232+ return scalarFromToken(value);
233+ }
234+}
235+
236+function stripComment(s: string): string {
237+ const i = s.search(/(^|\s)#/);
238+ return i < 0 ? s : s.slice(0, i);
239+}
240+
241+function quoteClosed(text: string): boolean {
242+ try {
243+ parseQuoted(text);
244+ return true;
245+ } catch {
246+ return false;
247+ }
248+}
249+
250+function parseQuoted(text: string): { value: string; end: number } {
251+ const q = text[0]!;
252+ let out = "";
253+ let i = 1;
254+ if (q === "'") {
255+ for (; i < text.length; i++) {
256+ const ch = text[i]!;
257+ if (ch === "'") {
258+ if (text[i + 1] === "'") {
259+ out += "'";
260+ i++;
261+ continue;
262+ }
263+ return { value: out, end: i + 1 };
264+ }
265+ out += ch;
266+ }
267+ throw new YamlError("unterminated single-quoted string");
268+ }
269+ for (; i < text.length; i++) {
270+ const ch = text[i]!;
271+ if (ch === "\\") {
272+ const n = text[++i];
273+ switch (n) {
274+ case "n": out += "\n"; break;
275+ case "t": out += "\t"; break;
276+ case "r": out += "\r"; break;
277+ case "0": out += "\0"; break;
278+ case "\\": out += "\\"; break;
279+ case '"': out += '"'; break;
280+ case "/": out += "/"; break;
281+ case " ": out += " "; break;
282+ case "e": out += "\x1b"; break;
283+ case "x": out += String.fromCharCode(parseInt(text.slice(i + 1, i + 3), 16)); i += 2; break;
284+ case "u": out += String.fromCharCode(parseInt(text.slice(i + 1, i + 5), 16)); i += 4; break;
285+ default: out += n ?? "";
286+ }
287+ continue;
288+ }
289+ if (ch === '"') return { value: out, end: i + 1 };
290+ out += ch;
291+ }
292+ throw new YamlError("unterminated double-quoted string");
293+}
294+
295+function flowBalanced(text: string): boolean {
296+ let depth = 0;
297+ let q: string | null = null;
298+ for (let i = 0; i < text.length; i++) {
299+ const ch = text[i]!;
300+ if (q) {
301+ if (ch === "\\" && q === '"') i++;
302+ else if (ch === q) q = null;
303+ continue;
304+ }
305+ if (ch === '"' || ch === "'") q = ch;
306+ else if (ch === "[" || ch === "{") depth++;
307+ else if (ch === "]" || ch === "}") depth--;
308+ else if (ch === "#" && /\s/.test(text[i - 1] ?? " ") && depth > 0) {
309+ // comment inside a flow on this line: ignore rest of the line
310+ const nl = text.indexOf("\n", i);
311+ i = nl < 0 ? text.length : nl;
312+ }
313+ }
314+ return depth <= 0;
315+}
316+
317+class FlowParser {
318+ i = 0;
319+ constructor(private s: string) {}
320+ ws(): void {
321+ while (this.i < this.s.length && /\s/.test(this.s[this.i]!)) this.i++;
322+ }
323+ value(): unknown {
324+ this.ws();
325+ const c = this.s[this.i];
326+ if (c === "[") {
327+ this.i++;
328+ const arr: unknown[] = [];
329+ for (;;) {
330+ this.ws();
331+ if (this.s[this.i] === "]") { this.i++; break; }
332+ arr.push(this.value());
333+ this.ws();
334+ if (this.s[this.i] === ",") this.i++;
335+ else if (this.s[this.i] === "]") { this.i++; break; }
336+ else if (this.i >= this.s.length) break;
337+ }
338+ return arr;
339+ }
340+ if (c === "{") {
341+ this.i++;
342+ const obj: Record<string, unknown> = {};
343+ for (;;) {
344+ this.ws();
345+ if (this.s[this.i] === "}") { this.i++; break; }
346+ const k = this.scalar(true);
347+ this.ws();
348+ let v: unknown = null;
349+ if (this.s[this.i] === ":") {
350+ this.i++;
351+ v = this.value();
352+ }
353+ obj[String(k)] = v;
354+ this.ws();
355+ if (this.s[this.i] === ",") this.i++;
356+ else if (this.s[this.i] === "}") { this.i++; break; }
357+ else if (this.i >= this.s.length) break;
358+ }
359+ return obj;
360+ }
361+ return this.scalar(false);
362+ }
363+ scalar(isKey: boolean): unknown {
364+ this.ws();
365+ const c = this.s[this.i];
366+ if (c === '"' || c === "'") {
367+ const r = parseQuoted(this.s.slice(this.i));
368+ this.i += r.end;
369+ return r.value;
370+ }
371+ let j = this.i;
372+ while (j < this.s.length) {
373+ const ch = this.s[j]!;
374+ if (ch === "," || ch === "]" || ch === "}") break;
375+ if (ch === ":" && (isKey || /[\s,\]}]/.test(this.s[j + 1] ?? " "))) break;
376+ j++;
377+ }
378+ const tok = this.s.slice(this.i, j).trim();
379+ this.i = j;
380+ return scalarFromToken(tok);
381+ }
382+}
383+
384+function scalarFromToken(tok: string): unknown {
385+ const t = tok.trim();
386+ if (t.startsWith('"') || t.startsWith("'")) return parseQuoted(t).value;
387+ if (t === "" || t === "~" || t === "null" || t === "Null" || t === "NULL") return null;
388+ if (/^(true|True|TRUE)$/.test(t)) return true;
389+ if (/^(false|False|FALSE)$/.test(t)) return false;
390+ if (/^[-+]?(0|[1-9][0-9]*)$/.test(t)) return Number(t);
391+ if (/^[-+]?(\d+\.\d*|\.\d+|\d+)([eE][-+]?\d+)?$/.test(t)) return Number(t);
392+ return t;
393+}
addedcontainers/runner/tsconfig.json+1−0View file
@@ -1,0 +1,1 @@
1+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"] }, "include": ["src", "test"] }
addedpackage.json+22−0View file
@@ -1,0 +1,22 @@
1+{
2+ "name": "diffinery",
3+ "private": true,
4+ "packageManager": "pnpm@10.28.0",
5+ "scripts": {
6+ "build": "turbo run build",
7+ "typecheck": "turbo run typecheck",
8+ "test": "turbo run test",
9+ "dev": "pnpm --filter @diffinery/app dev"
10+ },
11+ "devDependencies": {
12+ "turbo": "^2.11.7",
13+ "typescript": "^5.9.3"
14+ },
15+ "pnpm": {
16+ "onlyBuiltDependencies": [
17+ "esbuild",
18+ "workerd",
19+ "sharp"
20+ ]
21+ }
22+}
addedpackages/git-core/package.json+10−0View file
@@ -1,0 +1,10 @@
1+{
2+ "name": "@diffinery/git-core",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "exports": { ".": "./src/index.ts" },
7+ "scripts": { "typecheck": "tsc --noEmit", "test": "vitest run" },
8+ "dependencies": { "pako": "^2.1.0", "diff": "^8.0.0" },
9+ "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0", "@types/pako": "^2.0.3", "@types/node": "^22.0.0" }
10+}
addedpackages/git-core/src/bytes.ts+79−0View file
@@ -1,0 +1,79 @@
1+const encoder = new TextEncoder();
2+const decoder = new TextDecoder();
3+
4+export const utf8 = (s: string): Uint8Array => encoder.encode(s);
5+export const fromUtf8 = (b: Uint8Array): string => decoder.decode(b);
6+
7+export function concat(parts: readonly Uint8Array[]): Uint8Array {
8+ let len = 0;
9+ for (const p of parts) len += p.length;
10+ const out = new Uint8Array(len);
11+ let off = 0;
12+ for (const p of parts) {
13+ out.set(p, off);
14+ off += p.length;
15+ }
16+ return out;
17+}
18+
19+const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
20+
21+export function toHex(b: Uint8Array): string {
22+ let s = "";
23+ for (let i = 0; i < b.length; i++) s += HEX[b[i]!];
24+ return s;
25+}
26+
27+export function fromHex(h: string): Uint8Array {
28+ const out = new Uint8Array(h.length / 2);
29+ for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
30+ return out;
31+}
32+
33+export function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
34+ if (a.length !== b.length) return false;
35+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
36+ return true;
37+}
38+
39+/** Growable byte buffer used when assembling packs and pkt-line streams. */
40+export class ByteWriter {
41+ private buf = new Uint8Array(1024);
42+ private len = 0;
43+
44+ private ensure(extra: number) {
45+ if (this.len + extra <= this.buf.length) return;
46+ let cap = this.buf.length * 2;
47+ while (cap < this.len + extra) cap *= 2;
48+ const next = new Uint8Array(cap);
49+ next.set(this.buf.subarray(0, this.len));
50+ this.buf = next;
51+ }
52+
53+ write(b: Uint8Array): void {
54+ this.ensure(b.length);
55+ this.buf.set(b, this.len);
56+ this.len += b.length;
57+ }
58+
59+ byte(v: number): void {
60+ this.ensure(1);
61+ this.buf[this.len++] = v;
62+ }
63+
64+ uint32(v: number): void {
65+ this.ensure(4);
66+ this.buf[this.len++] = (v >>> 24) & 0xff;
67+ this.buf[this.len++] = (v >>> 16) & 0xff;
68+ this.buf[this.len++] = (v >>> 8) & 0xff;
69+ this.buf[this.len++] = v & 0xff;
70+ }
71+
72+ get length(): number {
73+ return this.len;
74+ }
75+
76+ bytes(): Uint8Array {
77+ return this.buf.slice(0, this.len);
78+ }
79+}
addedpackages/git-core/src/delta.ts+47−0View file
@@ -1,0 +1,47 @@
1+/** Apply a git delta (as found in OFS_DELTA / REF_DELTA pack entries) to a base. */
2+export function applyDelta(base: Uint8Array, delta: Uint8Array): Uint8Array {
3+ let i = 0;
4+ const varint = (): number => {
5+ let result = 0;
6+ let shift = 0;
7+ let c: number;
8+ do {
9+ c = delta[i++]!;
10+ result += (c & 0x7f) * 2 ** shift;
11+ shift += 7;
12+ } while (c & 0x80);
13+ return result;
14+ };
15+ const srcSize = varint();
16+ if (srcSize !== base.length) throw new Error(`delta base size mismatch: ${srcSize} != ${base.length}`);
17+ const dstSize = varint();
18+ const out = new Uint8Array(dstSize);
19+ let o = 0;
20+ while (i < delta.length) {
21+ const op = delta[i++]!;
22+ if (op & 0x80) {
23+ let off = 0;
24+ let size = 0;
25+ if (op & 0x01) off |= delta[i++]!;
26+ if (op & 0x02) off |= delta[i++]! << 8;
27+ if (op & 0x04) off |= delta[i++]! << 16;
28+ if (op & 0x08) off = (off | (delta[i++]! << 24)) >>> 0;
29+ if (op & 0x10) size |= delta[i++]!;
30+ if (op & 0x20) size |= delta[i++]! << 8;
31+ if (op & 0x40) size |= delta[i++]! << 16;
32+ if (size === 0) size = 0x10000;
33+ if (off + size > base.length || o + size > dstSize) throw new Error("delta copy out of range");
34+ out.set(base.subarray(off, off + size), o);
35+ o += size;
36+ } else if (op > 0) {
37+ if (o + op > dstSize || i + op > delta.length) throw new Error("delta insert out of range");
38+ out.set(delta.subarray(i, i + op), o);
39+ i += op;
40+ o += op;
41+ } else {
42+ throw new Error("invalid delta opcode 0");
43+ }
44+ }
45+ if (o !== dstSize) throw new Error(`delta produced ${o} bytes, expected ${dstSize}`);
46+ return out;
47+}
addedpackages/git-core/src/diff.ts+227−0View file
@@ -1,0 +1,227 @@
1+import { diffArrays, structuredPatch } from "diff";
2+import { fromUtf8 } from "./bytes.js";
3+import { isTreeMode, serializeTree, type TreeEntry } from "./objects.js";
4+import { mustRead, readTree, type ObjectReader, type ObjectStore } from "./store.js";
5+
6+export type ChangeStatus = "added" | "modified" | "deleted";
7+
8+export interface TreeChange {
9+ path: string;
10+ status: ChangeStatus;
11+ oldOid?: string;
12+ newOid?: string;
13+ oldMode?: string;
14+ newMode?: string;
15+}
16+
17+/** Recursive diff between two trees (either may be null for an empty tree). */
18+export async function diffTrees(store: ObjectReader, oldTree: string | null, newTree: string | null, prefix = ""): Promise<TreeChange[]> {
19+ if (oldTree === newTree) return [];
20+ const a = new Map((oldTree ? await readTree(store, oldTree) : []).map((e) => [e.name, e]));
21+ const b = new Map((newTree ? await readTree(store, newTree) : []).map((e) => [e.name, e]));
22+ const names = [...new Set([...a.keys(), ...b.keys()])].sort();
23+ const out: TreeChange[] = [];
24+ for (const name of names) {
25+ const ea = a.get(name);
26+ const eb = b.get(name);
27+ const path = prefix ? `${prefix}/${name}` : name;
28+ if (ea && eb && ea.oid === eb.oid && ea.mode === eb.mode) continue;
29+ const aDir = ea && isTreeMode(ea.mode);
30+ const bDir = eb && isTreeMode(eb.mode);
31+ if (aDir || bDir) {
32+ out.push(...(await diffTrees(store, aDir ? ea!.oid : null, bDir ? eb!.oid : null, path)));
33+ if (ea && !aDir) out.push({ path, status: "deleted", oldOid: ea.oid, oldMode: ea.mode });
34+ if (eb && !bDir) out.push({ path, status: "added", newOid: eb.oid, newMode: eb.mode });
35+ continue;
36+ }
37+ if (!ea) out.push({ path, status: "added", newOid: eb!.oid, newMode: eb!.mode });
38+ else if (!eb) out.push({ path, status: "deleted", oldOid: ea.oid, oldMode: ea.mode });
39+ else out.push({ path, status: "modified", oldOid: ea.oid, newOid: eb.oid, oldMode: ea.mode, newMode: eb.mode });
40+ }
41+ return out.sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
42+}
43+
44+export function isBinary(content: Uint8Array): boolean {
45+ const n = Math.min(content.length, 8000);
46+ for (let i = 0; i < n; i++) if (content[i] === 0) return true;
47+ return false;
48+}
49+
50+export interface Hunk {
51+ oldStart: number;
52+ oldLines: number;
53+ newStart: number;
54+ newLines: number;
55+ lines: string[]; // prefixed with ' ', '+', '-'
56+}
57+
58+export interface FileDiff extends TreeChange {
59+ binary: boolean;
60+ additions: number;
61+ deletions: number;
62+ hunks: Hunk[];
63+ /** Set when the diff was too large to render. */
64+ truncated?: boolean;
65+}
66+
67+const MAX_DIFF_BYTES = 1024 * 1024;
68+
69+export async function fileDiff(store: ObjectReader, change: TreeChange, context = 3): Promise<FileDiff> {
70+ const oldC = change.oldOid ? (await mustRead(store, change.oldOid)).content : new Uint8Array(0);
71+ const newC = change.newOid ? (await mustRead(store, change.newOid)).content : new Uint8Array(0);
72+ if (isBinary(oldC) || isBinary(newC)) return { ...change, binary: true, additions: 0, deletions: 0, hunks: [] };
73+ if (oldC.length + newC.length > MAX_DIFF_BYTES) return { ...change, binary: false, additions: 0, deletions: 0, hunks: [], truncated: true };
74+ const patch = structuredPatch(change.path, change.path, fromUtf8(oldC), fromUtf8(newC), "", "", { context });
75+ let additions = 0;
76+ let deletions = 0;
77+ const hunks = patch.hunks.map((h) => {
78+ for (const l of h.lines) {
79+ if (l.startsWith("+")) additions++;
80+ else if (l.startsWith("-")) deletions++;
81+ }
82+ return { oldStart: h.oldStart, oldLines: h.oldLines, newStart: h.newStart, newLines: h.newLines, lines: h.lines };
83+ });
84+ return { ...change, binary: false, additions, deletions, hunks };
85+}
86+
87+export function formatUnified(d: FileDiff): string {
88+ const a = d.status === "added" ? "/dev/null" : `a/${d.path}`;
89+ const b = d.status === "deleted" ? "/dev/null" : `b/${d.path}`;
90+ const head = `diff --git a/${d.path} b/${d.path}\n--- ${a}\n+++ ${b}\n`;
91+ if (d.binary) return `${head}Binary files differ\n`;
92+ return head + d.hunks.map((h) => `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n${h.lines.join("\n")}\n`).join("");
93+}
94+
95+// ---------------------------------------------------------------- three-way merge
96+
97+interface Edit {
98+ start: number; // base line index
99+ end: number; // exclusive
100+ lines: string[];
101+}
102+
103+function edits(base: string[], other: string[]): Edit[] {
104+ const out: Edit[] = [];
105+ let bi = 0;
106+ let cur: Edit | null = null;
107+ for (const part of diffArrays(base, other)) {
108+ if (part.removed) {
109+ cur ??= { start: bi, end: bi, lines: [] };
110+ cur.end += part.count ?? part.value.length;
111+ bi += part.count ?? part.value.length;
112+ } else if (part.added) {
113+ cur ??= { start: bi, end: bi, lines: [] };
114+ cur.lines.push(...part.value);
115+ } else {
116+ if (cur) out.push(cur);
117+ cur = null;
118+ bi += part.count ?? part.value.length;
119+ }
120+ }
121+ if (cur) out.push(cur);
122+ return out;
123+}
124+
125+const splitLines = (s: string): string[] => (s === "" ? [] : s.split(/(?<=\n)/));
126+
127+/** Line-based diff3. Returns merged text, or null when the sides conflict. */
128+export function mergeText(base: string, ours: string, theirs: string): string | null {
129+ if (ours === theirs) return ours;
130+ if (base === ours) return theirs;
131+ if (base === theirs) return ours;
132+ const b = splitLines(base);
133+ const ea = edits(b, splitLines(ours));
134+ const eb = edits(b, splitLines(theirs));
135+ const out: string[] = [];
136+ let pos = 0;
137+ let i = 0;
138+ let j = 0;
139+ while (i < ea.length || j < eb.length) {
140+ const a = ea[i];
141+ const c = eb[j];
142+ let take: Edit;
143+ if (a && c && a.start < c.end + (c.start === c.end ? 1 : 0) && c.start < a.end + (a.start === a.end ? 1 : 0)) {
144+ // Overlapping edits: fine only when identical.
145+ if (a.start === c.start && a.end === c.end && a.lines.join("") === c.lines.join("")) {
146+ take = a;
147+ i++;
148+ j++;
149+ } else return null;
150+ } else if (a && (!c || a.start < c.start || (a.start === c.start && a.end <= c.end))) {
151+ take = a;
152+ i++;
153+ } else {
154+ take = c!;
155+ j++;
156+ }
157+ out.push(...b.slice(pos, take.start), ...take.lines);
158+ pos = take.end;
159+ }
160+ out.push(...b.slice(pos));
161+ return out.join("");
162+}
163+
164+export interface MergeResult {
165+ tree: string | null;
166+ conflicts: string[];
167+}
168+
169+/** Three-way merge of trees. File-level with line-level diff3 for text files. */
170+export async function mergeTrees(store: ObjectStore, base: string | null, ours: string, theirs: string): Promise<MergeResult> {
171+ const conflicts: string[] = [];
172+ const mergeDir = async (b: string | null, o: string | null, t: string | null, prefix: string): Promise<string | null> => {
173+ if (o === t) return o;
174+ if (b === o) return t;
175+ if (b === t) return o;
176+ const mb = new Map((b ? await readTree(store, b) : []).map((e) => [e.name, e]));
177+ const mo = new Map((o ? await readTree(store, o) : []).map((e) => [e.name, e]));
178+ const mt = new Map((t ? await readTree(store, t) : []).map((e) => [e.name, e]));
179+ const result: TreeEntry[] = [];
180+ for (const name of new Set([...mb.keys(), ...mo.keys(), ...mt.keys()])) {
181+ const eb = mb.get(name);
182+ const eo = mo.get(name);
183+ const et = mt.get(name);
184+ const path = prefix ? `${prefix}/${name}` : name;
185+ const same = (x?: TreeEntry, y?: TreeEntry) => (!x && !y) || (!!x && !!y && x.oid === y.oid && x.mode === y.mode);
186+ if (same(eo, et)) {
187+ if (eo) result.push(eo);
188+ continue;
189+ }
190+ if (same(eb, eo)) {
191+ if (et) result.push(et);
192+ continue;
193+ }
194+ if (same(eb, et)) {
195+ if (eo) result.push(eo);
196+ continue;
197+ }
198+ const dir = (e?: TreeEntry) => (e && isTreeMode(e.mode) ? e.oid : null);
199+ if ((!eo || isTreeMode(eo.mode)) && (!et || isTreeMode(et.mode)) && (!eb || isTreeMode(eb.mode))) {
200+ const merged = await mergeDir(dir(eb), dir(eo), dir(et), path);
201+ if (merged) result.push({ mode: "40000", name, oid: merged });
202+ continue;
203+ }
204+ if (eo && et && !isTreeMode(eo.mode) && !isTreeMode(et.mode) && eo.mode === et.mode) {
205+ const [cb, co, ct] = await Promise.all([
206+ eb && !isTreeMode(eb.mode) ? mustRead(store, eb.oid) : null,
207+ mustRead(store, eo.oid),
208+ mustRead(store, et.oid),
209+ ]);
210+ if (!isBinary(co.content) && !isBinary(ct.content) && (!cb || !isBinary(cb.content))) {
211+ const merged = mergeText(cb ? fromUtf8(cb.content) : "", fromUtf8(co.content), fromUtf8(ct.content));
212+ if (merged !== null) {
213+ const oid = await store.write("blob", new TextEncoder().encode(merged));
214+ result.push({ mode: eo.mode, name, oid });
215+ continue;
216+ }
217+ }
218+ }
219+ conflicts.push(path);
220+ if (eo) result.push(eo);
221+ }
222+ if (!result.length) return null;
223+ return store.write("tree", serializeTree(result));
224+ };
225+ const tree = await mergeDir(base, ours, theirs, "");
226+ return { tree, conflicts };
227+}
addedpackages/git-core/src/index.ts+10−0View file
@@ -1,0 +1,10 @@
1+export * from "./bytes.js";
2+export * from "./sha1.js";
3+export * from "./zlib.js";
4+export * from "./objects.js";
5+export * from "./pktline.js";
6+export * from "./delta.js";
7+export * from "./pack.js";
8+export * from "./store.js";
9+export * from "./diff.js";
10+export * from "./memory-store.js";
addedpackages/git-core/src/memory-store.ts+18−0View file
@@ -1,0 +1,18 @@
1+import { hashObject, type GitObject, type ObjectType } from "./objects.js";
2+import type { ObjectStore } from "./store.js";
3+
4+/** In-memory object store, used in tests and as a write buffer. */
5+export class MemoryStore implements ObjectStore {
6+ readonly objects = new Map<string, GitObject>();
7+ constructor(private fallback?: { read(oid: string): Promise<GitObject | null> }) {}
8+
9+ async read(oid: string): Promise<GitObject | null> {
10+ return this.objects.get(oid) ?? (this.fallback ? this.fallback.read(oid) : null);
11+ }
12+
13+ async write(type: ObjectType, content: Uint8Array): Promise<string> {
14+ const oid = await hashObject(type, content);
15+ if (!this.objects.has(oid)) this.objects.set(oid, { type, content });
16+ return oid;
17+ }
18+}
addedpackages/git-core/src/objects.ts+165−0View file
@@ -1,0 +1,165 @@
1+import { concat, fromHex, fromUtf8, toHex, utf8 } from "./bytes.js";
2+import { sha1Hex } from "./sha1.js";
3+
4+export type ObjectType = "commit" | "tree" | "blob" | "tag";
5+
6+export const TYPE_CODES: Record<ObjectType, number> = { commit: 1, tree: 2, blob: 3, tag: 4 };
7+export const CODE_TYPES: Record<number, ObjectType> = { 1: "commit", 2: "tree", 3: "blob", 4: "tag" };
8+
9+export const ZERO_OID = "0000000000000000000000000000000000000000";
10+export const EMPTY_TREE_OID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
11+
12+export interface GitObject {
13+ type: ObjectType;
14+ content: Uint8Array;
15+}
16+
17+export function objectHeader(type: ObjectType, size: number): Uint8Array {
18+ return utf8(`${type} ${size}\0`);
19+}
20+
21+export async function hashObject(type: ObjectType, content: Uint8Array): Promise<string> {
22+ return sha1Hex(concat([objectHeader(type, content.length), content]));
23+}
24+
25+export const isOid = (s: string): boolean => /^[0-9a-f]{40}$/.test(s);
26+
27+// ---------------------------------------------------------------- trees
28+
29+export interface TreeEntry {
30+ mode: string; // "100644" | "100755" | "120000" | "40000" | "160000"
31+ name: string;
32+ oid: string;
33+}
34+
35+export const isTreeMode = (mode: string): boolean => mode === "40000" || mode === "040000";
36+
37+export function parseTree(content: Uint8Array): TreeEntry[] {
38+ const entries: TreeEntry[] = [];
39+ let i = 0;
40+ while (i < content.length) {
41+ const sp = content.indexOf(0x20, i);
42+ const nul = content.indexOf(0x00, sp);
43+ if (sp < 0 || nul < 0) throw new Error("malformed tree");
44+ const mode = fromUtf8(content.subarray(i, sp));
45+ const name = fromUtf8(content.subarray(sp + 1, nul));
46+ const oid = toHex(content.subarray(nul + 1, nul + 21));
47+ entries.push({ mode: mode === "040000" ? "40000" : mode, name, oid });
48+ i = nul + 21;
49+ }
50+ return entries;
51+}
52+
53+/** Git sorts tree entries as if directory names had a trailing slash. */
54+function treeSortKey(e: TreeEntry): string {
55+ return isTreeMode(e.mode) ? `${e.name}/` : e.name;
56+}
57+
58+export function compareTreeEntries(a: TreeEntry, b: TreeEntry): number {
59+ const ka = utf8(treeSortKey(a));
60+ const kb = utf8(treeSortKey(b));
61+ const n = Math.min(ka.length, kb.length);
62+ for (let i = 0; i < n; i++) if (ka[i] !== kb[i]) return ka[i]! - kb[i]!;
63+ return ka.length - kb.length;
64+}
65+
66+export function serializeTree(entries: readonly TreeEntry[]): Uint8Array {
67+ const sorted = [...entries].sort(compareTreeEntries);
68+ return concat(sorted.flatMap((e) => [utf8(`${e.mode} ${e.name}\0`), fromHex(e.oid)]));
69+}
70+
71+// ---------------------------------------------------------------- commits & tags
72+
73+export interface Signature {
74+ name: string;
75+ email: string;
76+ /** Seconds since epoch. */
77+ timestamp: number;
78+ /** e.g. "+0200" */
79+ timezone: string;
80+}
81+
82+export interface Commit {
83+ tree: string;
84+ parents: string[];
85+ author: Signature;
86+ committer: Signature;
87+ message: string;
88+ gpgsig?: string;
89+ /** Extra headers we preserve but don't interpret. */
90+ extraHeaders?: [string, string][];
91+}
92+
93+export interface Tag {
94+ object: string;
95+ type: ObjectType;
96+ tag: string;
97+ tagger?: Signature;
98+ message: string;
99+}
100+
101+export function parseSignature(s: string): Signature {
102+ const m = /^(.*) <(.*)> (-?\d+) ([+-]\d{4})$/.exec(s);
103+ if (!m) return { name: s, email: "", timestamp: 0, timezone: "+0000" };
104+ return { name: m[1]!, email: m[2]!, timestamp: Number(m[3]), timezone: m[4]! };
105+}
106+
107+export function formatSignature(s: Signature): string {
108+ return `${s.name} <${s.email}> ${s.timestamp} ${s.timezone}`;
109+}
110+
111+function parseHeaders(text: string): { headers: [string, string][]; message: string } {
112+ const headers: [string, string][] = [];
113+ const end = text.indexOf("\n\n");
114+ const head = end >= 0 ? text.slice(0, end) : text;
115+ const message = end >= 0 ? text.slice(end + 2) : "";
116+ for (const line of head.split("\n")) {
117+ if (line.startsWith(" ") && headers.length) {
118+ const last = headers[headers.length - 1]!;
119+ last[1] += `\n${line.slice(1)}`;
120+ } else {
121+ const sp = line.indexOf(" ");
122+ headers.push([line.slice(0, sp), line.slice(sp + 1)]);
123+ }
124+ }
125+ return { headers, message };
126+}
127+
128+export function parseCommit(content: Uint8Array): Commit {
129+ const { headers, message } = parseHeaders(fromUtf8(content));
130+ const c: Commit = { tree: "", parents: [], author: parseSignature(""), committer: parseSignature(""), message };
131+ for (const [k, v] of headers) {
132+ if (k === "tree") c.tree = v;
133+ else if (k === "parent") c.parents.push(v);
134+ else if (k === "author") c.author = parseSignature(v);
135+ else if (k === "committer") c.committer = parseSignature(v);
136+ else if (k === "gpgsig") c.gpgsig = v;
137+ else (c.extraHeaders ??= []).push([k, v]);
138+ }
139+ return c;
140+}
141+
142+export function serializeCommit(c: Commit): Uint8Array {
143+ const lines = [`tree ${c.tree}`, ...c.parents.map((p) => `parent ${p}`), `author ${formatSignature(c.author)}`, `committer ${formatSignature(c.committer)}`];
144+ for (const [k, v] of c.extraHeaders ?? []) lines.push(`${k} ${v.replace(/\n/g, "\n ")}`);
145+ if (c.gpgsig) lines.push(`gpgsig ${c.gpgsig.replace(/\n/g, "\n ")}`);
146+ return utf8(`${lines.join("\n")}\n\n${c.message}`);
147+}
148+
149+export function parseTag(content: Uint8Array): Tag {
150+ const { headers, message } = parseHeaders(fromUtf8(content));
151+ const t: Tag = { object: "", type: "commit", tag: "", message };
152+ for (const [k, v] of headers) {
153+ if (k === "object") t.object = v;
154+ else if (k === "type") t.type = v as ObjectType;
155+ else if (k === "tag") t.tag = v;
156+ else if (k === "tagger") t.tagger = parseSignature(v);
157+ }
158+ return t;
159+}
160+
161+export function serializeTag(t: Tag): Uint8Array {
162+ const lines = [`object ${t.object}`, `type ${t.type}`, `tag ${t.tag}`];
163+ if (t.tagger) lines.push(`tagger ${formatSignature(t.tagger)}`);
164+ return utf8(`${lines.join("\n")}\n\n${t.message}`);
165+}
addedpackages/git-core/src/pack.ts+156−0View file
@@ -1,0 +1,156 @@
1+import { ByteWriter, equalBytes, fromUtf8, toHex } from "./bytes.js";
2+import { applyDelta } from "./delta.js";
3+import { CODE_TYPES, TYPE_CODES, hashObject, type GitObject, type ObjectType } from "./objects.js";
4+import { sha1 } from "./sha1.js";
5+import { inflateAt } from "./zlib.js";
6+
7+const OFS_DELTA = 6;
8+const REF_DELTA = 7;
9+
10+export interface PackedObject extends GitObject {
11+ oid: string;
12+}
13+
14+interface RawEntry {
15+ offset: number;
16+ typeCode: number;
17+ data: Uint8Array; // inflated: full content, or delta instructions
18+ baseOffset?: number;
19+ baseOid?: string;
20+}
21+
22+export type ExternalLookup = (oid: string) => Promise<GitObject | null>;
23+
24+/**
25+ * Parse a packfile and fully resolve every object (including deltas).
26+ * `external` resolves REF_DELTA bases that are not inside the pack (thin packs).
27+ */
28+export async function parsePack(pack: Uint8Array, external?: ExternalLookup): Promise<PackedObject[]> {
29+ if (pack.length < 32 || fromUtf8(pack.subarray(0, 4)) !== "PACK") throw new Error("not a packfile");
30+ const view = new DataView(pack.buffer, pack.byteOffset, pack.byteLength);
31+ const version = view.getUint32(4);
32+ if (version !== 2 && version !== 3) throw new Error(`unsupported pack version ${version}`);
33+ const count = view.getUint32(8);
34+
35+ const trailer = pack.subarray(pack.length - 20);
36+ const digest = await sha1(pack.subarray(0, pack.length - 20));
37+ if (!equalBytes(trailer, digest)) throw new Error("pack checksum mismatch");
38+
39+ const raws: RawEntry[] = [];
40+ let pos = 12;
41+ for (let n = 0; n < count; n++) {
42+ const offset = pos;
43+ let c = pack[pos++]!;
44+ const typeCode = (c >> 4) & 7;
45+ let size = c & 15;
46+ let shift = 4;
47+ while (c & 0x80) {
48+ c = pack[pos++]!;
49+ size += (c & 0x7f) * 2 ** shift;
50+ shift += 7;
51+ }
52+ const entry: RawEntry = { offset, typeCode, data: new Uint8Array(0) };
53+ if (typeCode === OFS_DELTA) {
54+ c = pack[pos++]!;
55+ let ofs = c & 0x7f;
56+ while (c & 0x80) {
57+ c = pack[pos++]!;
58+ ofs = (ofs + 1) * 128 + (c & 0x7f);
59+ }
60+ entry.baseOffset = offset - ofs;
61+ } else if (typeCode === REF_DELTA) {
62+ entry.baseOid = toHex(pack.subarray(pos, pos + 20));
63+ pos += 20;
64+ } else if (!CODE_TYPES[typeCode]) {
65+ throw new Error(`bad object type ${typeCode} at ${offset}`);
66+ }
67+ const { out, consumed } = inflateAt(pack, pos, size);
68+ if (out.length !== size) throw new Error(`size mismatch at ${offset}: ${out.length} != ${size}`);
69+ entry.data = out;
70+ pos += consumed;
71+ raws.push(entry);
72+ }
73+ if (pos !== pack.length - 20) throw new Error("trailing garbage in pack");
74+
75+ const byOffset = new Map<number, PackedObject>();
76+ const byOid = new Map<string, PackedObject>();
77+ const results: PackedObject[] = [];
78+ const add = async (raw: RawEntry, type: ObjectType, content: Uint8Array) => {
79+ const obj: PackedObject = { oid: await hashObject(type, content), type, content };
80+ byOffset.set(raw.offset, obj);
81+ byOid.set(obj.oid, obj);
82+ results.push(obj);
83+ };
84+
85+ let pending: RawEntry[] = [];
86+ for (const raw of raws) {
87+ const t = CODE_TYPES[raw.typeCode];
88+ if (t) await add(raw, t, raw.data);
89+ else pending.push(raw);
90+ }
91+
92+ // Resolve deltas; chains may reference each other in any order.
93+ while (pending.length) {
94+ const next: RawEntry[] = [];
95+ for (const raw of pending) {
96+ let base: GitObject | undefined | null;
97+ if (raw.baseOffset !== undefined) base = byOffset.get(raw.baseOffset);
98+ else base = byOid.get(raw.baseOid!);
99+ if (!base) {
100+ next.push(raw);
101+ continue;
102+ }
103+ await add(raw, base.type, applyDelta(base.content, raw.data));
104+ }
105+ if (next.length === pending.length) {
106+ // No progress: remaining bases must come from outside the pack.
107+ let progressed = false;
108+ for (const raw of next) {
109+ if (raw.baseOid && external) {
110+ const base = await external(raw.baseOid);
111+ if (base) {
112+ byOid.set(raw.baseOid, { oid: raw.baseOid, ...base });
113+ progressed = true;
114+ }
115+ }
116+ }
117+ if (!progressed) throw new Error(`unresolvable delta base(s): ${next.map((r) => r.baseOid ?? `@${r.baseOffset}`).join(", ")}`);
118+ }
119+ pending = next.filter((r) => !byOffset.has(r.offset));
120+ }
121+ return results;
122+}
123+
124+export interface PackEntryInput {
125+ type: ObjectType;
126+ /** Uncompressed size of the object content. */
127+ size: number;
128+ /** zlib-deflated object content. */
129+ deflated: Uint8Array;
130+}
131+
132+function writeEntryHeader(w: ByteWriter, typeCode: number, size: number) {
133+ let c = (typeCode << 4) | (size & 15);
134+ size = Math.floor(size / 16);
135+ while (size > 0) {
136+ w.byte(c | 0x80);
137+ c = size & 0x7f;
138+ size = Math.floor(size / 128);
139+ }
140+ w.byte(c);
141+}
142+
143+/** Build a version 2 packfile of undeltified objects from pre-deflated content. */
144+export async function writePack(entries: readonly PackEntryInput[]): Promise<Uint8Array> {
145+ const w = new ByteWriter();
146+ w.write(new TextEncoder().encode("PACK"));
147+ w.uint32(2);
148+ w.uint32(entries.length);
149+ for (const e of entries) {
150+ writeEntryHeader(w, TYPE_CODES[e.type], e.size);
151+ w.write(e.deflated);
152+ }
153+ const body = w.bytes();
154+ w.write(await sha1(body));
155+ return w.bytes();
156+}
addedpackages/git-core/src/pktline.ts+55−0View file
@@ -1,0 +1,55 @@
1+import { ByteWriter, concat, fromUtf8, utf8 } from "./bytes.js";
2+
3+export const FLUSH = utf8("0000");
4+export const DELIM = utf8("0001");
5+export const RESPONSE_END = utf8("0002");
6+
7+export function pkt(data: string | Uint8Array): Uint8Array {
8+ const body = typeof data === "string" ? utf8(data) : data;
9+ if (body.length > 65516) throw new Error("pkt-line too long");
10+ return concat([utf8((body.length + 4).toString(16).padStart(4, "0")), body]);
11+}
12+
13+export type Pkt = { type: "data"; data: Uint8Array } | { type: "flush" } | { type: "delim" } | { type: "end" };
14+
15+/** Decode a buffer of pkt-lines. Returns the packets and the offset where parsing stopped. */
16+export function readPkts(buf: Uint8Array, start = 0, stopAtFlush = false): { pkts: Pkt[]; offset: number } {
17+ const pkts: Pkt[] = [];
18+ let i = start;
19+ while (i + 4 <= buf.length) {
20+ const len = parseInt(fromUtf8(buf.subarray(i, i + 4)), 16);
21+ if (Number.isNaN(len)) throw new Error(`bad pkt-line length at ${i}`);
22+ if (len === 0) {
23+ pkts.push({ type: "flush" });
24+ i += 4;
25+ if (stopAtFlush) break;
26+ continue;
27+ }
28+ if (len === 1) {
29+ pkts.push({ type: "delim" });
30+ i += 4;
31+ continue;
32+ }
33+ if (len === 2) {
34+ pkts.push({ type: "end" });
35+ i += 4;
36+ continue;
37+ }
38+ if (len < 4 || i + len > buf.length) throw new Error(`truncated pkt-line at ${i}`);
39+ pkts.push({ type: "data", data: buf.subarray(i + 4, i + len) });
40+ i += len;
41+ }
42+ return { pkts, offset: i };
43+}
44+
45+/** Text of a data pkt without its trailing newline. */
46+export const pktText = (p: Pkt): string => (p.type === "data" ? fromUtf8(p.data).replace(/\n$/, "") : "");
47+
48+/** Wrap pack bytes in side-band-64k packets on the given band. */
49+export function sideband(band: 1 | 2 | 3, data: Uint8Array, w: ByteWriter): void {
50+ const max = 65515;
51+ for (let i = 0; i < data.length; i += max) {
52+ const chunk = data.subarray(i, Math.min(data.length, i + max));
53+ w.write(pkt(concat([new Uint8Array([band]), chunk])));
54+ }
55+}
addedpackages/git-core/src/sha1.ts+15−0View file
@@ -1,0 +1,15 @@
1+import { toHex } from "./bytes.js";
2+
3+/** SHA-1 using WebCrypto (available in Workers and Node 20+). */
4+export async function sha1(data: Uint8Array): Promise<Uint8Array> {
5+ const digest = await crypto.subtle.digest("SHA-1", data as BufferSource);
6+ return new Uint8Array(digest);
7+}
8+
9+export async function sha1Hex(data: Uint8Array): Promise<string> {
10+ return toHex(await sha1(data));
11+}
12+
13+export async function sha256Hex(data: Uint8Array): Promise<string> {
14+ return toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", data as BufferSource)));
15+}
addedpackages/git-core/src/store.ts+297−0View file
@@ -1,0 +1,297 @@
1+import {
2+ isTreeMode,
3+ parseCommit,
4+ parseTag,
5+ parseTree,
6+ serializeTree,
7+ type Commit,
8+ type GitObject,
9+ type ObjectType,
10+ type TreeEntry,
11+} from "./objects.js";
12+
13+export interface ObjectReader {
14+ read(oid: string): Promise<GitObject | null>;
15+}
16+
17+export interface ObjectWriter {
18+ /** Store an object and return its oid. Must be idempotent. */
19+ write(type: ObjectType, content: Uint8Array): Promise<string>;
20+}
21+
22+export type ObjectStore = ObjectReader & ObjectWriter;
23+
24+export class MissingObjectError extends Error {
25+ constructor(public oid: string) {
26+ super(`missing object ${oid}`);
27+ }
28+}
29+
30+export async function mustRead(store: ObjectReader, oid: string, type?: ObjectType): Promise<GitObject> {
31+ const o = await store.read(oid);
32+ if (!o) throw new MissingObjectError(oid);
33+ if (type && o.type !== type) throw new Error(`object ${oid} is a ${o.type}, expected ${type}`);
34+ return o;
35+}
36+
37+export async function readCommit(store: ObjectReader, oid: string): Promise<Commit> {
38+ return parseCommit((await mustRead(store, oid, "commit")).content);
39+}
40+
41+export async function readTree(store: ObjectReader, oid: string): Promise<TreeEntry[]> {
42+ return parseTree((await mustRead(store, oid, "tree")).content);
43+}
44+
45+/** Peel tags until a commit oid is reached. */
46+export async function peelToCommit(store: ObjectReader, oid: string): Promise<string> {
47+ for (let i = 0; i < 16; i++) {
48+ const o = await mustRead(store, oid);
49+ if (o.type === "commit") return oid;
50+ if (o.type !== "tag") throw new Error(`${oid} does not point to a commit`);
51+ oid = parseTag(o.content).object;
52+ }
53+ throw new Error("tag chain too deep");
54+}
55+
56+/** Resolve a path inside a tree. Returns null when it does not exist. */
57+export async function resolvePath(store: ObjectReader, treeOid: string, path: string): Promise<TreeEntry | null> {
58+ const parts = path.split("/").filter(Boolean);
59+ if (parts.length === 0) return { mode: "40000", name: "", oid: treeOid };
60+ let current = treeOid;
61+ for (let i = 0; i < parts.length; i++) {
62+ const entries = await readTree(store, current);
63+ const entry = entries.find((e) => e.name === parts[i]);
64+ if (!entry) return null;
65+ if (i === parts.length - 1) return entry;
66+ if (!isTreeMode(entry.mode)) return null;
67+ current = entry.oid;
68+ }
69+ return null;
70+}
71+
72+/** Walk all files of a tree recursively. */
73+export async function listFiles(store: ObjectReader, treeOid: string, prefix = ""): Promise<Array<TreeEntry & { path: string }>> {
74+ const out: Array<TreeEntry & { path: string }> = [];
75+ for (const e of await readTree(store, treeOid)) {
76+ const path = prefix ? `${prefix}/${e.name}` : e.name;
77+ if (isTreeMode(e.mode)) out.push(...(await listFiles(store, e.oid, path)));
78+ else out.push({ ...e, path });
79+ }
80+ return out;
81+}
82+
83+// ---------------------------------------------------------------- history
84+
85+/** Commits reachable from `tips`, newest first by committer date, stopping at `stop`. */
86+export async function walkCommits(
87+ store: ObjectReader,
88+ tips: readonly string[],
89+ opts: { stop?: Set<string>; limit?: number; firstParent?: boolean } = {},
90+): Promise<Array<{ oid: string; commit: Commit }>> {
91+ const seen = new Set<string>(opts.stop ?? []);
92+ const queue: Array<{ oid: string; commit: Commit }> = [];
93+ const out: Array<{ oid: string; commit: Commit }> = [];
94+ const push = async (oid: string) => {
95+ if (seen.has(oid)) return;
96+ seen.add(oid);
97+ const o = await store.read(oid);
98+ if (!o || o.type !== "commit") return; // shallow / missing parents are tolerated
99+ queue.push({ oid, commit: parseCommit(o.content) });
100+ };
101+ for (const t of tips) await push(t);
102+ while (queue.length && (opts.limit === undefined || out.length < opts.limit)) {
103+ queue.sort((a, b) => b.commit.committer.timestamp - a.commit.committer.timestamp);
104+ const next = queue.shift()!;
105+ out.push(next);
106+ const parents = opts.firstParent ? next.commit.parents.slice(0, 1) : next.commit.parents;
107+ for (const p of parents) await push(p);
108+ }
109+ return out;
110+}
111+
112+async function ancestors(store: ObjectReader, oid: string): Promise<Set<string>> {
113+ const set = new Set<string>();
114+ const stack = [oid];
115+ while (stack.length) {
116+ const cur = stack.pop()!;
117+ if (set.has(cur)) continue;
118+ set.add(cur);
119+ const o = await store.read(cur);
120+ if (!o || o.type !== "commit") continue;
121+ stack.push(...parseCommit(o.content).parents);
122+ }
123+ return set;
124+}
125+
126+export async function isAncestor(store: ObjectReader, ancestor: string, descendant: string): Promise<boolean> {
127+ if (ancestor === descendant) return true;
128+ const stack = [descendant];
129+ const seen = new Set<string>();
130+ while (stack.length) {
131+ const cur = stack.pop()!;
132+ if (cur === ancestor) return true;
133+ if (seen.has(cur)) continue;
134+ seen.add(cur);
135+ const o = await store.read(cur);
136+ if (!o || o.type !== "commit") continue;
137+ stack.push(...parseCommit(o.content).parents);
138+ }
139+ return false;
140+}
141+
142+/** Best common ancestor of two commits (null if unrelated). */
143+export async function mergeBase(store: ObjectReader, a: string, b: string): Promise<string | null> {
144+ const ancA = await ancestors(store, a);
145+ // BFS from b by date; the first commit found in ancA that is not an ancestor of another candidate wins.
146+ const candidates: string[] = [];
147+ for (const { oid } of await walkCommits(store, [b])) {
148+ if (ancA.has(oid)) candidates.push(oid);
149+ }
150+ if (!candidates.length) return null;
151+ for (const c of candidates) {
152+ let dominated = false;
153+ for (const other of candidates) {
154+ if (other !== c && (await isAncestor(store, c, other))) {
155+ dominated = true;
156+ break;
157+ }
158+ }
159+ if (!dominated) return c;
160+ }
161+ return candidates[0]!;
162+}
163+
164+// ---------------------------------------------------------------- pack selection
165+
166+async function markTree(store: ObjectReader, treeOid: string, into: Set<string>): Promise<void> {
167+ if (into.has(treeOid)) return;
168+ into.add(treeOid);
169+ for (const e of await readTree(store, treeOid)) {
170+ if (e.mode === "160000") continue; // submodule commit, not stored here
171+ if (isTreeMode(e.mode)) await markTree(store, e.oid, into);
172+ else into.add(e.oid);
173+ }
174+}
175+
176+/**
177+ * Objects a client needs given what it wants and what it already has, in an
178+ * order suitable for a pack (commits, then trees and blobs, then tags).
179+ */
180+export async function objectsForFetch(store: ObjectReader, wants: readonly string[], haves: readonly string[]): Promise<string[]> {
181+ const haveCommits = new Set<string>();
182+ for (const h of haves) {
183+ const o = await store.read(h);
184+ if (o?.type === "commit") for (const a of await ancestors(store, h)) haveCommits.add(a);
185+ }
186+ const tags: string[] = [];
187+ const commitTips: string[] = [];
188+ const extra: string[] = [];
189+ for (const w of wants) {
190+ let oid = w;
191+ for (let i = 0; i < 16; i++) {
192+ const o = await mustRead(store, oid);
193+ if (o.type === "tag") {
194+ tags.push(oid);
195+ oid = parseTag(o.content).object;
196+ continue;
197+ }
198+ if (o.type === "commit") commitTips.push(oid);
199+ else extra.push(oid);
200+ break;
201+ }
202+ }
203+ const commits = await walkCommits(store, commitTips, { stop: haveCommits });
204+
205+ // Trees and blobs the client already has: everything under the boundary commits' trees.
206+ const uninteresting = new Set<string>();
207+ const boundary = new Set<string>();
208+ for (const { commit } of commits) for (const p of commit.parents) if (haveCommits.has(p)) boundary.add(p);
209+ for (const b of boundary) await markTree(store, (await readCommit(store, b)).tree, uninteresting);
210+
211+ const out: string[] = [];
212+ const added = new Set<string>();
213+ const add = (oid: string) => {
214+ if (!added.has(oid) && !uninteresting.has(oid)) {
215+ added.add(oid);
216+ out.push(oid);
217+ }
218+ };
219+ for (const { oid } of commits) add(oid);
220+ const walkTree = async (treeOid: string) => {
221+ if (added.has(treeOid) || uninteresting.has(treeOid)) return;
222+ add(treeOid);
223+ for (const e of await readTree(store, treeOid)) {
224+ if (e.mode === "160000") continue;
225+ if (isTreeMode(e.mode)) await walkTree(e.oid);
226+ else add(e.oid);
227+ }
228+ };
229+ for (const { commit } of commits) await walkTree(commit.tree);
230+ for (const oid of extra) {
231+ const o = await mustRead(store, oid);
232+ if (o.type === "tree") await walkTree(oid);
233+ else add(oid);
234+ }
235+ for (const t of tags) add(t);
236+ return out;
237+}
238+
239+// ---------------------------------------------------------------- writing trees
240+
241+export interface FileChange {
242+ path: string;
243+ /** New content; omit together with `delete: true` to remove the path. */
244+ content?: Uint8Array;
245+ /** Use an existing blob instead of content. */
246+ oid?: string;
247+ mode?: "100644" | "100755" | "120000";
248+ delete?: boolean;
249+}
250+
251+/** Apply file changes to a tree (or an empty tree) and return the new tree oid. */
252+export async function applyChanges(store: ObjectStore, baseTree: string | null, changes: readonly FileChange[]): Promise<string> {
253+ type Node = { entries: Map<string, TreeEntry>; children: Map<string, Node>; base: string | null };
254+ const load = async (oid: string | null): Promise<Node> => ({
255+ entries: new Map(oid ? (await readTree(store, oid)).map((e) => [e.name, e]) : []),
256+ children: new Map(),
257+ base: oid,
258+ });
259+ const root = await load(baseTree);
260+ const dirFor = async (parts: string[]): Promise<Node> => {
261+ let node = root;
262+ for (const part of parts) {
263+ let child = node.children.get(part);
264+ if (!child) {
265+ const existing = node.entries.get(part);
266+ child = await load(existing && isTreeMode(existing.mode) ? existing.oid : null);
267+ node.children.set(part, child);
268+ }
269+ node = child;
270+ }
271+ return node;
272+ };
273+ for (const ch of changes) {
274+ const parts = ch.path.split("/").filter(Boolean);
275+ const name = parts.pop();
276+ if (!name) throw new Error(`invalid path: ${ch.path}`);
277+ const dir = await dirFor(parts);
278+ if (ch.delete) {
279+ dir.entries.delete(name);
280+ dir.children.delete(name);
281+ continue;
282+ }
283+ const oid = ch.oid ?? (await store.write("blob", ch.content ?? new Uint8Array(0)));
284+ dir.children.delete(name);
285+ dir.entries.set(name, { mode: ch.mode ?? dir.entries.get(name)?.mode ?? "100644", name, oid });
286+ }
287+ const finish = async (node: Node): Promise<string | null> => {
288+ for (const [name, child] of node.children) {
289+ const oid = await finish(child);
290+ if (oid) node.entries.set(name, { mode: "40000", name, oid });
291+ else node.entries.delete(name);
292+ }
293+ if (node.entries.size === 0) return null;
294+ return store.write("tree", serializeTree([...node.entries.values()]));
295+ };
296+ return (await finish(root)) ?? (await store.write("tree", new Uint8Array(0)));
297+}
addedpackages/git-core/src/zlib.ts+33−0View file
@@ -1,0 +1,33 @@
1+import pako from "pako";
2+
3+/** zlib-deflate (RFC 1950), the format git uses for loose objects and pack entries. */
4+export function deflate(data: Uint8Array): Uint8Array {
5+ return pako.deflate(data);
6+}
7+
8+export function inflate(data: Uint8Array): Uint8Array {
9+ return pako.inflate(data);
10+}
11+
12+/**
13+ * Inflate one zlib stream that starts at `offset` inside `data`, and report how
14+ * many input bytes it consumed. Pack files concatenate zlib streams without
15+ * lengths, so the consumed count is how we find the next entry.
16+ */
17+export function inflateAt(data: Uint8Array, offset: number, expectedSize?: number): { out: Uint8Array; consumed: number } {
18+ const inflator = new pako.Inflate() as pako.Inflate & { ended: boolean };
19+ // Feed in bounded windows so a huge trailing buffer is not copied repeatedly.
20+ const window = Math.max(64 * 1024, expectedSize ? expectedSize + 1024 : 0);
21+ let pos = offset;
22+ while (!inflator.ended && pos < data.length) {
23+ const end = Math.min(data.length, pos + window);
24+ inflator.push(data.subarray(pos, end), false);
25+ if (inflator.err) throw new Error(`zlib error at ${offset}: ${inflator.msg}`);
26+ pos = end;
27+ }
28+ if (!inflator.ended) throw new Error(`truncated zlib stream at offset ${offset}`);
29+ const strm = (inflator as unknown as { strm: { avail_in: number } }).strm;
30+ const consumed = pos - offset - strm.avail_in;
31+ const out = inflator.result as Uint8Array;
32+ return { out: out ?? new Uint8Array(0), consumed };
33+}
addedpackages/git-core/test/pack.test.ts+139−0View file
@@ -1,0 +1,139 @@
1+import { execFileSync } from "node:child_process";
2+import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
5+import { describe, expect, it } from "vitest";
6+import {
7+ MemoryStore,
8+ applyChanges,
9+ deflate,
10+ diffTrees,
11+ fileDiff,
12+ mergeBase,
13+ mergeText,
14+ mergeTrees,
15+ objectsForFetch,
16+ parseCommit,
17+ parsePack,
18+ readCommit,
19+ serializeCommit,
20+ utf8,
21+ writePack,
22+ fromUtf8,
23+ listFiles,
24+} from "../src/index.js";
25+
26+const git = (cwd: string, ...args: string[]) =>
27+ execFileSync("git", args, { cwd, env: { ...process.env, GIT_AUTHOR_NAME: "T", GIT_AUTHOR_EMAIL: "t@x", GIT_COMMITTER_NAME: "T", GIT_COMMITTER_EMAIL: "t@x" } });
28+
29+function makeRepo(): string {
30+ const dir = mkdtempSync(join(tmpdir(), "gc-"));
31+ git(dir, "init", "-q", "-b", "main");
32+ const big = Array.from({ length: 400 }, (_, i) => `line ${i} of a fairly long file to encourage deltas`).join("\n");
33+ writeFileSync(join(dir, "a.txt"), big);
34+ mkdirSync(join(dir, "src/deep"), { recursive: true });
35+ writeFileSync(join(dir, "src/deep/x.js"), "console.log(1)\n");
36+ writeFileSync(join(dir, "bin.dat"), Buffer.from([0, 1, 2, 3, 0, 255]));
37+ git(dir, "add", "-A");
38+ git(dir, "commit", "-q", "-m", "first");
39+ writeFileSync(join(dir, "a.txt"), big.replace("line 200", "LINE 200"));
40+ git(dir, "commit", "-qam", "second");
41+ git(dir, "tag", "-a", "v1", "-m", "release");
42+ return dir;
43+}
44+
45+async function loadAll(dir: string): Promise<{ store: MemoryStore; expected: string[] }> {
46+ const expected = git(dir, "rev-list", "--objects", "--all").toString().trim().split("\n").map((l) => l.split(" ")[0]!);
47+ expected.push(git(dir, "rev-parse", "v1").toString().trim());
48+ const pack = execFileSync("git", ["pack-objects", "--stdout", "--delta-base-offset"], { cwd: dir, input: expected.join("\n") + "\n" });
49+ const objs = await parsePack(new Uint8Array(pack));
50+ const store = new MemoryStore();
51+ for (const o of objs) await store.write(o.type, o.content);
52+ return { store, expected };
53+}
54+
55+describe("packfiles", () => {
56+ it("parses a real git pack with ofs deltas and verifies all oids", async () => {
57+ const dir = makeRepo();
58+ const { store, expected } = await loadAll(dir);
59+ for (const oid of expected) expect(store.objects.has(oid), oid).toBe(true);
60+ });
61+
62+ it("writes packs that git accepts", async () => {
63+ const dir = makeRepo();
64+ const { store } = await loadAll(dir);
65+ const head = git(dir, "rev-parse", "HEAD").toString().trim();
66+ const oids = await objectsForFetch(store, [head], []);
67+ const pack = await writePack(
68+ await Promise.all(oids.map(async (oid) => {
69+ const o = (await store.read(oid))!;
70+ return { type: o.type, size: o.content.length, deflated: deflate(o.content) };
71+ })),
72+ );
73+ const out = mkdtempSync(join(tmpdir(), "gc-out-"));
74+ git(out, "init", "-q", "--bare");
75+ const p = join(out, "objects/pack/pack-x.pack");
76+ writeFileSync(p, pack);
77+ git(out, "index-pack", p);
78+ git(out, "update-ref", "refs/heads/main", head);
79+ git(out, "fsck", "--strict", "main");
80+ });
81+
82+ it("sends only new objects on incremental fetch", async () => {
83+ const dir = makeRepo();
84+ const { store } = await loadAll(dir);
85+ const head = git(dir, "rev-parse", "HEAD").toString().trim();
86+ const first = git(dir, "rev-parse", "HEAD~1").toString().trim();
87+ const oids = await objectsForFetch(store, [head], [first]);
88+ // new commit + new root tree + new a.txt blob
89+ expect(oids.length).toBe(3);
90+ });
91+});
92+
93+describe("trees, diffs and merges", () => {
94+ it("builds commits from file changes and diffs them", async () => {
95+ const store = new MemoryStore();
96+ const t1 = await applyChanges(store, null, [
97+ { path: "README.md", content: utf8("hello\n") },
98+ { path: "src/a.ts", content: utf8("a\n") },
99+ ]);
100+ const t2 = await applyChanges(store, t1, [
101+ { path: "src/a.ts", content: utf8("a\nb\n") },
102+ { path: "README.md", delete: true },
103+ { path: "src/new/b.ts", content: utf8("x") },
104+ ]);
105+ const changes = await diffTrees(store, t1, t2);
106+ expect(changes.map((c) => `${c.status}:${c.path}`)).toEqual(["deleted:README.md", "modified:src/a.ts", "added:src/new/b.ts"]);
107+ const d = await fileDiff(store, changes[1]!);
108+ expect(d.additions).toBe(1);
109+ expect((await listFiles(store, t2)).map((f) => f.path)).toEqual(["src/a.ts", "src/new/b.ts"]);
110+ });
111+
112+ it("merges non-overlapping line edits and detects conflicts", () => {
113+ const base = "a\nb\nc\nd\ne\n";
114+ expect(mergeText(base, "A\nb\nc\nd\ne\n", "a\nb\nc\nd\nE\n")).toBe("A\nb\nc\nd\nE\n");
115+ expect(mergeText(base, "a\nX\nc\nd\ne\n", "a\nY\nc\nd\ne\n")).toBeNull();
116+ expect(mergeText(base, "a\nb\nc\nd\ne\nf\n", "z\na\nb\nc\nd\ne\n")).toBe("z\na\nb\nc\nd\ne\nf\n");
117+ });
118+
119+ it("three-way merges trees and finds the merge base", async () => {
120+ const store = new MemoryStore();
121+ const sig = { name: "T", email: "t@x", timestamp: 1, timezone: "+0000" };
122+ const commit = async (tree: string, parents: string[], ts: number) =>
123+ store.write("commit", serializeCommit({ tree, parents, author: { ...sig, timestamp: ts }, committer: { ...sig, timestamp: ts }, message: "m\n" }));
124+ const tb = await applyChanges(store, null, [{ path: "f.txt", content: utf8("1\n2\n3\n4\n5\n") }, { path: "g.txt", content: utf8("g") }]);
125+ const base = await commit(tb, [], 1);
126+ const to = await applyChanges(store, tb, [{ path: "f.txt", content: utf8("one\n2\n3\n4\n5\n") }, { path: "new.txt", content: utf8("n") }]);
127+ const ours = await commit(to, [base], 2);
128+ const tt = await applyChanges(store, tb, [{ path: "f.txt", content: utf8("1\n2\n3\n4\nfive\n") }, { path: "g.txt", delete: true }]);
129+ const theirs = await commit(tt, [base], 3);
130+ expect(await mergeBase(store, ours, theirs)).toBe(base);
131+ const res = await mergeTrees(store, tb, to, tt);
132+ expect(res.conflicts).toEqual([]);
133+ const files = await listFiles(store, res.tree!);
134+ expect(files.map((f) => f.path)).toEqual(["f.txt", "new.txt"]);
135+ expect(fromUtf8((await store.read(files[0]!.oid))!.content)).toBe("one\n2\n3\n4\nfive\n");
136+ expect(parseCommit((await store.read(ours))!.content).parents).toEqual([base]);
137+ expect((await readCommit(store, theirs)).tree).toBe(tt);
138+ });
139+});
addedpackages/git-core/tsconfig.json+1−0View file
@@ -1,0 +1,1 @@
1+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "lib": ["ES2023", "DOM"] }, "include": ["src", "test"] }
addedpackages/shared/package.json+9−0View file
@@ -1,0 +1,9 @@
1+{
2+ "name": "@diffinery/shared",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "exports": { ".": "./src/index.ts" },
7+ "scripts": { "typecheck": "tsc --noEmit" },
8+ "devDependencies": { "typescript": "^5.9.3" }
9+}
addedpackages/shared/src/events.ts+18−0View file
@@ -1,0 +1,18 @@
1+/** Events flowing through the `events` queue. Payload shapes follow GitHub webhooks where practical. */
2+export type DiffineryEvent =
3+ | { type: "push"; repoId: string; owner: string; repo: string; ref: string; before: string; after: string; actor: Actor; forced: boolean; created: boolean; deleted: boolean; at: string }
4+ | { type: "pull_request"; action: "opened" | "synchronize" | "closed" | "reopened" | "edited" | "merged"; repoId: string; owner: string; repo: string; number: number; headRef: string; headSha: string; baseRef: string; actor: Actor; at: string }
5+ | { type: "issues"; action: "opened" | "closed" | "reopened" | "edited"; repoId: string; owner: string; repo: string; number: number; actor: Actor; at: string }
6+ | { type: "issue_comment"; action: "created"; repoId: string; owner: string; repo: string; number: number; commentId: string; actor: Actor; at: string }
7+ | { type: "pull_request_review"; action: "submitted"; repoId: string; owner: string; repo: string; number: number; state: string; actor: Actor; at: string }
8+ | { type: "workflow_run"; action: "requested" | "completed"; repoId: string; owner: string; repo: string; runId: string; conclusion?: string; actor: Actor; at: string }
9+ | { type: "workflow_dispatch"; repoId: string; owner: string; repo: string; ref: string; workflow: string; inputs: Record<string, unknown>; actor: Actor; at: string }
10+ | { type: "audit"; action: string; actor: Actor; target?: string; detail?: Record<string, unknown>; at: string };
11+
12+export interface Actor {
13+ id: string;
14+ login: string;
15+ kind: "user" | "agent" | "system";
16+ /** For agents: the human or team that owns the agent. */
17+ onBehalfOf?: string;
18+}
addedpackages/shared/src/index.ts+2−0View file
@@ -1,0 +1,2 @@
1+export * from "./runner.js";
2+export * from "./events.js";
addedpackages/shared/src/runner.ts+102−0View file
@@ -1,0 +1,102 @@
1+/**
2+ * Contract between the Diffinery control plane (Worker) and the runner agent
3+ * that executes a single job inside a Cloudflare Container.
4+ *
5+ * All runner endpoints live under `${apiUrl}/runner/jobs/:jobId/...` and are
6+ * authenticated with `Authorization: Bearer <job token>`. The job token is
7+ * scoped to one job, one repository (read access for clone) and expires when
8+ * the job's timeout elapses.
9+ */
10+
11+export type Conclusion = "success" | "failure" | "cancelled" | "skipped";
12+
13+export interface StepSpec {
14+ id?: string;
15+ name?: string;
16+ if?: string;
17+ run?: string;
18+ uses?: string;
19+ with?: Record<string, string>;
20+ env?: Record<string, string>;
21+ shell?: string;
22+ "working-directory"?: string;
23+ "continue-on-error"?: boolean | string;
24+ "timeout-minutes"?: number;
25+}
26+
27+export interface NeedsResult {
28+ result: Conclusion;
29+ outputs: Record<string, string>;
30+}
31+
32+export interface JobSpec {
33+ runId: string;
34+ jobId: string;
35+ /** Key of the job in the workflow file, e.g. `build`. */
36+ jobKey: string;
37+ /** Display name, already evaluated (matrix values substituted). */
38+ name: string;
39+ repo: { owner: string; name: string; cloneUrl: string; defaultBranch: string };
40+ sha: string;
41+ ref: string;
42+ workflowPath: string;
43+ /** The full `github` context (event name, payload, actor, sha, ref, ...). */
44+ github: Record<string, unknown>;
45+ /** Workflow-level env merged with job-level env; values may contain `${{ }}`. */
46+ env: Record<string, string>;
47+ defaults?: { run?: { shell?: string; "working-directory"?: string } };
48+ matrix: Record<string, unknown>;
49+ strategy: { "fail-fast": boolean; "job-index": number; "job-total": number; "max-parallel"?: number };
50+ needs: Record<string, NeedsResult>;
51+ inputs: Record<string, unknown>;
52+ vars: Record<string, string>;
53+ /** Only the secrets this job may see. Values must be masked in logs. */
54+ secrets: Record<string, string>;
55+ /** Job `outputs:` map; values are expressions evaluated after the steps. */
56+ outputs: Record<string, string>;
57+ steps: StepSpec[];
58+ timeoutMinutes: number;
59+ continueOnError: boolean;
60+ /** Whether `permissions: id-token: write` was granted. */
61+ idToken: boolean;
62+ /** Base URL of the Diffinery API, e.g. `https://diffinery.com`. */
63+ apiUrl: string;
64+ /** Bearer token for runner endpoints and for `git clone` (as password). */
65+ token: string;
66+}
67+
68+export interface LogLine {
69+ /** ISO timestamp. */
70+ t: string;
71+ /** Index of the step this line belongs to; -1 for job setup/teardown. */
72+ step: number;
73+ text: string;
74+}
75+
76+export interface StepReport {
77+ status: "in_progress" | "completed";
78+ conclusion?: Conclusion;
79+ name: string;
80+ startedAt?: string;
81+ completedAt?: string;
82+}
83+
84+export interface JobCompletion {
85+ conclusion: Conclusion;
86+ outputs: Record<string, string>;
87+ /** Markdown written to $GITHUB_STEP_SUMMARY, if any. */
88+ summary?: string;
89+ /** Short machine-readable reason when the job failed (for agents). */
90+ failure?: { step?: number; message: string };
91+}
92+
93+/** Runner API paths, relative to `${apiUrl}/runner/jobs/${jobId}`. */
94+export const RunnerPaths = {
95+ spec: "",
96+ logs: "/logs", // POST { lines: LogLine[] }
97+ step: (index: number) => `/steps/${index}`, // POST StepReport
98+ complete: "/complete", // POST JobCompletion
99+ cache: "/cache", // GET ?key=&restore=a,b -> { key, url } | 404 ; PUT /cache/:key body=tar.gz
100+ artifacts: "/artifacts", // PUT /artifacts/:name body=zip|tar ; GET /artifacts/:name
101+ oidc: "/oidc", // POST { audience } -> { value }
102+} as const;
addedpackages/shared/tsconfig.json+1−0View file
@@ -1,0 +1,1 @@
1+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src"] }
addedpackages/workflow-yaml/package.json+10−0View file
@@ -1,0 +1,10 @@
1+{
2+ "name": "@diffinery/workflow-yaml",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "exports": { ".": "./src/index.ts" },
7+ "scripts": { "typecheck": "tsc --noEmit", "test": "vitest run" },
8+ "dependencies": { "yaml": "^2.9.1" },
9+ "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0" }
10+}
addedpackages/workflow-yaml/src/cron.ts+171−0View file
@@ -1,0 +1,171 @@
1+/**
2+ * POSIX 5-field cron (minute hour day-of-month month day-of-week), evaluated
3+ * in UTC, as used by `on.schedule`. Supports `*`, lists, ranges, steps and
4+ * month/day names. As in POSIX cron, when both day-of-month and day-of-week
5+ * are restricted a date matches if either one matches.
6+ */
7+
8+export interface ParsedCron {
9+ minutes: Set<number>;
10+ hours: Set<number>;
11+ daysOfMonth: Set<number>;
12+ months: Set<number>;
13+ daysOfWeek: Set<number>;
14+ /** Day-of-month field was `*` (or `*` with a step). */
15+ domWildcard: boolean;
16+ /** Day-of-week field was `*` (or `*` with a step). */
17+ dowWildcard: boolean;
18+}
19+
20+export class CronError extends Error {
21+ constructor(message: string) {
22+ super(message);
23+ this.name = "CronError";
24+ }
25+}
26+
27+const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
28+const DAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
29+
30+interface FieldSpec {
31+ name: string;
32+ min: number;
33+ max: number;
34+ names?: string[];
35+ namesBase?: number;
36+}
37+
38+const FIELDS: FieldSpec[] = [
39+ { name: "minute", min: 0, max: 59 },
40+ { name: "hour", min: 0, max: 23 },
41+ { name: "day of month", min: 1, max: 31 },
42+ { name: "month", min: 1, max: 12, names: MONTHS, namesBase: 1 },
43+ // 7 is accepted as Sunday and normalised to 0.
44+ { name: "day of week", min: 0, max: 7, names: DAYS, namesBase: 0 },
45+];
46+
47+function parseValue(text: string, spec: FieldSpec, cron: string): number {
48+ const upper = text.toUpperCase();
49+ if (spec.names) {
50+ const idx = spec.names.indexOf(upper);
51+ if (idx >= 0) return idx + spec.namesBase!;
52+ }
53+ if (!/^\d+$/.test(text)) throw new CronError(`Invalid ${spec.name} value '${text}' in cron '${cron}'`);
54+ const n = Number(text);
55+ if (n < spec.min || n > spec.max) {
56+ throw new CronError(`${spec.name} value ${n} out of range ${spec.min}-${spec.max} in cron '${cron}'`);
57+ }
58+ return n;
59+}
60+
61+function parseField(field: string, spec: FieldSpec, cron: string): { values: Set<number>; wildcard: boolean } {
62+ const values = new Set<number>();
63+ let wildcard = false;
64+ for (const part of field.split(",")) {
65+ if (part === "") throw new CronError(`Empty list element in ${spec.name} field of cron '${cron}'`);
66+ const [range, stepText, extra] = part.split("/");
67+ if (extra !== undefined) throw new CronError(`Invalid step in ${spec.name} field of cron '${cron}'`);
68+ let step = 1;
69+ if (stepText !== undefined) {
70+ if (!/^\d+$/.test(stepText) || Number(stepText) === 0) {
71+ throw new CronError(`Invalid step '${stepText}' in ${spec.name} field of cron '${cron}'`);
72+ }
73+ step = Number(stepText);
74+ }
75+ let lo: number;
76+ let hi: number;
77+ if (range === "*") {
78+ lo = spec.min;
79+ hi = spec.name === "day of week" ? 6 : spec.max;
80+ wildcard = true;
81+ } else {
82+ const bounds = range!.split("-");
83+ if (bounds.length > 2) throw new CronError(`Invalid range '${range}' in cron '${cron}'`);
84+ lo = parseValue(bounds[0]!, spec, cron);
85+ hi = bounds.length === 2 ? parseValue(bounds[1]!, spec, cron) : stepText !== undefined ? spec.max : lo;
86+ if (hi < lo) throw new CronError(`Invalid range '${range}' (end before start) in cron '${cron}'`);
87+ }
88+ for (let v = lo; v <= hi; v += step) values.add(spec.name === "day of week" && v === 7 ? 0 : v);
89+ }
90+ return { values, wildcard };
91+}
92+
93+/** Parses a cron expression; throws CronError with a descriptive message when invalid. */
94+export function parseCron(cron: string): ParsedCron {
95+ const fields = cron.trim().split(/\s+/);
96+ if (fields.length !== 5 || fields[0] === "") {
97+ throw new CronError(`Cron '${cron}' must have exactly 5 fields (minute hour day-of-month month day-of-week)`);
98+ }
99+ const [mi, h, dom, mo, dow] = fields.map((f, i) => parseField(f, FIELDS[i]!, cron));
100+ return {
101+ minutes: mi!.values,
102+ hours: h!.values,
103+ daysOfMonth: dom!.values,
104+ months: mo!.values,
105+ daysOfWeek: dow!.values,
106+ domWildcard: dom!.wildcard,
107+ dowWildcard: dow!.wildcard,
108+ };
109+}
110+
111+/** Returns an error message for an invalid cron, or undefined. */
112+export function validateCron(cron: string): string | undefined {
113+ try {
114+ parseCron(cron);
115+ return undefined;
116+ } catch (e) {
117+ return (e as Error).message;
118+ }
119+}
120+
121+function matchesParsed(c: ParsedCron, date: Date): boolean {
122+ if (!c.minutes.has(date.getUTCMinutes())) return false;
123+ if (!c.hours.has(date.getUTCHours())) return false;
124+ if (!c.months.has(date.getUTCMonth() + 1)) return false;
125+ const domOk = c.daysOfMonth.has(date.getUTCDate());
126+ const dowOk = c.daysOfWeek.has(date.getUTCDay());
127+ if (c.domWildcard || c.dowWildcard) return domOk && dowOk;
128+ return domOk || dowOk;
129+}
130+
131+/** True if `date` (minute precision, UTC) matches the cron expression. */
132+export function cronMatches(cron: string | ParsedCron, date: Date): boolean {
133+ return matchesParsed(typeof cron === "string" ? parseCron(cron) : cron, date);
134+}
135+
136+/**
137+ * The first minute strictly after `after` that matches the cron, or null if
138+ * none is found within ~5 years (e.g. `0 0 31 2 *`).
139+ */
140+export function nextCronMatch(cron: string | ParsedCron, after: Date): Date | null {
141+ const c = typeof cron === "string" ? parseCron(cron) : cron;
142+ const t = new Date(after.getTime());
143+ t.setUTCSeconds(0, 0);
144+ t.setUTCMinutes(t.getUTCMinutes() + 1);
145+ const limit = after.getTime() + 5 * 366 * 24 * 3600 * 1000;
146+ while (t.getTime() <= limit) {
147+ if (!c.months.has(t.getUTCMonth() + 1)) {
148+ t.setUTCMonth(t.getUTCMonth() + 1, 1);
149+ t.setUTCHours(0, 0, 0, 0);
150+ continue;
151+ }
152+ const domOk = c.daysOfMonth.has(t.getUTCDate());
153+ const dowOk = c.daysOfWeek.has(t.getUTCDay());
154+ const dayOk = c.domWildcard || c.dowWildcard ? domOk && dowOk : domOk || dowOk;
155+ if (!dayOk) {
156+ t.setUTCDate(t.getUTCDate() + 1);
157+ t.setUTCHours(0, 0, 0, 0);
158+ continue;
159+ }
160+ if (!c.hours.has(t.getUTCHours())) {
161+ t.setUTCHours(t.getUTCHours() + 1, 0, 0, 0);
162+ continue;
163+ }
164+ if (!c.minutes.has(t.getUTCMinutes())) {
165+ t.setUTCMinutes(t.getUTCMinutes() + 1, 0, 0);
166+ continue;
167+ }
168+ return t;
169+ }
170+ return null;
171+}
addedpackages/workflow-yaml/src/expr/coerce.ts+143−0View file
@@ -1,0 +1,143 @@
1+/**
2+ * GitHub Actions expression type coercion rules
3+ * (https://docs.github.com/en/actions/learn-github-actions/expressions#operators).
4+ */
5+import { parseNumberLiteral } from "./lexer.js";
6+
7+export type Kind = "null" | "boolean" | "number" | "string" | "array" | "object";
8+
9+export function kindOf(v: unknown): Kind {
10+ if (v === null || v === undefined) return "null";
11+ switch (typeof v) {
12+ case "boolean":
13+ return "boolean";
14+ case "number":
15+ case "bigint":
16+ return "number";
17+ case "string":
18+ return "string";
19+ default:
20+ return Array.isArray(v) ? "array" : "object";
21+ }
22+}
23+
24+export function isTruthy(v: unknown): boolean {
25+ switch (kindOf(v)) {
26+ case "null":
27+ return false;
28+ case "boolean":
29+ return v as boolean;
30+ case "number": {
31+ const n = Number(v);
32+ return n !== 0 && !Number.isNaN(n);
33+ }
34+ case "string":
35+ return (v as string).length > 0;
36+ default:
37+ return true;
38+ }
39+}
40+
41+const NUMERIC_STRING = /^[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|Infinity)$/;
42+
43+/** null -> 0, true -> 1, false -> 0, '' -> 0, numeric strings parsed, everything else NaN. */
44+export function toNumber(v: unknown): number {
45+ switch (kindOf(v)) {
46+ case "null":
47+ return 0;
48+ case "boolean":
49+ return v ? 1 : 0;
50+ case "number":
51+ return Number(v);
52+ case "string": {
53+ const s = (v as string).trim();
54+ if (s === "") return 0;
55+ if (!NUMERIC_STRING.test(s)) return NaN;
56+ return parseNumberLiteral(s);
57+ }
58+ default:
59+ return NaN;
60+ }
61+}
62+
63+/** Formats a number the way GitHub prints it (integers without decimals, up to 15 significant digits). */
64+export function formatNumber(n: number): string {
65+ if (Number.isNaN(n)) return "NaN";
66+ if (n === Infinity) return "Infinity";
67+ if (n === -Infinity) return "-Infinity";
68+ if (Object.is(n, -0)) return "0";
69+ if (Number.isInteger(n) && Math.abs(n) < 1e21) return n.toFixed(0);
70+ return String(parseFloat(n.toPrecision(15)));
71+}
72+
73+/** String coercion used by functions (contains, format, join...). Arrays/objects become 'Array'/'Object'. */
74+export function toStr(v: unknown): string {
75+ switch (kindOf(v)) {
76+ case "null":
77+ return "";
78+ case "boolean":
79+ return v ? "true" : "false";
80+ case "number":
81+ return formatNumber(Number(v));
82+ case "string":
83+ return v as string;
84+ case "array":
85+ return "Array";
86+ default:
87+ return "Object";
88+ }
89+}
90+
91+/** String coercion used when interpolating `${{ }}` into a template: objects/arrays become JSON. */
92+export function toTemplateString(v: unknown): string {
93+ const k = kindOf(v);
94+ if (k === "array" || k === "object") return toJSONString(v);
95+ return toStr(v);
96+}
97+
98+export function toJSONString(v: unknown): string {
99+ return JSON.stringify(v === undefined ? null : v, (_k, val: unknown) => (typeof val === "bigint" ? Number(val) : val), 2) ?? "null";
100+}
101+
102+const fold = (s: string) => s.toUpperCase();
103+
104+/** GitHub loose equality (`==`). */
105+export function looseEquals(a: unknown, b: unknown): boolean {
106+ const ka = kindOf(a);
107+ const kb = kindOf(b);
108+ if (ka !== kb) {
109+ const na = toNumber(a);
110+ const nb = toNumber(b);
111+ return na === nb;
112+ }
113+ switch (ka) {
114+ case "null":
115+ return true;
116+ case "number":
117+ return Number(a) === Number(b);
118+ case "string":
119+ return fold(a as string) === fold(b as string);
120+ case "boolean":
121+ return a === b;
122+ default:
123+ return a === b;
124+ }
125+}
126+
127+/** Returns <0, 0, >0, or NaN when the values are not comparable. */
128+export function looseCompare(a: unknown, b: unknown): number {
129+ const ka = kindOf(a);
130+ const kb = kindOf(b);
131+ if (ka !== kb || ka === "null" || ka === "boolean" || ka === "number") {
132+ const na = toNumber(a);
133+ const nb = toNumber(b);
134+ if (Number.isNaN(na) || Number.isNaN(nb)) return NaN;
135+ return na === nb ? 0 : na < nb ? -1 : 1;
136+ }
137+ if (ka === "string") {
138+ const sa = fold(a as string);
139+ const sb = fold(b as string);
140+ return sa === sb ? 0 : sa < sb ? -1 : 1;
141+ }
142+ return NaN;
143+}
addedpackages/workflow-yaml/src/expr/evaluate.ts+292−0View file
@@ -1,0 +1,292 @@
1+import {
2+ isTruthy,
3+ kindOf,
4+ looseCompare,
5+ looseEquals,
6+ toJSONString,
7+ toNumber,
8+ toStr,
9+ toTemplateString,
10+} from "./coerce.js";
11+import { parseExpression, usesStatusFunction, type Node } from "./parser.js";
12+import { soleExpression, splitTemplate } from "./template.js";
13+
14+/** Contexts: github, env, vars, secrets, matrix, needs, steps, runner, inputs, job, jobs, strategy. */
15+export type ExprContext = Record<string, unknown>;
16+
17+export interface StatusFns {
18+ success(): boolean;
19+ failure(): boolean;
20+ cancelled(): boolean;
21+ always?(): boolean;
22+}
23+
24+export interface EvalExtra {
25+ /** Implementation of hashFiles(); when absent hashFiles() returns ''. */
26+ hashFiles?: (...patterns: string[]) => string;
27+}
28+
29+export class ExpressionEvaluationError extends Error {
30+ constructor(message: string) {
31+ super(message);
32+ this.name = "ExpressionEvaluationError";
33+ }
34+}
35+
36+const DEFAULT_STATUS: StatusFns = { success: () => true, failure: () => false, cancelled: () => false };
37+
38+/** Result of an object filter (`.*`); later dereferences map over its items. */
39+class Filtered {
40+ constructor(readonly items: unknown[]) {}
41+}
42+
43+const unwrap = (v: unknown): unknown => (v instanceof Filtered ? v.items : v);
44+
45+function getProperty(obj: unknown, key: unknown): unknown {
46+ const k = kindOf(obj);
47+ if (k === "array") {
48+ const arr = obj as unknown[];
49+ const n = toNumber(key);
50+ if (!Number.isInteger(n) || n < 0 || n >= arr.length) return null;
51+ return arr[n] ?? null;
52+ }
53+ if (k !== "object") return null;
54+ const rec = obj as Record<string, unknown>;
55+ const name = toStr(key);
56+ if (Object.prototype.hasOwnProperty.call(rec, name)) return rec[name] ?? null;
57+ const lower = name.toLowerCase();
58+ for (const own of Object.keys(rec)) {
59+ if (own.toLowerCase() === lower) return rec[own] ?? null;
60+ }
61+ return null;
62+}
63+
64+function wildcard(v: unknown, into: unknown[]): void {
65+ const k = kindOf(v);
66+ if (k === "array") into.push(...(v as unknown[]));
67+ else if (k === "object") into.push(...Object.values(v as Record<string, unknown>));
68+}
69+
70+class Evaluator {
71+ constructor(
72+ private readonly ctx: ExprContext,
73+ private readonly status: StatusFns,
74+ private readonly extra: EvalExtra,
75+ ) {}
76+
77+ value(node: Node): unknown {
78+ return unwrap(this.eval(node));
79+ }
80+
81+ private eval(node: Node): unknown {
82+ switch (node.type) {
83+ case "literal":
84+ return node.value;
85+ case "context":
86+ return getProperty(this.ctx, node.name);
87+ case "index":
88+ return this.index(node.object, node.index);
89+ case "not":
90+ return !isTruthy(this.value(node.operand));
91+ case "logical": {
92+ const left = this.value(node.left);
93+ if (node.op === "&&") return isTruthy(left) ? this.value(node.right) : left;
94+ return isTruthy(left) ? left : this.value(node.right);
95+ }
96+ case "binary": {
97+ const l = this.value(node.left);
98+ const r = this.value(node.right);
99+ switch (node.op) {
100+ case "==":
101+ return looseEquals(l, r);
102+ case "!=":
103+ return !looseEquals(l, r);
104+ case "<":
105+ return looseCompare(l, r) < 0;
106+ case "<=":
107+ return looseCompare(l, r) <= 0;
108+ case ">":
109+ return looseCompare(l, r) > 0;
110+ case ">=":
111+ return looseCompare(l, r) >= 0;
112+ }
113+ break;
114+ }
115+ case "call":
116+ return this.call(node.name, node.args);
117+ }
118+ return null;
119+ }
120+
121+ private index(objectNode: Node, indexNode: Node | "*"): unknown {
122+ const obj = this.eval(objectNode);
123+ if (obj instanceof Filtered) {
124+ const out: unknown[] = [];
125+ if (indexNode === "*") {
126+ for (const item of obj.items) wildcard(item, out);
127+ } else {
128+ const key = this.value(indexNode);
129+ for (const item of obj.items) {
130+ const v = getProperty(item, key);
131+ if (v !== null && v !== undefined) out.push(v);
132+ }
133+ }
134+ return new Filtered(out);
135+ }
136+ if (indexNode === "*") {
137+ const out: unknown[] = [];
138+ wildcard(obj, out);
139+ return new Filtered(out);
140+ }
141+ return getProperty(obj, this.value(indexNode));
142+ }
143+
144+ private call(name: string, argNodes: Node[]): unknown {
145+ switch (name) {
146+ case "success":
147+ return this.status.success();
148+ case "failure":
149+ return this.status.failure();
150+ case "cancelled":
151+ return this.status.cancelled();
152+ case "always":
153+ return this.status.always ? this.status.always() : true;
154+ }
155+ const args = argNodes.map((a) => this.value(a));
156+ switch (name) {
157+ case "contains": {
158+ const [search, item] = args;
159+ if (kindOf(search) === "array") return (search as unknown[]).some((el) => looseEquals(el, item));
160+ return toStr(search).toUpperCase().includes(toStr(item).toUpperCase());
161+ }
162+ case "startswith":
163+ return toStr(args[0]).toUpperCase().startsWith(toStr(args[1]).toUpperCase());
164+ case "endswith":
165+ return toStr(args[0]).toUpperCase().endsWith(toStr(args[1]).toUpperCase());
166+ case "format":
167+ return format(toStr(args[0]), args.slice(1));
168+ case "join": {
169+ const sep = args.length > 1 ? toStr(args[1]) : ",";
170+ const v = args[0];
171+ if (kindOf(v) === "array") return (v as unknown[]).map(toStr).join(sep);
172+ return toStr(v);
173+ }
174+ case "tojson":
175+ return toJSONString(args[0]);
176+ case "fromjson": {
177+ const text = toStr(args[0]);
178+ try {
179+ return JSON.parse(text) as unknown;
180+ } catch (e) {
181+ throw new ExpressionEvaluationError(`fromJSON: invalid JSON ${JSON.stringify(text)}: ${(e as Error).message}`);
182+ }
183+ }
184+ case "hashfiles":
185+ return this.extra.hashFiles ? this.extra.hashFiles(...args.map(toStr)) : "";
186+ }
187+ throw new ExpressionEvaluationError(`Unrecognized function '${name}'`);
188+ }
189+}
190+
191+/** GitHub's format(): `{N}` placeholders, `{{` and `}}` escapes. */
192+export function format(fmt: string, args: unknown[]): string {
193+ let out = "";
194+ for (let i = 0; i < fmt.length; i++) {
195+ const c = fmt[i];
196+ if (c === "{") {
197+ if (fmt[i + 1] === "{") {
198+ out += "{";
199+ i++;
200+ continue;
201+ }
202+ const close = fmt.indexOf("}", i);
203+ const idx = close < 0 ? "" : fmt.slice(i + 1, close);
204+ if (!/^\d+$/.test(idx)) throw new ExpressionEvaluationError(`format: invalid format string '${fmt}'`);
205+ const n = Number(idx);
206+ if (n >= args.length) {
207+ throw new ExpressionEvaluationError(`format: placeholder {${n}} has no matching argument in '${fmt}'`);
208+ }
209+ out += toStr(args[n]);
210+ i = close;
211+ } else if (c === "}") {
212+ if (fmt[i + 1] === "}") {
213+ out += "}";
214+ i++;
215+ continue;
216+ }
217+ throw new ExpressionEvaluationError(`format: invalid format string '${fmt}' (unescaped '}')`);
218+ } else {
219+ out += c;
220+ }
221+ }
222+ return out;
223+}
224+
225+/** Evaluates an expression (without `${{ }}`). Throws on syntax errors. */
226+export function evaluate(expr: string, ctx: ExprContext, status?: StatusFns, extra?: EvalExtra): unknown {
227+ const node = parseExpression(expr);
228+ return new Evaluator(ctx, status ?? DEFAULT_STATUS, extra ?? {}).value(node);
229+}
230+
231+/** Replaces every `${{ expr }}` in `template` with its string-coerced value. */
232+export function interpolate(template: string, ctx: ExprContext, status?: StatusFns, extra?: EvalExtra): string {
233+ if (!template.includes("${{")) return template;
234+ let out = "";
235+ for (const part of splitTemplate(template)) {
236+ out += part.kind === "text" ? part.text : toTemplateString(evaluate(part.expr, ctx, status, extra));
237+ }
238+ return out;
239+}
240+
241+/**
242+ * Evaluates a YAML value that may be an expression: a string that is exactly
243+ * `${{ expr }}` yields the raw (typed) result, other strings are interpolated,
244+ * and non-strings are returned unchanged.
245+ */
246+export function evaluateValue(value: unknown, ctx: ExprContext, status?: StatusFns, extra?: EvalExtra): unknown {
247+ if (typeof value !== "string") return value;
248+ const sole = soleExpression(value);
249+ if (sole !== undefined) return evaluate(sole, ctx, status, extra);
250+ return interpolate(value, ctx, status, extra);
251+}
252+
253+/**
254+ * Evaluates a job/step `if:` condition with GitHub rules: an absent condition
255+ * means `success()`, and a condition without a status function is implicitly
256+ * `success() && (cond)`. The condition may or may not be wrapped in `${{ }}`.
257+ */
258+export function evaluateIf(
259+ cond: string | boolean | undefined | null,
260+ ctx: ExprContext,
261+ status: StatusFns,
262+ extra?: EvalExtra,
263+): boolean {
264+ if (cond === undefined || cond === null) return status.success();
265+ if (typeof cond === "boolean") return cond && status.success();
266+ const trimmed = cond.trim();
267+ if (trimmed === "") return status.success();
268+ let expr = soleExpression(trimmed);
269+ if (expr === undefined) {
270+ if (trimmed.includes("${{")) {
271+ // Mixed text and expressions: the result is a string, truthy unless empty.
272+ const parts = splitTemplate(trimmed);
273+ const hasStatus = parts.some((p) => p.kind === "expr" && usesStatusFunction(parseExpression(p.expr)));
274+ const text = interpolate(trimmed, ctx, status, extra);
275+ return (hasStatus || status.success()) && isTruthy(text);
276+ }
277+ expr = trimmed;
278+ }
279+ const node = parseExpression(expr);
280+ if (!usesStatusFunction(node) && !status.success()) return false;
281+ return isTruthy(new Evaluator(ctx, status, extra ?? {}).value(node));
282+}
283+
284+/** Validates an expression's syntax; returns an error message or undefined. */
285+export function checkExpressionSyntax(expr: string): string | undefined {
286+ try {
287+ parseExpression(expr);
288+ return undefined;
289+ } catch (e) {
290+ return (e as Error).message;
291+ }
292+}
addedpackages/workflow-yaml/src/expr/lexer.ts+143−0View file
@@ -1,0 +1,143 @@
1+/**
2+ * Tokenizer for the GitHub Actions expression language
3+ * (https://docs.github.com/en/actions/learn-github-actions/expressions).
4+ */
5+
6+export type TokenKind =
7+ | "number"
8+ | "string"
9+ | "ident"
10+ | "true"
11+ | "false"
12+ | "null"
13+ | "("
14+ | ")"
15+ | "["
16+ | "]"
17+ | "."
18+ | ","
19+ | "*"
20+ | "!"
21+ | "<"
22+ | "<="
23+ | ">"
24+ | ">="
25+ | "=="
26+ | "!="
27+ | "&&"
28+ | "||"
29+ | "eof";
30+
31+export interface Token {
32+ kind: TokenKind;
33+ /** Raw source text of the token. */
34+ text: string;
35+ /** Parsed value for number/string literals. */
36+ value?: number | string;
37+ /** Offset in the expression source. */
38+ pos: number;
39+}
40+
41+export class ExpressionSyntaxError extends Error {
42+ constructor(
43+ message: string,
44+ readonly expression: string,
45+ readonly pos: number,
46+ ) {
47+ super(`${message} (at position ${pos + 1} in expression: ${expression})`);
48+ this.name = "ExpressionSyntaxError";
49+ }
50+}
51+
52+const isDigit = (c: string | undefined): boolean => c !== undefined && c >= "0" && c <= "9";
53+const isIdentStart = (c: string | undefined): boolean =>
54+ c !== undefined && ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_");
55+const isIdentPart = (c: string | undefined): boolean => isIdentStart(c) || isDigit(c) || c === "-";
56+
57+const NUMBER_RE = /^[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/;
58+
59+/** Parses the numeric literal syntax used by GitHub (JSON numbers, hex, octal, exponent). */
60+export function parseNumberLiteral(text: string): number {
61+ const t = text.trim();
62+ const sign = t.startsWith("-") ? -1 : 1;
63+ const body = t.replace(/^[+-]/, "");
64+ if (/^0[xX][0-9a-fA-F]+$/.test(body)) return sign * parseInt(body.slice(2), 16);
65+ if (/^0[oO][0-7]+$/.test(body)) return sign * parseInt(body.slice(2), 8);
66+ return Number(t);
67+}
68+
69+export function tokenize(src: string): Token[] {
70+ const tokens: Token[] = [];
71+ let i = 0;
72+ const push = (kind: TokenKind, text: string, pos: number, value?: number | string) =>
73+ tokens.push(value === undefined ? { kind, text, pos } : { kind, text, pos, value });
74+
75+ while (i < src.length) {
76+ const c = src[i]!;
77+ if (c === " " || c === "\t" || c === "\n" || c === "\r") {
78+ i++;
79+ continue;
80+ }
81+ const start = i;
82+ // String literal: single quotes, '' escapes a quote.
83+ if (c === "'") {
84+ let out = "";
85+ i++;
86+ for (;;) {
87+ if (i >= src.length) throw new ExpressionSyntaxError("Unterminated string literal", src, start);
88+ const ch = src[i]!;
89+ if (ch === "'") {
90+ if (src[i + 1] === "'") {
91+ out += "'";
92+ i += 2;
93+ continue;
94+ }
95+ i++;
96+ break;
97+ }
98+ out += ch;
99+ i++;
100+ }
101+ push("string", src.slice(start, i), start, out);
102+ continue;
103+ }
104+ if (c === '"') {
105+ throw new ExpressionSyntaxError("Unexpected double quote; string literals use single quotes", src, start);
106+ }
107+ // Numbers (a leading '-' or '+' is part of the literal, there is no unary minus).
108+ if (isDigit(c) || ((c === "-" || c === "+" || c === ".") && (isDigit(src[i + 1]) || (src[i + 1] === "." && isDigit(src[i + 2]))))) {
109+ const m = NUMBER_RE.exec(src.slice(i));
110+ if (!m) throw new ExpressionSyntaxError("Invalid number", src, start);
111+ const text = m[0];
112+ if (isIdentPart(src[i + text.length]) && src[i + text.length] !== "-") {
113+ throw new ExpressionSyntaxError(`Invalid number '${text}${src[i + text.length]}'`, src, start);
114+ }
115+ i += text.length;
116+ push("number", text, start, parseNumberLiteral(text));
117+ continue;
118+ }
119+ if (isIdentStart(c)) {
120+ while (isIdentPart(src[i])) i++;
121+ const text = src.slice(start, i);
122+ if (text === "true" || text === "false" || text === "null") push(text, text, start);
123+ else if (text === "NaN") push("number", text, start, NaN);
124+ else if (text === "Infinity") push("number", text, start, Infinity);
125+ else push("ident", text, start);
126+ continue;
127+ }
128+ const two = src.slice(i, i + 2);
129+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
130+ push(two, two, start);
131+ i += 2;
132+ continue;
133+ }
134+ if ("()[].,*!<>".includes(c)) {
135+ push(c as TokenKind, c, start);
136+ i++;
137+ continue;
138+ }
139+ throw new ExpressionSyntaxError(`Unexpected character '${c}'`, src, start);
140+ }
141+ push("eof", "", src.length);
142+ return tokens;
143+}
addedpackages/workflow-yaml/src/expr/parser.ts+216−0View file
@@ -1,0 +1,216 @@
1+import { ExpressionSyntaxError, tokenize, type Token, type TokenKind } from "./lexer.js";
2+
3+export type Node =
4+ | { type: "literal"; value: null | boolean | number | string }
5+ | { type: "context"; name: string }
6+ | { type: "index"; object: Node; index: Node | "*" }
7+ | { type: "call"; name: string; args: Node[] }
8+ | { type: "not"; operand: Node }
9+ | { type: "binary"; op: "==" | "!=" | "<" | "<=" | ">" | ">="; left: Node; right: Node }
10+ | { type: "logical"; op: "&&" | "||"; left: Node; right: Node };
11+
12+/** Built-in functions: lower-cased name -> [min args, max args]. */
13+export const FUNCTIONS: Record<string, { name: string; min: number; max: number }> = {
14+ contains: { name: "contains", min: 2, max: 2 },
15+ startswith: { name: "startsWith", min: 2, max: 2 },
16+ endswith: { name: "endsWith", min: 2, max: 2 },
17+ format: { name: "format", min: 1, max: Infinity },
18+ join: { name: "join", min: 1, max: 2 },
19+ tojson: { name: "toJSON", min: 1, max: 1 },
20+ fromjson: { name: "fromJSON", min: 1, max: 1 },
21+ hashfiles: { name: "hashFiles", min: 1, max: Infinity },
22+ success: { name: "success", min: 0, max: 0 },
23+ failure: { name: "failure", min: 0, max: 0 },
24+ cancelled: { name: "cancelled", min: 0, max: 0 },
25+ always: { name: "always", min: 0, max: 0 },
26+};
27+
28+export const STATUS_FUNCTIONS = new Set(["success", "failure", "cancelled", "always"]);
29+
30+class Parser {
31+ private i = 0;
32+ constructor(
33+ private readonly src: string,
34+ private readonly tokens: Token[],
35+ ) {}
36+
37+ private peek(): Token {
38+ return this.tokens[this.i]!;
39+ }
40+ private next(): Token {
41+ return this.tokens[this.i++]!;
42+ }
43+ private fail(message: string, tok: Token = this.peek()): never {
44+ throw new ExpressionSyntaxError(message, this.src, tok.pos);
45+ }
46+ private expect(kind: TokenKind): Token {
47+ const t = this.peek();
48+ if (t.kind !== kind) this.fail(`Expected '${kind}' but found ${describe(t)}`);
49+ return this.next();
50+ }
51+
52+ parse(): Node {
53+ if (this.peek().kind === "eof") this.fail("Empty expression");
54+ const node = this.parseOr();
55+ if (this.peek().kind !== "eof") this.fail(`Unexpected ${describe(this.peek())}`);
56+ return node;
57+ }
58+
59+ private parseOr(): Node {
60+ let left = this.parseAnd();
61+ while (this.peek().kind === "||") {
62+ this.next();
63+ left = { type: "logical", op: "||", left, right: this.parseAnd() };
64+ }
65+ return left;
66+ }
67+
68+ private parseAnd(): Node {
69+ let left = this.parseEquality();
70+ while (this.peek().kind === "&&") {
71+ this.next();
72+ left = { type: "logical", op: "&&", left, right: this.parseEquality() };
73+ }
74+ return left;
75+ }
76+
77+ private parseEquality(): Node {
78+ let left = this.parseComparison();
79+ for (;;) {
80+ const k = this.peek().kind;
81+ if (k !== "==" && k !== "!=") return left;
82+ this.next();
83+ left = { type: "binary", op: k, left, right: this.parseComparison() };
84+ }
85+ }
86+
87+ private parseComparison(): Node {
88+ let left = this.parseUnary();
89+ for (;;) {
90+ const k = this.peek().kind;
91+ if (k !== "<" && k !== "<=" && k !== ">" && k !== ">=") return left;
92+ this.next();
93+ left = { type: "binary", op: k, left, right: this.parseUnary() };
94+ }
95+ }
96+
97+ private parseUnary(): Node {
98+ if (this.peek().kind === "!") {
99+ this.next();
100+ return { type: "not", operand: this.parseUnary() };
101+ }
102+ return this.parsePostfix(this.parsePrimary());
103+ }
104+
105+ private parsePostfix(node: Node): Node {
106+ for (;;) {
107+ const t = this.peek();
108+ if (t.kind === ".") {
109+ this.next();
110+ const p = this.peek();
111+ if (p.kind === "*") {
112+ this.next();
113+ node = { type: "index", object: node, index: "*" };
114+ } else if (p.kind === "ident" || p.kind === "true" || p.kind === "false" || p.kind === "null") {
115+ this.next();
116+ node = { type: "index", object: node, index: { type: "literal", value: p.text } };
117+ } else {
118+ this.fail(`Expected property name after '.' but found ${describe(p)}`);
119+ }
120+ } else if (t.kind === "[") {
121+ this.next();
122+ if (this.peek().kind === "*") {
123+ this.next();
124+ this.expect("]");
125+ node = { type: "index", object: node, index: "*" };
126+ } else {
127+ const index = this.parseOr();
128+ this.expect("]");
129+ node = { type: "index", object: node, index };
130+ }
131+ } else {
132+ return node;
133+ }
134+ }
135+ }
136+
137+ private parsePrimary(): Node {
138+ const t = this.next();
139+ switch (t.kind) {
140+ case "number":
141+ case "string":
142+ return { type: "literal", value: t.value! };
143+ case "true":
144+ return { type: "literal", value: true };
145+ case "false":
146+ return { type: "literal", value: false };
147+ case "null":
148+ return { type: "literal", value: null };
149+ case "(": {
150+ const inner = this.parseOr();
151+ this.expect(")");
152+ return inner;
153+ }
154+ case "ident": {
155+ if (this.peek().kind === "(") {
156+ this.next();
157+ const fn = FUNCTIONS[t.text.toLowerCase()];
158+ if (!fn) this.fail(`Unrecognized function '${t.text}'`, t);
159+ const args: Node[] = [];
160+ if (this.peek().kind !== ")") {
161+ for (;;) {
162+ args.push(this.parseOr());
163+ if (this.peek().kind === ",") {
164+ this.next();
165+ continue;
166+ }
167+ break;
168+ }
169+ }
170+ this.expect(")");
171+ if (args.length < fn.min || args.length > fn.max) {
172+ const expected = fn.min === fn.max ? `${fn.min}` : fn.max === Infinity ? `at least ${fn.min}` : `${fn.min}-${fn.max}`;
173+ this.fail(`Function '${fn.name}' expects ${expected} argument(s) but got ${args.length}`, t);
174+ }
175+ return { type: "call", name: t.text.toLowerCase(), args };
176+ }
177+ return { type: "context", name: t.text };
178+ }
179+ default:
180+ return this.fail(`Unexpected ${describe(t)}`, t);
181+ }
182+ }
183+}
184+
185+function describe(t: Token): string {
186+ return t.kind === "eof" ? "end of expression" : `'${t.text}'`;
187+}
188+
189+const cache = new Map<string, Node>();
190+
191+/** Parses an expression (without the surrounding `${{ }}`). Throws ExpressionSyntaxError. */
192+export function parseExpression(src: string): Node {
193+ const hit = cache.get(src);
194+ if (hit) return hit;
195+ const node = new Parser(src, tokenize(src)).parse();
196+ if (cache.size > 2000) cache.clear();
197+ cache.set(src, node);
198+ return node;
199+}
200+
201+/** True if the AST calls success(), failure(), cancelled() or always(). */
202+export function usesStatusFunction(node: Node): boolean {
203+ switch (node.type) {
204+ case "call":
205+ return STATUS_FUNCTIONS.has(node.name) || node.args.some(usesStatusFunction);
206+ case "index":
207+ return usesStatusFunction(node.object) || (node.index !== "*" && usesStatusFunction(node.index));
208+ case "not":
209+ return usesStatusFunction(node.operand);
210+ case "binary":
211+ case "logical":
212+ return usesStatusFunction(node.left) || usesStatusFunction(node.right);
213+ default:
214+ return false;
215+ }
216+}
addedpackages/workflow-yaml/src/expr/template.ts+68−0View file
@@ -1,0 +1,68 @@
1+/** Splitting of strings containing `${{ expr }}` placeholders. */
2+
3+export type TemplatePart =
4+ | { kind: "text"; text: string }
5+ | { kind: "expr"; expr: string; start: number; end: number };
6+
7+export class TemplateError extends Error {
8+ constructor(
9+ message: string,
10+ readonly offset: number,
11+ ) {
12+ super(message);
13+ this.name = "TemplateError";
14+ }
15+}
16+
17+export function containsExpression(s: string): boolean {
18+ return typeof s === "string" && s.includes("${{");
19+}
20+
21+/**
22+ * Splits a template into literal text and expressions. `}}` inside a
23+ * single-quoted string literal does not close the expression.
24+ * Throws TemplateError when a `${{` is not closed.
25+ */
26+export function splitTemplate(s: string): TemplatePart[] {
27+ const parts: TemplatePart[] = [];
28+ let pos = 0;
29+ for (;;) {
30+ const open = s.indexOf("${{", pos);
31+ if (open < 0) {
32+ if (pos < s.length) parts.push({ kind: "text", text: s.slice(pos) });
33+ return parts;
34+ }
35+ if (open > pos) parts.push({ kind: "text", text: s.slice(pos, open) });
36+ let i = open + 3;
37+ let inString = false;
38+ let close = -1;
39+ while (i < s.length) {
40+ const c = s[i];
41+ if (c === "'") inString = !inString; // '' escapes toggle twice, which is a no-op
42+ else if (!inString && c === "}" && s[i + 1] === "}") {
43+ close = i;
44+ break;
45+ }
46+ i++;
47+ }
48+ if (close < 0) {
49+ throw new TemplateError(`Unclosed expression: '\${{' at offset ${open} has no matching '}}'`, open);
50+ }
51+ parts.push({ kind: "expr", expr: s.slice(open + 3, close).trim(), start: open, end: close + 2 });
52+ pos = close + 2;
53+ }
54+}
55+
56+/** If the whole (trimmed) string is a single `${{ expr }}`, returns the inner expression. */
57+export function soleExpression(s: string): string | undefined {
58+ const t = s.trim();
59+ if (!t.startsWith("${{")) return undefined;
60+ let parts: TemplatePart[];
61+ try {
62+ parts = splitTemplate(t);
63+ } catch {
64+ return undefined;
65+ }
66+ const only = parts[0];
67+ return parts.length === 1 && only?.kind === "expr" ? only.expr : undefined;
68+}
addedpackages/workflow-yaml/src/filters.ts+194−0View file
@@ -1,0 +1,194 @@
1+/**
2+ * Event filters: GitHub filter-pattern globbing and `on:` trigger matching.
3+ * https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
4+ */
5+import type { PullRequestTrigger, PushTrigger, Workflow, WorkflowRunTrigger } from "./model.js";
6+
7+export type TriggerEvent =
8+ | { name: "push"; ref: string; changedFiles?: string[] }
9+ | { name: "pull_request" | "pull_request_target"; action: string; baseRef: string; changedFiles?: string[] }
10+ | { name: "schedule"; cron: string }
11+ | { name: "workflow_dispatch" }
12+ | {
13+ name: string;
14+ action?: string;
15+ /** workflow_run: name of the workflow that completed. */
16+ workflow?: string;
17+ /** workflow_run: head branch of the triggering run. */
18+ branch?: string;
19+ };
20+
21+const regexCache = new Map<string, RegExp>();
22+
23+/**
24+ * Converts a GitHub filter pattern to a RegExp:
25+ * `*` any chars except `/`; `**` any chars; `?` zero or one of the preceding
26+ * character; `+` one or more of the preceding character; `[...]` a character
27+ * class/range; `\` escapes the next character. A leading double-star-slash
28+ * segment may also match nothing, so `docs/a.md` matches the pattern
29+ * double-star-slash-docs-slash-double-star.
30+ */
31+export function globToRegExp(pattern: string): RegExp {
32+ const cached = regexCache.get(pattern);
33+ if (cached) return cached;
34+ let re = "";
35+ let canQuantify = false;
36+ for (let i = 0; i < pattern.length; i++) {
37+ const c = pattern[i]!;
38+ if (c === "\\" && i + 1 < pattern.length) {
39+ re += escapeRe(pattern[++i]!);
40+ canQuantify = true;
41+ } else if (c === "*") {
42+ if (pattern[i + 1] === "*") {
43+ i++;
44+ if (pattern[i + 1] === "/") {
45+ i++;
46+ re += "(?:.*/)?";
47+ } else {
48+ re += ".*";
49+ }
50+ } else {
51+ re += "[^/]*";
52+ }
53+ canQuantify = false;
54+ } else if ((c === "?" || c === "+") && canQuantify) {
55+ re += c;
56+ canQuantify = false;
57+ } else if (c === "[") {
58+ const close = pattern.indexOf("]", i + 1);
59+ if (close > i + 1) {
60+ const body = pattern.slice(i + 1, close).replace(/[\\\]^]/g, (m) => `\\${m}`);
61+ re += `[${body}]`;
62+ i = close;
63+ canQuantify = true;
64+ } else {
65+ re += "\\[";
66+ canQuantify = true;
67+ }
68+ } else {
69+ re += escapeRe(c);
70+ canQuantify = true;
71+ }
72+ }
73+ const out = new RegExp(`^${re}$`);
74+ if (regexCache.size > 5000) regexCache.clear();
75+ regexCache.set(pattern, out);
76+ return out;
77+}
78+
79+function escapeRe(c: string): string {
80+ return /[.*+?^${}()|[\]\\/]/.test(c) ? `\\${c}` : c;
81+}
82+
83+/** Matches a single filter pattern (without a leading `!`) against a value. */
84+export function globMatch(pattern: string, value: string): boolean {
85+ return globToRegExp(pattern).test(value);
86+}
87+
88+/**
89+ * Applies an ordered pattern list where `!pattern` negates: the value matches
90+ * when the last pattern that matches it is a positive one.
91+ */
92+export function matchesPatternList(patterns: readonly string[], value: string): boolean {
93+ let matched = false;
94+ for (const p of patterns) {
95+ if (p.startsWith("!")) {
96+ if (matched && globMatch(p.slice(1), value)) matched = false;
97+ } else if (!matched && globMatch(p, value)) {
98+ matched = true;
99+ }
100+ }
101+ return matched;
102+}
103+
104+/** Include/ignore filter pair. Undefined filters accept everything. */
105+function passesFilter(value: string, include?: string[], ignore?: string[]): boolean {
106+ if (include) return matchesPatternList(include, value);
107+ if (ignore) return !matchesPatternList(ignore, value);
108+ return true;
109+}
110+
111+/**
112+ * Path filters: with `paths`, at least one changed file must match; with
113+ * `paths-ignore`, the workflow runs unless every changed file is ignored.
114+ * When the changed files are unknown the filter passes (GitHub runs the
115+ * workflow when it cannot compute the diff).
116+ */
117+function passesPaths(changed: string[] | undefined, paths?: string[], pathsIgnore?: string[]): boolean {
118+ if (changed === undefined || (!paths && !pathsIgnore)) return true;
119+ if (paths) return changed.some((f) => matchesPatternList(paths, f));
120+ return changed.some((f) => !matchesPatternList(pathsIgnore!, f));
121+}
122+
123+function stripPrefix(ref: string, prefix: string): string {
124+ return ref.startsWith(prefix) ? ref.slice(prefix.length) : ref;
125+}
126+
127+function matchesPush(cfg: PushTrigger, ref: string, changedFiles?: string[]): boolean {
128+ const hasBranchFilter = cfg.branches !== undefined || cfg.branchesIgnore !== undefined;
129+ const hasTagFilter = cfg.tags !== undefined || cfg.tagsIgnore !== undefined;
130+ if (ref.startsWith("refs/tags/")) {
131+ const tag = ref.slice("refs/tags/".length);
132+ if (hasTagFilter) return passesFilter(tag, cfg.tags, cfg.tagsIgnore);
133+ // Only branch filters: tag pushes do not trigger. Path filters are not evaluated for tags.
134+ return !hasBranchFilter;
135+ }
136+ const branch = stripPrefix(ref, "refs/heads/");
137+ if (hasBranchFilter) {
138+ if (!passesFilter(branch, cfg.branches, cfg.branchesIgnore)) return false;
139+ } else if (hasTagFilter) {
140+ return false;
141+ }
142+ return passesPaths(changedFiles, cfg.paths, cfg.pathsIgnore);
143+}
144+
145+export const DEFAULT_PULL_REQUEST_TYPES = ["opened", "synchronize", "reopened"];
146+
147+function matchesPullRequest(cfg: PullRequestTrigger, action: string, baseRef: string, changedFiles?: string[]): boolean {
148+ const types = cfg.types ?? DEFAULT_PULL_REQUEST_TYPES;
149+ if (!types.includes(action)) return false;
150+ const base = stripPrefix(baseRef, "refs/heads/");
151+ if (!passesFilter(base, cfg.branches, cfg.branchesIgnore)) return false;
152+ return passesPaths(changedFiles, cfg.paths, cfg.pathsIgnore);
153+}
154+
155+const normalizeCron = (c: string) => c.trim().split(/\s+/).join(" ");
156+
157+/** Decides whether a workflow is triggered by an event, following GitHub semantics. */
158+export function matchesTrigger(wf: Workflow, ev: TriggerEvent): boolean {
159+ const on = wf.on;
160+ if (!Object.prototype.hasOwnProperty.call(on, ev.name)) return false;
161+ switch (ev.name) {
162+ case "push": {
163+ const e = ev as Extract<TriggerEvent, { name: "push" }>;
164+ return matchesPush(on.push ?? {}, e.ref, e.changedFiles);
165+ }
166+ case "pull_request":
167+ case "pull_request_target": {
168+ const e = ev as Extract<TriggerEvent, { name: "pull_request" | "pull_request_target" }>;
169+ return matchesPullRequest(on[ev.name] ?? {}, e.action, e.baseRef, e.changedFiles);
170+ }
171+ case "schedule": {
172+ const e = ev as Extract<TriggerEvent, { name: "schedule" }>;
173+ return (on.schedule ?? []).some((s) => normalizeCron(s.cron) === normalizeCron(e.cron));
174+ }
175+ case "workflow_dispatch":
176+ case "workflow_call":
177+ return true;
178+ case "workflow_run": {
179+ const e = ev as { action?: string; workflow?: string; branch?: string };
180+ const cfg: WorkflowRunTrigger = on.workflow_run ?? {};
181+ if (e.action !== undefined && cfg.types && !cfg.types.includes(e.action)) return false;
182+ if (e.workflow !== undefined && cfg.workflows && !cfg.workflows.includes(e.workflow)) return false;
183+ if (e.branch !== undefined && !passesFilter(e.branch, cfg.branches, cfg.branchesIgnore)) return false;
184+ return true;
185+ }
186+ default: {
187+ const e = ev as { action?: string };
188+ const cfg = on[ev.name] as { types?: unknown } | undefined;
189+ const types = cfg && Array.isArray(cfg.types) ? (cfg.types as string[]) : undefined;
190+ if (e.action !== undefined && types && !types.includes(e.action)) return false;
191+ return true;
192+ }
193+ }
194+}
addedpackages/workflow-yaml/src/model.ts+174−0View file
@@ -1,0 +1,174 @@
1+/** Normalized workflow model produced by parseWorkflow(). */
2+
3+export interface WorkflowError {
4+ /** Dotted path into the workflow, e.g. `jobs.build.steps[2].run`. */
5+ path: string;
6+ /** 1-based line in the YAML source, when known. */
7+ line?: number;
8+ /** 1-based column in the YAML source, when known. */
9+ col?: number;
10+ /** Message; warnings are prefixed with `warning:`. */
11+ message: string;
12+}
13+
14+export interface RefFilters {
15+ branches?: string[];
16+ branchesIgnore?: string[];
17+ tags?: string[];
18+ tagsIgnore?: string[];
19+ paths?: string[];
20+ pathsIgnore?: string[];
21+}
22+
23+export interface PushTrigger extends RefFilters {}
24+
25+export interface PullRequestTrigger {
26+ types?: string[];
27+ branches?: string[];
28+ branchesIgnore?: string[];
29+ paths?: string[];
30+ pathsIgnore?: string[];
31+}
32+
33+export interface ScheduleTrigger {
34+ cron: string;
35+}
36+
37+export interface DispatchInput {
38+ description?: string;
39+ required?: boolean;
40+ default?: unknown;
41+ /** string | boolean | number | choice | environment (workflow_dispatch) — string | boolean | number (workflow_call). */
42+ type?: string;
43+ options?: string[];
44+ deprecationMessage?: string;
45+}
46+
47+export interface WorkflowDispatchTrigger {
48+ inputs?: Record<string, DispatchInput>;
49+}
50+
51+export interface WorkflowCallTrigger {
52+ inputs?: Record<string, DispatchInput>;
53+ outputs?: Record<string, { description?: string; value: string }>;
54+ secrets?: Record<string, { description?: string; required?: boolean }>;
55+}
56+
57+export interface WorkflowRunTrigger {
58+ workflows?: string[];
59+ types?: string[];
60+ branches?: string[];
61+ branchesIgnore?: string[];
62+}
63+
64+/** Generic event configuration (issues, issue_comment, release, ...). */
65+export interface ActivityTrigger {
66+ types?: string[];
67+ [key: string]: unknown;
68+}
69+
70+export interface WorkflowOn {
71+ push?: PushTrigger;
72+ pull_request?: PullRequestTrigger;
73+ pull_request_target?: PullRequestTrigger;
74+ schedule?: ScheduleTrigger[];
75+ workflow_dispatch?: WorkflowDispatchTrigger;
76+ workflow_call?: WorkflowCallTrigger;
77+ workflow_run?: WorkflowRunTrigger;
78+ [event: string]: unknown;
79+}
80+
81+export interface Defaults {
82+ run?: { shell?: string; "working-directory"?: string };
83+}
84+
85+export type Permissions = "read-all" | "write-all" | Record<string, string>;
86+
87+export interface Concurrency {
88+ group: string;
89+ /** Boolean, or an expression string evaluated at runtime. */
90+ cancelInProgress: boolean | string;
91+}
92+
93+export interface ContainerSpec {
94+ image: string;
95+ credentials?: { username?: string; password?: string };
96+ env?: Record<string, string>;
97+ ports?: Array<string | number>;
98+ volumes?: string[];
99+ options?: string;
100+}
101+
102+export interface EnvironmentSpec {
103+ name: string;
104+ url?: string;
105+}
106+
107+/** Compatible with `StepSpec` in @diffinery/shared. */
108+export interface StepSpec {
109+ id?: string;
110+ name?: string;
111+ if?: string;
112+ run?: string;
113+ uses?: string;
114+ with?: Record<string, string>;
115+ env?: Record<string, string>;
116+ shell?: string;
117+ "working-directory"?: string;
118+ "continue-on-error"?: boolean | string;
119+ "timeout-minutes"?: number | string;
120+}
121+
122+export interface Strategy {
123+ /** Raw matrix (map, or a `${{ }}` expression string); expand with expandMatrix(). */
124+ matrix?: unknown;
125+ /** Defaults to true; may be an expression string. */
126+ failFast: boolean | string;
127+ maxParallel?: number | string;
128+}
129+
130+export interface Job {
131+ key: string;
132+ name?: string;
133+ /** Runner labels; an expression string stays a single (unevaluated) entry. */
134+ runsOn: string[];
135+ /** Runner group, from `runs-on: { group: ... }`. */
136+ runsOnGroup?: string;
137+ needs: string[];
138+ if?: string;
139+ env: Record<string, string>;
140+ defaults: Defaults;
141+ outputs: Record<string, string>;
142+ steps: StepSpec[];
143+ strategy?: Strategy;
144+ /** Defaults to 360; may be an expression string. */
145+ timeoutMinutes: number | string;
146+ /** Defaults to false; may be an expression string. */
147+ continueOnError: boolean | string;
148+ permissions?: Permissions;
149+ concurrency?: Concurrency;
150+ services?: Record<string, ContainerSpec>;
151+ container?: ContainerSpec;
152+ environment?: EnvironmentSpec;
153+ /** Reusable workflow reference (`owner/repo/.github/workflows/x.yml@ref` or `./.github/workflows/x.yml`). */
154+ uses?: string;
155+ /** Inputs for a reusable workflow call (values keep their YAML types). */
156+ with?: Record<string, unknown>;
157+ secrets?: "inherit" | Record<string, string>;
158+}
159+
160+export interface Workflow {
161+ /** Path the workflow was loaded from, e.g. `.github/workflows/ci.yml`. */
162+ path: string;
163+ name?: string;
164+ runName?: string;
165+ on: WorkflowOn;
166+ env: Record<string, string>;
167+ defaults: Defaults;
168+ permissions?: Permissions;
169+ concurrency?: Concurrency;
170+ /** Jobs keyed by job id, in file order. */
171+ jobs: Record<string, Job>;
172+ /** The YAML document as plain JS (aliases and merge keys resolved). */
173+ raw: Record<string, unknown>;
174+}
addedpackages/workflow-yaml/tsconfig.json+1−0View file
@@ -1,0 +1,1 @@
1+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src", "test"] }
addedpnpm-lock.yaml+1131−0View file
@@ -1,0 +1,1131 @@
1+lockfileVersion: '9.0'
2+
3+settings:
4+ autoInstallPeers: true
5+ excludeLinksFromLockfile: false
6+
7+importers:
8+
9+ .:
10+ devDependencies:
11+ turbo:
12+ specifier: ^2.11.7
13+ version: 2.11.7
14+ typescript:
15+ specifier: ^5.9.3
16+ version: 5.9.3
17+
18+ containers/runner:
19+ dependencies:
20+ '@diffinery/shared':
21+ specifier: workspace:*
22+ version: link:../../packages/shared
23+ '@diffinery/workflow-yaml':
24+ specifier: workspace:*
25+ version: link:../../packages/workflow-yaml
26+ devDependencies:
27+ '@types/node':
28+ specifier: ^22.0.0
29+ version: 22.20.5
30+ esbuild:
31+ specifier: ^0.25.0
32+ version: 0.25.12
33+ typescript:
34+ specifier: ^5.9.3
35+ version: 5.9.3
36+ vitest:
37+ specifier: ^4.1.0
38+ version: 4.1.11(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1))
39+
40+ packages/git-core:
41+ dependencies:
42+ diff:
43+ specifier: ^8.0.0
44+ version: 8.0.4
45+ pako:
46+ specifier: ^2.1.0
47+ version: 2.2.0
48+ devDependencies:
49+ '@types/node':
50+ specifier: ^22.0.0
51+ version: 22.20.5
52+ '@types/pako':
53+ specifier: ^2.0.3
54+ version: 2.0.4
55+ typescript:
56+ specifier: ^5.9.3
57+ version: 5.9.3
58+ vitest:
59+ specifier: ^4.1.0
60+ version: 4.1.11(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1))
61+
62+ packages/shared:
63+ devDependencies:
64+ typescript:
65+ specifier: ^5.9.3
66+ version: 5.9.3
67+
68+ packages/workflow-yaml:
69+ dependencies:
70+ yaml:
71+ specifier: ^2.9.1
72+ version: 2.9.1
73+ devDependencies:
74+ typescript:
75+ specifier: ^5.9.3
76+ version: 5.9.3
77+ vitest:
78+ specifier: ^4.1.0
79+ version: 4.1.11(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1))
80+
81+packages:
82+
83+ '@esbuild/aix-ppc64@0.25.12':
84+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
85+ engines: {node: '>=18'}
86+ cpu: [ppc64]
87+ os: [aix]
88+
89+ '@esbuild/android-arm64@0.25.12':
90+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
91+ engines: {node: '>=18'}
92+ cpu: [arm64]
93+ os: [android]
94+
95+ '@esbuild/android-arm@0.25.12':
96+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
97+ engines: {node: '>=18'}
98+ cpu: [arm]
99+ os: [android]
100+
101+ '@esbuild/android-x64@0.25.12':
102+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
103+ engines: {node: '>=18'}
104+ cpu: [x64]
105+ os: [android]
106+
107+ '@esbuild/darwin-arm64@0.25.12':
108+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
109+ engines: {node: '>=18'}
110+ cpu: [arm64]
111+ os: [darwin]
112+
113+ '@esbuild/darwin-x64@0.25.12':
114+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
115+ engines: {node: '>=18'}
116+ cpu: [x64]
117+ os: [darwin]
118+
119+ '@esbuild/freebsd-arm64@0.25.12':
120+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
121+ engines: {node: '>=18'}
122+ cpu: [arm64]
123+ os: [freebsd]
124+
125+ '@esbuild/freebsd-x64@0.25.12':
126+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
127+ engines: {node: '>=18'}
128+ cpu: [x64]
129+ os: [freebsd]
130+
131+ '@esbuild/linux-arm64@0.25.12':
132+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
133+ engines: {node: '>=18'}
134+ cpu: [arm64]
135+ os: [linux]
136+
137+ '@esbuild/linux-arm@0.25.12':
138+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
139+ engines: {node: '>=18'}
140+ cpu: [arm]
141+ os: [linux]
142+
143+ '@esbuild/linux-ia32@0.25.12':
144+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
145+ engines: {node: '>=18'}
146+ cpu: [ia32]
147+ os: [linux]
148+
149+ '@esbuild/linux-loong64@0.25.12':
150+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
151+ engines: {node: '>=18'}
152+ cpu: [loong64]
153+ os: [linux]
154+
155+ '@esbuild/linux-mips64el@0.25.12':
156+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
157+ engines: {node: '>=18'}
158+ cpu: [mips64el]
159+ os: [linux]
160+
161+ '@esbuild/linux-ppc64@0.25.12':
162+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
163+ engines: {node: '>=18'}
164+ cpu: [ppc64]
165+ os: [linux]
166+
167+ '@esbuild/linux-riscv64@0.25.12':
168+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
169+ engines: {node: '>=18'}
170+ cpu: [riscv64]
171+ os: [linux]
172+
173+ '@esbuild/linux-s390x@0.25.12':
174+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
175+ engines: {node: '>=18'}
176+ cpu: [s390x]
177+ os: [linux]
178+
179+ '@esbuild/linux-x64@0.25.12':
180+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
181+ engines: {node: '>=18'}
182+ cpu: [x64]
183+ os: [linux]
184+
185+ '@esbuild/netbsd-arm64@0.25.12':
186+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
187+ engines: {node: '>=18'}
188+ cpu: [arm64]
189+ os: [netbsd]
190+
191+ '@esbuild/netbsd-x64@0.25.12':
192+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
193+ engines: {node: '>=18'}
194+ cpu: [x64]
195+ os: [netbsd]
196+
197+ '@esbuild/openbsd-arm64@0.25.12':
198+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
199+ engines: {node: '>=18'}
200+ cpu: [arm64]
201+ os: [openbsd]
202+
203+ '@esbuild/openbsd-x64@0.25.12':
204+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
205+ engines: {node: '>=18'}
206+ cpu: [x64]
207+ os: [openbsd]
208+
209+ '@esbuild/openharmony-arm64@0.25.12':
210+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
211+ engines: {node: '>=18'}
212+ cpu: [arm64]
213+ os: [openharmony]
214+
215+ '@esbuild/sunos-x64@0.25.12':
216+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
217+ engines: {node: '>=18'}
218+ cpu: [x64]
219+ os: [sunos]
220+
221+ '@esbuild/win32-arm64@0.25.12':
222+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
223+ engines: {node: '>=18'}
224+ cpu: [arm64]
225+ os: [win32]
226+
227+ '@esbuild/win32-ia32@0.25.12':
228+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
229+ engines: {node: '>=18'}
230+ cpu: [ia32]
231+ os: [win32]
232+
233+ '@esbuild/win32-x64@0.25.12':
234+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
235+ engines: {node: '>=18'}
236+ cpu: [x64]
237+ os: [win32]
238+
239+ '@jridgewell/sourcemap-codec@1.6.0':
240+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
241+
242+ '@oxc-project/types@0.153.0':
243+ resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
244+
245+ '@rolldown/binding-android-arm-eabi@1.2.13':
246+ resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==}
247+ engines: {node: ^20.19.0 || >=22.12.0}
248+ cpu: [arm]
249+ os: [android]
250+
251+ '@rolldown/binding-android-arm64@1.2.13':
252+ resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==}
253+ engines: {node: ^20.19.0 || >=22.12.0}
254+ cpu: [arm64]
255+ os: [android]
256+
257+ '@rolldown/binding-darwin-arm64@1.2.13':
258+ resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==}
259+ engines: {node: ^20.19.0 || >=22.12.0}
260+ cpu: [arm64]
261+ os: [darwin]
262+
263+ '@rolldown/binding-darwin-x64@1.2.13':
264+ resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==}
265+ engines: {node: ^20.19.0 || >=22.12.0}
266+ cpu: [x64]
267+ os: [darwin]
268+
269+ '@rolldown/binding-freebsd-x64@1.2.13':
270+ resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==}
271+ engines: {node: ^20.19.0 || >=22.12.0}
272+ cpu: [x64]
273+ os: [freebsd]
274+
275+ '@rolldown/binding-linux-arm-gnueabihf@1.2.13':
276+ resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==}
277+ engines: {node: ^20.19.0 || >=22.12.0}
278+ cpu: [arm]
279+ os: [linux]
280+
281+ '@rolldown/binding-linux-arm64-gnu@1.2.13':
282+ resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==}
283+ engines: {node: ^20.19.0 || >=22.12.0}
284+ cpu: [arm64]
285+ os: [linux]
286+
287+ '@rolldown/binding-linux-arm64-musl@1.2.13':
288+ resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==}
289+ engines: {node: ^20.19.0 || >=22.12.0}
290+ cpu: [arm64]
291+ os: [linux]
292+
293+ '@rolldown/binding-linux-ppc64-gnu@1.2.13':
294+ resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==}
295+ engines: {node: ^20.19.0 || >=22.12.0}
296+ cpu: [ppc64]
297+ os: [linux]
298+
299+ '@rolldown/binding-linux-s390x-gnu@1.2.13':
300+ resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==}
301+ engines: {node: ^20.19.0 || >=22.12.0}
302+ cpu: [s390x]
303+ os: [linux]
304+
305+ '@rolldown/binding-linux-x64-gnu@1.2.13':
306+ resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==}
307+ engines: {node: ^20.19.0 || >=22.12.0}
308+ cpu: [x64]
309+ os: [linux]
310+
311+ '@rolldown/binding-linux-x64-musl@1.2.13':
312+ resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==}
313+ engines: {node: ^20.19.0 || >=22.12.0}
314+ cpu: [x64]
315+ os: [linux]
316+
317+ '@rolldown/binding-openharmony-arm64@1.2.13':
318+ resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==}
319+ engines: {node: ^20.19.0 || >=22.12.0}
320+ cpu: [arm64]
321+ os: [openharmony]
322+
323+ '@rolldown/binding-win32-arm64-msvc@1.2.13':
324+ resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==}
325+ engines: {node: ^20.19.0 || >=22.12.0}
326+ cpu: [arm64]
327+ os: [win32]
328+
329+ '@rolldown/binding-win32-x64-msvc@1.2.13':
330+ resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==}
331+ engines: {node: ^20.19.0 || >=22.12.0}
332+ cpu: [x64]
333+ os: [win32]
334+
335+ '@rolldown/pluginutils@1.0.1':
336+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
337+
338+ '@standard-schema/spec@1.1.0':
339+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
340+
341+ '@turbo/darwin-64@2.11.7':
342+ resolution: {integrity: sha512-Z2LFOxwbiJueD1miByXJS/BrW21vv6YaQXZustty5jEX4vQYw6zyh101Sz1QO7WG0mqBzKagZ/DoAeeujI0/Sw==}
343+ cpu: [x64]
344+ os: [darwin]
345+
346+ '@turbo/darwin-arm64@2.11.7':
347+ resolution: {integrity: sha512-Vhv8ybgIflosNDSSRD9fNXHlCFA60dsGyTqQmc7l28SEX5jOaweCCD4BBYNd8/p0WGkw3e7tZbfA9w6FsujGvw==}
348+ cpu: [arm64]
349+ os: [darwin]
350+
351+ '@turbo/linux-64@2.11.7':
352+ resolution: {integrity: sha512-rAqRp3blQKk1+ylMGlZHAW0eJjVNuuGUm4ZrC6RU1Im8tLryvaO4a2zLQ/FQi1TRlmKqKV9YXBki2YeytlLbXA==}
353+ cpu: [x64]
354+ os: [android, linux]
355+
356+ '@turbo/linux-arm64@2.11.7':
357+ resolution: {integrity: sha512-thVYGov7S+llXAxrl3kBb/efalav0xhrjl5NHCR6AfKx14JkbqaetvbVwyss4FvZ9rt+Pv3xsTb+8SIYix8DTg==}
358+ cpu: [arm64]
359+ os: [android, linux]
360+
361+ '@turbo/windows-64@2.11.7':
362+ resolution: {integrity: sha512-ZIoKT7J7KutgYl4bGxmmgeUQjG+7E0nY8BF9dxUOiMH2vdTsM4JQ2uSwTYFkTR2Llbg6ghGjSmuZ8LQtkLEYjg==}
363+ cpu: [x64]
364+ os: [win32]
365+
366+ '@turbo/windows-arm64@2.11.7':
367+ resolution: {integrity: sha512-88Xv2f7SLGVJ68lRRx8o375dKmnZYNdDtvlKcPA0kZpcF93eAQd8l96BKQ0/AMHG0bMJA0Z5QqoqtetxzNsC+g==}
368+ cpu: [arm64]
369+ os: [win32]
370+
371+ '@types/chai@5.2.3':
372+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
373+
374+ '@types/deep-eql@4.0.2':
375+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
376+
377+ '@types/estree@1.0.9':
378+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
379+
380+ '@types/node@22.20.5':
381+ resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
382+
383+ '@types/pako@2.0.4':
384+ resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
385+
386+ '@vitest/expect@4.1.11':
387+ resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
388+
389+ '@vitest/mocker@4.1.11':
390+ resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
391+ peerDependencies:
392+ msw: ^2.4.9
393+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
394+ peerDependenciesMeta:
395+ msw:
396+ optional: true
397+ vite:
398+ optional: true
399+
400+ '@vitest/pretty-format@4.1.11':
401+ resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
402+
403+ '@vitest/runner@4.1.11':
404+ resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
405+
406+ '@vitest/snapshot@4.1.11':
407+ resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
408+
409+ '@vitest/spy@4.1.11':
410+ resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
411+
412+ '@vitest/utils@4.1.11':
413+ resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
414+
415+ assertion-error@2.0.1:
416+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
417+ engines: {node: '>=12'}
418+
419+ chai@6.3.0:
420+ resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
421+ engines: {node: '>=18'}
422+
423+ convert-source-map@2.0.0:
424+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
425+
426+ detect-libc@2.1.2:
427+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
428+ engines: {node: '>=8'}
429+
430+ diff@8.0.4:
431+ resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
432+ engines: {node: '>=0.3.1'}
433+
434+ es-module-lexer@2.3.2:
435+ resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
436+
437+ esbuild@0.25.12:
438+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
439+ engines: {node: '>=18'}
440+ hasBin: true
441+
442+ estree-walker@3.0.3:
443+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
444+
445+ expect-type@1.4.0:
446+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
447+ engines: {node: '>=12.0.0'}
448+
449+ fdir@6.5.0:
450+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
451+ engines: {node: '>=12.0.0'}
452+ peerDependencies:
453+ picomatch: ^3 || ^4
454+ peerDependenciesMeta:
455+ picomatch:
456+ optional: true
457+
458+ fsevents@2.3.3:
459+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
460+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
461+ os: [darwin]
462+
463+ lightningcss-android-arm64@1.33.0:
464+ resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
465+ engines: {node: '>= 12.0.0'}
466+ cpu: [arm64]
467+ os: [android]
468+
469+ lightningcss-darwin-arm64@1.33.0:
470+ resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
471+ engines: {node: '>= 12.0.0'}
472+ cpu: [arm64]
473+ os: [darwin]
474+
475+ lightningcss-darwin-x64@1.33.0:
476+ resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
477+ engines: {node: '>= 12.0.0'}
478+ cpu: [x64]
479+ os: [darwin]
480+
481+ lightningcss-freebsd-x64@1.33.0:
482+ resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
483+ engines: {node: '>= 12.0.0'}
484+ cpu: [x64]
485+ os: [freebsd]
486+
487+ lightningcss-linux-arm-gnueabihf@1.33.0:
488+ resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
489+ engines: {node: '>= 12.0.0'}
490+ cpu: [arm]
491+ os: [linux]
492+
493+ lightningcss-linux-arm64-gnu@1.33.0:
494+ resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
495+ engines: {node: '>= 12.0.0'}
496+ cpu: [arm64]
497+ os: [linux]
498+
499+ lightningcss-linux-arm64-musl@1.33.0:
500+ resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
501+ engines: {node: '>= 12.0.0'}
502+ cpu: [arm64]
503+ os: [linux]
504+
505+ lightningcss-linux-x64-gnu@1.33.0:
506+ resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
507+ engines: {node: '>= 12.0.0'}
508+ cpu: [x64]
509+ os: [linux]
510+
511+ lightningcss-linux-x64-musl@1.33.0:
512+ resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
513+ engines: {node: '>= 12.0.0'}
514+ cpu: [x64]
515+ os: [linux]
516+
517+ lightningcss-win32-arm64-msvc@1.33.0:
518+ resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
519+ engines: {node: '>= 12.0.0'}
520+ cpu: [arm64]
521+ os: [win32]
522+
523+ lightningcss-win32-x64-msvc@1.33.0:
524+ resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
525+ engines: {node: '>= 12.0.0'}
526+ cpu: [x64]
527+ os: [win32]
528+
529+ lightningcss@1.33.0:
530+ resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
531+ engines: {node: '>= 12.0.0'}
532+
533+ magic-string@0.30.21:
534+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
535+
536+ nanoid@3.3.20:
537+ resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
538+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
539+ hasBin: true
540+
541+ obug@2.2.1:
542+ resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
543+ engines: {node: '>=12.20.0'}
544+
545+ pako@2.2.0:
546+ resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==}
547+
548+ pathe@2.0.3:
549+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
550+
551+ picocolors@1.1.1:
552+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
553+
554+ picomatch@4.0.7:
555+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
556+ engines: {node: '>=12'}
557+
558+ postcss@8.5.29:
559+ resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
560+ engines: {node: ^10 || ^12 || >=14}
561+
562+ rolldown@1.2.13:
563+ resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==}
564+ engines: {node: ^20.19.0 || >=22.12.0}
565+ hasBin: true
566+
567+ siginfo@2.0.0:
568+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
569+
570+ source-map-js@1.2.2:
571+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
572+ engines: {node: '>=0.10.0'}
573+
574+ stackback@0.0.2:
575+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
576+
577+ std-env@4.3.0:
578+ resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
579+
580+ tinybench@2.9.0:
581+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
582+
583+ tinyexec@1.3.1:
584+ resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
585+ engines: {node: '>=18'}
586+
587+ tinyglobby@0.2.17:
588+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
589+ engines: {node: '>=12.0.0'}
590+
591+ tinyrainbow@3.2.0:
592+ resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==}
593+ engines: {node: '>=14.0.0'}
594+
595+ turbo@2.11.7:
596+ resolution: {integrity: sha512-RhXHbqfvY6o3xt/zSHCwSNwLrZlXiqHzewi4eAj9PtZ1T1CqgMJ2yIDb7SKPTG9IikTm925mfzL6hldQVdyNWw==}
597+ hasBin: true
598+
599+ typescript@5.9.3:
600+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
601+ engines: {node: '>=14.17'}
602+ hasBin: true
603+
604+ undici-types@6.21.0:
605+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
606+
607+ vite@8.3.4:
608+ resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
609+ engines: {node: ^20.19.0 || >=22.12.0}
610+ hasBin: true
611+ peerDependencies:
612+ '@types/node': ^20.19.0 || >=22.12.0
613+ '@vitejs/devtools': ^0.7.1
614+ esbuild: ^0.27.0 || ^0.28.0
615+ jiti: '>=1.21.0'
616+ less: ^4.0.0
617+ sass: ^1.70.0
618+ sass-embedded: ^1.70.0
619+ stylus: '>=0.54.8'
620+ sugarss: ^5.0.0
621+ terser: ^5.16.0
622+ tsx: ^4.8.1
623+ yaml: ^2.4.2
624+ peerDependenciesMeta:
625+ '@types/node':
626+ optional: true
627+ '@vitejs/devtools':
628+ optional: true
629+ esbuild:
630+ optional: true
631+ jiti:
632+ optional: true
633+ less:
634+ optional: true
635+ sass:
636+ optional: true
637+ sass-embedded:
638+ optional: true
639+ stylus:
640+ optional: true
641+ sugarss:
642+ optional: true
643+ terser:
644+ optional: true
645+ tsx:
646+ optional: true
647+ yaml:
648+ optional: true
649+
650+ vitest@4.1.11:
651+ resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
652+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
653+ hasBin: true
654+ peerDependencies:
655+ '@edge-runtime/vm': '*'
656+ '@opentelemetry/api': ^1.9.0
657+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
658+ '@vitest/browser-playwright': 4.1.11
659+ '@vitest/browser-preview': 4.1.11
660+ '@vitest/browser-webdriverio': 4.1.11
661+ '@vitest/coverage-istanbul': 4.1.11
662+ '@vitest/coverage-v8': 4.1.11
663+ '@vitest/ui': 4.1.11
664+ happy-dom: '*'
665+ jsdom: '*'
666+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
667+ peerDependenciesMeta:
668+ '@edge-runtime/vm':
669+ optional: true
670+ '@opentelemetry/api':
671+ optional: true
672+ '@types/node':
673+ optional: true
674+ '@vitest/browser-playwright':
675+ optional: true
676+ '@vitest/browser-preview':
677+ optional: true
678+ '@vitest/browser-webdriverio':
679+ optional: true
680+ '@vitest/coverage-istanbul':
681+ optional: true
682+ '@vitest/coverage-v8':
683+ optional: true
684+ '@vitest/ui':
685+ optional: true
686+ happy-dom:
687+ optional: true
688+ jsdom:
689+ optional: true
690+
691+ why-is-node-running@2.3.0:
692+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
693+ engines: {node: '>=8'}
694+ hasBin: true
695+
696+ yaml@2.9.1:
697+ resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==}
698+ engines: {node: '>= 14.6'}
699+ hasBin: true
700+
701+snapshots:
702+
703+ '@esbuild/aix-ppc64@0.25.12':
704+ optional: true
705+
706+ '@esbuild/android-arm64@0.25.12':
707+ optional: true
708+
709+ '@esbuild/android-arm@0.25.12':
710+ optional: true
711+
712+ '@esbuild/android-x64@0.25.12':
713+ optional: true
714+
715+ '@esbuild/darwin-arm64@0.25.12':
716+ optional: true
717+
718+ '@esbuild/darwin-x64@0.25.12':
719+ optional: true
720+
721+ '@esbuild/freebsd-arm64@0.25.12':
722+ optional: true
723+
724+ '@esbuild/freebsd-x64@0.25.12':
725+ optional: true
726+
727+ '@esbuild/linux-arm64@0.25.12':
728+ optional: true
729+
730+ '@esbuild/linux-arm@0.25.12':
731+ optional: true
732+
733+ '@esbuild/linux-ia32@0.25.12':
734+ optional: true
735+
736+ '@esbuild/linux-loong64@0.25.12':
737+ optional: true
738+
739+ '@esbuild/linux-mips64el@0.25.12':
740+ optional: true
741+
742+ '@esbuild/linux-ppc64@0.25.12':
743+ optional: true
744+
745+ '@esbuild/linux-riscv64@0.25.12':
746+ optional: true
747+
748+ '@esbuild/linux-s390x@0.25.12':
749+ optional: true
750+
751+ '@esbuild/linux-x64@0.25.12':
752+ optional: true
753+
754+ '@esbuild/netbsd-arm64@0.25.12':
755+ optional: true
756+
757+ '@esbuild/netbsd-x64@0.25.12':
758+ optional: true
759+
760+ '@esbuild/openbsd-arm64@0.25.12':
761+ optional: true
762+
763+ '@esbuild/openbsd-x64@0.25.12':
764+ optional: true
765+
766+ '@esbuild/openharmony-arm64@0.25.12':
767+ optional: true
768+
769+ '@esbuild/sunos-x64@0.25.12':
770+ optional: true
771+
772+ '@esbuild/win32-arm64@0.25.12':
773+ optional: true
774+
775+ '@esbuild/win32-ia32@0.25.12':
776+ optional: true
777+
778+ '@esbuild/win32-x64@0.25.12':
779+ optional: true
780+
781+ '@jridgewell/sourcemap-codec@1.6.0': {}
782+
783+ '@oxc-project/types@0.153.0': {}
784+
785+ '@rolldown/binding-android-arm-eabi@1.2.13':
786+ optional: true
787+
788+ '@rolldown/binding-android-arm64@1.2.13':
789+ optional: true
790+
791+ '@rolldown/binding-darwin-arm64@1.2.13':
792+ optional: true
793+
794+ '@rolldown/binding-darwin-x64@1.2.13':
795+ optional: true
796+
797+ '@rolldown/binding-freebsd-x64@1.2.13':
798+ optional: true
799+
800+ '@rolldown/binding-linux-arm-gnueabihf@1.2.13':
801+ optional: true
802+
803+ '@rolldown/binding-linux-arm64-gnu@1.2.13':
804+ optional: true
805+
806+ '@rolldown/binding-linux-arm64-musl@1.2.13':
807+ optional: true
808+
809+ '@rolldown/binding-linux-ppc64-gnu@1.2.13':
810+ optional: true
811+
812+ '@rolldown/binding-linux-s390x-gnu@1.2.13':
813+ optional: true
814+
815+ '@rolldown/binding-linux-x64-gnu@1.2.13':
816+ optional: true
817+
818+ '@rolldown/binding-linux-x64-musl@1.2.13':
819+ optional: true
820+
821+ '@rolldown/binding-openharmony-arm64@1.2.13':
822+ optional: true
823+
824+ '@rolldown/binding-win32-arm64-msvc@1.2.13':
825+ optional: true
826+
827+ '@rolldown/binding-win32-x64-msvc@1.2.13':
828+ optional: true
829+
830+ '@rolldown/pluginutils@1.0.1': {}
831+
832+ '@standard-schema/spec@1.1.0': {}
833+
834+ '@turbo/darwin-64@2.11.7':
835+ optional: true
836+
837+ '@turbo/darwin-arm64@2.11.7':
838+ optional: true
839+
840+ '@turbo/linux-64@2.11.7':
841+ optional: true
842+
843+ '@turbo/linux-arm64@2.11.7':
844+ optional: true
845+
846+ '@turbo/windows-64@2.11.7':
847+ optional: true
848+
849+ '@turbo/windows-arm64@2.11.7':
850+ optional: true
851+
852+ '@types/chai@5.2.3':
853+ dependencies:
854+ '@types/deep-eql': 4.0.2
855+ assertion-error: 2.0.1
856+
857+ '@types/deep-eql@4.0.2': {}
858+
859+ '@types/estree@1.0.9': {}
860+
861+ '@types/node@22.20.5':
862+ dependencies:
863+ undici-types: 6.21.0
864+
865+ '@types/pako@2.0.4': {}
866+
867+ '@vitest/expect@4.1.11':
868+ dependencies:
869+ '@standard-schema/spec': 1.1.0
870+ '@types/chai': 5.2.3
871+ '@vitest/spy': 4.1.11
872+ '@vitest/utils': 4.1.11
873+ chai: 6.3.0
874+ tinyrainbow: 3.2.0
875+
876+ '@vitest/mocker@4.1.11(vite@8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1))':
877+ dependencies:
878+ '@vitest/spy': 4.1.11
879+ estree-walker: 3.0.3
880+ magic-string: 0.30.21
881+ optionalDependencies:
882+ vite: 8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1)
883+
884+ '@vitest/pretty-format@4.1.11':
885+ dependencies:
886+ tinyrainbow: 3.2.0
887+
888+ '@vitest/runner@4.1.11':
889+ dependencies:
890+ '@vitest/utils': 4.1.11
891+ pathe: 2.0.3
892+
893+ '@vitest/snapshot@4.1.11':
894+ dependencies:
895+ '@vitest/pretty-format': 4.1.11
896+ '@vitest/utils': 4.1.11
897+ magic-string: 0.30.21
898+ pathe: 2.0.3
899+
900+ '@vitest/spy@4.1.11': {}
901+
902+ '@vitest/utils@4.1.11':
903+ dependencies:
904+ '@vitest/pretty-format': 4.1.11
905+ convert-source-map: 2.0.0
906+ tinyrainbow: 3.2.0
907+
908+ assertion-error@2.0.1: {}
909+
910+ chai@6.3.0: {}
911+
912+ convert-source-map@2.0.0: {}
913+
914+ detect-libc@2.1.2: {}
915+
916+ diff@8.0.4: {}
917+
918+ es-module-lexer@2.3.2: {}
919+
920+ esbuild@0.25.12:
921+ optionalDependencies:
922+ '@esbuild/aix-ppc64': 0.25.12
923+ '@esbuild/android-arm': 0.25.12
924+ '@esbuild/android-arm64': 0.25.12
925+ '@esbuild/android-x64': 0.25.12
926+ '@esbuild/darwin-arm64': 0.25.12
927+ '@esbuild/darwin-x64': 0.25.12
928+ '@esbuild/freebsd-arm64': 0.25.12
929+ '@esbuild/freebsd-x64': 0.25.12
930+ '@esbuild/linux-arm': 0.25.12
931+ '@esbuild/linux-arm64': 0.25.12
932+ '@esbuild/linux-ia32': 0.25.12
933+ '@esbuild/linux-loong64': 0.25.12
934+ '@esbuild/linux-mips64el': 0.25.12
935+ '@esbuild/linux-ppc64': 0.25.12
936+ '@esbuild/linux-riscv64': 0.25.12
937+ '@esbuild/linux-s390x': 0.25.12
938+ '@esbuild/linux-x64': 0.25.12
939+ '@esbuild/netbsd-arm64': 0.25.12
940+ '@esbuild/netbsd-x64': 0.25.12
941+ '@esbuild/openbsd-arm64': 0.25.12
942+ '@esbuild/openbsd-x64': 0.25.12
943+ '@esbuild/openharmony-arm64': 0.25.12
944+ '@esbuild/sunos-x64': 0.25.12
945+ '@esbuild/win32-arm64': 0.25.12
946+ '@esbuild/win32-ia32': 0.25.12
947+ '@esbuild/win32-x64': 0.25.12
948+
949+ estree-walker@3.0.3:
950+ dependencies:
951+ '@types/estree': 1.0.9
952+
953+ expect-type@1.4.0: {}
954+
955+ fdir@6.5.0(picomatch@4.0.7):
956+ optionalDependencies:
957+ picomatch: 4.0.7
958+
959+ fsevents@2.3.3:
960+ optional: true
961+
962+ lightningcss-android-arm64@1.33.0:
963+ optional: true
964+
965+ lightningcss-darwin-arm64@1.33.0:
966+ optional: true
967+
968+ lightningcss-darwin-x64@1.33.0:
969+ optional: true
970+
971+ lightningcss-freebsd-x64@1.33.0:
972+ optional: true
973+
974+ lightningcss-linux-arm-gnueabihf@1.33.0:
975+ optional: true
976+
977+ lightningcss-linux-arm64-gnu@1.33.0:
978+ optional: true
979+
980+ lightningcss-linux-arm64-musl@1.33.0:
981+ optional: true
982+
983+ lightningcss-linux-x64-gnu@1.33.0:
984+ optional: true
985+
986+ lightningcss-linux-x64-musl@1.33.0:
987+ optional: true
988+
989+ lightningcss-win32-arm64-msvc@1.33.0:
990+ optional: true
991+
992+ lightningcss-win32-x64-msvc@1.33.0:
993+ optional: true
994+
995+ lightningcss@1.33.0:
996+ dependencies:
997+ detect-libc: 2.1.2
998+ optionalDependencies:
999+ lightningcss-android-arm64: 1.33.0
1000+ lightningcss-darwin-arm64: 1.33.0
1001+ lightningcss-darwin-x64: 1.33.0
1002+ lightningcss-freebsd-x64: 1.33.0
1003+ lightningcss-linux-arm-gnueabihf: 1.33.0
1004+ lightningcss-linux-arm64-gnu: 1.33.0
1005+ lightningcss-linux-arm64-musl: 1.33.0
1006+ lightningcss-linux-x64-gnu: 1.33.0
1007+ lightningcss-linux-x64-musl: 1.33.0
1008+ lightningcss-win32-arm64-msvc: 1.33.0
1009+ lightningcss-win32-x64-msvc: 1.33.0
1010+
1011+ magic-string@0.30.21:
1012+ dependencies:
1013+ '@jridgewell/sourcemap-codec': 1.6.0
1014+
1015+ nanoid@3.3.20: {}
1016+
1017+ obug@2.2.1: {}
1018+
1019+ pako@2.2.0: {}
1020+
1021+ pathe@2.0.3: {}
1022+
1023+ picocolors@1.1.1: {}
1024+
1025+ picomatch@4.0.7: {}
1026+
1027+ postcss@8.5.29:
1028+ dependencies:
1029+ nanoid: 3.3.20
1030+ picocolors: 1.1.1
1031+ source-map-js: 1.2.2
1032+
1033+ rolldown@1.2.13:
1034+ dependencies:
1035+ '@oxc-project/types': 0.153.0
1036+ '@rolldown/pluginutils': 1.0.1
1037+ optionalDependencies:
1038+ '@rolldown/binding-android-arm-eabi': 1.2.13
1039+ '@rolldown/binding-android-arm64': 1.2.13
1040+ '@rolldown/binding-darwin-arm64': 1.2.13
1041+ '@rolldown/binding-darwin-x64': 1.2.13
1042+ '@rolldown/binding-freebsd-x64': 1.2.13
1043+ '@rolldown/binding-linux-arm-gnueabihf': 1.2.13
1044+ '@rolldown/binding-linux-arm64-gnu': 1.2.13
1045+ '@rolldown/binding-linux-arm64-musl': 1.2.13
1046+ '@rolldown/binding-linux-ppc64-gnu': 1.2.13
1047+ '@rolldown/binding-linux-s390x-gnu': 1.2.13
1048+ '@rolldown/binding-linux-x64-gnu': 1.2.13
1049+ '@rolldown/binding-linux-x64-musl': 1.2.13
1050+ '@rolldown/binding-openharmony-arm64': 1.2.13
1051+ '@rolldown/binding-win32-arm64-msvc': 1.2.13
1052+ '@rolldown/binding-win32-x64-msvc': 1.2.13
1053+
1054+ siginfo@2.0.0: {}
1055+
1056+ source-map-js@1.2.2: {}
1057+
1058+ stackback@0.0.2: {}
1059+
1060+ std-env@4.3.0: {}
1061+
1062+ tinybench@2.9.0: {}
1063+
1064+ tinyexec@1.3.1: {}
1065+
1066+ tinyglobby@0.2.17:
1067+ dependencies:
1068+ fdir: 6.5.0(picomatch@4.0.7)
1069+ picomatch: 4.0.7
1070+
1071+ tinyrainbow@3.2.0: {}
1072+
1073+ turbo@2.11.7:
1074+ optionalDependencies:
1075+ '@turbo/darwin-64': 2.11.7
1076+ '@turbo/darwin-arm64': 2.11.7
1077+ '@turbo/linux-64': 2.11.7
1078+ '@turbo/linux-arm64': 2.11.7
1079+ '@turbo/windows-64': 2.11.7
1080+ '@turbo/windows-arm64': 2.11.7
1081+
1082+ typescript@5.9.3: {}
1083+
1084+ undici-types@6.21.0: {}
1085+
1086+ vite@8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1):
1087+ dependencies:
1088+ lightningcss: 1.33.0
1089+ picomatch: 4.0.7
1090+ postcss: 8.5.29
1091+ rolldown: 1.2.13
1092+ tinyglobby: 0.2.17
1093+ optionalDependencies:
1094+ '@types/node': 22.20.5
1095+ esbuild: 0.25.12
1096+ fsevents: 2.3.3
1097+ yaml: 2.9.1
1098+
1099+ vitest@4.1.11(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1)):
1100+ dependencies:
1101+ '@vitest/expect': 4.1.11
1102+ '@vitest/mocker': 4.1.11(vite@8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1))
1103+ '@vitest/pretty-format': 4.1.11
1104+ '@vitest/runner': 4.1.11
1105+ '@vitest/snapshot': 4.1.11
1106+ '@vitest/spy': 4.1.11
1107+ '@vitest/utils': 4.1.11
1108+ es-module-lexer: 2.3.2
1109+ expect-type: 1.4.0
1110+ magic-string: 0.30.21
1111+ obug: 2.2.1
1112+ pathe: 2.0.3
1113+ picomatch: 4.0.7
1114+ std-env: 4.3.0
1115+ tinybench: 2.9.0
1116+ tinyexec: 1.3.1
1117+ tinyglobby: 0.2.17
1118+ tinyrainbow: 3.2.0
1119+ vite: 8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1)
1120+ why-is-node-running: 2.3.0
1121+ optionalDependencies:
1122+ '@types/node': 22.20.5
1123+ transitivePeerDependencies:
1124+ - msw
1125+
1126+ why-is-node-running@2.3.0:
1127+ dependencies:
1128+ siginfo: 2.0.0
1129+ stackback: 0.0.2
1130+
1131+ yaml@2.9.1: {}
addedpnpm-workspace.yaml+4−0View file
@@ -1,0 +1,4 @@
1+packages:
2+ - apps/*
3+ - packages/*
4+ - containers/*
addedtsconfig.base.json+17−0View file
@@ -1,0 +1,17 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "lib": ["ES2023"],
7+ "strict": true,
8+ "noUncheckedIndexedAccess": true,
9+ "skipLibCheck": true,
10+ "esModuleInterop": true,
11+ "resolveJsonModule": true,
12+ "isolatedModules": true,
13+ "verbatimModuleSyntax": true,
14+ "declaration": true,
15+ "sourceMap": true
16+ }
17+}
addedturbo.json+8−0View file
@@ -1,0 +1,8 @@
1+{
2+ "$schema": "https://turbo.build/schema.json",
3+ "tasks": {
4+ "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
5+ "typecheck": { "dependsOn": ["^build"] },
6+ "test": { "dependsOn": ["^build"] }
7+ }
8+}