diff --git a/packages/cre-sdk-examples/package.json b/packages/cre-sdk-examples/package.json index 8e5d089..fe67082 100644 --- a/packages/cre-sdk-examples/package.json +++ b/packages/cre-sdk-examples/package.json @@ -1,7 +1,7 @@ { "name": "@chainlink/cre-sdk-examples", "private": true, - "version": "1.4.0", + "version": "1.6.0", "type": "module", "author": "Ernest Nowacki", "license": "BUSL-1.1", diff --git a/packages/cre-sdk-javy-plugin/package.json b/packages/cre-sdk-javy-plugin/package.json index c145903..95d4ac8 100644 --- a/packages/cre-sdk-javy-plugin/package.json +++ b/packages/cre-sdk-javy-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@chainlink/cre-sdk-javy-plugin", - "version": "1.2.0", + "version": "1.5.0", "type": "module", "bin": { "cre-setup": "bin/setup.ts", diff --git a/packages/cre-sdk/package.json b/packages/cre-sdk/package.json index 1855f55..90de255 100644 --- a/packages/cre-sdk/package.json +++ b/packages/cre-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@chainlink/cre-sdk", - "version": "1.4.0", + "version": "1.6.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/cre-sdk/scripts/run.ts b/packages/cre-sdk/scripts/run.ts index e9b984e..6815fde 100644 --- a/packages/cre-sdk/scripts/run.ts +++ b/packages/cre-sdk/scripts/run.ts @@ -5,6 +5,7 @@ import { WorkflowRuntimeCompatibilityError } from './src/validate-workflow-runti const availableScripts = [ 'build-types', + 'check-determinism', // Check for non-deterministic patterns in workflow source 'compile-to-js', 'compile-to-wasm', 'compile-workflow', // TS -> JS -> WASM compilation in single script diff --git a/packages/cre-sdk/scripts/src/check-determinism.test.ts b/packages/cre-sdk/scripts/src/check-determinism.test.ts new file mode 100644 index 0000000..45c2a83 --- /dev/null +++ b/packages/cre-sdk/scripts/src/check-determinism.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +let tempDir: string + +const scriptsDir = path.resolve(import.meta.dir, '..') +const runScript = path.join(scriptsDir, 'run.ts') + +beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'cre-check-determinism-test-')) +}) + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) +}) + +const runCheckDeterminism = (filePath: string) => + spawnSync(process.execPath, [runScript, 'check-determinism', filePath], { + cwd: scriptsDir, + encoding: 'utf-8', + }) + +describe('check-determinism CLI', () => { + test('fails when the input file does not exist', () => { + const missingFile = path.join(tempDir, 'does-not-exist.ts') + const result = runCheckDeterminism(missingFile) + + expect(result.status).toBe(1) + expect(result.stdout).not.toContain('No non-determinism warnings found.') + expect(result.stderr).toContain(`āŒ File not found: ${missingFile}`) + }) + + test('prints warnings for non-deterministic patterns and exits 0', () => { + const filePath = path.join(tempDir, 'workflow.ts') + writeFileSync(filePath, `const result = await Promise.race([]);\n`, 'utf-8') + const result = runCheckDeterminism(filePath) + + expect(result.status).toBe(0) + expect(result.stderr).toContain('Non-determinism warnings') + expect(result.stderr).toContain('Promise.race()') + }) + + test('prints success message for clean workflow and exits 0', () => { + const filePath = path.join(tempDir, 'workflow.ts') + writeFileSync(filePath, `const x = 1;\n`, 'utf-8') + const result = runCheckDeterminism(filePath) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('No non-determinism warnings found.') + }) + + test('fails when no input file is provided', () => { + const result = spawnSync(process.execPath, [runScript, 'check-determinism'], { + cwd: scriptsDir, + encoding: 'utf-8', + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Usage:') + }) +}) diff --git a/packages/cre-sdk/scripts/src/check-determinism.ts b/packages/cre-sdk/scripts/src/check-determinism.ts new file mode 100644 index 0000000..d313b45 --- /dev/null +++ b/packages/cre-sdk/scripts/src/check-determinism.ts @@ -0,0 +1,32 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' +import { checkWorkflowDeterminism, printDeterminismWarnings } from './validate-workflow-determinism' + +const printUsage = () => { + console.error('Usage: bun scripts/run.ts check-determinism ') + console.error('Example:') + console.error(' bun scripts/run.ts check-determinism src/workflows/my-workflow/index.ts') +} + +export const main = () => { + const inputPath = process.argv[3] + + if (!inputPath) { + printUsage() + process.exit(1) + } + + const resolvedInput = path.resolve(inputPath) + if (!existsSync(resolvedInput)) { + console.error(`āŒ File not found: ${resolvedInput}`) + process.exit(1) + } + + const warnings = checkWorkflowDeterminism(resolvedInput) + + if (warnings.length > 0) { + printDeterminismWarnings(warnings) + } else { + console.info('No non-determinism warnings found.') + } +} diff --git a/packages/cre-sdk/scripts/src/compile-to-js.ts b/packages/cre-sdk/scripts/src/compile-to-js.ts index 7b75e38..188c019 100644 --- a/packages/cre-sdk/scripts/src/compile-to-js.ts +++ b/packages/cre-sdk/scripts/src/compile-to-js.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { $ } from 'bun' import { parseCompileCliArgs, skipTypeChecksFlag } from './compile-cli-args' import { assertWorkflowTypecheck } from './typecheck-workflow' +import { checkWorkflowDeterminism, printDeterminismWarnings } from './validate-workflow-determinism' import { assertWorkflowRuntimeCompatibility } from './validate-workflow-runtime-compat' import { wrapWorkflowCode } from './workflow-wrapper' @@ -60,6 +61,12 @@ export const main = async ( assertWorkflowTypecheck(resolvedInput) } assertWorkflowRuntimeCompatibility(resolvedInput) + if (!parsedSkipTypeChecks) { + const warnings = checkWorkflowDeterminism(resolvedInput) + if (warnings.length > 0) { + printDeterminismWarnings(warnings) + } + } console.info(`šŸ“ Using input file: ${resolvedInput}`) // If no explicit output path → same dir, swap extension to .js diff --git a/packages/cre-sdk/scripts/src/validate-shared.ts b/packages/cre-sdk/scripts/src/validate-shared.ts new file mode 100644 index 0000000..7475a51 --- /dev/null +++ b/packages/cre-sdk/scripts/src/validate-shared.ts @@ -0,0 +1,400 @@ +/** + * Shared utilities for workflow validation modules. + * + * This module provides common types and functions used by both the runtime + * compatibility validator (`validate-workflow-runtime-compat.ts`) and the + * determinism warning analyzer (`validate-workflow-determinism.ts`). + */ + +import { existsSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import * as ts from 'typescript' + +/** + * A single detected violation: a location in the source code where a + * restricted or discouraged API is referenced. + */ +export type Violation = { + /** Absolute path to the file containing the violation. */ + filePath: string + /** 1-based line number. */ + line: number + /** 1-based column number. */ + column: number + /** Human-readable description of the violation. */ + message: string +} + +/** File extensions treated as scannable source code. */ +export const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'] + +/** Resolves a file path to an absolute path using the current working directory. */ +export const toAbsolutePath = (filePath: string) => path.resolve(filePath) + +export const defaultValidationCompilerOptions: ts.CompilerOptions = { + allowJs: true, + checkJs: true, + noEmit: true, + skipLibCheck: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, +} + +export const formatDiagnostic = (diagnostic: ts.Diagnostic) => { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + if (!diagnostic.file || diagnostic.start == null) { + return message + } + + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) + return `${toAbsolutePath(diagnostic.file.fileName)}:${line + 1}:${character + 1} ${message}` +} + +/** + * Loads compiler options from the nearest tsconfig.json so validation runs + * against the same ambient/type environment as the workflow project. + */ +export const loadClosestTsconfigCompilerOptions = ( + entryFilePath: string, +): ts.CompilerOptions | null => { + const configPath = ts.findConfigFile( + path.dirname(entryFilePath), + ts.sys.fileExists, + 'tsconfig.json', + ) + if (!configPath) { + return null + } + + let unrecoverableDiagnostic: ts.Diagnostic | null = null + const parsed = ts.getParsedCommandLineOfConfigFile( + configPath, + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + unrecoverableDiagnostic = diagnostic + }, + }, + ) + + if (!parsed) { + if (unrecoverableDiagnostic) { + throw new Error( + `Failed to parse TypeScript config for workflow validation.\n${formatDiagnostic(unrecoverableDiagnostic)}`, + ) + } + return null + } + + return parsed.options +} + +/** + * Maps a file extension to the appropriate TypeScript {@link ts.ScriptKind} + * so the parser handles JSX, CommonJS, and ESM files correctly. + */ +export const getScriptKind = (filePath: string): ts.ScriptKind => { + switch (path.extname(filePath).toLowerCase()) { + case '.js': + return ts.ScriptKind.JS + case '.jsx': + return ts.ScriptKind.JSX + case '.mjs': + return ts.ScriptKind.JS + case '.cjs': + return ts.ScriptKind.JS + case '.tsx': + return ts.ScriptKind.TSX + case '.mts': + return ts.ScriptKind.TS + case '.cts': + return ts.ScriptKind.TS + default: + return ts.ScriptKind.TS + } +} + +/** + * Creates a {@link Violation} with 1-based line and column numbers derived + * from a character position in the source file. + */ +export const createViolation = ( + filePath: string, + pos: number, + sourceFile: ts.SourceFile, + message: string, +): Violation => { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(pos) + return { + filePath: toAbsolutePath(filePath), + line: line + 1, + column: character + 1, + message, + } +} + +/** Returns `true` if the specifier looks like a relative or absolute file path. */ +export const isRelativeImport = (specifier: string) => { + return specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/') +} + +/** + * Attempts to resolve a relative import specifier to an absolute file path. + * Tries the path as-is first, then appends each known source extension, then + * looks for an index file inside the directory. Returns `null` if nothing is + * found on disk. + */ +export const resolveRelativeImport = (fromFilePath: string, specifier: string): string | null => { + const basePath = specifier.startsWith('/') + ? path.resolve(specifier) + : path.resolve(path.dirname(fromFilePath), specifier) + + if (existsSync(basePath) && statSync(basePath).isFile()) { + return toAbsolutePath(basePath) + } + + for (const extension of sourceExtensions) { + const withExtension = `${basePath}${extension}` + if (existsSync(withExtension)) { + return toAbsolutePath(withExtension) + } + } + + for (const extension of sourceExtensions) { + const asIndex = path.join(basePath, `index${extension}`) + if (existsSync(asIndex)) { + return toAbsolutePath(asIndex) + } + } + + return null +} + +/** + * Extracts a string literal from the first argument of a call expression. + * Used for `require('node:fs')` and `import('node:fs')` patterns. + * Returns `null` if the first argument is not a static string literal. + */ +export const getStringLiteralFromCall = (node: ts.CallExpression): string | null => { + const [firstArg] = node.arguments + if (!firstArg || !ts.isStringLiteral(firstArg)) return null + return firstArg.text +} + +/** + * Checks whether an identifier AST node is the **name being declared** (as + * opposed to a reference/usage). For example, in `const fetch = ...` the + * `fetch` token is a declaration name, while in `fetch(url)` it is a usage. + * + * This distinction is critical so that user-defined variables that shadow + * restricted global names are not flagged as violations. + */ +export const isDeclarationName = (identifier: ts.Identifier): boolean => { + const parent = identifier.parent + + // Variable, function, class, interface, type alias, enum, module, + // type parameter, parameter, binding element, import names, enum member, + // property/method declarations, property assignments, and labels. + if ( + (ts.isFunctionDeclaration(parent) && parent.name === identifier) || + (ts.isFunctionExpression(parent) && parent.name === identifier) || + (ts.isClassDeclaration(parent) && parent.name === identifier) || + (ts.isClassExpression(parent) && parent.name === identifier) || + (ts.isInterfaceDeclaration(parent) && parent.name === identifier) || + (ts.isTypeAliasDeclaration(parent) && parent.name === identifier) || + (ts.isEnumDeclaration(parent) && parent.name === identifier) || + (ts.isModuleDeclaration(parent) && parent.name === identifier) || + (ts.isTypeParameterDeclaration(parent) && parent.name === identifier) || + (ts.isVariableDeclaration(parent) && parent.name === identifier) || + (ts.isParameter(parent) && parent.name === identifier) || + (ts.isBindingElement(parent) && parent.name === identifier) || + (ts.isImportClause(parent) && parent.name === identifier) || + (ts.isImportSpecifier(parent) && parent.name === identifier) || + (ts.isNamespaceImport(parent) && parent.name === identifier) || + (ts.isImportEqualsDeclaration(parent) && parent.name === identifier) || + (ts.isNamespaceExport(parent) && parent.name === identifier) || + (ts.isEnumMember(parent) && parent.name === identifier) || + (ts.isPropertyDeclaration(parent) && parent.name === identifier) || + (ts.isPropertySignature(parent) && parent.name === identifier) || + (ts.isMethodDeclaration(parent) && parent.name === identifier) || + (ts.isMethodSignature(parent) && parent.name === identifier) || + (ts.isGetAccessorDeclaration(parent) && parent.name === identifier) || + (ts.isSetAccessorDeclaration(parent) && parent.name === identifier) || + (ts.isPropertyAssignment(parent) && parent.name === identifier) || + (ts.isShorthandPropertyAssignment(parent) && parent.name === identifier) || + (ts.isLabeledStatement(parent) && parent.label === identifier) + ) { + return true + } + + // Property access (obj.fetch), qualified names (Ns.fetch), and type + // references (SomeType) — the right-hand identifier is not a standalone + // usage of the global name. + if ( + (ts.isPropertyAccessExpression(parent) && parent.name === identifier) || + (ts.isQualifiedName(parent) && parent.right === identifier) || + (ts.isTypeReferenceNode(parent) && parent.typeName === identifier) + ) { + return true + } + + return false +} + +/** + * Walks the local import graph starting from `entryFilePath` and collects + * all reachable local source files. Also invokes an optional `onModuleSpecifier` + * callback for each import specifier found, allowing callers to collect + * module-level violations. + * + * Returns the set of absolute paths to all local source files reachable from + * the entry point. + */ +export const collectLocalSourceFiles = ( + entryFilePath: string, + onModuleSpecifier?: ( + specifier: string, + pos: number, + sourceFile: ts.SourceFile, + filePath: string, + ) => void, +): Set => { + const rootFile = toAbsolutePath(entryFilePath) + const filesToScan = [rootFile] + const scannedFiles = new Set() + const localSourceFiles = new Set() + + while (filesToScan.length > 0) { + const currentFile = filesToScan.pop() + if (!currentFile || scannedFiles.has(currentFile)) continue + scannedFiles.add(currentFile) + + if (!existsSync(currentFile)) continue + localSourceFiles.add(currentFile) + + const fileContents = readFileSync(currentFile, 'utf-8') + const sourceFile = ts.createSourceFile( + currentFile, + fileContents, + ts.ScriptTarget.Latest, + true, + getScriptKind(currentFile), + ) + + collectImports(sourceFile, currentFile, (specifier, pos) => { + onModuleSpecifier?.(specifier, pos, sourceFile, currentFile) + + if (!isRelativeImport(specifier)) return + const resolved = resolveRelativeImport(currentFile, specifier) + if (resolved && !scannedFiles.has(resolved)) { + filesToScan.push(resolved) + } + }) + } + + return localSourceFiles +} + +/** + * Walks the AST of a single source file and invokes `onSpecifier` for every + * module specifier found in import/export/require/dynamic-import syntax. + */ +const collectImports = ( + sourceFile: ts.SourceFile, + filePath: string, + onSpecifier: (specifier: string, pos: number) => void, +) => { + const visit = (node: ts.Node) => { + // import ... from 'specifier' + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + onSpecifier(node.moduleSpecifier.text, node.moduleSpecifier.getStart(sourceFile)) + } + + // export ... from 'specifier' + if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + onSpecifier(node.moduleSpecifier.text, node.moduleSpecifier.getStart(sourceFile)) + } + + // import fs = require('specifier') + if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) && + node.moduleReference.expression && + ts.isStringLiteral(node.moduleReference.expression) + ) { + onSpecifier( + node.moduleReference.expression.text, + node.moduleReference.expression.getStart(sourceFile), + ) + } + + if (ts.isCallExpression(node)) { + // require('specifier') + if (ts.isIdentifier(node.expression) && node.expression.text === 'require') { + const requiredModule = getStringLiteralFromCall(node) + if (requiredModule) { + onSpecifier(requiredModule, node.arguments[0].getStart(sourceFile)) + } + } + + // import('specifier') + if (node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const importedModule = getStringLiteralFromCall(node) + if (importedModule) { + onSpecifier(importedModule, node.arguments[0].getStart(sourceFile)) + } + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) +} + +/** + * Creates a TypeScript program from the collected local source files, + * merging project compiler options with validation defaults. + */ +export const createValidationProgram = ( + entryFilePath: string, + localSourceFiles: Set, +): ts.Program => { + const projectCompilerOptions = loadClosestTsconfigCompilerOptions(entryFilePath) ?? {} + return ts.createProgram({ + rootNames: [...localSourceFiles], + options: { + ...defaultValidationCompilerOptions, + ...projectCompilerOptions, + allowJs: true, + checkJs: true, + noEmit: true, + skipLibCheck: true, + }, + }) +} + +/** + * Sorts violations by file path, then line, then column, and formats them + * as a list of strings with relative paths. + */ +export const formatViolations = (violations: Violation[]): string => { + const sorted = [...violations].sort((a, b) => { + if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath) + if (a.line !== b.line) return a.line - b.line + return a.column - b.column + }) + + return sorted + .map((violation) => { + const relativePath = path.relative(process.cwd(), violation.filePath) + return `- ${relativePath}:${violation.line}:${violation.column} ${violation.message}` + }) + .join('\n') +} diff --git a/packages/cre-sdk/scripts/src/validate-workflow-determinism.test.ts b/packages/cre-sdk/scripts/src/validate-workflow-determinism.test.ts new file mode 100644 index 0000000..f15c5bf --- /dev/null +++ b/packages/cre-sdk/scripts/src/validate-workflow-determinism.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { checkWorkflowDeterminism } from './validate-workflow-determinism' + +let tempDir: string + +beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'cre-determinism-test-')) +}) + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) +}) + +/** Write a file in the temp directory and return its absolute path. */ +const writeTemp = (filename: string, content: string): string => { + const filePath = path.join(tempDir, filename) + const dir = path.dirname(filePath) + mkdirSync(dir, { recursive: true }) + writeFileSync(filePath, content, 'utf-8') + return filePath +} + +/** Assert that the analyzer returns warnings matching the given patterns. */ +const expectWarnings = (entryPath: string, expectedPatterns: (string | RegExp)[]) => { + const warnings = checkWorkflowDeterminism(entryPath) + expect(warnings.length).toBeGreaterThan(0) + const combined = warnings.map((w) => w.message).join('\n') + for (const pattern of expectedPatterns) { + if (typeof pattern === 'string') { + expect(combined).toContain(pattern) + } else { + expect(combined).toMatch(pattern) + } + } +} + +/** Assert that the analyzer returns NO warnings. */ +const expectNoWarnings = (entryPath: string) => { + const warnings = checkWorkflowDeterminism(entryPath) + expect(warnings).toEqual([]) +} + +// --------------------------------------------------------------------------- +// Promise.race() / Promise.any() +// --------------------------------------------------------------------------- + +describe('Promise.race(), Promise.any(), and Promise.all()', () => { + test('detects Promise.race()', () => { + const entry = writeTemp( + 'workflow.ts', + `const result = await Promise.race([Promise.resolve(1), Promise.resolve(2)]);\n`, + ) + expectWarnings(entry, ['Promise.race() is non-deterministic']) + }) + + test('detects globalThis.Promise.race()', () => { + const entry = writeTemp( + 'workflow.ts', + `const result = await globalThis.Promise.race([Promise.resolve(1), Promise.resolve(2)]);\n`, + ) + expectWarnings(entry, ['Promise.race() is non-deterministic']) + }) + + test('detects Promise.any()', () => { + const entry = writeTemp( + 'workflow.ts', + `const result = await Promise.any([Promise.resolve(1), Promise.resolve(2)]);\n`, + ) + expectWarnings(entry, ['Promise.any() is non-deterministic']) + }) + + test('detects Promise.all()', () => { + const entry = writeTemp( + 'workflow.ts', + `const results = await Promise.all([Promise.resolve(1), Promise.resolve(2)]);\n`, + ) + expectWarnings(entry, ['Promise.all() executes promises concurrently']) + }) + + test('detects globalThis.Promise.all()', () => { + const entry = writeTemp( + 'workflow.ts', + `const results = await globalThis.Promise.all([Promise.resolve(1), Promise.resolve(2)]);\n`, + ) + expectWarnings(entry, ['Promise.all() executes promises concurrently']) + }) + + test('does NOT flag Promise.allSettled()', () => { + const entry = writeTemp( + 'workflow.ts', + `const results = await Promise.allSettled([Promise.resolve(1)]);\n`, + ) + expectNoWarnings(entry) + }) + + test('does NOT flag user-defined object named Promise with race method', () => { + const entry = writeTemp( + 'workflow.ts', + `const Promise = { race: (x: any) => x };\nPromise.race([1, 2]);\n`, + ) + expectNoWarnings(entry) + }) + + test('does NOT flag property access obj.race()', () => { + const entry = writeTemp('workflow.ts', `const obj = { race: () => 42 };\nobj.race();\n`) + expectNoWarnings(entry) + }) +}) + +// --------------------------------------------------------------------------- +// Date.now() / new Date() +// --------------------------------------------------------------------------- + +describe('Date.now() and new Date()', () => { + test('detects Date.now()', () => { + const entry = writeTemp('workflow.ts', `const ts = Date.now();\n`) + expectWarnings(entry, ['Date.now() uses the system clock']) + }) + + test('detects globalThis.Date.now()', () => { + const entry = writeTemp('workflow.ts', `const ts = globalThis.Date.now();\n`) + expectWarnings(entry, ['Date.now() uses the system clock']) + }) + + test('detects new Date() with no arguments', () => { + const entry = writeTemp('workflow.ts', `const d = new Date();\n`) + expectWarnings(entry, ['new Date() without arguments uses the system clock']) + }) + + test('detects new globalThis.Date() with no arguments', () => { + const entry = writeTemp('workflow.ts', `const d = new globalThis.Date();\n`) + expectWarnings(entry, ['new Date() without arguments uses the system clock']) + }) + + test('detects new Date without parens', () => { + const entry = writeTemp('workflow.ts', `const d = new Date;\n`) + expectWarnings(entry, ['new Date() without arguments uses the system clock']) + }) + + test('does NOT flag new Date(timestamp)', () => { + const entry = writeTemp('workflow.ts', `const d = new Date(1700000000000);\n`) + expectNoWarnings(entry) + }) + + test('does NOT flag new Date(string)', () => { + const entry = writeTemp('workflow.ts', `const d = new Date('2024-01-01');\n`) + expectNoWarnings(entry) + }) + + test('does NOT flag user-defined Date class', () => { + const entry = writeTemp( + 'workflow.ts', + `class Date { static now() { return 42; } }\nDate.now();\n`, + ) + expectNoWarnings(entry) + }) + + test('does NOT flag user-defined Date variable with now method', () => { + const entry = writeTemp('workflow.ts', `const Date = { now: () => 42 };\nDate.now();\n`) + expectNoWarnings(entry) + }) + + test('does NOT let an inner block Date shadow suppress outer Date.now()', () => { + const entry = writeTemp( + 'workflow.ts', + `if (true) { const Date = { now: () => 42 }; }\nconst ts = Date.now();\n`, + ) + expectWarnings(entry, ['Date.now() uses the system clock']) + }) + + test('does NOT flag Date.now() when Date is shadowed in the active block', () => { + const entry = writeTemp( + 'workflow.ts', + `if (true) { const Date = { now: () => 42 };\n Date.now();\n}\n`, + ) + expectNoWarnings(entry) + }) + + test('does NOT flag globalThis.Date.now() when globalThis is shadowed locally', () => { + const entry = writeTemp( + 'workflow.ts', + `const globalThis = { Date: { now: () => 42 } };\nglobalThis.Date.now();\n`, + ) + expectNoWarnings(entry) + }) + + test('warns when Date.now() appears before the local Date declaration in the same scope', () => { + const entry = writeTemp( + 'workflow.ts', + `const ts = Date.now();\nconst Date = { now: () => 42 };\n`, + ) + expectWarnings(entry, ['Date.now() uses the system clock']) + }) +}) + +// --------------------------------------------------------------------------- +// for...in loops +// --------------------------------------------------------------------------- + +describe('for...in loops', () => { + test('detects for...in loop', () => { + const entry = writeTemp( + 'workflow.ts', + `const obj = { a: 1, b: 2 };\nfor (const key in obj) { console.log(key); }\n`, + ) + expectWarnings(entry, ['for...in loop iteration order is not guaranteed']) + }) + + test('does NOT flag for...of loop', () => { + const entry = writeTemp( + 'workflow.ts', + `const arr = [1, 2, 3];\nfor (const val of arr) { console.log(val); }\n`, + ) + expectNoWarnings(entry) + }) + + test('does NOT flag regular for loop', () => { + const entry = writeTemp('workflow.ts', `for (let i = 0; i < 10; i++) { console.log(i); }\n`) + expectNoWarnings(entry) + }) +}) + +// --------------------------------------------------------------------------- +// Object.keys/values/entries() without .sort() +// --------------------------------------------------------------------------- + +describe('Object.keys/values/entries() without .sort()', () => { + test('detects Object.keys() without sort', () => { + const entry = writeTemp('workflow.ts', `const keys = Object.keys({ a: 1, b: 2 });\n`) + expectWarnings(entry, ['Object.keys() returns items in an order that may vary']) + }) + + test('detects Object.values() without sort', () => { + const entry = writeTemp('workflow.ts', `const vals = Object.values({ a: 1, b: 2 });\n`) + expectWarnings(entry, ['Object.values() returns items in an order that may vary']) + }) + + test('detects Object.entries() without sort', () => { + const entry = writeTemp('workflow.ts', `const entries = Object.entries({ a: 1, b: 2 });\n`) + expectWarnings(entry, ['Object.entries() returns items in an order that may vary']) + }) + + test('detects globalThis.Object.keys() without sort', () => { + const entry = writeTemp('workflow.ts', `const keys = globalThis.Object.keys({ a: 1, b: 2 });\n`) + expectWarnings(entry, ['Object.keys() returns items in an order that may vary']) + }) + + test('does NOT flag Object.keys().sort()', () => { + const entry = writeTemp('workflow.ts', `const keys = Object.keys({ a: 1, b: 2 }).sort();\n`) + expectNoWarnings(entry) + }) + + test('does NOT flag Object.entries().sort()', () => { + const entry = writeTemp( + 'workflow.ts', + `const entries = Object.entries({ a: 1, b: 2 }).sort();\n`, + ) + expectNoWarnings(entry) + }) + + test('does NOT flag Object.keys().toSorted()', () => { + const entry = writeTemp('workflow.ts', `const keys = Object.keys({ a: 1, b: 2 }).toSorted();\n`) + expectNoWarnings(entry) + }) + + test('does NOT flag Object.keys().filter().sort()', () => { + const entry = writeTemp( + 'workflow.ts', + `const keys = Object.keys({ a: 1, b: 2 }).filter(k => k !== 'a').sort();\n`, + ) + expectNoWarnings(entry) + }) + + test('does NOT flag Object.keys().map().filter().sort()', () => { + const entry = writeTemp( + 'workflow.ts', + `const keys = Object.keys({ a: 1, b: 2 }).map(k => k.toUpperCase()).filter(k => k !== 'A').sort();\n`, + ) + expectNoWarnings(entry) + }) + + test('detects Object.keys().map() (no sort)', () => { + const entry = writeTemp( + 'workflow.ts', + `const mapped = Object.keys({ a: 1, b: 2 }).map(k => k.toUpperCase());\n`, + ) + expectWarnings(entry, ['Object.keys() returns items in an order that may vary']) + }) + + test('detects Object.keys().filter() without sort', () => { + const entry = writeTemp( + 'workflow.ts', + `const filtered = Object.keys({ a: 1, b: 2 }).filter(k => k !== 'a');\n`, + ) + expectWarnings(entry, ['Object.keys() returns items in an order that may vary']) + }) + + test('does NOT flag user-defined Object variable', () => { + const entry = writeTemp( + 'workflow.ts', + `const Object = { keys: (x: any) => [] };\nObject.keys({ a: 1 });\n`, + ) + expectNoWarnings(entry) + }) + + test('does NOT flag Object.freeze() or other safe methods', () => { + const entry = writeTemp( + 'workflow.ts', + `const frozen = Object.freeze({ a: 1, b: 2 });\nconst assigned = Object.assign({}, { a: 1 });\n`, + ) + expectNoWarnings(entry) + }) + + test('warns when Object.keys() appears before the local Object declaration in the same scope', () => { + const entry = writeTemp( + 'workflow.ts', + `const keys = Object.keys({ a: 1 });\nconst Object = { keys: (x: any) => [] as string[] };\n`, + ) + expectWarnings(entry, ['Object.keys() returns items in an order that may vary']) + }) +}) + +// --------------------------------------------------------------------------- +// Safe patterns (should NOT warn) +// --------------------------------------------------------------------------- + +describe('safe patterns', () => { + test('does NOT flag Math.random()', () => { + const entry = writeTemp('workflow.ts', `const r = Math.random();\n`) + expectNoWarnings(entry) + }) + + test('clean workflow produces no warnings', () => { + const entry = writeTemp( + 'workflow.ts', + ` +const data = { z: 1, a: 2, m: 3 }; +const sortedKeys = Object.keys(data).sort(); +for (const key of sortedKeys) { + console.log(key); +} +const results = await Promise.resolve([1, 2]); +const d = new Date(1700000000000); +`, + ) + expectNoWarnings(entry) + }) + + test('empty file produces no warnings', () => { + const entry = writeTemp('workflow.ts', '') + expectNoWarnings(entry) + }) +}) + +// --------------------------------------------------------------------------- +// Transitive analysis +// --------------------------------------------------------------------------- + +describe('transitive analysis', () => { + test('detects warnings in transitively imported files', () => { + writeTemp('helper.ts', `export const getTime = () => Date.now();\n`) + const entry = writeTemp( + 'workflow.ts', + `import { getTime } from './helper';\nconsole.log(getTime());\n`, + ) + expectWarnings(entry, ['Date.now() uses the system clock']) + }) + + test('reports multiple warnings from multiple files', () => { + writeTemp('helper.ts', `export const racePromises = () => Promise.race([]);\n`) + const entry = writeTemp( + 'workflow.ts', + `import { racePromises } from './helper';\nconst d = new Date();\n`, + ) + expectWarnings(entry, ['Promise.race() is non-deterministic', 'new Date() without arguments']) + }) +}) + +// --------------------------------------------------------------------------- +// Output format +// --------------------------------------------------------------------------- + +describe('output format', () => { + test('warnings include file path and line/column info', () => { + const entry = writeTemp('workflow.ts', `const d = new Date();\n`) + const warnings = checkWorkflowDeterminism(entry) + expect(warnings.length).toBe(1) + expect(warnings[0].filePath).toContain('workflow.ts') + expect(warnings[0].line).toBe(1) + expect(warnings[0].column).toBeGreaterThan(0) + }) + + test('warnings are returned as array (not thrown)', () => { + const entry = writeTemp( + 'workflow.ts', + `Promise.race([]);\nDate.now();\nfor (const k in {}) {}\n`, + ) + const warnings = checkWorkflowDeterminism(entry) + expect(warnings.length).toBe(3) + }) + + test('non-existent entry file returns no warnings', () => { + const nonExistent = path.join(tempDir, 'does-not-exist.ts') + expectNoWarnings(nonExistent) + }) +}) diff --git a/packages/cre-sdk/scripts/src/validate-workflow-determinism.ts b/packages/cre-sdk/scripts/src/validate-workflow-determinism.ts new file mode 100644 index 0000000..0f749d7 --- /dev/null +++ b/packages/cre-sdk/scripts/src/validate-workflow-determinism.ts @@ -0,0 +1,545 @@ +/** + * Workflow Determinism Warning Analyzer + * + * CRE workflows execute on a Decentralized Oracle Network (DON) where multiple + * nodes must reach consensus. Non-deterministic patterns — code that can produce + * different results on different nodes — may prevent consensus. + * + * This module performs **static analysis** on workflow source code to detect + * patterns that are likely non-deterministic and warns developers at build time. + * Unlike the runtime compatibility validator, this module produces **warnings** + * that do not block compilation. + * + * ## Detected patterns + * + * - `Promise.race()` / `Promise.any()` / `Promise.all()` — concurrent execution, timing-dependent + * - `Date.now()` / `new Date()` — system clock varies across nodes + * - `for...in` loops — iteration order is not guaranteed by the spec + * - `Object.keys()` / `Object.values()` / `Object.entries()` without `.sort()` + * + * ## What is NOT detected + * + * - `Math.random()` — the Javy plugin overrides it with a seeded ChaCha8 CSPRNG + * that is deterministic in NODE mode. Safe by design. + * + * @see https://docs.chain.link/cre/concepts/non-determinism-ts + */ + +import * as ts from 'typescript' +import { + collectLocalSourceFiles, + createValidationProgram, + createViolation, + formatViolations, + toAbsolutePath, + type Violation, +} from './validate-shared' + +const DOCS_URL = 'https://docs.chain.link/cre/concepts/non-determinism-ts' + +/** + * A global object reference can be accessed directly (`Date`) or via + * `globalThis.Date`. + */ +type GlobalObjectReference = { + identifier: ts.Identifier +} + +const bindingNameContains = (bindingName: ts.BindingName, name: string): boolean => { + if (ts.isIdentifier(bindingName)) { + return bindingName.text === name + } + + if (ts.isObjectBindingPattern(bindingName)) { + return bindingName.elements.some((element) => bindingNameContains(element.name, name)) + } + + return bindingName.elements.some( + (element) => !ts.isOmittedExpression(element) && bindingNameContains(element.name, name), + ) +} + +const importClauseDeclaresName = (importClause: ts.ImportClause, name: string): boolean => { + if (importClause.name?.text === name) { + return true + } + + const namedBindings = importClause.namedBindings + if (!namedBindings) { + return false + } + + if (ts.isNamespaceImport(namedBindings)) { + return namedBindings.name.text === name + } + + return namedBindings.elements.some((element) => element.name.text === name) +} + +const variableDeclarationListDeclaresName = ( + declarationList: ts.VariableDeclarationList, + name: string, +): boolean => + declarationList.declarations.some((declaration) => bindingNameContains(declaration.name, name)) + +const statementDeclaresRuntimeName = (statement: ts.Statement, name: string): boolean => { + if (ts.isVariableStatement(statement)) { + return variableDeclarationListDeclaresName(statement.declarationList, name) + } + + if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) { + return statement.name?.text === name + } + + if (ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)) { + return statement.name.text === name + } + + if (ts.isImportDeclaration(statement) && statement.importClause) { + return importClauseDeclaresName(statement.importClause, name) + } + + if (ts.isImportEqualsDeclaration(statement)) { + return statement.name.text === name + } + + if ( + (ts.isForStatement(statement) || + ts.isForInStatement(statement) || + ts.isForOfStatement(statement)) && + statement.initializer && + ts.isVariableDeclarationList(statement.initializer) + ) { + return variableDeclarationListDeclaresName(statement.initializer, name) + } + + if (ts.isSwitchStatement(statement)) { + return statement.caseBlock.clauses.some((clause) => + clause.statements.some((clauseStatement) => + statementDeclaresRuntimeName(clauseStatement, name), + ), + ) + } + + return false +} + +/** + * Checks whether a name is shadowed by a declaration that is actually visible + * at the current use site. This avoids suppressing warnings due to unrelated + * declarations in nested scopes elsewhere in the file. + */ +const hasLocalDeclarationInScope = ( + name: string, + referenceNode: ts.Node, + currentSourceFile: ts.SourceFile, +): boolean => { + let current: ts.Node | undefined = referenceNode + + while (current) { + if ( + (ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current)) && + current.name && + ts.isIdentifier(current.name) && + current.name.text === name + ) { + return true + } + + if ( + (ts.isClassDeclaration(current) || ts.isClassExpression(current)) && + current.name && + ts.isIdentifier(current.name) && + current.name.text === name + ) { + return true + } + + if ( + ts.isFunctionLike(current) && + current.parameters.some((param) => bindingNameContains(param.name, name)) + ) { + return true + } + + if (ts.isCatchClause(current) && current.variableDeclaration) { + if (bindingNameContains(current.variableDeclaration.name, name)) { + return true + } + } + + if ( + (ts.isForStatement(current) || + ts.isForInStatement(current) || + ts.isForOfStatement(current)) && + current.initializer && + ts.isVariableDeclarationList(current.initializer) + ) { + if (variableDeclarationListDeclaresName(current.initializer, name)) { + return true + } + } + + if (ts.isSourceFile(current) || ts.isBlock(current) || ts.isModuleBlock(current)) { + const refPos = referenceNode.pos + if ( + current.statements.some((statement) => { + if (!statementDeclaresRuntimeName(statement, name)) return false + // Function declarations and imports are hoisted / always visible in scope. + if ( + ts.isFunctionDeclaration(statement) || + ts.isImportDeclaration(statement) || + ts.isImportEqualsDeclaration(statement) + ) { + return true + } + // Other declarations (const, let, var, class) are only a shadow once + // they have been fully declared — i.e. the statement ends before the usage. + return statement.end <= refPos + }) + ) { + return true + } + } + + if (ts.isSwitchStatement(current)) { + const refPos = referenceNode.pos + if ( + current.caseBlock.clauses.some((clause) => + clause.statements.some((statement) => { + if (!statementDeclaresRuntimeName(statement, name)) return false + if ( + ts.isFunctionDeclaration(statement) || + ts.isImportDeclaration(statement) || + ts.isImportEqualsDeclaration(statement) + ) { + return true + } + return statement.end <= refPos + }), + ) + ) { + return true + } + } + + if (current === currentSourceFile) { + break + } + + current = current.parent + } + + return false +} + +const hasLocalDeclarationViaChecker = ( + identifier: ts.Identifier, + checker: ts.TypeChecker, + localSourceFiles: Set, +): boolean => { + const symbol = checker.getSymbolAtLocation(identifier) + return ( + symbol?.declarations?.some((declaration) => { + if (!localSourceFiles.has(toAbsolutePath(declaration.getSourceFile().fileName))) { + return false + } + // Function declarations and import bindings are hoisted / available throughout + // their scope — always count as a shadow regardless of position. + if ( + ts.isFunctionDeclaration(declaration) || + ts.isImportClause(declaration) || + ts.isImportSpecifier(declaration) || + ts.isNamespaceImport(declaration) || + ts.isImportEqualsDeclaration(declaration) + ) { + return true + } + // const / let / var / class are only visible once their declaration is complete. + // A usage that appears before the declaration (e.g. due to TDZ) still refers + // to the global, so do not suppress the warning. + return declaration.end <= identifier.pos + }) ?? false + ) +} + +const getGlobalObjectReference = ( + expression: ts.LeftHandSideExpression, + objectName: string, +): GlobalObjectReference | null => { + if (ts.isIdentifier(expression) && expression.text === objectName) { + return { identifier: expression } + } + + if ( + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === 'globalThis' && + expression.name.text === objectName + ) { + return { identifier: expression.expression } + } + + return null +} + +/** + * Determines whether an expression resolves to a true global object (e.g. + * `Date`, `Promise`, `Object`) rather than a user-defined local with the + * same name. + * + * Uses a two-layer approach: + * 1. **Type-checker** (`hasLocalDeclarationViaChecker`) — the authoritative + * source when the checker can resolve the symbol. This handles most TS + * files with full type information. + * 2. **AST scope walk** (`hasLocalDeclarationInScope`) — a syntactic fallback + * for cases where the type-checker cannot resolve the symbol (e.g. loose + * JS files, files outside the compilation root, or declaration-less + * globals). This mirrors the scoping rules manually so we still suppress + * warnings for locally-shadowed names. + * + * The runtime compat validator only needs the type-checker layer because it + * checks simple identifier names (`fetch`, `setTimeout`). This validator + * additionally needs the AST fallback because it checks property-access + * patterns on globals (`Date.now()`, `Promise.race()`) where the root + * identifier may not have a resolvable symbol. + */ +const resolvesToGlobalObject = ( + expression: ts.LeftHandSideExpression, + objectName: string, + checker: ts.TypeChecker, + localSourceFiles: Set, + currentSourceFile: ts.SourceFile, +): boolean => { + const reference = getGlobalObjectReference(expression, objectName) + if (!reference) { + return false + } + + if (hasLocalDeclarationViaChecker(reference.identifier, checker, localSourceFiles)) { + return false + } + + const fallbackName = reference.identifier.text + return !hasLocalDeclarationInScope(fallbackName, reference.identifier, currentSourceFile) +} + +/** + * Checks whether a `CallExpression` is a method call on a global object. + * For example, `Promise.race(...)` or `globalThis.Promise.race(...)`. + * + * Returns the method name if matched, otherwise `null`. + */ +const getGlobalMethodCall = ( + node: ts.CallExpression, + objectName: string, + methodNames: Set, + checker: ts.TypeChecker, + localSourceFiles: Set, + currentSourceFile: ts.SourceFile, +): string | null => { + if (!ts.isPropertyAccessExpression(node.expression)) return null + + const propAccess = node.expression + if (!methodNames.has(propAccess.name.text)) return null + if ( + !resolvesToGlobalObject( + propAccess.expression, + objectName, + checker, + localSourceFiles, + currentSourceFile, + ) + ) { + return null + } + + return propAccess.name.text +} + +/** + * Checks whether a call to `Object.keys/values/entries()` is followed anywhere + * in the method chain by `.sort()` or `.toSorted()`, which makes the iteration + * order deterministic. + * + * Handles both direct chaining (`Object.keys(obj).sort()`) and intermediate + * calls (`Object.keys(obj).filter(...).sort()`). A `.sort()` that appears after + * any number of intermediate method calls still produces a deterministically + * ordered result. + * + * Note: this check is syntactic — it does not verify that the array returned + * by `Object.keys/values/entries()` is the same one eventually sorted. + * Patterns such as assigning to a variable and sorting later are not detected. + * + * The chain walk is capped at {@link MAX_CHAIN_DEPTH} iterations to guard + * against degenerate or malformed ASTs. + */ +const MAX_CHAIN_DEPTH = 50 +const isFollowedBySort = (callNode: ts.CallExpression): boolean => { + let current: ts.Node = callNode + + for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) { + const parent = current.parent + if (!ts.isPropertyAccessExpression(parent)) return false + // The PropertyAccessExpression must be the callee of a CallExpression + if (!ts.isCallExpression(parent.parent)) return false + + if (parent.name.text === 'sort' || parent.name.text === 'toSorted') return true + + // Some other chained method call — keep walking up the chain + current = parent.parent + } + + return false +} + +/** + * Collects determinism warnings from all local source files in the program. + */ +const collectDeterminismWarnings = ( + program: ts.Program, + localSourceFiles: Set, + warnings: Violation[], +) => { + const checker = program.getTypeChecker() + + const promiseMethods = new Set(['race', 'any', 'all']) + const dateMethods = new Set(['now']) + const objectIterationMethods = new Set(['keys', 'values', 'entries']) + + for (const sourceFile of program.getSourceFiles()) { + const resolvedSourcePath = toAbsolutePath(sourceFile.fileName) + if (!localSourceFiles.has(resolvedSourcePath)) continue + + const visit = (node: ts.Node) => { + // --- Promise.race() / Promise.any() --- + if (ts.isCallExpression(node)) { + const promiseMethod = getGlobalMethodCall( + node, + 'Promise', + promiseMethods, + checker, + localSourceFiles, + sourceFile, + ) + if (promiseMethod) { + const promiseWarning = + promiseMethod === 'all' + ? `Promise.all() executes promises concurrently — side effects may occur in different orders across nodes.` + : `Promise.${promiseMethod}() is non-deterministic — the first ${promiseMethod === 'race' ? 'settled' : 'fulfilled'} promise wins, and timing varies across nodes.` + warnings.push( + createViolation( + resolvedSourcePath, + node.expression.getStart(sourceFile), + sourceFile, + promiseWarning, + ), + ) + } + + // --- Date.now() --- + const dateMethod = getGlobalMethodCall( + node, + 'Date', + dateMethods, + checker, + localSourceFiles, + sourceFile, + ) + if (dateMethod) { + warnings.push( + createViolation( + resolvedSourcePath, + node.expression.getStart(sourceFile), + sourceFile, + 'Date.now() uses the system clock which varies across nodes.', + ), + ) + } + + // --- Object.keys/values/entries() without .sort() --- + const objectMethod = getGlobalMethodCall( + node, + 'Object', + objectIterationMethods, + checker, + localSourceFiles, + sourceFile, + ) + if (objectMethod && !isFollowedBySort(node)) { + warnings.push( + createViolation( + resolvedSourcePath, + node.expression.getStart(sourceFile), + sourceFile, + `Object.${objectMethod}() returns items in an order that may vary across engines. Chain with .sort() for deterministic ordering.`, + ), + ) + } + } + + // --- new Date() with no arguments --- + if ( + ts.isNewExpression(node) && + resolvesToGlobalObject(node.expression, 'Date', checker, localSourceFiles, sourceFile) && + (!node.arguments || node.arguments.length === 0) + ) { + warnings.push( + createViolation( + resolvedSourcePath, + node.getStart(sourceFile), + sourceFile, + 'new Date() without arguments uses the system clock which varies across nodes. Pass an explicit timestamp instead.', + ), + ) + } + + // --- for...in loops --- + if (ts.isForInStatement(node)) { + warnings.push( + createViolation( + resolvedSourcePath, + node.getStart(sourceFile), + sourceFile, + 'for...in loop iteration order is not guaranteed by the spec and may vary across engines. Use for...of with Object.keys().sort() instead.', + ), + ) + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + } +} + +/** + * Analyzes a workflow entry file (and all local files it transitively imports) + * for non-deterministic patterns that may prevent DON consensus. + * + * Returns an array of warnings. Does **not** throw — compilation should + * continue regardless of warnings. + */ +export const checkWorkflowDeterminism = (entryFilePath: string): Violation[] => { + const rootFile = toAbsolutePath(entryFilePath) + const localSourceFiles = collectLocalSourceFiles(rootFile) + const program = createValidationProgram(rootFile, localSourceFiles) + const warnings: Violation[] = [] + + collectDeterminismWarnings(program, localSourceFiles, warnings) + + return warnings +} + +/** + * Prints determinism warnings to stderr in a user-friendly format. + */ +export const printDeterminismWarnings = (warnings: Violation[]) => { + console.warn( + `\nāš ļø Non-determinism warnings (compilation will continue): +These patterns may prevent nodes from reaching consensus on the DON. +See ${DOCS_URL} + +${formatViolations(warnings)}\n`, + ) +} diff --git a/packages/cre-sdk/scripts/src/validate-workflow-runtime-compat.ts b/packages/cre-sdk/scripts/src/validate-workflow-runtime-compat.ts index 7bb637d..6fab171 100644 --- a/packages/cre-sdk/scripts/src/validate-workflow-runtime-compat.ts +++ b/packages/cre-sdk/scripts/src/validate-workflow-runtime-compat.ts @@ -67,24 +67,16 @@ * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime */ -import { existsSync, readFileSync, statSync } from 'node:fs' -import path from 'node:path' import * as ts from 'typescript' - -/** - * A single detected violation: a location in the source code where a - * restricted API is referenced. - */ -type Violation = { - /** Absolute path to the file containing the violation. */ - filePath: string - /** 1-based line number. */ - line: number - /** 1-based column number. */ - column: number - /** Human-readable description of the violation. */ - message: string -} +import { + collectLocalSourceFiles, + createValidationProgram, + createViolation, + formatViolations, + isDeclarationName, + toAbsolutePath, + type Violation, +} from './validate-shared' /** * Node.js built-in module specifiers that are not available in the QuickJS @@ -134,9 +126,6 @@ const restrictedGlobalApis = new Set([ 'setInterval', ]) -/** File extensions treated as scannable source code. */ -const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'] - /** * Error thrown when one or more runtime-incompatible API usages are detected. * The message includes a docs link and a formatted list of every violation @@ -144,336 +133,18 @@ const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', */ class WorkflowRuntimeCompatibilityError extends Error { constructor(violations: Violation[]) { - const sortedViolations = [...violations].sort((a, b) => { - if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath) - if (a.line !== b.line) return a.line - b.line - return a.column - b.column - }) - - const formattedViolations = sortedViolations - .map((violation) => { - const relativePath = path.relative(process.cwd(), violation.filePath) - return `- ${relativePath}:${violation.line}:${violation.column} ${violation.message}` - }) - .join('\n') - super( `Unsupported API usage found in workflow source. CRE workflows run on Javy (QuickJS), not full Node.js. Use CRE capabilities instead (for example, HTTPClient instead of fetch/node:http). See https://docs.chain.link/cre/concepts/typescript-wasm-runtime -${formattedViolations}`, +${formatViolations(violations)}`, ) this.name = 'WorkflowRuntimeCompatibilityError' } } -/** Resolves a file path to an absolute path using the current working directory. */ -const toAbsolutePath = (filePath: string) => path.resolve(filePath) - -const defaultValidationCompilerOptions: ts.CompilerOptions = { - allowJs: true, - checkJs: true, - noEmit: true, - skipLibCheck: true, - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler, -} - -const formatDiagnostic = (diagnostic: ts.Diagnostic) => { - const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') - if (!diagnostic.file || diagnostic.start == null) { - return message - } - - const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) - return `${toAbsolutePath(diagnostic.file.fileName)}:${line + 1}:${character + 1} ${message}` -} - -/** - * Loads compiler options from the nearest tsconfig.json so validation runs - * against the same ambient/type environment as the workflow project. - */ -const loadClosestTsconfigCompilerOptions = (entryFilePath: string): ts.CompilerOptions | null => { - const configPath = ts.findConfigFile( - path.dirname(entryFilePath), - ts.sys.fileExists, - 'tsconfig.json', - ) - if (!configPath) { - return null - } - - let unrecoverableDiagnostic: ts.Diagnostic | null = null - const parsed = ts.getParsedCommandLineOfConfigFile( - configPath, - {}, - { - ...ts.sys, - onUnRecoverableConfigFileDiagnostic: (diagnostic) => { - unrecoverableDiagnostic = diagnostic - }, - }, - ) - - if (!parsed) { - if (unrecoverableDiagnostic) { - throw new Error( - `Failed to parse TypeScript config for workflow validation.\n${formatDiagnostic(unrecoverableDiagnostic)}`, - ) - } - return null - } - - return parsed.options -} - -/** - * Maps a file extension to the appropriate TypeScript {@link ts.ScriptKind} - * so the parser handles JSX, CommonJS, and ESM files correctly. - */ -const getScriptKind = (filePath: string): ts.ScriptKind => { - switch (path.extname(filePath).toLowerCase()) { - case '.js': - return ts.ScriptKind.JS - case '.jsx': - return ts.ScriptKind.JSX - case '.mjs': - return ts.ScriptKind.JS - case '.cjs': - return ts.ScriptKind.JS - case '.tsx': - return ts.ScriptKind.TSX - case '.mts': - return ts.ScriptKind.TS - case '.cts': - return ts.ScriptKind.TS - default: - return ts.ScriptKind.TS - } -} - -/** - * Creates a {@link Violation} with 1-based line and column numbers derived - * from a character position in the source file. - */ -const createViolation = ( - filePath: string, - pos: number, - sourceFile: ts.SourceFile, - message: string, -): Violation => { - const { line, character } = sourceFile.getLineAndCharacterOfPosition(pos) - return { - filePath: toAbsolutePath(filePath), - line: line + 1, - column: character + 1, - message, - } -} - -/** Returns `true` if the specifier looks like a relative or absolute file path. */ -const isRelativeImport = (specifier: string) => { - return specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/') -} - -/** - * Attempts to resolve a relative import specifier to an absolute file path. - * Tries the path as-is first, then appends each known source extension, then - * looks for an index file inside the directory. Returns `null` if nothing is - * found on disk. - */ -const resolveRelativeImport = (fromFilePath: string, specifier: string): string | null => { - const basePath = specifier.startsWith('/') - ? path.resolve(specifier) - : path.resolve(path.dirname(fromFilePath), specifier) - - if (existsSync(basePath) && statSync(basePath).isFile()) { - return toAbsolutePath(basePath) - } - - for (const extension of sourceExtensions) { - const withExtension = `${basePath}${extension}` - if (existsSync(withExtension)) { - return toAbsolutePath(withExtension) - } - } - - for (const extension of sourceExtensions) { - const asIndex = path.join(basePath, `index${extension}`) - if (existsSync(asIndex)) { - return toAbsolutePath(asIndex) - } - } - - return null -} - -/** - * Extracts a string literal from the first argument of a call expression. - * Used for `require('node:fs')` and `import('node:fs')` patterns. - * Returns `null` if the first argument is not a static string literal. - */ -const getStringLiteralFromCall = (node: ts.CallExpression): string | null => { - const [firstArg] = node.arguments - if (!firstArg || !ts.isStringLiteral(firstArg)) return null - return firstArg.text -} - -/** - * **Pass 1 — Module import analysis.** - * - * Walks the AST of a single source file and: - * - Flags any import/export/require/dynamic-import of a restricted module. - * - Enqueues relative imports for recursive scanning so the validator - * transitively covers the entire local dependency graph. - * - * Handles all module import syntaxes: - * - `import ... from 'node:fs'` - * - `export ... from 'node:fs'` - * - `import fs = require('node:fs')` - * - `require('node:fs')` - * - `import('node:fs')` - */ -const collectModuleUsage = ( - sourceFile: ts.SourceFile, - filePath: string, - violations: Violation[], - enqueueFile: (nextFile: string) => void, -) => { - const checkModuleSpecifier = (specifier: string, pos: number) => { - if (restrictedModuleSpecifiers.has(specifier)) { - violations.push( - createViolation( - filePath, - pos, - sourceFile, - `'${specifier}' is not available in CRE workflow runtime.`, - ), - ) - } - - if (!isRelativeImport(specifier)) return - const resolved = resolveRelativeImport(filePath, specifier) - if (resolved) { - enqueueFile(resolved) - } - } - - const visit = (node: ts.Node) => { - // import ... from 'specifier' - if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { - checkModuleSpecifier(node.moduleSpecifier.text, node.moduleSpecifier.getStart(sourceFile)) - } - - // export ... from 'specifier' - if ( - ts.isExportDeclaration(node) && - node.moduleSpecifier && - ts.isStringLiteral(node.moduleSpecifier) - ) { - checkModuleSpecifier(node.moduleSpecifier.text, node.moduleSpecifier.getStart(sourceFile)) - } - - // import fs = require('specifier') - if ( - ts.isImportEqualsDeclaration(node) && - ts.isExternalModuleReference(node.moduleReference) && - node.moduleReference.expression && - ts.isStringLiteral(node.moduleReference.expression) - ) { - checkModuleSpecifier( - node.moduleReference.expression.text, - node.moduleReference.expression.getStart(sourceFile), - ) - } - - if (ts.isCallExpression(node)) { - // require('specifier') - if (ts.isIdentifier(node.expression) && node.expression.text === 'require') { - const requiredModule = getStringLiteralFromCall(node) - if (requiredModule) { - checkModuleSpecifier(requiredModule, node.arguments[0].getStart(sourceFile)) - } - } - - // import('specifier') - if (node.expression.kind === ts.SyntaxKind.ImportKeyword) { - const importedModule = getStringLiteralFromCall(node) - if (importedModule) { - checkModuleSpecifier(importedModule, node.arguments[0].getStart(sourceFile)) - } - } - } - - ts.forEachChild(node, visit) - } - - visit(sourceFile) -} - -/** - * Checks whether an identifier AST node is the **name being declared** (as - * opposed to a reference/usage). For example, in `const fetch = ...` the - * `fetch` token is a declaration name, while in `fetch(url)` it is a usage. - * - * This distinction is critical so that user-defined variables that shadow - * restricted global names are not flagged as violations. - */ -const isDeclarationName = (identifier: ts.Identifier): boolean => { - const parent = identifier.parent - - // Variable, function, class, interface, type alias, enum, module, - // type parameter, parameter, binding element, import names, enum member, - // property/method declarations, property assignments, and labels. - if ( - (ts.isFunctionDeclaration(parent) && parent.name === identifier) || - (ts.isFunctionExpression(parent) && parent.name === identifier) || - (ts.isClassDeclaration(parent) && parent.name === identifier) || - (ts.isClassExpression(parent) && parent.name === identifier) || - (ts.isInterfaceDeclaration(parent) && parent.name === identifier) || - (ts.isTypeAliasDeclaration(parent) && parent.name === identifier) || - (ts.isEnumDeclaration(parent) && parent.name === identifier) || - (ts.isModuleDeclaration(parent) && parent.name === identifier) || - (ts.isTypeParameterDeclaration(parent) && parent.name === identifier) || - (ts.isVariableDeclaration(parent) && parent.name === identifier) || - (ts.isParameter(parent) && parent.name === identifier) || - (ts.isBindingElement(parent) && parent.name === identifier) || - (ts.isImportClause(parent) && parent.name === identifier) || - (ts.isImportSpecifier(parent) && parent.name === identifier) || - (ts.isNamespaceImport(parent) && parent.name === identifier) || - (ts.isImportEqualsDeclaration(parent) && parent.name === identifier) || - (ts.isNamespaceExport(parent) && parent.name === identifier) || - (ts.isEnumMember(parent) && parent.name === identifier) || - (ts.isPropertyDeclaration(parent) && parent.name === identifier) || - (ts.isPropertySignature(parent) && parent.name === identifier) || - (ts.isMethodDeclaration(parent) && parent.name === identifier) || - (ts.isMethodSignature(parent) && parent.name === identifier) || - (ts.isGetAccessorDeclaration(parent) && parent.name === identifier) || - (ts.isSetAccessorDeclaration(parent) && parent.name === identifier) || - (ts.isPropertyAssignment(parent) && parent.name === identifier) || - (ts.isShorthandPropertyAssignment(parent) && parent.name === identifier) || - (ts.isLabeledStatement(parent) && parent.label === identifier) - ) { - return true - } - - // Property access (obj.fetch), qualified names (Ns.fetch), and type - // references (SomeType) — the right-hand identifier is not a standalone - // usage of the global name. - if ( - (ts.isPropertyAccessExpression(parent) && parent.name === identifier) || - (ts.isQualifiedName(parent) && parent.right === identifier) || - (ts.isTypeReferenceNode(parent) && parent.typeName === identifier) - ) { - return true - } - - return false -} - /** * **Pass 2 — Global API analysis.** * @@ -577,50 +248,27 @@ const collectGlobalApiUsage = ( */ export const assertWorkflowRuntimeCompatibility = (entryFilePath: string) => { const rootFile = toAbsolutePath(entryFilePath) - const projectCompilerOptions = loadClosestTsconfigCompilerOptions(rootFile) ?? {} - const filesToScan = [rootFile] - const scannedFiles = new Set() - const localSourceFiles = new Set() const violations: Violation[] = [] // Pass 1: Walk the local import graph and collect module-level violations. - while (filesToScan.length > 0) { - const currentFile = filesToScan.pop() - if (!currentFile || scannedFiles.has(currentFile)) continue - scannedFiles.add(currentFile) - - if (!existsSync(currentFile)) continue - localSourceFiles.add(currentFile) - - const fileContents = readFileSync(currentFile, 'utf-8') - const sourceFile = ts.createSourceFile( - currentFile, - fileContents, - ts.ScriptTarget.Latest, - true, - getScriptKind(currentFile), - ) - - collectModuleUsage(sourceFile, currentFile, violations, (nextFile) => { - if (!scannedFiles.has(nextFile)) { - filesToScan.push(nextFile) + const localSourceFiles = collectLocalSourceFiles( + rootFile, + (specifier, pos, sourceFile, filePath) => { + if (restrictedModuleSpecifiers.has(specifier)) { + violations.push( + createViolation( + filePath, + pos, + sourceFile, + `'${specifier}' is not available in CRE workflow runtime.`, + ), + ) } - }) - } - - // Pass 2: Use the type-checker to detect restricted global API usage. - const program = ts.createProgram({ - rootNames: [...localSourceFiles], - options: { - ...defaultValidationCompilerOptions, - ...projectCompilerOptions, - allowJs: true, - checkJs: true, - noEmit: true, - skipLibCheck: true, }, - }) + ) + // Pass 2: Use the type-checker to detect restricted global API usage. + const program = createValidationProgram(rootFile, localSourceFiles) collectGlobalApiUsage(program, localSourceFiles, violations) if (violations.length > 0) {