Diffinery

codician/diffinerypublic

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

292 lines · 9.5 KBRaw History
1import {
2 isTruthy,
3 kindOf,
4 looseCompare,
5 looseEquals,
6 toJSONString,
7 toNumber,
8 toStr,
9 toTemplateString,
10} from "./coerce.js";
11import { parseExpression, usesStatusFunction, type Node } from "./parser.js";
12import { soleExpression, splitTemplate } from "./template.js";
13
14/** Contexts: github, env, vars, secrets, matrix, needs, steps, runner, inputs, job, jobs, strategy. */
15export type ExprContext = Record<string, unknown>;
16
17export interface StatusFns {
18 success(): boolean;
19 failure(): boolean;
20 cancelled(): boolean;
21 always?(): boolean;
22}
23
24export interface EvalExtra {
25 /** Implementation of hashFiles(); when absent hashFiles() returns ''. */
26 hashFiles?: (...patterns: string[]) => string;
27}
28
29export class ExpressionEvaluationError extends Error {
30 constructor(message: string) {
31 super(message);
32 this.name = "ExpressionEvaluationError";
33 }
34}
35
36const DEFAULT_STATUS: StatusFns = { success: () => true, failure: () => false, cancelled: () => false };
37
38/** Result of an object filter (`.*`); later dereferences map over its items. */
39class Filtered {
40 constructor(readonly items: unknown[]) {}
41}
42
43const unwrap = (v: unknown): unknown => (v instanceof Filtered ? v.items : v);
44
45function 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
64function 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
70class 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. */
192export 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. */
226export 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. */
232export 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 */
246export 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 */
258export 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. */
285export 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}