/** * 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') }