takos-actions-engine 1.0.0
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/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +3477 -0
- package/coverage/coverage-final.json +20 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +176 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/context/base.ts.html +1792 -0
- package/coverage/src/context/env.ts.html +1243 -0
- package/coverage/src/context/index.html +161 -0
- package/coverage/src/context/index.ts.html +229 -0
- package/coverage/src/context/secrets.ts.html +1276 -0
- package/coverage/src/index.html +131 -0
- package/coverage/src/index.ts.html +502 -0
- package/coverage/src/parser/expression.ts.html +2854 -0
- package/coverage/src/parser/index.html +161 -0
- package/coverage/src/parser/index.ts.html +163 -0
- package/coverage/src/parser/validator.ts.html +1588 -0
- package/coverage/src/parser/workflow.ts.html +616 -0
- package/coverage/src/scheduler/dependency.ts.html +1138 -0
- package/coverage/src/scheduler/index.html +221 -0
- package/coverage/src/scheduler/index.ts.html +214 -0
- package/coverage/src/scheduler/job-context.ts.html +265 -0
- package/coverage/src/scheduler/job-policy.ts.html +559 -0
- package/coverage/src/scheduler/job.ts.html +1816 -0
- package/coverage/src/scheduler/listener-registry.ts.html +199 -0
- package/coverage/src/scheduler/step.ts.html +2206 -0
- package/coverage/src/scheduler/steps-context.ts.html +217 -0
- package/coverage/src/types.ts.html +1897 -0
- package/coverage/src/utils/index.html +116 -0
- package/coverage/src/utils/needs.ts.html +127 -0
- package/dist/__tests__/context/env.test.d.ts +2 -0
- package/dist/__tests__/context/env.test.d.ts.map +1 -0
- package/dist/__tests__/context/env.test.js +28 -0
- package/dist/__tests__/context/env.test.js.map +1 -0
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.js +50 -0
- package/dist/__tests__/index.test.js.map +1 -0
- package/dist/__tests__/parser/expression.test.d.ts +2 -0
- package/dist/__tests__/parser/expression.test.d.ts.map +1 -0
- package/dist/__tests__/parser/expression.test.js +116 -0
- package/dist/__tests__/parser/expression.test.js.map +1 -0
- package/dist/__tests__/parser/workflow.test.d.ts +2 -0
- package/dist/__tests__/parser/workflow.test.d.ts.map +1 -0
- package/dist/__tests__/parser/workflow.test.js +134 -0
- package/dist/__tests__/parser/workflow.test.js.map +1 -0
- package/dist/__tests__/scheduler/dependency.test.d.ts +2 -0
- package/dist/__tests__/scheduler/dependency.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/dependency.test.js +41 -0
- package/dist/__tests__/scheduler/dependency.test.js.map +1 -0
- package/dist/__tests__/scheduler/job-context.test.d.ts +2 -0
- package/dist/__tests__/scheduler/job-context.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/job-context.test.js +108 -0
- package/dist/__tests__/scheduler/job-context.test.js.map +1 -0
- package/dist/__tests__/scheduler/job-policy.test.d.ts +2 -0
- package/dist/__tests__/scheduler/job-policy.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/job-policy.test.js +159 -0
- package/dist/__tests__/scheduler/job-policy.test.js.map +1 -0
- package/dist/__tests__/scheduler/job.test.d.ts +2 -0
- package/dist/__tests__/scheduler/job.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/job.test.js +826 -0
- package/dist/__tests__/scheduler/job.test.js.map +1 -0
- package/dist/__tests__/scheduler/listener-registry.test.d.ts +2 -0
- package/dist/__tests__/scheduler/listener-registry.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/listener-registry.test.js +79 -0
- package/dist/__tests__/scheduler/listener-registry.test.js.map +1 -0
- package/dist/__tests__/scheduler/step.test.d.ts +2 -0
- package/dist/__tests__/scheduler/step.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/step.test.js +209 -0
- package/dist/__tests__/scheduler/step.test.js.map +1 -0
- package/dist/__tests__/scheduler/steps-context.test.d.ts +2 -0
- package/dist/__tests__/scheduler/steps-context.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/steps-context.test.js +43 -0
- package/dist/__tests__/scheduler/steps-context.test.js.map +1 -0
- package/dist/constants.d.ts +47 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +53 -0
- package/dist/constants.js.map +1 -0
- package/dist/context.d.ts +37 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +105 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/evaluator-builtins.d.ts +14 -0
- package/dist/parser/evaluator-builtins.d.ts.map +1 -0
- package/dist/parser/evaluator-builtins.js +258 -0
- package/dist/parser/evaluator-builtins.js.map +1 -0
- package/dist/parser/evaluator.d.ts +38 -0
- package/dist/parser/evaluator.d.ts.map +1 -0
- package/dist/parser/evaluator.js +257 -0
- package/dist/parser/evaluator.js.map +1 -0
- package/dist/parser/expression.d.ts +20 -0
- package/dist/parser/expression.d.ts.map +1 -0
- package/dist/parser/expression.js +128 -0
- package/dist/parser/expression.js.map +1 -0
- package/dist/parser/tokenizer.d.ts +26 -0
- package/dist/parser/tokenizer.d.ts.map +1 -0
- package/dist/parser/tokenizer.js +162 -0
- package/dist/parser/tokenizer.js.map +1 -0
- package/dist/parser/validator.d.ts +13 -0
- package/dist/parser/validator.d.ts.map +1 -0
- package/dist/parser/validator.js +383 -0
- package/dist/parser/validator.js.map +1 -0
- package/dist/parser/workflow.d.ts +30 -0
- package/dist/parser/workflow.d.ts.map +1 -0
- package/dist/parser/workflow.js +152 -0
- package/dist/parser/workflow.js.map +1 -0
- package/dist/scheduler/dependency.d.ts +37 -0
- package/dist/scheduler/dependency.d.ts.map +1 -0
- package/dist/scheduler/dependency.js +133 -0
- package/dist/scheduler/dependency.js.map +1 -0
- package/dist/scheduler/job-policy.d.ts +23 -0
- package/dist/scheduler/job-policy.d.ts.map +1 -0
- package/dist/scheduler/job-policy.js +117 -0
- package/dist/scheduler/job-policy.js.map +1 -0
- package/dist/scheduler/job.d.ts +151 -0
- package/dist/scheduler/job.d.ts.map +1 -0
- package/dist/scheduler/job.js +348 -0
- package/dist/scheduler/job.js.map +1 -0
- package/dist/scheduler/step-output-parser.d.ts +14 -0
- package/dist/scheduler/step-output-parser.d.ts.map +1 -0
- package/dist/scheduler/step-output-parser.js +70 -0
- package/dist/scheduler/step-output-parser.js.map +1 -0
- package/dist/scheduler/step.d.ts +74 -0
- package/dist/scheduler/step.d.ts.map +1 -0
- package/dist/scheduler/step.js +387 -0
- package/dist/scheduler/step.js.map +1 -0
- package/dist/types.d.ts +499 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/workflow-models.d.ts +504 -0
- package/dist/workflow-models.d.ts.map +1 -0
- package/dist/workflow-models.js +5 -0
- package/dist/workflow-models.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/context/env.test.ts +38 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/parser/expression.test.ts +151 -0
- package/src/__tests__/parser/workflow.test.ts +151 -0
- package/src/__tests__/scheduler/dependency.test.ts +51 -0
- package/src/__tests__/scheduler/job-context.test.ts +119 -0
- package/src/__tests__/scheduler/job-policy.test.ts +195 -0
- package/src/__tests__/scheduler/job.test.ts +1014 -0
- package/src/__tests__/scheduler/listener-registry.test.ts +95 -0
- package/src/__tests__/scheduler/step.test.ts +258 -0
- package/src/__tests__/scheduler/steps-context.test.ts +49 -0
- package/src/constants.ts +61 -0
- package/src/context.ts +153 -0
- package/src/index.ts +64 -0
- package/src/parser/evaluator-builtins.ts +315 -0
- package/src/parser/evaluator.ts +333 -0
- package/src/parser/expression.ts +154 -0
- package/src/parser/tokenizer.ts +191 -0
- package/src/parser/validator.ts +444 -0
- package/src/parser/workflow.ts +176 -0
- package/src/scheduler/dependency.ts +180 -0
- package/src/scheduler/job-policy.ts +198 -0
- package/src/scheduler/job.ts +523 -0
- package/src/scheduler/step-output-parser.ts +94 -0
- package/src/scheduler/step.ts +543 -0
- package/src/workflow-models.ts +593 -0
- package/tsconfig.json +14 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in function implementations for the expression evaluator
|
|
3
|
+
*/
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { MAX_FROM_JSON_SIZE } from '../constants.js';
|
|
9
|
+
import type { ExecutionContext } from '../workflow-models.js';
|
|
10
|
+
|
|
11
|
+
const GLOB_PATTERN_CHARS = /[*?[\]]/;
|
|
12
|
+
const REGEXP_META_CHARS = /[|\\{}()[\]^$+?.]/g;
|
|
13
|
+
|
|
14
|
+
function normalizePathLike(value: string): string {
|
|
15
|
+
return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function containsGlobPattern(pattern: string): boolean {
|
|
19
|
+
return GLOB_PATTERN_CHARS.test(pattern);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function globToRegExp(pattern: string): RegExp {
|
|
23
|
+
const normalized = normalizePathLike(pattern);
|
|
24
|
+
let regex = '^';
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
27
|
+
const char = normalized[i];
|
|
28
|
+
|
|
29
|
+
if (char === '*') {
|
|
30
|
+
if (normalized[i + 1] === '*') {
|
|
31
|
+
regex += '.*';
|
|
32
|
+
i++;
|
|
33
|
+
} else {
|
|
34
|
+
regex += '[^/]*';
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (char === '?') {
|
|
40
|
+
regex += '[^/]';
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
regex += char.replace(REGEXP_META_CHARS, '\\$&');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
regex += '$';
|
|
48
|
+
return new RegExp(regex);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isInsideWorkspace(workspace: string, targetPath: string): boolean {
|
|
52
|
+
const relativePath = relative(workspace, targetPath);
|
|
53
|
+
return (
|
|
54
|
+
relativePath === '' ||
|
|
55
|
+
(!relativePath.startsWith('..') && !isAbsolute(relativePath))
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function collectWorkspaceFiles(workspace: string): string[] {
|
|
60
|
+
const files: string[] = [];
|
|
61
|
+
const queue = [workspace];
|
|
62
|
+
|
|
63
|
+
while (queue.length > 0) {
|
|
64
|
+
const currentDir = queue.pop();
|
|
65
|
+
if (!currentDir) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = readdirSync(currentDir, { withFileTypes: true });
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const absolutePath = resolve(currentDir, entry.name);
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
queue.push(absolutePath);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!entry.isFile()) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const relativePath = normalizePathLike(relative(workspace, absolutePath));
|
|
87
|
+
if (relativePath.length > 0) {
|
|
88
|
+
files.push(relativePath);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
files.sort();
|
|
94
|
+
return files;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveWorkspaceFile(
|
|
98
|
+
workspace: string,
|
|
99
|
+
fileOrRelativePath: string
|
|
100
|
+
): string | null {
|
|
101
|
+
const absolutePath = isAbsolute(fileOrRelativePath)
|
|
102
|
+
? resolve(fileOrRelativePath)
|
|
103
|
+
: resolve(workspace, fileOrRelativePath);
|
|
104
|
+
|
|
105
|
+
if (!isInsideWorkspace(workspace, absolutePath)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (!lstatSync(absolutePath).isFile()) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return normalizePathLike(relative(workspace, absolutePath));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hashFileSha256(filePath: string): string | null {
|
|
121
|
+
try {
|
|
122
|
+
const content = readFileSync(filePath);
|
|
123
|
+
return createHash('sha256').update(content).digest('hex');
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function fnContains(args: unknown[]): boolean {
|
|
130
|
+
const [search, item] = args;
|
|
131
|
+
if (typeof search === 'string') {
|
|
132
|
+
return search.toLowerCase().includes(String(item).toLowerCase());
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(search)) {
|
|
135
|
+
return search.includes(item);
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function fnStartsWith(args: unknown[]): boolean {
|
|
141
|
+
const [str, searchStr] = args;
|
|
142
|
+
return String(str).toLowerCase().startsWith(String(searchStr).toLowerCase());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function fnEndsWith(args: unknown[]): boolean {
|
|
146
|
+
const [str, searchStr] = args;
|
|
147
|
+
return String(str).toLowerCase().endsWith(String(searchStr).toLowerCase());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function fnFormat(args: unknown[]): string {
|
|
151
|
+
const [template, ...values] = args;
|
|
152
|
+
if (template === null || template === undefined) {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
let result = String(template);
|
|
156
|
+
for (let i = 0; i < values.length; i++) {
|
|
157
|
+
result = result.split(`{${i}}`).join(String(values[i]));
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function fnJoin(args: unknown[]): string {
|
|
163
|
+
const [arr, separator = ','] = args;
|
|
164
|
+
if (Array.isArray(arr)) {
|
|
165
|
+
return arr.join(String(separator));
|
|
166
|
+
}
|
|
167
|
+
return String(arr);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function fnToJSON(args: unknown[]): string {
|
|
171
|
+
return JSON.stringify(args[0]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function fnFromJSON(args: unknown[]): unknown {
|
|
175
|
+
const str = String(args[0]);
|
|
176
|
+
// Limit input size to prevent OOM from attacker-controlled JSON strings
|
|
177
|
+
if (str.length > MAX_FROM_JSON_SIZE) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(str);
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function fnHashFiles(args: unknown[], context: ExecutionContext): string {
|
|
188
|
+
const includePatterns: string[] = [];
|
|
189
|
+
const excludePatterns: string[] = [];
|
|
190
|
+
|
|
191
|
+
for (const arg of args) {
|
|
192
|
+
if (typeof arg !== 'string') {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pattern = arg.trim();
|
|
197
|
+
if (pattern.length === 0) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (pattern.startsWith('!') && pattern.length > 1) {
|
|
202
|
+
excludePatterns.push(pattern.slice(1));
|
|
203
|
+
} else {
|
|
204
|
+
includePatterns.push(pattern);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (includePatterns.length === 0) {
|
|
209
|
+
return '';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return evaluateHashFiles(includePatterns, excludePatterns, context);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function evaluateHashFiles(
|
|
216
|
+
includePatterns: string[],
|
|
217
|
+
excludePatterns: string[],
|
|
218
|
+
context: ExecutionContext
|
|
219
|
+
): string {
|
|
220
|
+
const workspace = resolve(context.github.workspace || process.cwd());
|
|
221
|
+
const files = collectMatchedHashFiles(
|
|
222
|
+
workspace,
|
|
223
|
+
includePatterns,
|
|
224
|
+
excludePatterns
|
|
225
|
+
);
|
|
226
|
+
if (files.length === 0) {
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const fileHashes = hashFiles(workspace, files);
|
|
231
|
+
if (fileHashes.length === 0) {
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
234
|
+
if (fileHashes.length === 1) {
|
|
235
|
+
return fileHashes[0];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const aggregate = createHash('sha256');
|
|
239
|
+
for (const fileHash of fileHashes) {
|
|
240
|
+
aggregate.update(fileHash);
|
|
241
|
+
}
|
|
242
|
+
return aggregate.digest('hex');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function collectMatchedHashFiles(
|
|
246
|
+
workspace: string,
|
|
247
|
+
includePatterns: string[],
|
|
248
|
+
excludePatterns: string[]
|
|
249
|
+
): string[] {
|
|
250
|
+
const matchedFiles = new Set<string>();
|
|
251
|
+
let workspaceFiles: string[] | undefined;
|
|
252
|
+
|
|
253
|
+
const applyPattern = (pattern: string, include: boolean): void => {
|
|
254
|
+
if (containsGlobPattern(pattern)) {
|
|
255
|
+
workspaceFiles ??= collectWorkspaceFiles(workspace);
|
|
256
|
+
const regexp = globToRegExp(pattern);
|
|
257
|
+
for (const file of workspaceFiles) {
|
|
258
|
+
if (regexp.test(file)) {
|
|
259
|
+
if (include) {
|
|
260
|
+
matchedFiles.add(file);
|
|
261
|
+
} else {
|
|
262
|
+
matchedFiles.delete(file);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const file = resolveWorkspaceFile(workspace, pattern);
|
|
270
|
+
if (!file) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (include) {
|
|
274
|
+
matchedFiles.add(file);
|
|
275
|
+
} else {
|
|
276
|
+
matchedFiles.delete(file);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
for (const pattern of includePatterns) {
|
|
281
|
+
applyPattern(pattern, true);
|
|
282
|
+
}
|
|
283
|
+
for (const pattern of excludePatterns) {
|
|
284
|
+
applyPattern(pattern, false);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return [...matchedFiles].sort();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function hashFiles(workspace: string, files: string[]): string[] {
|
|
291
|
+
const fileHashes: string[] = [];
|
|
292
|
+
for (const file of files) {
|
|
293
|
+
const fileHash = hashFileSha256(resolve(workspace, file));
|
|
294
|
+
if (fileHash) {
|
|
295
|
+
fileHashes.push(fileHash);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return fileHashes;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function fnSuccess(context: ExecutionContext): boolean {
|
|
302
|
+
return context.job.status === 'success';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function fnAlways(): boolean {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function fnCancelled(context: ExecutionContext): boolean {
|
|
310
|
+
return context.job.status === 'cancelled';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function fnFailure(context: ExecutionContext): boolean {
|
|
314
|
+
return context.job.status === 'failure';
|
|
315
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expression evaluator
|
|
3
|
+
* Handles parsing and evaluation of tokenized GitHub Actions expressions
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
MAX_EVALUATE_CALLS,
|
|
7
|
+
MAX_PARSE_ACCESS_DEPTH,
|
|
8
|
+
} from '../constants.js';
|
|
9
|
+
import type { ExecutionContext } from '../workflow-models.js';
|
|
10
|
+
import { ExpressionError } from './tokenizer.js';
|
|
11
|
+
import type { Token, TokenType } from './tokenizer.js';
|
|
12
|
+
import {
|
|
13
|
+
fnContains,
|
|
14
|
+
fnStartsWith,
|
|
15
|
+
fnEndsWith,
|
|
16
|
+
fnFormat,
|
|
17
|
+
fnJoin,
|
|
18
|
+
fnToJSON,
|
|
19
|
+
fnFromJSON,
|
|
20
|
+
fnHashFiles,
|
|
21
|
+
fnSuccess,
|
|
22
|
+
fnAlways,
|
|
23
|
+
fnCancelled,
|
|
24
|
+
fnFailure,
|
|
25
|
+
} from './evaluator-builtins.js';
|
|
26
|
+
|
|
27
|
+
const BLOCKED_PROPERTY_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
28
|
+
const COMPARISON_OPERATORS = new Set(['==', '!=', '<', '>', '<=', '>=']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Simple expression parser and evaluator
|
|
32
|
+
*/
|
|
33
|
+
export class ExpressionEvaluator {
|
|
34
|
+
private readonly tokens: Token[];
|
|
35
|
+
private pos: number;
|
|
36
|
+
private readonly context: ExecutionContext;
|
|
37
|
+
private readonly expression: string;
|
|
38
|
+
private evaluateCallCount: number;
|
|
39
|
+
private readonly contextMap: Readonly<Record<string, unknown>>;
|
|
40
|
+
|
|
41
|
+
constructor(tokens: Token[], context: ExecutionContext, expression: string) {
|
|
42
|
+
this.tokens = tokens;
|
|
43
|
+
this.pos = 0;
|
|
44
|
+
this.context = context;
|
|
45
|
+
this.expression = expression;
|
|
46
|
+
this.evaluateCallCount = 0;
|
|
47
|
+
this.contextMap = {
|
|
48
|
+
github: context.github,
|
|
49
|
+
env: context.env,
|
|
50
|
+
vars: context.vars,
|
|
51
|
+
secrets: context.secrets,
|
|
52
|
+
runner: context.runner,
|
|
53
|
+
job: context.job,
|
|
54
|
+
steps: context.steps,
|
|
55
|
+
needs: context.needs,
|
|
56
|
+
strategy: context.strategy,
|
|
57
|
+
matrix: context.matrix,
|
|
58
|
+
inputs: context.inputs,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private current(): Token {
|
|
63
|
+
return this.tokens[this.pos];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private advance(): Token {
|
|
67
|
+
const token = this.current();
|
|
68
|
+
this.pos++;
|
|
69
|
+
return token;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private match(type: TokenType): boolean {
|
|
73
|
+
if (this.current().type === type) {
|
|
74
|
+
this.advance();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private expect(type: TokenType): Token {
|
|
81
|
+
const token = this.current();
|
|
82
|
+
if (token.type !== type) {
|
|
83
|
+
throw new ExpressionError(
|
|
84
|
+
`Expected ${type} but got ${token.type}`,
|
|
85
|
+
this.tokenSource()
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return this.advance();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private tokenSource(): string {
|
|
92
|
+
return this.tokens.map((t) => t.raw).join('');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private getIdentifierValue(token: Token): string {
|
|
96
|
+
if (token.type !== 'identifier' || typeof token.value !== 'string') {
|
|
97
|
+
const valueType = token.value === null ? 'null' : typeof token.value;
|
|
98
|
+
throw new ExpressionError(
|
|
99
|
+
`Expected identifier token with string value but got ${token.type}(${valueType})`,
|
|
100
|
+
this.expression
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return token.value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse and evaluate expression
|
|
108
|
+
*/
|
|
109
|
+
evaluate(): unknown {
|
|
110
|
+
this.evaluateCallCount++;
|
|
111
|
+
if (this.evaluateCallCount > MAX_EVALUATE_CALLS) {
|
|
112
|
+
throw new ExpressionError(
|
|
113
|
+
`Expression evaluate call limit exceeded: ${MAX_EVALUATE_CALLS}`,
|
|
114
|
+
this.expression
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return this.parseOr();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private parseOr(): unknown {
|
|
121
|
+
let left = this.parseAnd();
|
|
122
|
+
while (this.current().value === '||') {
|
|
123
|
+
this.advance();
|
|
124
|
+
const right = this.parseAnd();
|
|
125
|
+
left = this.toBoolean(left) || this.toBoolean(right);
|
|
126
|
+
}
|
|
127
|
+
return left;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private parseAnd(): unknown {
|
|
131
|
+
let left = this.parseComparison();
|
|
132
|
+
while (this.current().value === '&&') {
|
|
133
|
+
this.advance();
|
|
134
|
+
const right = this.parseComparison();
|
|
135
|
+
left = this.toBoolean(left) && this.toBoolean(right);
|
|
136
|
+
}
|
|
137
|
+
return left;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private parseComparison(): unknown {
|
|
141
|
+
const left = this.parseUnary();
|
|
142
|
+
const op = this.current().value;
|
|
143
|
+
if (typeof op === 'string' && COMPARISON_OPERATORS.has(op)) {
|
|
144
|
+
this.advance();
|
|
145
|
+
const right = this.parseUnary();
|
|
146
|
+
return this.compare(left, op, right);
|
|
147
|
+
}
|
|
148
|
+
return left;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private parseUnary(): unknown {
|
|
152
|
+
if (this.current().value === '!') {
|
|
153
|
+
this.advance();
|
|
154
|
+
const value = this.parseUnary();
|
|
155
|
+
return !this.toBoolean(value);
|
|
156
|
+
}
|
|
157
|
+
return this.parseAccess();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private checkAccessDepth(depth: number): void {
|
|
161
|
+
if (depth > MAX_PARSE_ACCESS_DEPTH) {
|
|
162
|
+
throw new ExpressionError(
|
|
163
|
+
`Expression access depth limit exceeded: ${MAX_PARSE_ACCESS_DEPTH}`,
|
|
164
|
+
this.expression
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private parseAccess(): unknown {
|
|
170
|
+
let value = this.parsePrimary();
|
|
171
|
+
let depth = 0;
|
|
172
|
+
|
|
173
|
+
while (true) {
|
|
174
|
+
if (this.match('dot')) {
|
|
175
|
+
this.checkAccessDepth(++depth);
|
|
176
|
+
const prop = this.getIdentifierValue(this.expect('identifier'));
|
|
177
|
+
value = this.getProperty(value, prop);
|
|
178
|
+
} else if (this.match('lbracket')) {
|
|
179
|
+
this.checkAccessDepth(++depth);
|
|
180
|
+
const index = this.evaluate();
|
|
181
|
+
this.expect('rbracket');
|
|
182
|
+
value = this.getProperty(value, index);
|
|
183
|
+
} else {
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private parsePrimary(): unknown {
|
|
192
|
+
const token = this.current();
|
|
193
|
+
|
|
194
|
+
if (token.type === 'string' || token.type === 'number' || token.type === 'boolean') {
|
|
195
|
+
this.advance();
|
|
196
|
+
return token.value;
|
|
197
|
+
}
|
|
198
|
+
if (token.type === 'null') {
|
|
199
|
+
this.advance();
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (token.type === 'identifier') {
|
|
204
|
+
const name = this.getIdentifierValue(this.advance());
|
|
205
|
+
|
|
206
|
+
// Check if it's a function call
|
|
207
|
+
if (this.current().type === 'lparen') {
|
|
208
|
+
return this.parseFunction(name);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Context variable
|
|
212
|
+
return this.getContextValue(name);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (token.type === 'lparen') {
|
|
216
|
+
this.advance();
|
|
217
|
+
const value = this.evaluate();
|
|
218
|
+
this.expect('rparen');
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
throw new ExpressionError(
|
|
223
|
+
`Unexpected token: ${token.type}`,
|
|
224
|
+
this.tokenSource()
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private parseFunction(name: string): unknown {
|
|
229
|
+
this.expect('lparen');
|
|
230
|
+
const args: unknown[] = [];
|
|
231
|
+
|
|
232
|
+
if (this.current().type !== 'rparen') {
|
|
233
|
+
args.push(this.evaluate());
|
|
234
|
+
while (this.match('comma')) {
|
|
235
|
+
args.push(this.evaluate());
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
this.expect('rparen');
|
|
240
|
+
return this.callFunction(name, args);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private getContextValue(name: string): unknown {
|
|
244
|
+
return this.contextMap[name];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private getProperty(obj: unknown, key: unknown): unknown {
|
|
248
|
+
if (obj === null || obj === undefined) {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
const keyString = String(key);
|
|
252
|
+
if (BLOCKED_PROPERTY_KEYS.has(keyString)) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
if (typeof obj === 'object') {
|
|
256
|
+
return (obj as Record<string, unknown>)[keyString];
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private compare(left: unknown, op: string, right: unknown): boolean {
|
|
262
|
+
if (op === '==' || op === '!=') {
|
|
263
|
+
return op === '==' ? left === right : left !== right;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const NUMERIC_OPERATORS: Record<string, (l: number, r: number) => boolean> = {
|
|
267
|
+
'<': (l, r) => l < r,
|
|
268
|
+
'>': (l, r) => l > r,
|
|
269
|
+
'<=': (l, r) => l <= r,
|
|
270
|
+
'>=': (l, r) => l >= r,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const numericOp = NUMERIC_OPERATORS[op];
|
|
274
|
+
if (!numericOp) {
|
|
275
|
+
throw new ExpressionError(
|
|
276
|
+
`Unknown comparison operator: ${op}`,
|
|
277
|
+
this.expression
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const l = Number(left);
|
|
282
|
+
const r = Number(right);
|
|
283
|
+
if (Number.isNaN(l) || Number.isNaN(r)) {
|
|
284
|
+
throw new ExpressionError(
|
|
285
|
+
`Comparison operator '${op}' received a NaN operand`,
|
|
286
|
+
this.expression
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return numericOp(l, r);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private toBoolean(value: unknown): boolean {
|
|
293
|
+
if (value === null || value === undefined || value === '') {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
if (typeof value === 'boolean') {
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
if (typeof value === 'number') {
|
|
300
|
+
return value !== 0;
|
|
301
|
+
}
|
|
302
|
+
if (typeof value === 'string') {
|
|
303
|
+
return value.length > 0;
|
|
304
|
+
}
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private callFunction(name: string, args: unknown[]): unknown {
|
|
309
|
+
const FUNCTIONS: Record<string, () => unknown> = {
|
|
310
|
+
'contains': () => fnContains(args),
|
|
311
|
+
'startsWith': () => fnStartsWith(args),
|
|
312
|
+
'endsWith': () => fnEndsWith(args),
|
|
313
|
+
'format': () => fnFormat(args),
|
|
314
|
+
'join': () => fnJoin(args),
|
|
315
|
+
'toJSON': () => fnToJSON(args),
|
|
316
|
+
'fromJSON': () => fnFromJSON(args),
|
|
317
|
+
'hashFiles': () => fnHashFiles(args, this.context),
|
|
318
|
+
'success': () => fnSuccess(this.context),
|
|
319
|
+
'always': () => fnAlways(),
|
|
320
|
+
'cancelled': () => fnCancelled(this.context),
|
|
321
|
+
'failure': () => fnFailure(this.context),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const fn = FUNCTIONS[name];
|
|
325
|
+
if (!fn) {
|
|
326
|
+
throw new ExpressionError(
|
|
327
|
+
`Unknown function: ${name}`,
|
|
328
|
+
this.tokenSource()
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return fn();
|
|
332
|
+
}
|
|
333
|
+
}
|