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
41 files changed+4748−0
Files (41)
- .gitignore+9−0
- containers/runner/package.json+13−0
- containers/runner/src/api.ts+222−0
- containers/runner/src/commands.ts+89−0
- containers/runner/src/mask.ts+43−0
- containers/runner/src/yaml.ts+393−0
- containers/runner/tsconfig.json+1−0
- package.json+22−0
- packages/git-core/package.json+10−0
- packages/git-core/src/bytes.ts+79−0
- packages/git-core/src/delta.ts+47−0
- packages/git-core/src/diff.ts+227−0
- packages/git-core/src/index.ts+10−0
- packages/git-core/src/memory-store.ts+18−0
- packages/git-core/src/objects.ts+165−0
- packages/git-core/src/pack.ts+156−0
- packages/git-core/src/pktline.ts+55−0
- packages/git-core/src/sha1.ts+15−0
- packages/git-core/src/store.ts+297−0
- packages/git-core/src/zlib.ts+33−0
- packages/git-core/test/pack.test.ts+139−0
- packages/git-core/tsconfig.json+1−0
- packages/shared/package.json+9−0
- packages/shared/src/events.ts+18−0
- packages/shared/src/index.ts+2−0
- packages/shared/src/runner.ts+102−0
- packages/shared/tsconfig.json+1−0
- packages/workflow-yaml/package.json+10−0
- packages/workflow-yaml/src/cron.ts+171−0
- packages/workflow-yaml/src/expr/coerce.ts+143−0
- packages/workflow-yaml/src/expr/evaluate.ts+292−0
- packages/workflow-yaml/src/expr/lexer.ts+143−0
- packages/workflow-yaml/src/expr/parser.ts+216−0
- packages/workflow-yaml/src/expr/template.ts+68−0
- packages/workflow-yaml/src/filters.ts+194−0
- packages/workflow-yaml/src/model.ts+174−0
- packages/workflow-yaml/tsconfig.json+1−0
- pnpm-lock.yaml+1131−0
- pnpm-workspace.yaml+4−0
- tsconfig.base.json+17−0
- turbo.json+8−0
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/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 | +} |