willfire 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/expr.d.ts +69 -0
- package/dist/expr.js +388 -0
- package/dist/predict.d.ts +9 -2
- package/dist/predict.js +85 -18
- package/package.json +1 -1
package/dist/expr.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tri-state evaluator for the slice of GitHub expressions that job `if:`
|
|
3
|
+
* conditions actually use.
|
|
4
|
+
*
|
|
5
|
+
* The point is not to reimplement the expression language. It is to stop
|
|
6
|
+
* returning `unknown` for conditions that are already decided. A reusable
|
|
7
|
+
* workflow's jobs commonly guard on the caller's own literals:
|
|
8
|
+
*
|
|
9
|
+
* if: ${{ github.event_name == 'pull_request'
|
|
10
|
+
* && (inputs.gates == '' || contains(inputs.gates, '"mutation"'))
|
|
11
|
+
* && (needs.detect.outputs.mutation_languages || ...) != '[]' }}
|
|
12
|
+
*
|
|
13
|
+
* The last clause needs a job that has not run yet. The middle one does not:
|
|
14
|
+
* the caller passed `gates` as a literal string, and it does not contain
|
|
15
|
+
* `"mutation"`, so the clause is false, so the whole `&&` is false whatever
|
|
16
|
+
* `detect` reports. Deciding that is the difference between a predictable
|
|
17
|
+
* check name and a hole in the prediction.
|
|
18
|
+
*
|
|
19
|
+
* Two ideas carry the whole file:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Truthiness can be known when the value is not.** `A && B` with an
|
|
22
|
+
* unknown `A` and a false `B` is false either way. So the lattice has four
|
|
23
|
+
* points, not three: a concrete value, known-truthy, known-falsy, and
|
|
24
|
+
* nothing at all.
|
|
25
|
+
*
|
|
26
|
+
* 2. **Unrecognized is unknown, never a guess.** An unparseable condition, an
|
|
27
|
+
* unsupported function, a comparison between types we do not model — all
|
|
28
|
+
* collapse to `unknown` and leave the caller exactly where it was. This
|
|
29
|
+
* adds no tolerance and no third outcome; it converts conditions that are
|
|
30
|
+
* already decidable into the answer they already have.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* A partially-known value.
|
|
34
|
+
*
|
|
35
|
+
* `truthy` and `falsy` exist because short-circuiting yields truthiness
|
|
36
|
+
* without a value: `unknown && false` is falsy, but there is no string to
|
|
37
|
+
* hand back for it. Collapsing those into `unknown` would throw away the
|
|
38
|
+
* only fact worth having.
|
|
39
|
+
*/
|
|
40
|
+
export type Val = {
|
|
41
|
+
kind: "value";
|
|
42
|
+
v: string | number | boolean;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "truthy";
|
|
45
|
+
} | {
|
|
46
|
+
kind: "falsy";
|
|
47
|
+
} | {
|
|
48
|
+
kind: "unknown";
|
|
49
|
+
};
|
|
50
|
+
export declare const UNKNOWN: Val;
|
|
51
|
+
/** What a bare path resolves against. Anything absent is unknown, not empty. */
|
|
52
|
+
export interface Scope {
|
|
53
|
+
/**
|
|
54
|
+
* The inputs the caller passed, plus declared defaults for the ones it did
|
|
55
|
+
* not. A key mapped to `unknown` is deliberate: the caller supplied it, but
|
|
56
|
+
* as a `${{ }}` template we cannot evaluate. That is different from absent.
|
|
57
|
+
*/
|
|
58
|
+
inputs?: Record<string, Val>;
|
|
59
|
+
/** `github.*` values that are fixed for the run being predicted. */
|
|
60
|
+
github?: Record<string, string>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Evaluate a condition to a truthiness, or null when it cannot be settled.
|
|
64
|
+
*
|
|
65
|
+
* The `${{ }}` wrapper is optional in `if:` and stripped when present. A
|
|
66
|
+
* condition that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
|
|
67
|
+
* interpolation rather than an expression, and is not modelled.
|
|
68
|
+
*/
|
|
69
|
+
export declare function evaluate(cond: string, scope?: Scope): boolean | null;
|
package/dist/expr.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tri-state evaluator for the slice of GitHub expressions that job `if:`
|
|
3
|
+
* conditions actually use.
|
|
4
|
+
*
|
|
5
|
+
* The point is not to reimplement the expression language. It is to stop
|
|
6
|
+
* returning `unknown` for conditions that are already decided. A reusable
|
|
7
|
+
* workflow's jobs commonly guard on the caller's own literals:
|
|
8
|
+
*
|
|
9
|
+
* if: ${{ github.event_name == 'pull_request'
|
|
10
|
+
* && (inputs.gates == '' || contains(inputs.gates, '"mutation"'))
|
|
11
|
+
* && (needs.detect.outputs.mutation_languages || ...) != '[]' }}
|
|
12
|
+
*
|
|
13
|
+
* The last clause needs a job that has not run yet. The middle one does not:
|
|
14
|
+
* the caller passed `gates` as a literal string, and it does not contain
|
|
15
|
+
* `"mutation"`, so the clause is false, so the whole `&&` is false whatever
|
|
16
|
+
* `detect` reports. Deciding that is the difference between a predictable
|
|
17
|
+
* check name and a hole in the prediction.
|
|
18
|
+
*
|
|
19
|
+
* Two ideas carry the whole file:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Truthiness can be known when the value is not.** `A && B` with an
|
|
22
|
+
* unknown `A` and a false `B` is false either way. So the lattice has four
|
|
23
|
+
* points, not three: a concrete value, known-truthy, known-falsy, and
|
|
24
|
+
* nothing at all.
|
|
25
|
+
*
|
|
26
|
+
* 2. **Unrecognized is unknown, never a guess.** An unparseable condition, an
|
|
27
|
+
* unsupported function, a comparison between types we do not model — all
|
|
28
|
+
* collapse to `unknown` and leave the caller exactly where it was. This
|
|
29
|
+
* adds no tolerance and no third outcome; it converts conditions that are
|
|
30
|
+
* already decidable into the answer they already have.
|
|
31
|
+
*/
|
|
32
|
+
export const UNKNOWN = { kind: "unknown" };
|
|
33
|
+
/**
|
|
34
|
+
* GitHub's truthiness: empty string, zero, `false` and null are false, and
|
|
35
|
+
* every other value is true. `'0'` and `'false'` are non-empty strings, so
|
|
36
|
+
* both are true — the same trap as JavaScript, kept deliberately identical.
|
|
37
|
+
*/
|
|
38
|
+
function truthy(val) {
|
|
39
|
+
switch (val.kind) {
|
|
40
|
+
case "truthy":
|
|
41
|
+
return true;
|
|
42
|
+
case "falsy":
|
|
43
|
+
return false;
|
|
44
|
+
case "unknown":
|
|
45
|
+
return null;
|
|
46
|
+
case "value": {
|
|
47
|
+
const v = val.v;
|
|
48
|
+
if (typeof v === "boolean")
|
|
49
|
+
return v;
|
|
50
|
+
if (typeof v === "number")
|
|
51
|
+
return v !== 0;
|
|
52
|
+
return v !== "";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Wrap a decided answer as a concrete boolean value.
|
|
58
|
+
*
|
|
59
|
+
* Comparison, negation and the string functions all yield a real boolean, so
|
|
60
|
+
* they produce a `value` — which keeps `!x == true` comparable. Only
|
|
61
|
+
* short-circuiting produces the bare `truthy`/`falsy` points, because that is
|
|
62
|
+
* the one case where truthiness is known and the value is not.
|
|
63
|
+
*/
|
|
64
|
+
function asBool(b) {
|
|
65
|
+
return b === null ? UNKNOWN : { kind: "value", v: b };
|
|
66
|
+
}
|
|
67
|
+
const OPS = ["&&", "||", "==", "!=", "<=", ">=", "!", "<", ">", "(", ")", ","];
|
|
68
|
+
/**
|
|
69
|
+
* Split a condition into tokens, or return null if it contains something this
|
|
70
|
+
* evaluator has no token for. Returning null rather than throwing keeps the
|
|
71
|
+
* "unrecognized is unknown" rule in one place at the top of `evaluate`.
|
|
72
|
+
*/
|
|
73
|
+
function tokenize(src) {
|
|
74
|
+
const out = [];
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < src.length) {
|
|
77
|
+
const c = src[i];
|
|
78
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
79
|
+
i++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Single-quoted string. GitHub escapes an inner quote by doubling it.
|
|
83
|
+
if (c === "'") {
|
|
84
|
+
let j = i + 1;
|
|
85
|
+
let s = "";
|
|
86
|
+
for (;;) {
|
|
87
|
+
if (j >= src.length)
|
|
88
|
+
return null; // unterminated
|
|
89
|
+
if (src[j] === "'") {
|
|
90
|
+
if (src[j + 1] === "'") {
|
|
91
|
+
s += "'";
|
|
92
|
+
j += 2;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
j++;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
s += src[j];
|
|
99
|
+
j++;
|
|
100
|
+
}
|
|
101
|
+
out.push({ t: "str", v: s });
|
|
102
|
+
i = j;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const op = OPS.find((o) => src.startsWith(o, i));
|
|
106
|
+
if (op != null) {
|
|
107
|
+
out.push({ t: "op", v: op });
|
|
108
|
+
i += op.length;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const word = /^[A-Za-z_][A-Za-z0-9_.\-]*/.exec(src.slice(i));
|
|
112
|
+
if (word != null) {
|
|
113
|
+
const w = word[0];
|
|
114
|
+
i += w.length;
|
|
115
|
+
const lower = w.toLowerCase();
|
|
116
|
+
if (lower === "true")
|
|
117
|
+
out.push({ t: "bool", v: true });
|
|
118
|
+
else if (lower === "false")
|
|
119
|
+
out.push({ t: "bool", v: false });
|
|
120
|
+
else if (lower === "null")
|
|
121
|
+
out.push({ t: "null" });
|
|
122
|
+
else
|
|
123
|
+
out.push({ t: "path", v: w });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const num = /^-?\d+(\.\d+)?/.exec(src.slice(i));
|
|
127
|
+
if (num != null) {
|
|
128
|
+
out.push({ t: "num", v: Number(num[0]) });
|
|
129
|
+
i += num[0].length;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
return null; // a character we have no token for
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
// --------------------------------------------------------------------- parser
|
|
137
|
+
/**
|
|
138
|
+
* Recursive descent over GitHub's precedence order, loosest first:
|
|
139
|
+
* `||`, then `&&`, then comparison, then `!`, then a primary.
|
|
140
|
+
*
|
|
141
|
+
* `&&` and `||` are value operators, not boolean ones — `a || b` yields the
|
|
142
|
+
* first truthy operand, which is why `(x || y) != '[]'` parses as a
|
|
143
|
+
* comparison against a coalesced value rather than a boolean.
|
|
144
|
+
*/
|
|
145
|
+
class Parser {
|
|
146
|
+
toks;
|
|
147
|
+
scope;
|
|
148
|
+
pos = 0;
|
|
149
|
+
constructor(toks, scope) {
|
|
150
|
+
this.toks = toks;
|
|
151
|
+
this.scope = scope;
|
|
152
|
+
}
|
|
153
|
+
peek() {
|
|
154
|
+
return this.toks[this.pos];
|
|
155
|
+
}
|
|
156
|
+
eatOp(v) {
|
|
157
|
+
const t = this.peek();
|
|
158
|
+
if (t != null && t.t === "op" && t.v === v) {
|
|
159
|
+
this.pos++;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
done() {
|
|
165
|
+
return this.pos >= this.toks.length;
|
|
166
|
+
}
|
|
167
|
+
or() {
|
|
168
|
+
let left = this.and();
|
|
169
|
+
while (this.eatOp("||")) {
|
|
170
|
+
const right = this.and();
|
|
171
|
+
left = coalesceOr(left, right);
|
|
172
|
+
}
|
|
173
|
+
return left;
|
|
174
|
+
}
|
|
175
|
+
and() {
|
|
176
|
+
let left = this.cmp();
|
|
177
|
+
while (this.eatOp("&&")) {
|
|
178
|
+
const right = this.cmp();
|
|
179
|
+
left = coalesceAnd(left, right);
|
|
180
|
+
}
|
|
181
|
+
return left;
|
|
182
|
+
}
|
|
183
|
+
cmp() {
|
|
184
|
+
const left = this.unary();
|
|
185
|
+
for (const op of ["==", "!=", "<=", ">=", "<", ">"]) {
|
|
186
|
+
if (this.eatOp(op)) {
|
|
187
|
+
const right = this.unary();
|
|
188
|
+
return compare(op, left, right);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return left;
|
|
192
|
+
}
|
|
193
|
+
unary() {
|
|
194
|
+
if (this.eatOp("!")) {
|
|
195
|
+
const v = this.unary();
|
|
196
|
+
return asBool(negate(truthy(v)));
|
|
197
|
+
}
|
|
198
|
+
return this.primary();
|
|
199
|
+
}
|
|
200
|
+
primary() {
|
|
201
|
+
const t = this.peek();
|
|
202
|
+
if (t == null)
|
|
203
|
+
return UNKNOWN;
|
|
204
|
+
if (t.t === "op" && t.v === "(") {
|
|
205
|
+
this.pos++;
|
|
206
|
+
const v = this.or();
|
|
207
|
+
if (!this.eatOp(")"))
|
|
208
|
+
return UNKNOWN;
|
|
209
|
+
return v;
|
|
210
|
+
}
|
|
211
|
+
if (t.t === "str") {
|
|
212
|
+
this.pos++;
|
|
213
|
+
return { kind: "value", v: t.v };
|
|
214
|
+
}
|
|
215
|
+
if (t.t === "num") {
|
|
216
|
+
this.pos++;
|
|
217
|
+
return { kind: "value", v: t.v };
|
|
218
|
+
}
|
|
219
|
+
if (t.t === "bool") {
|
|
220
|
+
this.pos++;
|
|
221
|
+
return { kind: "value", v: t.v };
|
|
222
|
+
}
|
|
223
|
+
if (t.t === "null") {
|
|
224
|
+
this.pos++;
|
|
225
|
+
return { kind: "value", v: "" };
|
|
226
|
+
}
|
|
227
|
+
if (t.t === "path") {
|
|
228
|
+
this.pos++;
|
|
229
|
+
// A `(` right after a name makes it a call, not a path.
|
|
230
|
+
if (this.eatOp("("))
|
|
231
|
+
return this.call(t.v);
|
|
232
|
+
return this.lookup(t.v);
|
|
233
|
+
}
|
|
234
|
+
return UNKNOWN;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* A function call. Arguments are always parsed, even for functions we cannot
|
|
238
|
+
* evaluate — the tokens have to be consumed either way or the rest of the
|
|
239
|
+
* expression parses against the wrong position.
|
|
240
|
+
*/
|
|
241
|
+
call(name) {
|
|
242
|
+
const args = [];
|
|
243
|
+
if (!this.eatOp(")")) {
|
|
244
|
+
for (;;) {
|
|
245
|
+
args.push(this.or());
|
|
246
|
+
if (this.eatOp(","))
|
|
247
|
+
continue;
|
|
248
|
+
if (this.eatOp(")"))
|
|
249
|
+
break;
|
|
250
|
+
return UNKNOWN; // malformed argument list
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return applyFunction(name.toLowerCase(), args);
|
|
254
|
+
}
|
|
255
|
+
lookup(path) {
|
|
256
|
+
const dot = path.indexOf(".");
|
|
257
|
+
if (dot < 0)
|
|
258
|
+
return UNKNOWN;
|
|
259
|
+
const head = path.slice(0, dot);
|
|
260
|
+
const rest = path.slice(dot + 1);
|
|
261
|
+
if (head === "inputs")
|
|
262
|
+
return this.scope.inputs?.[rest] ?? UNKNOWN;
|
|
263
|
+
if (head === "github") {
|
|
264
|
+
const v = this.scope.github?.[rest];
|
|
265
|
+
return v === undefined ? UNKNOWN : { kind: "value", v };
|
|
266
|
+
}
|
|
267
|
+
// `needs.*`, `steps.*`, `matrix.*`, `env.*`, `vars.*`, `secrets.*`: all
|
|
268
|
+
// require something that has not happened yet at prediction time. This is
|
|
269
|
+
// the seam where a `needs` context would attach if willfire ever computes
|
|
270
|
+
// a called workflow's outputs ahead of the run.
|
|
271
|
+
return UNKNOWN;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function negate(b) {
|
|
275
|
+
return b === null ? null : !b;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* `A && B`. Short-circuits from either side: a falsy left decides it, and so
|
|
279
|
+
* does a falsy right, because a truthy left would then yield the falsy right.
|
|
280
|
+
* Only "left unknown, right not falsy" is genuinely undecided.
|
|
281
|
+
*/
|
|
282
|
+
function coalesceAnd(left, right) {
|
|
283
|
+
const l = truthy(left);
|
|
284
|
+
if (l === false)
|
|
285
|
+
return left;
|
|
286
|
+
if (l === true)
|
|
287
|
+
return right;
|
|
288
|
+
return truthy(right) === false ? { kind: "falsy" } : UNKNOWN;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* `A || B`. The mirror image: a truthy left decides it, and so does a truthy
|
|
292
|
+
* right, since a falsy left would then yield the truthy right.
|
|
293
|
+
*/
|
|
294
|
+
function coalesceOr(left, right) {
|
|
295
|
+
const l = truthy(left);
|
|
296
|
+
if (l === true)
|
|
297
|
+
return left;
|
|
298
|
+
if (l === false)
|
|
299
|
+
return right;
|
|
300
|
+
return truthy(right) === true ? { kind: "truthy" } : UNKNOWN;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Comparison, deliberately narrow: both sides must be concrete and of the same
|
|
304
|
+
* primitive type.
|
|
305
|
+
*
|
|
306
|
+
* GitHub coerces across types when it compares, and the corner cases are
|
|
307
|
+
* genuinely surprising (`'' == 0` is true). Every comparison that matters in
|
|
308
|
+
* practice is string-to-string — `inputs.gates == ''`,
|
|
309
|
+
* `github.event_name == 'pull_request'` — so modelling the coercion table
|
|
310
|
+
* would add risk without adding reach. Mixed types return unknown.
|
|
311
|
+
*/
|
|
312
|
+
function compare(op, left, right) {
|
|
313
|
+
if (left.kind !== "value" || right.kind !== "value")
|
|
314
|
+
return UNKNOWN;
|
|
315
|
+
const a = left.v;
|
|
316
|
+
const b = right.v;
|
|
317
|
+
if (typeof a !== typeof b)
|
|
318
|
+
return UNKNOWN;
|
|
319
|
+
if (op === "==")
|
|
320
|
+
return asBool(a === b);
|
|
321
|
+
if (op === "!=")
|
|
322
|
+
return asBool(a !== b);
|
|
323
|
+
// Ordering on booleans is not modelled. GitHub coerces them to numbers, and
|
|
324
|
+
// the answer is never one a workflow author meant to ask for.
|
|
325
|
+
if (typeof a === "boolean" || typeof b === "boolean")
|
|
326
|
+
return UNKNOWN;
|
|
327
|
+
if (op === "<")
|
|
328
|
+
return asBool(a < b);
|
|
329
|
+
if (op === "<=")
|
|
330
|
+
return asBool(a <= b);
|
|
331
|
+
if (op === ">")
|
|
332
|
+
return asBool(a > b);
|
|
333
|
+
return asBool(a >= b);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The functions worth modelling.
|
|
337
|
+
*
|
|
338
|
+
* `always()` is true by definition. `contains` on two known strings is the one
|
|
339
|
+
* that unlocks the fleet's `gates` pattern. `success()`, `failure()` and
|
|
340
|
+
* `cancelled()` depend on jobs that have not run, and everything else is
|
|
341
|
+
* simply not modelled — all unknown.
|
|
342
|
+
*/
|
|
343
|
+
function applyFunction(name, args) {
|
|
344
|
+
if (name === "always")
|
|
345
|
+
return { kind: "value", v: true };
|
|
346
|
+
if (name === "contains" && args.length === 2) {
|
|
347
|
+
const [hay, needle] = args;
|
|
348
|
+
if (hay.kind !== "value" || needle.kind !== "value")
|
|
349
|
+
return UNKNOWN;
|
|
350
|
+
if (typeof hay.v !== "string" || typeof needle.v !== "string")
|
|
351
|
+
return UNKNOWN;
|
|
352
|
+
return asBool(hay.v.includes(needle.v));
|
|
353
|
+
}
|
|
354
|
+
if ((name === "startswith" || name === "endswith") && args.length === 2) {
|
|
355
|
+
const [s, part] = args;
|
|
356
|
+
if (s.kind !== "value" || part.kind !== "value")
|
|
357
|
+
return UNKNOWN;
|
|
358
|
+
if (typeof s.v !== "string" || typeof part.v !== "string")
|
|
359
|
+
return UNKNOWN;
|
|
360
|
+
return asBool(name === "startswith" ? s.v.startsWith(part.v) : s.v.endsWith(part.v));
|
|
361
|
+
}
|
|
362
|
+
return UNKNOWN;
|
|
363
|
+
}
|
|
364
|
+
// ---------------------------------------------------------------------- entry
|
|
365
|
+
/**
|
|
366
|
+
* Evaluate a condition to a truthiness, or null when it cannot be settled.
|
|
367
|
+
*
|
|
368
|
+
* The `${{ }}` wrapper is optional in `if:` and stripped when present. A
|
|
369
|
+
* condition that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
|
|
370
|
+
* interpolation rather than an expression, and is not modelled.
|
|
371
|
+
*/
|
|
372
|
+
export function evaluate(cond, scope = {}) {
|
|
373
|
+
const stripped = cond.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
|
|
374
|
+
if (stripped === "")
|
|
375
|
+
return null;
|
|
376
|
+
if (stripped.includes("${{"))
|
|
377
|
+
return null;
|
|
378
|
+
const toks = tokenize(stripped);
|
|
379
|
+
if (toks == null || toks.length === 0)
|
|
380
|
+
return null;
|
|
381
|
+
const p = new Parser(toks, scope);
|
|
382
|
+
const val = p.or();
|
|
383
|
+
// Trailing tokens mean the grammar did not cover this condition; whatever
|
|
384
|
+
// was parsed describes only a prefix of it, so it decides nothing.
|
|
385
|
+
if (!p.done())
|
|
386
|
+
return null;
|
|
387
|
+
return truthy(val);
|
|
388
|
+
}
|
package/dist/predict.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Octokit } from "@octokit/rest";
|
|
3
|
+
import { type Scope } from "./expr.js";
|
|
3
4
|
interface EntryBase {
|
|
4
5
|
workflow: string;
|
|
5
6
|
reason: string;
|
|
@@ -82,8 +83,14 @@ type Workflow = Record<string, any>;
|
|
|
82
83
|
type Combo = Record<string, any> | null;
|
|
83
84
|
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
84
85
|
export declare function expandMatrix(strategy: any): Combo[] | null;
|
|
85
|
-
/**
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Return run|skipped|unknown for a job-level `if:`.
|
|
88
|
+
*
|
|
89
|
+
* `scope` carries the inputs the calling workflow passed down. Without it a
|
|
90
|
+
* reusable workflow's guards are all unknown, because every one of them is
|
|
91
|
+
* written against `inputs.*`.
|
|
92
|
+
*/
|
|
93
|
+
export declare function evalIf(cond: any, scope?: Scope): "run" | "skipped" | "unknown";
|
|
87
94
|
/**
|
|
88
95
|
* One expanded job, before it is paired with its workflow. Named `ExpandedJob`
|
|
89
96
|
* because `JobEntry` is now the exported job-level variant of `Entry`.
|
package/dist/predict.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// src/names.test.ts.
|
|
13
13
|
import { Octokit } from "@octokit/rest";
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
15
|
+
import { evaluate, UNKNOWN } from "./expr.js";
|
|
15
16
|
/** Tag a job display name. Rejects the workflow-level sentinel. */
|
|
16
17
|
export const jobName = (name) => name;
|
|
17
18
|
/** Narrow to the workflow-level variant without inspecting the sentinel. */
|
|
@@ -170,7 +171,10 @@ function expandMatrixDetailed(strategy) {
|
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
combos.push(...extra);
|
|
173
|
-
|
|
174
|
+
// Zero combinations is a real answer, not a missing one: an empty axis, or an
|
|
175
|
+
// `exclude` that removes everything, schedules no jobs at all. Only an absent
|
|
176
|
+
// `matrix:` key means "one unsuffixed job", and that returned above.
|
|
177
|
+
return combos;
|
|
174
178
|
}
|
|
175
179
|
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
176
180
|
export function expandMatrix(strategy) {
|
|
@@ -272,23 +276,83 @@ function skippedDisplayName(jobId, job) {
|
|
|
272
276
|
const raw = job != null && job.name != null ? String(job.name) : null;
|
|
273
277
|
return { name: raw ?? jobId, resolved: true };
|
|
274
278
|
}
|
|
275
|
-
/**
|
|
276
|
-
|
|
279
|
+
/**
|
|
280
|
+
* The `github.*` values that are fixed for everything this module predicts.
|
|
281
|
+
* `predict` only ever answers for a pull request, so `event_name` is not a
|
|
282
|
+
* variable — which is what lets a `github.event_name == 'pull_request'` guard
|
|
283
|
+
* resolve instead of hanging the job on an unknown.
|
|
284
|
+
*/
|
|
285
|
+
const PR_GITHUB_CONTEXT = { event_name: "pull_request" };
|
|
286
|
+
/**
|
|
287
|
+
* Return run|skipped|unknown for a job-level `if:`.
|
|
288
|
+
*
|
|
289
|
+
* `scope` carries the inputs the calling workflow passed down. Without it a
|
|
290
|
+
* reusable workflow's guards are all unknown, because every one of them is
|
|
291
|
+
* written against `inputs.*`.
|
|
292
|
+
*/
|
|
293
|
+
export function evalIf(cond, scope = {}) {
|
|
277
294
|
if (cond == null)
|
|
278
295
|
return "run";
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
return "
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
296
|
+
const verdict = evaluate(String(cond), {
|
|
297
|
+
inputs: scope.inputs,
|
|
298
|
+
github: { ...PR_GITHUB_CONTEXT, ...scope.github },
|
|
299
|
+
});
|
|
300
|
+
if (verdict === null)
|
|
301
|
+
return "unknown";
|
|
302
|
+
return verdict ? "run" : "skipped";
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* A `with:` value as the callee will see it.
|
|
306
|
+
*
|
|
307
|
+
* A value carrying `${{ }}` is left unknown rather than evaluated. Resolving
|
|
308
|
+
* it would mean evaluating the caller's own expression context, and every
|
|
309
|
+
* caller in practice passes plain literals — so the reach that would buy is
|
|
310
|
+
* not worth the surface. Unknown here is the same unknown as before this
|
|
311
|
+
* existed; nothing regresses.
|
|
312
|
+
*/
|
|
313
|
+
function inputLiteral(raw) {
|
|
314
|
+
if (raw == null)
|
|
315
|
+
return { kind: "value", v: "" };
|
|
316
|
+
if (typeof raw === "boolean" || typeof raw === "number")
|
|
317
|
+
return { kind: "value", v: raw };
|
|
318
|
+
if (typeof raw === "string")
|
|
319
|
+
return raw.includes("${{") ? UNKNOWN : { kind: "value", v: raw };
|
|
320
|
+
return UNKNOWN;
|
|
321
|
+
}
|
|
322
|
+
/** The `on.workflow_call.inputs` block, tolerating the YAML 1.1 `on` -> true key. */
|
|
323
|
+
function workflowCallInputs(wf) {
|
|
324
|
+
const on = wf?.["on"] ?? wf?.["true"];
|
|
325
|
+
if (on == null || typeof on !== "object")
|
|
326
|
+
return {};
|
|
327
|
+
const call = on["workflow_call"];
|
|
328
|
+
if (call == null || typeof call !== "object")
|
|
329
|
+
return {};
|
|
330
|
+
const inputs = call["inputs"];
|
|
331
|
+
return inputs != null && typeof inputs === "object" ? inputs : {};
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* What `inputs.*` resolves to inside a called workflow: what the caller passed,
|
|
335
|
+
* over the defaults the callee declares.
|
|
336
|
+
*
|
|
337
|
+
* A declared input the caller omits falls back to its `default`. A declared
|
|
338
|
+
* input with no default and no caller value is unknown rather than empty — the
|
|
339
|
+
* workflow would be invalid if it were required, and guessing empty would
|
|
340
|
+
* silently decide guards that are not decided.
|
|
341
|
+
*/
|
|
342
|
+
function calleeInputs(withBlock, subWf) {
|
|
343
|
+
const out = {};
|
|
344
|
+
for (const [name, decl] of Object.entries(workflowCallInputs(subWf))) {
|
|
345
|
+
out[name] =
|
|
346
|
+
decl != null && typeof decl === "object" && "default" in decl
|
|
347
|
+
? inputLiteral(decl["default"])
|
|
348
|
+
: UNKNOWN;
|
|
349
|
+
}
|
|
350
|
+
if (withBlock != null && typeof withBlock === "object") {
|
|
351
|
+
for (const [name, raw] of Object.entries(withBlock)) {
|
|
352
|
+
out[name] = inputLiteral(raw);
|
|
353
|
+
}
|
|
290
354
|
}
|
|
291
|
-
return
|
|
355
|
+
return out;
|
|
292
356
|
}
|
|
293
357
|
/**
|
|
294
358
|
* GitHub allows a reusable-workflow call chain four levels deep. Past that the
|
|
@@ -331,13 +395,13 @@ export function parseUses(uses) {
|
|
|
331
395
|
return null;
|
|
332
396
|
return { path, source: { owner, repo, ref } };
|
|
333
397
|
}
|
|
334
|
-
async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = "", prefixResolved = true) {
|
|
398
|
+
async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = "", prefixResolved = true, scope = {}) {
|
|
335
399
|
const entries = [];
|
|
336
400
|
const jobs = wf.jobs ?? {};
|
|
337
401
|
const statuses = {};
|
|
338
402
|
for (const [jobId, jobRaw] of Object.entries(jobs)) {
|
|
339
403
|
const job = jobRaw ?? {};
|
|
340
|
-
let status = evalIf(job.if);
|
|
404
|
+
let status = evalIf(job.if, scope);
|
|
341
405
|
let reason = job.if != null ? `if: ${JSON.stringify(job.if)}` : "";
|
|
342
406
|
let needs = job.needs ?? [];
|
|
343
407
|
if (typeof needs === "string")
|
|
@@ -392,6 +456,8 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
392
456
|
// Where the callee's own `./` calls will resolve. A remote `uses:` moves
|
|
393
457
|
// this to the callee's repo and pinned ref; a local one leaves it alone.
|
|
394
458
|
let subSource = source;
|
|
459
|
+
// What `inputs.*` means on the other side of the call.
|
|
460
|
+
let subScope = {};
|
|
395
461
|
const target = parseUses(uses);
|
|
396
462
|
if (depth + 1 > MAX_REUSABLE_DEPTH) {
|
|
397
463
|
failure = `reusable workflow nested deeper than ${MAX_REUSABLE_DEPTH} levels`;
|
|
@@ -408,6 +474,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
408
474
|
else {
|
|
409
475
|
try {
|
|
410
476
|
subWf = parseYaml(content);
|
|
477
|
+
subScope = { inputs: calleeInputs(job.with, subWf ?? {}) };
|
|
411
478
|
}
|
|
412
479
|
catch (e) {
|
|
413
480
|
failure = `YAML parse error in ${uses}: ${e}`;
|
|
@@ -427,7 +494,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
427
494
|
});
|
|
428
495
|
continue;
|
|
429
496
|
}
|
|
430
|
-
entries.push(...(await expandJobs(subWf, ctx, fetchWorkflow, subSource, depth + 1, `${baseName} / `, nameResolved)));
|
|
497
|
+
entries.push(...(await expandJobs(subWf, ctx, fetchWorkflow, subSource, depth + 1, `${baseName} / `, nameResolved, subScope)));
|
|
431
498
|
}
|
|
432
499
|
continue;
|
|
433
500
|
}
|