@chainlink/cre-sdk 1.6.0-alpha.2 → 1.6.0-alpha.3
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/README.md +7 -1
- package/bin/cre-compile.ts +34 -17
- package/dist/generated/capabilities/blockchain/aptos/v1alpha/client_pb.d.ts +1023 -0
- package/dist/generated/capabilities/blockchain/aptos/v1alpha/client_pb.js +290 -0
- package/dist/generated/capabilities/blockchain/solana/v1alpha/client_pb.d.ts +2904 -0
- package/dist/generated/capabilities/blockchain/solana/v1alpha/client_pb.js +506 -0
- package/dist/generated-sdk/capabilities/blockchain/aptos/v1alpha/client_sdk_gen.d.ts +52 -0
- package/dist/generated-sdk/capabilities/blockchain/aptos/v1alpha/client_sdk_gen.js +186 -0
- package/dist/generated-sdk/capabilities/blockchain/solana/v1alpha/client_sdk_gen.d.ts +92 -0
- package/dist/generated-sdk/capabilities/blockchain/solana/v1alpha/client_sdk_gen.js +343 -0
- package/dist/sdk/cre/index.d.ts +6 -0
- package/dist/sdk/cre/index.js +8 -0
- package/dist/sdk/report.js +0 -15
- package/dist/sdk/test/generated/capabilities/blockchain/aptos/v1alpha/aptos_mock_gen.d.ts +25 -0
- package/dist/sdk/test/generated/capabilities/blockchain/aptos/v1alpha/aptos_mock_gen.js +111 -0
- package/dist/sdk/test/generated/capabilities/blockchain/solana/v1alpha/solana_mock_gen.d.ts +33 -0
- package/dist/sdk/test/generated/capabilities/blockchain/solana/v1alpha/solana_mock_gen.js +178 -0
- package/dist/sdk/test/generated/index.d.ts +2 -0
- package/dist/sdk/test/generated/index.js +2 -0
- package/package.json +3 -3
- package/scripts/run-standard-tests.sh +3 -3
- package/scripts/run.ts +6 -1
- package/scripts/src/check-determinism.test.ts +64 -0
- package/scripts/src/check-determinism.ts +32 -0
- package/scripts/src/compile-cli-args.test.ts +32 -0
- package/scripts/src/compile-cli-args.ts +35 -0
- package/scripts/src/compile-to-js.test.ts +90 -0
- package/scripts/src/compile-to-js.ts +53 -7
- package/scripts/src/compile-to-wasm.ts +18 -32
- package/scripts/src/compile-workflow.ts +55 -27
- package/scripts/src/generate-chain-selectors.ts +9 -27
- package/scripts/src/generate-sdks.ts +12 -0
- package/scripts/src/typecheck-workflow.test.ts +77 -0
- package/scripts/src/typecheck-workflow.ts +96 -0
- package/scripts/src/validate-shared.ts +400 -0
- package/scripts/src/validate-workflow-determinism.test.ts +409 -0
- package/scripts/src/validate-workflow-determinism.ts +545 -0
- package/scripts/src/validate-workflow-runtime-compat.ts +25 -377
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Determinism Warning Analyzer
|
|
3
|
+
*
|
|
4
|
+
* CRE workflows execute on a Decentralized Oracle Network (DON) where multiple
|
|
5
|
+
* nodes must reach consensus. Non-deterministic patterns — code that can produce
|
|
6
|
+
* different results on different nodes — may prevent consensus.
|
|
7
|
+
*
|
|
8
|
+
* This module performs **static analysis** on workflow source code to detect
|
|
9
|
+
* patterns that are likely non-deterministic and warns developers at build time.
|
|
10
|
+
* Unlike the runtime compatibility validator, this module produces **warnings**
|
|
11
|
+
* that do not block compilation.
|
|
12
|
+
*
|
|
13
|
+
* ## Detected patterns
|
|
14
|
+
*
|
|
15
|
+
* - `Promise.race()` / `Promise.any()` / `Promise.all()` — concurrent execution, timing-dependent
|
|
16
|
+
* - `Date.now()` / `new Date()` — system clock varies across nodes
|
|
17
|
+
* - `for...in` loops — iteration order is not guaranteed by the spec
|
|
18
|
+
* - `Object.keys()` / `Object.values()` / `Object.entries()` without `.sort()`
|
|
19
|
+
*
|
|
20
|
+
* ## What is NOT detected
|
|
21
|
+
*
|
|
22
|
+
* - `Math.random()` — the Javy plugin overrides it with a seeded ChaCha8 CSPRNG
|
|
23
|
+
* that is deterministic in NODE mode. Safe by design.
|
|
24
|
+
*
|
|
25
|
+
* @see https://docs.chain.link/cre/concepts/non-determinism-ts
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import * as ts from 'typescript'
|
|
29
|
+
import {
|
|
30
|
+
collectLocalSourceFiles,
|
|
31
|
+
createValidationProgram,
|
|
32
|
+
createViolation,
|
|
33
|
+
formatViolations,
|
|
34
|
+
toAbsolutePath,
|
|
35
|
+
type Violation,
|
|
36
|
+
} from './validate-shared'
|
|
37
|
+
|
|
38
|
+
const DOCS_URL = 'https://docs.chain.link/cre/concepts/non-determinism-ts'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A global object reference can be accessed directly (`Date`) or via
|
|
42
|
+
* `globalThis.Date`.
|
|
43
|
+
*/
|
|
44
|
+
type GlobalObjectReference = {
|
|
45
|
+
identifier: ts.Identifier
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const bindingNameContains = (bindingName: ts.BindingName, name: string): boolean => {
|
|
49
|
+
if (ts.isIdentifier(bindingName)) {
|
|
50
|
+
return bindingName.text === name
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (ts.isObjectBindingPattern(bindingName)) {
|
|
54
|
+
return bindingName.elements.some((element) => bindingNameContains(element.name, name))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return bindingName.elements.some(
|
|
58
|
+
(element) => !ts.isOmittedExpression(element) && bindingNameContains(element.name, name),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const importClauseDeclaresName = (importClause: ts.ImportClause, name: string): boolean => {
|
|
63
|
+
if (importClause.name?.text === name) {
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const namedBindings = importClause.namedBindings
|
|
68
|
+
if (!namedBindings) {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (ts.isNamespaceImport(namedBindings)) {
|
|
73
|
+
return namedBindings.name.text === name
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return namedBindings.elements.some((element) => element.name.text === name)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const variableDeclarationListDeclaresName = (
|
|
80
|
+
declarationList: ts.VariableDeclarationList,
|
|
81
|
+
name: string,
|
|
82
|
+
): boolean =>
|
|
83
|
+
declarationList.declarations.some((declaration) => bindingNameContains(declaration.name, name))
|
|
84
|
+
|
|
85
|
+
const statementDeclaresRuntimeName = (statement: ts.Statement, name: string): boolean => {
|
|
86
|
+
if (ts.isVariableStatement(statement)) {
|
|
87
|
+
return variableDeclarationListDeclaresName(statement.declarationList, name)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) {
|
|
91
|
+
return statement.name?.text === name
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)) {
|
|
95
|
+
return statement.name.text === name
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (ts.isImportDeclaration(statement) && statement.importClause) {
|
|
99
|
+
return importClauseDeclaresName(statement.importClause, name)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (ts.isImportEqualsDeclaration(statement)) {
|
|
103
|
+
return statement.name.text === name
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
(ts.isForStatement(statement) ||
|
|
108
|
+
ts.isForInStatement(statement) ||
|
|
109
|
+
ts.isForOfStatement(statement)) &&
|
|
110
|
+
statement.initializer &&
|
|
111
|
+
ts.isVariableDeclarationList(statement.initializer)
|
|
112
|
+
) {
|
|
113
|
+
return variableDeclarationListDeclaresName(statement.initializer, name)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (ts.isSwitchStatement(statement)) {
|
|
117
|
+
return statement.caseBlock.clauses.some((clause) =>
|
|
118
|
+
clause.statements.some((clauseStatement) =>
|
|
119
|
+
statementDeclaresRuntimeName(clauseStatement, name),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Checks whether a name is shadowed by a declaration that is actually visible
|
|
129
|
+
* at the current use site. This avoids suppressing warnings due to unrelated
|
|
130
|
+
* declarations in nested scopes elsewhere in the file.
|
|
131
|
+
*/
|
|
132
|
+
const hasLocalDeclarationInScope = (
|
|
133
|
+
name: string,
|
|
134
|
+
referenceNode: ts.Node,
|
|
135
|
+
currentSourceFile: ts.SourceFile,
|
|
136
|
+
): boolean => {
|
|
137
|
+
let current: ts.Node | undefined = referenceNode
|
|
138
|
+
|
|
139
|
+
while (current) {
|
|
140
|
+
if (
|
|
141
|
+
(ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current)) &&
|
|
142
|
+
current.name &&
|
|
143
|
+
ts.isIdentifier(current.name) &&
|
|
144
|
+
current.name.text === name
|
|
145
|
+
) {
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
(ts.isClassDeclaration(current) || ts.isClassExpression(current)) &&
|
|
151
|
+
current.name &&
|
|
152
|
+
ts.isIdentifier(current.name) &&
|
|
153
|
+
current.name.text === name
|
|
154
|
+
) {
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (
|
|
159
|
+
ts.isFunctionLike(current) &&
|
|
160
|
+
current.parameters.some((param) => bindingNameContains(param.name, name))
|
|
161
|
+
) {
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (ts.isCatchClause(current) && current.variableDeclaration) {
|
|
166
|
+
if (bindingNameContains(current.variableDeclaration.name, name)) {
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
(ts.isForStatement(current) ||
|
|
173
|
+
ts.isForInStatement(current) ||
|
|
174
|
+
ts.isForOfStatement(current)) &&
|
|
175
|
+
current.initializer &&
|
|
176
|
+
ts.isVariableDeclarationList(current.initializer)
|
|
177
|
+
) {
|
|
178
|
+
if (variableDeclarationListDeclaresName(current.initializer, name)) {
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (ts.isSourceFile(current) || ts.isBlock(current) || ts.isModuleBlock(current)) {
|
|
184
|
+
const refPos = referenceNode.pos
|
|
185
|
+
if (
|
|
186
|
+
current.statements.some((statement) => {
|
|
187
|
+
if (!statementDeclaresRuntimeName(statement, name)) return false
|
|
188
|
+
// Function declarations and imports are hoisted / always visible in scope.
|
|
189
|
+
if (
|
|
190
|
+
ts.isFunctionDeclaration(statement) ||
|
|
191
|
+
ts.isImportDeclaration(statement) ||
|
|
192
|
+
ts.isImportEqualsDeclaration(statement)
|
|
193
|
+
) {
|
|
194
|
+
return true
|
|
195
|
+
}
|
|
196
|
+
// Other declarations (const, let, var, class) are only a shadow once
|
|
197
|
+
// they have been fully declared — i.e. the statement ends before the usage.
|
|
198
|
+
return statement.end <= refPos
|
|
199
|
+
})
|
|
200
|
+
) {
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (ts.isSwitchStatement(current)) {
|
|
206
|
+
const refPos = referenceNode.pos
|
|
207
|
+
if (
|
|
208
|
+
current.caseBlock.clauses.some((clause) =>
|
|
209
|
+
clause.statements.some((statement) => {
|
|
210
|
+
if (!statementDeclaresRuntimeName(statement, name)) return false
|
|
211
|
+
if (
|
|
212
|
+
ts.isFunctionDeclaration(statement) ||
|
|
213
|
+
ts.isImportDeclaration(statement) ||
|
|
214
|
+
ts.isImportEqualsDeclaration(statement)
|
|
215
|
+
) {
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
return statement.end <= refPos
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
) {
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (current === currentSourceFile) {
|
|
227
|
+
break
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
current = current.parent
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const hasLocalDeclarationViaChecker = (
|
|
237
|
+
identifier: ts.Identifier,
|
|
238
|
+
checker: ts.TypeChecker,
|
|
239
|
+
localSourceFiles: Set<string>,
|
|
240
|
+
): boolean => {
|
|
241
|
+
const symbol = checker.getSymbolAtLocation(identifier)
|
|
242
|
+
return (
|
|
243
|
+
symbol?.declarations?.some((declaration) => {
|
|
244
|
+
if (!localSourceFiles.has(toAbsolutePath(declaration.getSourceFile().fileName))) {
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
// Function declarations and import bindings are hoisted / available throughout
|
|
248
|
+
// their scope — always count as a shadow regardless of position.
|
|
249
|
+
if (
|
|
250
|
+
ts.isFunctionDeclaration(declaration) ||
|
|
251
|
+
ts.isImportClause(declaration) ||
|
|
252
|
+
ts.isImportSpecifier(declaration) ||
|
|
253
|
+
ts.isNamespaceImport(declaration) ||
|
|
254
|
+
ts.isImportEqualsDeclaration(declaration)
|
|
255
|
+
) {
|
|
256
|
+
return true
|
|
257
|
+
}
|
|
258
|
+
// const / let / var / class are only visible once their declaration is complete.
|
|
259
|
+
// A usage that appears before the declaration (e.g. due to TDZ) still refers
|
|
260
|
+
// to the global, so do not suppress the warning.
|
|
261
|
+
return declaration.end <= identifier.pos
|
|
262
|
+
}) ?? false
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const getGlobalObjectReference = (
|
|
267
|
+
expression: ts.LeftHandSideExpression,
|
|
268
|
+
objectName: string,
|
|
269
|
+
): GlobalObjectReference | null => {
|
|
270
|
+
if (ts.isIdentifier(expression) && expression.text === objectName) {
|
|
271
|
+
return { identifier: expression }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (
|
|
275
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
276
|
+
ts.isIdentifier(expression.expression) &&
|
|
277
|
+
expression.expression.text === 'globalThis' &&
|
|
278
|
+
expression.name.text === objectName
|
|
279
|
+
) {
|
|
280
|
+
return { identifier: expression.expression }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Determines whether an expression resolves to a true global object (e.g.
|
|
288
|
+
* `Date`, `Promise`, `Object`) rather than a user-defined local with the
|
|
289
|
+
* same name.
|
|
290
|
+
*
|
|
291
|
+
* Uses a two-layer approach:
|
|
292
|
+
* 1. **Type-checker** (`hasLocalDeclarationViaChecker`) — the authoritative
|
|
293
|
+
* source when the checker can resolve the symbol. This handles most TS
|
|
294
|
+
* files with full type information.
|
|
295
|
+
* 2. **AST scope walk** (`hasLocalDeclarationInScope`) — a syntactic fallback
|
|
296
|
+
* for cases where the type-checker cannot resolve the symbol (e.g. loose
|
|
297
|
+
* JS files, files outside the compilation root, or declaration-less
|
|
298
|
+
* globals). This mirrors the scoping rules manually so we still suppress
|
|
299
|
+
* warnings for locally-shadowed names.
|
|
300
|
+
*
|
|
301
|
+
* The runtime compat validator only needs the type-checker layer because it
|
|
302
|
+
* checks simple identifier names (`fetch`, `setTimeout`). This validator
|
|
303
|
+
* additionally needs the AST fallback because it checks property-access
|
|
304
|
+
* patterns on globals (`Date.now()`, `Promise.race()`) where the root
|
|
305
|
+
* identifier may not have a resolvable symbol.
|
|
306
|
+
*/
|
|
307
|
+
const resolvesToGlobalObject = (
|
|
308
|
+
expression: ts.LeftHandSideExpression,
|
|
309
|
+
objectName: string,
|
|
310
|
+
checker: ts.TypeChecker,
|
|
311
|
+
localSourceFiles: Set<string>,
|
|
312
|
+
currentSourceFile: ts.SourceFile,
|
|
313
|
+
): boolean => {
|
|
314
|
+
const reference = getGlobalObjectReference(expression, objectName)
|
|
315
|
+
if (!reference) {
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (hasLocalDeclarationViaChecker(reference.identifier, checker, localSourceFiles)) {
|
|
320
|
+
return false
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const fallbackName = reference.identifier.text
|
|
324
|
+
return !hasLocalDeclarationInScope(fallbackName, reference.identifier, currentSourceFile)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Checks whether a `CallExpression` is a method call on a global object.
|
|
329
|
+
* For example, `Promise.race(...)` or `globalThis.Promise.race(...)`.
|
|
330
|
+
*
|
|
331
|
+
* Returns the method name if matched, otherwise `null`.
|
|
332
|
+
*/
|
|
333
|
+
const getGlobalMethodCall = (
|
|
334
|
+
node: ts.CallExpression,
|
|
335
|
+
objectName: string,
|
|
336
|
+
methodNames: Set<string>,
|
|
337
|
+
checker: ts.TypeChecker,
|
|
338
|
+
localSourceFiles: Set<string>,
|
|
339
|
+
currentSourceFile: ts.SourceFile,
|
|
340
|
+
): string | null => {
|
|
341
|
+
if (!ts.isPropertyAccessExpression(node.expression)) return null
|
|
342
|
+
|
|
343
|
+
const propAccess = node.expression
|
|
344
|
+
if (!methodNames.has(propAccess.name.text)) return null
|
|
345
|
+
if (
|
|
346
|
+
!resolvesToGlobalObject(
|
|
347
|
+
propAccess.expression,
|
|
348
|
+
objectName,
|
|
349
|
+
checker,
|
|
350
|
+
localSourceFiles,
|
|
351
|
+
currentSourceFile,
|
|
352
|
+
)
|
|
353
|
+
) {
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return propAccess.name.text
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Checks whether a call to `Object.keys/values/entries()` is followed anywhere
|
|
362
|
+
* in the method chain by `.sort()` or `.toSorted()`, which makes the iteration
|
|
363
|
+
* order deterministic.
|
|
364
|
+
*
|
|
365
|
+
* Handles both direct chaining (`Object.keys(obj).sort()`) and intermediate
|
|
366
|
+
* calls (`Object.keys(obj).filter(...).sort()`). A `.sort()` that appears after
|
|
367
|
+
* any number of intermediate method calls still produces a deterministically
|
|
368
|
+
* ordered result.
|
|
369
|
+
*
|
|
370
|
+
* Note: this check is syntactic — it does not verify that the array returned
|
|
371
|
+
* by `Object.keys/values/entries()` is the same one eventually sorted.
|
|
372
|
+
* Patterns such as assigning to a variable and sorting later are not detected.
|
|
373
|
+
*
|
|
374
|
+
* The chain walk is capped at {@link MAX_CHAIN_DEPTH} iterations to guard
|
|
375
|
+
* against degenerate or malformed ASTs.
|
|
376
|
+
*/
|
|
377
|
+
const MAX_CHAIN_DEPTH = 50
|
|
378
|
+
const isFollowedBySort = (callNode: ts.CallExpression): boolean => {
|
|
379
|
+
let current: ts.Node = callNode
|
|
380
|
+
|
|
381
|
+
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
|
|
382
|
+
const parent = current.parent
|
|
383
|
+
if (!ts.isPropertyAccessExpression(parent)) return false
|
|
384
|
+
// The PropertyAccessExpression must be the callee of a CallExpression
|
|
385
|
+
if (!ts.isCallExpression(parent.parent)) return false
|
|
386
|
+
|
|
387
|
+
if (parent.name.text === 'sort' || parent.name.text === 'toSorted') return true
|
|
388
|
+
|
|
389
|
+
// Some other chained method call — keep walking up the chain
|
|
390
|
+
current = parent.parent
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return false
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Collects determinism warnings from all local source files in the program.
|
|
398
|
+
*/
|
|
399
|
+
const collectDeterminismWarnings = (
|
|
400
|
+
program: ts.Program,
|
|
401
|
+
localSourceFiles: Set<string>,
|
|
402
|
+
warnings: Violation[],
|
|
403
|
+
) => {
|
|
404
|
+
const checker = program.getTypeChecker()
|
|
405
|
+
|
|
406
|
+
const promiseMethods = new Set(['race', 'any', 'all'])
|
|
407
|
+
const dateMethods = new Set(['now'])
|
|
408
|
+
const objectIterationMethods = new Set(['keys', 'values', 'entries'])
|
|
409
|
+
|
|
410
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
411
|
+
const resolvedSourcePath = toAbsolutePath(sourceFile.fileName)
|
|
412
|
+
if (!localSourceFiles.has(resolvedSourcePath)) continue
|
|
413
|
+
|
|
414
|
+
const visit = (node: ts.Node) => {
|
|
415
|
+
// --- Promise.race() / Promise.any() ---
|
|
416
|
+
if (ts.isCallExpression(node)) {
|
|
417
|
+
const promiseMethod = getGlobalMethodCall(
|
|
418
|
+
node,
|
|
419
|
+
'Promise',
|
|
420
|
+
promiseMethods,
|
|
421
|
+
checker,
|
|
422
|
+
localSourceFiles,
|
|
423
|
+
sourceFile,
|
|
424
|
+
)
|
|
425
|
+
if (promiseMethod) {
|
|
426
|
+
const promiseWarning =
|
|
427
|
+
promiseMethod === 'all'
|
|
428
|
+
? `Promise.all() executes promises concurrently — side effects may occur in different orders across nodes.`
|
|
429
|
+
: `Promise.${promiseMethod}() is non-deterministic — the first ${promiseMethod === 'race' ? 'settled' : 'fulfilled'} promise wins, and timing varies across nodes.`
|
|
430
|
+
warnings.push(
|
|
431
|
+
createViolation(
|
|
432
|
+
resolvedSourcePath,
|
|
433
|
+
node.expression.getStart(sourceFile),
|
|
434
|
+
sourceFile,
|
|
435
|
+
promiseWarning,
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- Date.now() ---
|
|
441
|
+
const dateMethod = getGlobalMethodCall(
|
|
442
|
+
node,
|
|
443
|
+
'Date',
|
|
444
|
+
dateMethods,
|
|
445
|
+
checker,
|
|
446
|
+
localSourceFiles,
|
|
447
|
+
sourceFile,
|
|
448
|
+
)
|
|
449
|
+
if (dateMethod) {
|
|
450
|
+
warnings.push(
|
|
451
|
+
createViolation(
|
|
452
|
+
resolvedSourcePath,
|
|
453
|
+
node.expression.getStart(sourceFile),
|
|
454
|
+
sourceFile,
|
|
455
|
+
'Date.now() uses the system clock which varies across nodes.',
|
|
456
|
+
),
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// --- Object.keys/values/entries() without .sort() ---
|
|
461
|
+
const objectMethod = getGlobalMethodCall(
|
|
462
|
+
node,
|
|
463
|
+
'Object',
|
|
464
|
+
objectIterationMethods,
|
|
465
|
+
checker,
|
|
466
|
+
localSourceFiles,
|
|
467
|
+
sourceFile,
|
|
468
|
+
)
|
|
469
|
+
if (objectMethod && !isFollowedBySort(node)) {
|
|
470
|
+
warnings.push(
|
|
471
|
+
createViolation(
|
|
472
|
+
resolvedSourcePath,
|
|
473
|
+
node.expression.getStart(sourceFile),
|
|
474
|
+
sourceFile,
|
|
475
|
+
`Object.${objectMethod}() returns items in an order that may vary across engines. Chain with .sort() for deterministic ordering.`,
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// --- new Date() with no arguments ---
|
|
482
|
+
if (
|
|
483
|
+
ts.isNewExpression(node) &&
|
|
484
|
+
resolvesToGlobalObject(node.expression, 'Date', checker, localSourceFiles, sourceFile) &&
|
|
485
|
+
(!node.arguments || node.arguments.length === 0)
|
|
486
|
+
) {
|
|
487
|
+
warnings.push(
|
|
488
|
+
createViolation(
|
|
489
|
+
resolvedSourcePath,
|
|
490
|
+
node.getStart(sourceFile),
|
|
491
|
+
sourceFile,
|
|
492
|
+
'new Date() without arguments uses the system clock which varies across nodes. Pass an explicit timestamp instead.',
|
|
493
|
+
),
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// --- for...in loops ---
|
|
498
|
+
if (ts.isForInStatement(node)) {
|
|
499
|
+
warnings.push(
|
|
500
|
+
createViolation(
|
|
501
|
+
resolvedSourcePath,
|
|
502
|
+
node.getStart(sourceFile),
|
|
503
|
+
sourceFile,
|
|
504
|
+
'for...in loop iteration order is not guaranteed by the spec and may vary across engines. Use for...of with Object.keys().sort() instead.',
|
|
505
|
+
),
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
ts.forEachChild(node, visit)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
visit(sourceFile)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Analyzes a workflow entry file (and all local files it transitively imports)
|
|
518
|
+
* for non-deterministic patterns that may prevent DON consensus.
|
|
519
|
+
*
|
|
520
|
+
* Returns an array of warnings. Does **not** throw — compilation should
|
|
521
|
+
* continue regardless of warnings.
|
|
522
|
+
*/
|
|
523
|
+
export const checkWorkflowDeterminism = (entryFilePath: string): Violation[] => {
|
|
524
|
+
const rootFile = toAbsolutePath(entryFilePath)
|
|
525
|
+
const localSourceFiles = collectLocalSourceFiles(rootFile)
|
|
526
|
+
const program = createValidationProgram(rootFile, localSourceFiles)
|
|
527
|
+
const warnings: Violation[] = []
|
|
528
|
+
|
|
529
|
+
collectDeterminismWarnings(program, localSourceFiles, warnings)
|
|
530
|
+
|
|
531
|
+
return warnings
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Prints determinism warnings to stderr in a user-friendly format.
|
|
536
|
+
*/
|
|
537
|
+
export const printDeterminismWarnings = (warnings: Violation[]) => {
|
|
538
|
+
console.warn(
|
|
539
|
+
`\n⚠️ Non-determinism warnings (compilation will continue):
|
|
540
|
+
These patterns may prevent nodes from reaching consensus on the DON.
|
|
541
|
+
See ${DOCS_URL}
|
|
542
|
+
|
|
543
|
+
${formatViolations(warnings)}\n`,
|
|
544
|
+
)
|
|
545
|
+
}
|