willfire 0.1.11 → 0.1.13
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/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/parseArgs.d.ts +9 -0
- package/dist/cli/parseArgs.js +42 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +41 -0
- package/dist/entries/index.d.ts +3 -0
- package/dist/entries/index.js +3 -0
- package/dist/entries/isJobEntry.d.ts +3 -0
- package/dist/entries/isJobEntry.js +2 -0
- package/dist/entries/isWorkflowEntry.d.ts +3 -0
- package/dist/entries/isWorkflowEntry.js +2 -0
- package/dist/entries/jobName.d.ts +3 -0
- package/dist/entries/jobName.js +2 -0
- package/dist/execute.d.ts +2 -2
- package/dist/execute.js +3 -1
- package/dist/expr/applyFunction.d.ts +11 -0
- package/dist/expr/applyFunction.js +41 -0
- package/dist/expr/asBool.d.ts +10 -0
- package/dist/expr/asBool.js +12 -0
- package/dist/expr/coalesceAnd.d.ts +7 -0
- package/dist/expr/coalesceAnd.js +17 -0
- package/dist/expr/coalesceOr.d.ts +6 -0
- package/dist/expr/coalesceOr.js +16 -0
- package/dist/expr/compare.d.ts +12 -0
- package/dist/expr/compare.js +54 -0
- package/dist/expr/cursor.d.ts +11 -0
- package/dist/expr/cursor.js +25 -0
- package/dist/expr/evaluate.d.ts +3 -0
- package/dist/expr/evaluate.js +6 -0
- package/dist/expr/evaluateValue.d.ts +14 -0
- package/dist/expr/evaluateValue.js +37 -0
- package/dist/expr/fromJson.d.ts +11 -0
- package/dist/expr/fromJson.js +29 -0
- package/dist/expr/index.d.ts +35 -0
- package/dist/expr/index.js +34 -0
- package/dist/expr/indexVal.d.ts +7 -0
- package/dist/expr/indexVal.js +34 -0
- package/dist/expr/lookup.d.ts +2 -0
- package/dist/expr/lookup.js +51 -0
- package/dist/expr/negate.d.ts +1 -0
- package/dist/expr/negate.js +3 -0
- package/dist/expr/parseAnd.d.ts +3 -0
- package/dist/expr/parseAnd.js +10 -0
- package/dist/expr/parseAtom.d.ts +3 -0
- package/dist/expr/parseAtom.js +43 -0
- package/dist/expr/parseCall.d.ts +9 -0
- package/dist/expr/parseCall.js +25 -0
- package/dist/expr/parseCmp.d.ts +3 -0
- package/dist/expr/parseCmp.js +12 -0
- package/dist/expr/parseOr.d.ts +11 -0
- package/dist/expr/parseOr.js +18 -0
- package/dist/expr/parsePrimary.d.ts +4 -0
- package/dist/expr/parsePrimary.js +16 -0
- package/dist/expr/parseUnary.d.ts +3 -0
- package/dist/expr/parseUnary.js +11 -0
- package/dist/expr/tokenize.d.ts +24 -0
- package/dist/expr/tokenize.js +74 -0
- package/dist/expr/truthy.d.ts +7 -0
- package/dist/expr/truthy.js +29 -0
- package/dist/expr/val.d.ts +72 -0
- package/dist/expr/val.js +1 -0
- package/dist/filters/index.d.ts +2 -0
- package/dist/filters/index.js +2 -0
- package/dist/filters/matchFilters.d.ts +2 -0
- package/dist/filters/matchFilters.js +12 -0
- package/dist/filters/patternToRegex.d.ts +1 -0
- package/dist/filters/patternToRegex.js +35 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/jobs/evalIf.d.ts +10 -0
- package/dist/jobs/evalIf.js +18 -0
- package/dist/jobs/expandJobs.d.ts +4 -0
- package/dist/jobs/expandJobs.js +247 -0
- package/dist/jobs/expandWorkflowJobs.d.ts +14 -0
- package/dist/jobs/expandWorkflowJobs.js +14 -0
- package/dist/jobs/index.d.ts +4 -0
- package/dist/jobs/index.js +4 -0
- package/dist/jobs/prScope.d.ts +10 -0
- package/dist/jobs/prScope.js +19 -0
- package/dist/matrix/expandMatrix.d.ts +4 -0
- package/dist/matrix/expandMatrix.js +6 -0
- package/dist/matrix/expandMatrixDetailed.d.ts +3 -0
- package/dist/matrix/expandMatrixDetailed.js +74 -0
- package/dist/matrix/formatMatrixValue.d.ts +8 -0
- package/dist/matrix/formatMatrixValue.js +16 -0
- package/dist/matrix/index.d.ts +4 -0
- package/dist/matrix/index.js +4 -0
- package/dist/matrix/matrixSuffix.d.ts +3 -0
- package/dist/matrix/matrixSuffix.js +8 -0
- package/dist/names/index.d.ts +4 -0
- package/dist/names/index.js +4 -0
- package/dist/names/jobDisplayName.d.ts +16 -0
- package/dist/names/jobDisplayName.js +25 -0
- package/dist/names/lookupPath.d.ts +1 -0
- package/dist/names/lookupPath.js +9 -0
- package/dist/names/renderName.d.ts +2 -0
- package/dist/names/renderName.js +26 -0
- package/dist/names/skippedDisplayName.d.ts +14 -0
- package/dist/names/skippedDisplayName.js +16 -0
- package/dist/predict/finalizePrediction.d.ts +2 -0
- package/dist/predict/finalizePrediction.js +19 -0
- package/dist/predict/index.d.ts +5 -0
- package/dist/predict/index.js +5 -0
- package/dist/predict/makeOctokit.d.ts +2 -0
- package/dist/predict/makeOctokit.js +7 -0
- package/dist/predict/predict.d.ts +3 -0
- package/dist/predict/predict.js +214 -0
- package/dist/predict/sourceKey.d.ts +3 -0
- package/dist/predict/sourceKey.js +2 -0
- package/dist/predict/stackTargetRef.d.ts +14 -0
- package/dist/predict/stackTargetRef.js +58 -0
- package/dist/triggers/getPrTrigger.d.ts +3 -0
- package/dist/triggers/getPrTrigger.js +18 -0
- package/dist/triggers/index.d.ts +2 -0
- package/dist/triggers/index.js +2 -0
- package/dist/triggers/workflowDispatches.d.ts +2 -0
- package/dist/triggers/workflowDispatches.js +45 -0
- package/dist/{predict.d.ts → types.d.ts} +64 -53
- package/dist/types.js +1 -0
- package/dist/uses/index.d.ts +1 -0
- package/dist/uses/index.js +1 -0
- package/dist/uses/parseUses.d.ts +15 -0
- package/dist/uses/parseUses.js +33 -0
- package/dist/verify.js +1 -1
- package/package.json +7 -7
- package/dist/expr.d.ts +0 -117
- package/dist/expr.js +0 -455
- package/dist/predict.js +0 -912
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "willfire",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|
|
@@ -17,16 +17,16 @@
|
|
|
17
17
|
"prediction"
|
|
18
18
|
],
|
|
19
19
|
"type": "module",
|
|
20
|
-
"main": "dist/
|
|
21
|
-
"types": "dist/
|
|
20
|
+
"main": "dist/index.js",
|
|
21
|
+
"types": "dist/index.d.ts",
|
|
22
22
|
"exports": {
|
|
23
23
|
".": {
|
|
24
|
-
"types": "./dist/
|
|
25
|
-
"default": "./dist/
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"bin": {
|
|
29
|
-
"willfire": "dist/
|
|
29
|
+
"willfire": "dist/cli.js"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.build.json",
|
|
39
|
-
"predict": "tsx src/
|
|
39
|
+
"predict": "tsx src/cli.ts",
|
|
40
40
|
"prepare": "pnpm build",
|
|
41
41
|
"test": "vitest run",
|
|
42
42
|
"test:coverage": "vitest run --coverage",
|
package/dist/expr.d.ts
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
/**
|
|
45
|
-
* An array or an object, which only `fromJSON` produces. Kept apart from
|
|
46
|
-
* `value` because nothing else in the language accepts one: comparison
|
|
47
|
-
* refuses it, and its truthiness is not modelled. Matrix expansion is the
|
|
48
|
-
* one consumer, and it asks for the array directly.
|
|
49
|
-
*/
|
|
50
|
-
| {
|
|
51
|
-
kind: "json";
|
|
52
|
-
v: unknown[] | Record<string, unknown>;
|
|
53
|
-
} | {
|
|
54
|
-
kind: "truthy";
|
|
55
|
-
} | {
|
|
56
|
-
kind: "falsy";
|
|
57
|
-
} | {
|
|
58
|
-
kind: "unknown";
|
|
59
|
-
};
|
|
60
|
-
export declare const UNKNOWN: Val;
|
|
61
|
-
/** What a bare path resolves against. Anything absent is unknown, not empty. */
|
|
62
|
-
export interface Scope {
|
|
63
|
-
/**
|
|
64
|
-
* The inputs the caller passed, plus declared defaults for the ones it did
|
|
65
|
-
* not. A key mapped to `unknown` is deliberate: the caller supplied it, but
|
|
66
|
-
* as a `${{ }}` template we cannot evaluate. That is different from absent.
|
|
67
|
-
*/
|
|
68
|
-
inputs?: Record<string, Val>;
|
|
69
|
-
/** `github.*` values that are fixed for the run being predicted. */
|
|
70
|
-
github?: Record<string, string>;
|
|
71
|
-
/**
|
|
72
|
-
* Outputs of jobs this workflow's jobs `needs`, keyed by job id.
|
|
73
|
-
*
|
|
74
|
-
* `outputs` is the *complete* set for that job, which is what makes a key
|
|
75
|
-
* that is absent from it mean the empty string — the same answer the runner
|
|
76
|
-
* gives for an output no step wrote. Handing in a partial map is therefore a
|
|
77
|
-
* lie, not a shortcut: leave the job out entirely instead, and every lookup
|
|
78
|
-
* against it stays unknown.
|
|
79
|
-
*
|
|
80
|
-
* The values are raw strings, because that is what a step wrote to
|
|
81
|
-
* `$GITHUB_OUTPUT` and what the runner substitutes. Parsing eagerly would
|
|
82
|
-
* break the guards written against them — `!= '[]'` compares a string to a
|
|
83
|
-
* string, and an array on the left makes it unknown. `fromJSON` is the only
|
|
84
|
-
* thing that turns one into a structure, at the point the workflow asks.
|
|
85
|
-
*/
|
|
86
|
-
needs?: Record<string, {
|
|
87
|
-
outputs: Record<string, string>;
|
|
88
|
-
}>;
|
|
89
|
-
/**
|
|
90
|
-
* Outputs of steps that already ran, keyed by step id. Only one caller can
|
|
91
|
-
* fill this honestly: the executor's step walk (see execute.ts), which is
|
|
92
|
-
* the single place a step has actually run by the time an expression reads
|
|
93
|
-
* it. The contract is the same as `needs`: a step named here carries its
|
|
94
|
-
* *complete* output set, so an absent key is the empty string the runner
|
|
95
|
-
* substitutes, while a step this map does not name stays unknown. A skipped
|
|
96
|
-
* step is present with no outputs at all — which is exactly what lets
|
|
97
|
-
* `steps.a.outputs.x || steps.b.outputs.x` coalesce past it.
|
|
98
|
-
*/
|
|
99
|
-
steps?: Record<string, {
|
|
100
|
-
outputs: Record<string, string>;
|
|
101
|
-
}>;
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Evaluate an expression to a value, or UNKNOWN when it cannot be settled.
|
|
105
|
-
*
|
|
106
|
-
* The `${{ }}` wrapper is optional in `if:` and stripped when present. An
|
|
107
|
-
* expression that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
|
|
108
|
-
* interpolation rather than an expression, and is not modelled.
|
|
109
|
-
*
|
|
110
|
-
* A `if:` wants {@link evaluate}, which is this narrowed to truthiness. This
|
|
111
|
-
* one is for the places that need the value itself — a matrix axis written as
|
|
112
|
-
* `${{ fromJSON(...) }}` is an array, and its truthiness says nothing about
|
|
113
|
-
* how many jobs it schedules.
|
|
114
|
-
*/
|
|
115
|
-
export declare function evaluateValue(expr: string, scope?: Scope): Val;
|
|
116
|
-
/** Evaluate a condition to a truthiness, or null when it cannot be settled. */
|
|
117
|
-
export declare function evaluate(cond: string, scope?: Scope): boolean | null;
|
package/dist/expr.js
DELETED
|
@@ -1,455 +0,0 @@
|
|
|
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
|
-
// GitHub does cast an array or an object to a boolean, but no workflow
|
|
47
|
-
// asks it to, and the answer is not worth guessing at to find out.
|
|
48
|
-
case "json":
|
|
49
|
-
return null;
|
|
50
|
-
case "value": {
|
|
51
|
-
const v = val.v;
|
|
52
|
-
if (typeof v === "boolean")
|
|
53
|
-
return v;
|
|
54
|
-
if (typeof v === "number")
|
|
55
|
-
return v !== 0;
|
|
56
|
-
return v !== "";
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Wrap a decided answer as a concrete boolean value.
|
|
62
|
-
*
|
|
63
|
-
* Comparison, negation and the string functions all yield a real boolean, so
|
|
64
|
-
* they produce a `value` — which keeps `!x == true` comparable. Only
|
|
65
|
-
* short-circuiting produces the bare `truthy`/`falsy` points, because that is
|
|
66
|
-
* the one case where truthiness is known and the value is not.
|
|
67
|
-
*/
|
|
68
|
-
function asBool(b) {
|
|
69
|
-
return b === null ? UNKNOWN : { kind: "value", v: b };
|
|
70
|
-
}
|
|
71
|
-
const OPS = ["&&", "||", "==", "!=", "<=", ">=", "!", "<", ">", "(", ")", ","];
|
|
72
|
-
/**
|
|
73
|
-
* Split a condition into tokens, or return null if it contains something this
|
|
74
|
-
* evaluator has no token for. Returning null rather than throwing keeps the
|
|
75
|
-
* "unrecognized is unknown" rule in one place at the top of `evaluate`.
|
|
76
|
-
*/
|
|
77
|
-
function tokenize(src) {
|
|
78
|
-
const out = [];
|
|
79
|
-
let i = 0;
|
|
80
|
-
while (i < src.length) {
|
|
81
|
-
const c = src[i];
|
|
82
|
-
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
83
|
-
i++;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
// Single-quoted string. GitHub escapes an inner quote by doubling it.
|
|
87
|
-
if (c === "'") {
|
|
88
|
-
let j = i + 1;
|
|
89
|
-
let s = "";
|
|
90
|
-
for (;;) {
|
|
91
|
-
if (j >= src.length)
|
|
92
|
-
return null; // unterminated
|
|
93
|
-
if (src[j] === "'") {
|
|
94
|
-
if (src[j + 1] === "'") {
|
|
95
|
-
s += "'";
|
|
96
|
-
j += 2;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
j++;
|
|
100
|
-
break;
|
|
101
|
-
}
|
|
102
|
-
s += src[j];
|
|
103
|
-
j++;
|
|
104
|
-
}
|
|
105
|
-
out.push({ t: "str", v: s });
|
|
106
|
-
i = j;
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
const op = OPS.find((o) => src.startsWith(o, i));
|
|
110
|
-
if (op != null) {
|
|
111
|
-
out.push({ t: "op", v: op });
|
|
112
|
-
i += op.length;
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
const word = /^[A-Za-z_][A-Za-z0-9_.\-]*/.exec(src.slice(i));
|
|
116
|
-
if (word != null) {
|
|
117
|
-
const w = word[0];
|
|
118
|
-
i += w.length;
|
|
119
|
-
const lower = w.toLowerCase();
|
|
120
|
-
if (lower === "true")
|
|
121
|
-
out.push({ t: "bool", v: true });
|
|
122
|
-
else if (lower === "false")
|
|
123
|
-
out.push({ t: "bool", v: false });
|
|
124
|
-
else if (lower === "null")
|
|
125
|
-
out.push({ t: "null" });
|
|
126
|
-
else
|
|
127
|
-
out.push({ t: "path", v: w });
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
const num = /^-?\d+(\.\d+)?/.exec(src.slice(i));
|
|
131
|
-
if (num != null) {
|
|
132
|
-
out.push({ t: "num", v: Number(num[0]) });
|
|
133
|
-
i += num[0].length;
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
return null; // a character we have no token for
|
|
137
|
-
}
|
|
138
|
-
return out;
|
|
139
|
-
}
|
|
140
|
-
// --------------------------------------------------------------------- parser
|
|
141
|
-
/**
|
|
142
|
-
* Recursive descent over GitHub's precedence order, loosest first:
|
|
143
|
-
* `||`, then `&&`, then comparison, then `!`, then a primary.
|
|
144
|
-
*
|
|
145
|
-
* `&&` and `||` are value operators, not boolean ones — `a || b` yields the
|
|
146
|
-
* first truthy operand, which is why `(x || y) != '[]'` parses as a
|
|
147
|
-
* comparison against a coalesced value rather than a boolean.
|
|
148
|
-
*/
|
|
149
|
-
class Parser {
|
|
150
|
-
toks;
|
|
151
|
-
scope;
|
|
152
|
-
pos = 0;
|
|
153
|
-
constructor(toks, scope) {
|
|
154
|
-
this.toks = toks;
|
|
155
|
-
this.scope = scope;
|
|
156
|
-
}
|
|
157
|
-
peek() {
|
|
158
|
-
return this.toks[this.pos];
|
|
159
|
-
}
|
|
160
|
-
eatOp(v) {
|
|
161
|
-
const t = this.peek();
|
|
162
|
-
if (t != null && t.t === "op" && t.v === v) {
|
|
163
|
-
this.pos++;
|
|
164
|
-
return true;
|
|
165
|
-
}
|
|
166
|
-
return false;
|
|
167
|
-
}
|
|
168
|
-
done() {
|
|
169
|
-
return this.pos >= this.toks.length;
|
|
170
|
-
}
|
|
171
|
-
or() {
|
|
172
|
-
let left = this.and();
|
|
173
|
-
while (this.eatOp("||")) {
|
|
174
|
-
const right = this.and();
|
|
175
|
-
left = coalesceOr(left, right);
|
|
176
|
-
}
|
|
177
|
-
return left;
|
|
178
|
-
}
|
|
179
|
-
and() {
|
|
180
|
-
let left = this.cmp();
|
|
181
|
-
while (this.eatOp("&&")) {
|
|
182
|
-
const right = this.cmp();
|
|
183
|
-
left = coalesceAnd(left, right);
|
|
184
|
-
}
|
|
185
|
-
return left;
|
|
186
|
-
}
|
|
187
|
-
cmp() {
|
|
188
|
-
const left = this.unary();
|
|
189
|
-
for (const op of ["==", "!=", "<=", ">=", "<", ">"]) {
|
|
190
|
-
if (this.eatOp(op)) {
|
|
191
|
-
const right = this.unary();
|
|
192
|
-
return compare(op, left, right);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return left;
|
|
196
|
-
}
|
|
197
|
-
unary() {
|
|
198
|
-
if (this.eatOp("!")) {
|
|
199
|
-
const v = this.unary();
|
|
200
|
-
return asBool(negate(truthy(v)));
|
|
201
|
-
}
|
|
202
|
-
return this.primary();
|
|
203
|
-
}
|
|
204
|
-
primary() {
|
|
205
|
-
const t = this.peek();
|
|
206
|
-
if (t == null)
|
|
207
|
-
return UNKNOWN;
|
|
208
|
-
if (t.t === "op" && t.v === "(") {
|
|
209
|
-
this.pos++;
|
|
210
|
-
const v = this.or();
|
|
211
|
-
if (!this.eatOp(")"))
|
|
212
|
-
return UNKNOWN;
|
|
213
|
-
return v;
|
|
214
|
-
}
|
|
215
|
-
if (t.t === "str") {
|
|
216
|
-
this.pos++;
|
|
217
|
-
return { kind: "value", v: t.v };
|
|
218
|
-
}
|
|
219
|
-
if (t.t === "num") {
|
|
220
|
-
this.pos++;
|
|
221
|
-
return { kind: "value", v: t.v };
|
|
222
|
-
}
|
|
223
|
-
if (t.t === "bool") {
|
|
224
|
-
this.pos++;
|
|
225
|
-
return { kind: "value", v: t.v };
|
|
226
|
-
}
|
|
227
|
-
if (t.t === "null") {
|
|
228
|
-
this.pos++;
|
|
229
|
-
return { kind: "value", v: "" };
|
|
230
|
-
}
|
|
231
|
-
if (t.t === "path") {
|
|
232
|
-
this.pos++;
|
|
233
|
-
// A `(` right after a name makes it a call, not a path.
|
|
234
|
-
if (this.eatOp("("))
|
|
235
|
-
return this.call(t.v);
|
|
236
|
-
return this.lookup(t.v);
|
|
237
|
-
}
|
|
238
|
-
return UNKNOWN;
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* A function call. Arguments are always parsed, even for functions we cannot
|
|
242
|
-
* evaluate — the tokens have to be consumed either way or the rest of the
|
|
243
|
-
* expression parses against the wrong position.
|
|
244
|
-
*/
|
|
245
|
-
call(name) {
|
|
246
|
-
const args = [];
|
|
247
|
-
if (!this.eatOp(")")) {
|
|
248
|
-
for (;;) {
|
|
249
|
-
args.push(this.or());
|
|
250
|
-
if (this.eatOp(","))
|
|
251
|
-
continue;
|
|
252
|
-
if (this.eatOp(")"))
|
|
253
|
-
break;
|
|
254
|
-
return UNKNOWN; // malformed argument list
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return applyFunction(name.toLowerCase(), args);
|
|
258
|
-
}
|
|
259
|
-
lookup(path) {
|
|
260
|
-
const dot = path.indexOf(".");
|
|
261
|
-
if (dot < 0)
|
|
262
|
-
return UNKNOWN;
|
|
263
|
-
const head = path.slice(0, dot);
|
|
264
|
-
const rest = path.slice(dot + 1);
|
|
265
|
-
if (head === "inputs")
|
|
266
|
-
return this.scope.inputs?.[rest] ?? UNKNOWN;
|
|
267
|
-
if (head === "github") {
|
|
268
|
-
const v = this.scope.github?.[rest];
|
|
269
|
-
return v === undefined ? UNKNOWN : { kind: "value", v };
|
|
270
|
-
}
|
|
271
|
-
if (head === "needs") {
|
|
272
|
-
// Only `needs.<job>.outputs.<name>` is modelled. `needs.<job>.result`
|
|
273
|
-
// is a verdict on a run that has not happened; anything else is not a
|
|
274
|
-
// shape the context has.
|
|
275
|
-
const parts = rest.split(".");
|
|
276
|
-
if (parts.length !== 3 || parts[1] !== "outputs")
|
|
277
|
-
return UNKNOWN;
|
|
278
|
-
const job = this.scope.needs?.[parts[0]];
|
|
279
|
-
if (job == null)
|
|
280
|
-
return UNKNOWN;
|
|
281
|
-
// A known job's missing output is the empty string, not a hole: the
|
|
282
|
-
// caller promised the set is complete, and that is what the runner
|
|
283
|
-
// substitutes for an output no step wrote.
|
|
284
|
-
return { kind: "value", v: job.outputs[parts[2]] ?? "" };
|
|
285
|
-
}
|
|
286
|
-
if (head === "steps") {
|
|
287
|
-
// The same shape as `needs`, for the same reason: only
|
|
288
|
-
// `steps.<id>.outputs.<name>` is modelled. `steps.<id>.outcome` and
|
|
289
|
-
// `.conclusion` are verdicts on how a step ran, which the executor does
|
|
290
|
-
// not track — a failed step fails the whole execution instead.
|
|
291
|
-
const parts = rest.split(".");
|
|
292
|
-
if (parts.length !== 3 || parts[1] !== "outputs")
|
|
293
|
-
return UNKNOWN;
|
|
294
|
-
const step = this.scope.steps?.[parts[0]];
|
|
295
|
-
if (step == null)
|
|
296
|
-
return UNKNOWN;
|
|
297
|
-
return { kind: "value", v: step.outputs[parts[2]] ?? "" };
|
|
298
|
-
}
|
|
299
|
-
// `matrix.*`, `env.*`, `vars.*`, `secrets.*`: all require something that
|
|
300
|
-
// has not happened yet at prediction time.
|
|
301
|
-
return UNKNOWN;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
function negate(b) {
|
|
305
|
-
return b === null ? null : !b;
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* `A && B`. Short-circuits from either side: a falsy left decides it, and so
|
|
309
|
-
* does a falsy right, because a truthy left would then yield the falsy right.
|
|
310
|
-
* Only "left unknown, right not falsy" is genuinely undecided.
|
|
311
|
-
*/
|
|
312
|
-
function coalesceAnd(left, right) {
|
|
313
|
-
const l = truthy(left);
|
|
314
|
-
if (l === false)
|
|
315
|
-
return left;
|
|
316
|
-
if (l === true)
|
|
317
|
-
return right;
|
|
318
|
-
return truthy(right) === false ? { kind: "falsy" } : UNKNOWN;
|
|
319
|
-
}
|
|
320
|
-
/**
|
|
321
|
-
* `A || B`. The mirror image: a truthy left decides it, and so does a truthy
|
|
322
|
-
* right, since a falsy left would then yield the truthy right.
|
|
323
|
-
*/
|
|
324
|
-
function coalesceOr(left, right) {
|
|
325
|
-
const l = truthy(left);
|
|
326
|
-
if (l === true)
|
|
327
|
-
return left;
|
|
328
|
-
if (l === false)
|
|
329
|
-
return right;
|
|
330
|
-
return truthy(right) === true ? { kind: "truthy" } : UNKNOWN;
|
|
331
|
-
}
|
|
332
|
-
/**
|
|
333
|
-
* Comparison, deliberately narrow: both sides must be concrete and of the same
|
|
334
|
-
* primitive type.
|
|
335
|
-
*
|
|
336
|
-
* GitHub coerces across types when it compares, and the corner cases are
|
|
337
|
-
* genuinely surprising (`'' == 0` is true). Every comparison that matters in
|
|
338
|
-
* practice is string-to-string — `inputs.gates == ''`,
|
|
339
|
-
* `github.event_name == 'pull_request'` — so modelling the coercion table
|
|
340
|
-
* would add risk without adding reach. Mixed types return unknown.
|
|
341
|
-
*/
|
|
342
|
-
function compare(op, left, right) {
|
|
343
|
-
if (left.kind !== "value" || right.kind !== "value")
|
|
344
|
-
return UNKNOWN;
|
|
345
|
-
const a = left.v;
|
|
346
|
-
const b = right.v;
|
|
347
|
-
if (typeof a !== typeof b)
|
|
348
|
-
return UNKNOWN;
|
|
349
|
-
if (op === "==")
|
|
350
|
-
return asBool(a === b);
|
|
351
|
-
if (op === "!=")
|
|
352
|
-
return asBool(a !== b);
|
|
353
|
-
// Ordering on booleans is not modelled. GitHub coerces them to numbers, and
|
|
354
|
-
// the answer is never one a workflow author meant to ask for.
|
|
355
|
-
if (typeof a === "boolean" || typeof b === "boolean")
|
|
356
|
-
return UNKNOWN;
|
|
357
|
-
if (op === "<")
|
|
358
|
-
return asBool(a < b);
|
|
359
|
-
if (op === "<=")
|
|
360
|
-
return asBool(a <= b);
|
|
361
|
-
if (op === ">")
|
|
362
|
-
return asBool(a > b);
|
|
363
|
-
return asBool(a >= b);
|
|
364
|
-
}
|
|
365
|
-
/**
|
|
366
|
-
* `fromJSON(s)` on a known string.
|
|
367
|
-
*
|
|
368
|
-
* The result is sorted into the lattice rather than dropped in whole: a scalar
|
|
369
|
-
* is an ordinary value and stays comparable, `null` has a known truthiness and
|
|
370
|
-
* no useful value, and only an array or an object needs the `json` point.
|
|
371
|
-
* Anything unparseable is unknown — a workflow that reaches this at runtime
|
|
372
|
-
* fails, and predicting a failure is not this function's job.
|
|
373
|
-
*/
|
|
374
|
-
function fromJson(arg) {
|
|
375
|
-
if (arg.kind !== "value" || typeof arg.v !== "string")
|
|
376
|
-
return UNKNOWN;
|
|
377
|
-
let parsed;
|
|
378
|
-
try {
|
|
379
|
-
parsed = JSON.parse(arg.v);
|
|
380
|
-
}
|
|
381
|
-
catch {
|
|
382
|
-
return UNKNOWN;
|
|
383
|
-
}
|
|
384
|
-
if (parsed === null)
|
|
385
|
-
return { kind: "falsy" };
|
|
386
|
-
if (typeof parsed === "object")
|
|
387
|
-
return { kind: "json", v: parsed };
|
|
388
|
-
return { kind: "value", v: parsed };
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* The functions worth modelling.
|
|
392
|
-
*
|
|
393
|
-
* `always()` is true by definition. `contains` on two known strings is the one
|
|
394
|
-
* that unlocks the fleet's `gates` pattern. `fromJSON` is what a dynamic matrix
|
|
395
|
-
* axis is built out of. `success()`, `failure()` and `cancelled()` depend on
|
|
396
|
-
* jobs that have not run, and everything else is simply not modelled — all
|
|
397
|
-
* unknown.
|
|
398
|
-
*/
|
|
399
|
-
function applyFunction(name, args) {
|
|
400
|
-
if (name === "always")
|
|
401
|
-
return { kind: "value", v: true };
|
|
402
|
-
if (name === "fromjson" && args.length === 1)
|
|
403
|
-
return fromJson(args[0]);
|
|
404
|
-
if (name === "contains" && args.length === 2) {
|
|
405
|
-
const [hay, needle] = args;
|
|
406
|
-
if (hay.kind !== "value" || needle.kind !== "value")
|
|
407
|
-
return UNKNOWN;
|
|
408
|
-
if (typeof hay.v !== "string" || typeof needle.v !== "string")
|
|
409
|
-
return UNKNOWN;
|
|
410
|
-
return asBool(hay.v.includes(needle.v));
|
|
411
|
-
}
|
|
412
|
-
if ((name === "startswith" || name === "endswith") && args.length === 2) {
|
|
413
|
-
const [s, part] = args;
|
|
414
|
-
if (s.kind !== "value" || part.kind !== "value")
|
|
415
|
-
return UNKNOWN;
|
|
416
|
-
if (typeof s.v !== "string" || typeof part.v !== "string")
|
|
417
|
-
return UNKNOWN;
|
|
418
|
-
return asBool(name === "startswith" ? s.v.startsWith(part.v) : s.v.endsWith(part.v));
|
|
419
|
-
}
|
|
420
|
-
return UNKNOWN;
|
|
421
|
-
}
|
|
422
|
-
// ---------------------------------------------------------------------- entry
|
|
423
|
-
/**
|
|
424
|
-
* Evaluate an expression to a value, or UNKNOWN when it cannot be settled.
|
|
425
|
-
*
|
|
426
|
-
* The `${{ }}` wrapper is optional in `if:` and stripped when present. An
|
|
427
|
-
* expression that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
|
|
428
|
-
* interpolation rather than an expression, and is not modelled.
|
|
429
|
-
*
|
|
430
|
-
* A `if:` wants {@link evaluate}, which is this narrowed to truthiness. This
|
|
431
|
-
* one is for the places that need the value itself — a matrix axis written as
|
|
432
|
-
* `${{ fromJSON(...) }}` is an array, and its truthiness says nothing about
|
|
433
|
-
* how many jobs it schedules.
|
|
434
|
-
*/
|
|
435
|
-
export function evaluateValue(expr, scope = {}) {
|
|
436
|
-
const stripped = expr.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
|
|
437
|
-
if (stripped === "")
|
|
438
|
-
return UNKNOWN;
|
|
439
|
-
if (stripped.includes("${{"))
|
|
440
|
-
return UNKNOWN;
|
|
441
|
-
const toks = tokenize(stripped);
|
|
442
|
-
if (toks == null || toks.length === 0)
|
|
443
|
-
return UNKNOWN;
|
|
444
|
-
const p = new Parser(toks, scope);
|
|
445
|
-
const val = p.or();
|
|
446
|
-
// Trailing tokens mean the grammar did not cover this expression; whatever
|
|
447
|
-
// was parsed describes only a prefix of it, so it decides nothing.
|
|
448
|
-
if (!p.done())
|
|
449
|
-
return UNKNOWN;
|
|
450
|
-
return val;
|
|
451
|
-
}
|
|
452
|
-
/** Evaluate a condition to a truthiness, or null when it cannot be settled. */
|
|
453
|
-
export function evaluate(cond, scope = {}) {
|
|
454
|
-
return truthy(evaluateValue(cond, scope));
|
|
455
|
-
}
|