@chainlink/cre-sdk 1.6.0-alpha.2 → 1.6.0-alpha.4
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 +41 -12
- package/dist/sdk/report.js +0 -15
- package/package.json +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 +11 -5
- package/scripts/src/compile-workflow.ts +60 -13
- package/scripts/src/generate-chain-selectors.ts +9 -27
- 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
|
@@ -78,34 +78,16 @@ const CHAIN_CONFIGS: ChainSelectorConfig[] = [
|
|
|
78
78
|
},
|
|
79
79
|
]
|
|
80
80
|
|
|
81
|
-
const
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// Try 2 levels up (in case cwd is already in scripts/src/)
|
|
88
|
-
join(process.cwd(), '..', '..', 'node_modules', 'chain-selectors', filename),
|
|
89
|
-
// Try current directory's node_modules
|
|
90
|
-
join(process.cwd(), 'node_modules', 'chain-selectors', filename),
|
|
91
|
-
// Try parent directory's node_modules
|
|
92
|
-
join(process.cwd(), '..', 'node_modules', 'chain-selectors', filename),
|
|
93
|
-
]
|
|
94
|
-
|
|
95
|
-
for (const path of possiblePaths) {
|
|
96
|
-
try {
|
|
97
|
-
return readFileSync(path, 'utf-8')
|
|
98
|
-
} catch {
|
|
99
|
-
// Try next path
|
|
100
|
-
continue
|
|
101
|
-
}
|
|
102
|
-
}
|
|
81
|
+
const resolveChainSelectorsDir = (): string => {
|
|
82
|
+
// Use require.resolve to find the package through bun/Node module resolution,
|
|
83
|
+
// which correctly handles workspace hoisting, .bun cache, etc.
|
|
84
|
+
const packageDir = require.resolve('chain-selectors/selectors.yml')
|
|
85
|
+
return join(packageDir, '..')
|
|
86
|
+
}
|
|
103
87
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
)}`,
|
|
108
|
-
)
|
|
88
|
+
const readYamlFile = (filename: string): string => {
|
|
89
|
+
const filePath = join(resolveChainSelectorsDir(), filename)
|
|
90
|
+
return readFileSync(filePath, 'utf-8')
|
|
109
91
|
}
|
|
110
92
|
|
|
111
93
|
const parseChainSelectors = (): NetworkInfo[] => {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { assertWorkflowTypecheck, WorkflowTypecheckError } from './typecheck-workflow'
|
|
6
|
+
|
|
7
|
+
let tempDir: string
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tempDir = mkdtempSync(path.join(tmpdir(), 'cre-typecheck-test-'))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const writeTemp = (filename: string, content: string): string => {
|
|
18
|
+
const filePath = path.join(tempDir, filename)
|
|
19
|
+
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
20
|
+
writeFileSync(filePath, content, 'utf-8')
|
|
21
|
+
return filePath
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('assertWorkflowTypecheck', () => {
|
|
25
|
+
test('passes for valid project using nearby tsconfig', () => {
|
|
26
|
+
writeTemp(
|
|
27
|
+
'tsconfig.json',
|
|
28
|
+
JSON.stringify(
|
|
29
|
+
{
|
|
30
|
+
compilerOptions: {
|
|
31
|
+
target: 'ES2022',
|
|
32
|
+
module: 'ESNext',
|
|
33
|
+
moduleResolution: 'Bundler',
|
|
34
|
+
skipLibCheck: true,
|
|
35
|
+
},
|
|
36
|
+
include: ['src/**/*.ts'],
|
|
37
|
+
},
|
|
38
|
+
null,
|
|
39
|
+
2,
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
const entry = writeTemp('src/workflow.ts', 'export const value: number = 42\n')
|
|
43
|
+
expect(() => assertWorkflowTypecheck(entry)).not.toThrow()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('fails when tsconfig cannot be found', () => {
|
|
47
|
+
const entry = writeTemp('src/workflow.ts', 'export const value = 1\n')
|
|
48
|
+
expect(() => assertWorkflowTypecheck(entry)).toThrow(WorkflowTypecheckError)
|
|
49
|
+
expect(() => assertWorkflowTypecheck(entry)).toThrow('Could not find tsconfig.json')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('fails on whole-project type errors outside entry file', () => {
|
|
53
|
+
writeTemp(
|
|
54
|
+
'tsconfig.json',
|
|
55
|
+
JSON.stringify(
|
|
56
|
+
{
|
|
57
|
+
compilerOptions: {
|
|
58
|
+
target: 'ES2022',
|
|
59
|
+
module: 'ESNext',
|
|
60
|
+
moduleResolution: 'Bundler',
|
|
61
|
+
skipLibCheck: true,
|
|
62
|
+
strict: true,
|
|
63
|
+
},
|
|
64
|
+
include: ['src/**/*.ts'],
|
|
65
|
+
},
|
|
66
|
+
null,
|
|
67
|
+
2,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const entry = writeTemp('src/workflow.ts', 'export const value: number = 1\n')
|
|
72
|
+
writeTemp('src/unrelated.ts', "export const shouldBeNumber: number = 'not-a-number'\n")
|
|
73
|
+
|
|
74
|
+
expect(() => assertWorkflowTypecheck(entry)).toThrow(WorkflowTypecheckError)
|
|
75
|
+
expect(() => assertWorkflowTypecheck(entry)).toThrow('unrelated.ts')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import * as ts from 'typescript'
|
|
3
|
+
import { skipTypeChecksFlag } from './compile-cli-args'
|
|
4
|
+
|
|
5
|
+
const toAbsolutePath = (filePath: string) => path.resolve(filePath)
|
|
6
|
+
|
|
7
|
+
const formatDiagnostic = (diagnostic: ts.Diagnostic): string => {
|
|
8
|
+
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
|
|
9
|
+
if (!diagnostic.file || diagnostic.start == null) {
|
|
10
|
+
return message
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const absoluteFilePath = toAbsolutePath(diagnostic.file.fileName)
|
|
14
|
+
const relativeFilePath = path.relative(process.cwd(), absoluteFilePath)
|
|
15
|
+
const displayPath = relativeFilePath || absoluteFilePath
|
|
16
|
+
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
|
|
17
|
+
return `${displayPath}:${line + 1}:${character + 1} ${message}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class WorkflowTypecheckError extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'WorkflowTypecheckError'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const findNearestTsconfigPath = (entryFilePath: string): string | null => {
|
|
28
|
+
const configPath = ts.findConfigFile(
|
|
29
|
+
path.dirname(entryFilePath),
|
|
30
|
+
ts.sys.fileExists,
|
|
31
|
+
'tsconfig.json',
|
|
32
|
+
)
|
|
33
|
+
return configPath ?? null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const assertWorkflowTypecheck = (entryFilePath: string) => {
|
|
37
|
+
const rootFile = toAbsolutePath(entryFilePath)
|
|
38
|
+
const configPath = findNearestTsconfigPath(rootFile)
|
|
39
|
+
if (!configPath) {
|
|
40
|
+
throw new WorkflowTypecheckError(
|
|
41
|
+
`TypeScript typecheck failed before workflow compilation.
|
|
42
|
+
Could not find tsconfig.json near: ${rootFile}
|
|
43
|
+
Create a tsconfig.json in your workflow project, or re-run compile with ${skipTypeChecksFlag}.`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let unrecoverableDiagnostic: ts.Diagnostic | null = null
|
|
48
|
+
const parsedConfig = ts.getParsedCommandLineOfConfigFile(
|
|
49
|
+
configPath,
|
|
50
|
+
{},
|
|
51
|
+
{
|
|
52
|
+
...ts.sys,
|
|
53
|
+
onUnRecoverableConfigFileDiagnostic: (diagnostic) => {
|
|
54
|
+
unrecoverableDiagnostic = diagnostic
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if (!parsedConfig) {
|
|
60
|
+
const details = unrecoverableDiagnostic ? formatDiagnostic(unrecoverableDiagnostic) : ''
|
|
61
|
+
throw new WorkflowTypecheckError(
|
|
62
|
+
`TypeScript typecheck failed before workflow compilation.
|
|
63
|
+
Failed to parse tsconfig.json: ${configPath}
|
|
64
|
+
${details}
|
|
65
|
+
Fix your tsconfig.json, or re-run compile with ${skipTypeChecksFlag}.`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const program = ts.createProgram({
|
|
70
|
+
rootNames: parsedConfig.fileNames,
|
|
71
|
+
options: {
|
|
72
|
+
...parsedConfig.options,
|
|
73
|
+
noEmit: true,
|
|
74
|
+
},
|
|
75
|
+
projectReferences: parsedConfig.projectReferences,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const diagnostics = [...parsedConfig.errors, ...ts.getPreEmitDiagnostics(program)].filter(
|
|
79
|
+
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if (diagnostics.length > 0) {
|
|
83
|
+
const formatted = diagnostics.map(formatDiagnostic).join('\n')
|
|
84
|
+
const relativeConfigPath = path.relative(process.cwd(), toAbsolutePath(configPath))
|
|
85
|
+
const displayConfigPath = relativeConfigPath || toAbsolutePath(configPath)
|
|
86
|
+
throw new WorkflowTypecheckError(
|
|
87
|
+
`TypeScript typecheck failed before workflow compilation.
|
|
88
|
+
Using tsconfig: ${displayConfigPath}
|
|
89
|
+
Fix TypeScript errors, or re-run compile with ${skipTypeChecksFlag}.
|
|
90
|
+
|
|
91
|
+
${formatted}`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { WorkflowTypecheckError }
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for workflow validation modules.
|
|
3
|
+
*
|
|
4
|
+
* This module provides common types and functions used by both the runtime
|
|
5
|
+
* compatibility validator (`validate-workflow-runtime-compat.ts`) and the
|
|
6
|
+
* determinism warning analyzer (`validate-workflow-determinism.ts`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import * as ts from 'typescript'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A single detected violation: a location in the source code where a
|
|
15
|
+
* restricted or discouraged API is referenced.
|
|
16
|
+
*/
|
|
17
|
+
export type Violation = {
|
|
18
|
+
/** Absolute path to the file containing the violation. */
|
|
19
|
+
filePath: string
|
|
20
|
+
/** 1-based line number. */
|
|
21
|
+
line: number
|
|
22
|
+
/** 1-based column number. */
|
|
23
|
+
column: number
|
|
24
|
+
/** Human-readable description of the violation. */
|
|
25
|
+
message: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** File extensions treated as scannable source code. */
|
|
29
|
+
export const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']
|
|
30
|
+
|
|
31
|
+
/** Resolves a file path to an absolute path using the current working directory. */
|
|
32
|
+
export const toAbsolutePath = (filePath: string) => path.resolve(filePath)
|
|
33
|
+
|
|
34
|
+
export const defaultValidationCompilerOptions: ts.CompilerOptions = {
|
|
35
|
+
allowJs: true,
|
|
36
|
+
checkJs: true,
|
|
37
|
+
noEmit: true,
|
|
38
|
+
skipLibCheck: true,
|
|
39
|
+
target: ts.ScriptTarget.ESNext,
|
|
40
|
+
module: ts.ModuleKind.ESNext,
|
|
41
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const formatDiagnostic = (diagnostic: ts.Diagnostic) => {
|
|
45
|
+
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
|
|
46
|
+
if (!diagnostic.file || diagnostic.start == null) {
|
|
47
|
+
return message
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
|
|
51
|
+
return `${toAbsolutePath(diagnostic.file.fileName)}:${line + 1}:${character + 1} ${message}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Loads compiler options from the nearest tsconfig.json so validation runs
|
|
56
|
+
* against the same ambient/type environment as the workflow project.
|
|
57
|
+
*/
|
|
58
|
+
export const loadClosestTsconfigCompilerOptions = (
|
|
59
|
+
entryFilePath: string,
|
|
60
|
+
): ts.CompilerOptions | null => {
|
|
61
|
+
const configPath = ts.findConfigFile(
|
|
62
|
+
path.dirname(entryFilePath),
|
|
63
|
+
ts.sys.fileExists,
|
|
64
|
+
'tsconfig.json',
|
|
65
|
+
)
|
|
66
|
+
if (!configPath) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let unrecoverableDiagnostic: ts.Diagnostic | null = null
|
|
71
|
+
const parsed = ts.getParsedCommandLineOfConfigFile(
|
|
72
|
+
configPath,
|
|
73
|
+
{},
|
|
74
|
+
{
|
|
75
|
+
...ts.sys,
|
|
76
|
+
onUnRecoverableConfigFileDiagnostic: (diagnostic) => {
|
|
77
|
+
unrecoverableDiagnostic = diagnostic
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if (!parsed) {
|
|
83
|
+
if (unrecoverableDiagnostic) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Failed to parse TypeScript config for workflow validation.\n${formatDiagnostic(unrecoverableDiagnostic)}`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return parsed.options
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Maps a file extension to the appropriate TypeScript {@link ts.ScriptKind}
|
|
96
|
+
* so the parser handles JSX, CommonJS, and ESM files correctly.
|
|
97
|
+
*/
|
|
98
|
+
export const getScriptKind = (filePath: string): ts.ScriptKind => {
|
|
99
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
100
|
+
case '.js':
|
|
101
|
+
return ts.ScriptKind.JS
|
|
102
|
+
case '.jsx':
|
|
103
|
+
return ts.ScriptKind.JSX
|
|
104
|
+
case '.mjs':
|
|
105
|
+
return ts.ScriptKind.JS
|
|
106
|
+
case '.cjs':
|
|
107
|
+
return ts.ScriptKind.JS
|
|
108
|
+
case '.tsx':
|
|
109
|
+
return ts.ScriptKind.TSX
|
|
110
|
+
case '.mts':
|
|
111
|
+
return ts.ScriptKind.TS
|
|
112
|
+
case '.cts':
|
|
113
|
+
return ts.ScriptKind.TS
|
|
114
|
+
default:
|
|
115
|
+
return ts.ScriptKind.TS
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Creates a {@link Violation} with 1-based line and column numbers derived
|
|
121
|
+
* from a character position in the source file.
|
|
122
|
+
*/
|
|
123
|
+
export const createViolation = (
|
|
124
|
+
filePath: string,
|
|
125
|
+
pos: number,
|
|
126
|
+
sourceFile: ts.SourceFile,
|
|
127
|
+
message: string,
|
|
128
|
+
): Violation => {
|
|
129
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(pos)
|
|
130
|
+
return {
|
|
131
|
+
filePath: toAbsolutePath(filePath),
|
|
132
|
+
line: line + 1,
|
|
133
|
+
column: character + 1,
|
|
134
|
+
message,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Returns `true` if the specifier looks like a relative or absolute file path. */
|
|
139
|
+
export const isRelativeImport = (specifier: string) => {
|
|
140
|
+
return specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Attempts to resolve a relative import specifier to an absolute file path.
|
|
145
|
+
* Tries the path as-is first, then appends each known source extension, then
|
|
146
|
+
* looks for an index file inside the directory. Returns `null` if nothing is
|
|
147
|
+
* found on disk.
|
|
148
|
+
*/
|
|
149
|
+
export const resolveRelativeImport = (fromFilePath: string, specifier: string): string | null => {
|
|
150
|
+
const basePath = specifier.startsWith('/')
|
|
151
|
+
? path.resolve(specifier)
|
|
152
|
+
: path.resolve(path.dirname(fromFilePath), specifier)
|
|
153
|
+
|
|
154
|
+
if (existsSync(basePath) && statSync(basePath).isFile()) {
|
|
155
|
+
return toAbsolutePath(basePath)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const extension of sourceExtensions) {
|
|
159
|
+
const withExtension = `${basePath}${extension}`
|
|
160
|
+
if (existsSync(withExtension)) {
|
|
161
|
+
return toAbsolutePath(withExtension)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const extension of sourceExtensions) {
|
|
166
|
+
const asIndex = path.join(basePath, `index${extension}`)
|
|
167
|
+
if (existsSync(asIndex)) {
|
|
168
|
+
return toAbsolutePath(asIndex)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extracts a string literal from the first argument of a call expression.
|
|
177
|
+
* Used for `require('node:fs')` and `import('node:fs')` patterns.
|
|
178
|
+
* Returns `null` if the first argument is not a static string literal.
|
|
179
|
+
*/
|
|
180
|
+
export const getStringLiteralFromCall = (node: ts.CallExpression): string | null => {
|
|
181
|
+
const [firstArg] = node.arguments
|
|
182
|
+
if (!firstArg || !ts.isStringLiteral(firstArg)) return null
|
|
183
|
+
return firstArg.text
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Checks whether an identifier AST node is the **name being declared** (as
|
|
188
|
+
* opposed to a reference/usage). For example, in `const fetch = ...` the
|
|
189
|
+
* `fetch` token is a declaration name, while in `fetch(url)` it is a usage.
|
|
190
|
+
*
|
|
191
|
+
* This distinction is critical so that user-defined variables that shadow
|
|
192
|
+
* restricted global names are not flagged as violations.
|
|
193
|
+
*/
|
|
194
|
+
export const isDeclarationName = (identifier: ts.Identifier): boolean => {
|
|
195
|
+
const parent = identifier.parent
|
|
196
|
+
|
|
197
|
+
// Variable, function, class, interface, type alias, enum, module,
|
|
198
|
+
// type parameter, parameter, binding element, import names, enum member,
|
|
199
|
+
// property/method declarations, property assignments, and labels.
|
|
200
|
+
if (
|
|
201
|
+
(ts.isFunctionDeclaration(parent) && parent.name === identifier) ||
|
|
202
|
+
(ts.isFunctionExpression(parent) && parent.name === identifier) ||
|
|
203
|
+
(ts.isClassDeclaration(parent) && parent.name === identifier) ||
|
|
204
|
+
(ts.isClassExpression(parent) && parent.name === identifier) ||
|
|
205
|
+
(ts.isInterfaceDeclaration(parent) && parent.name === identifier) ||
|
|
206
|
+
(ts.isTypeAliasDeclaration(parent) && parent.name === identifier) ||
|
|
207
|
+
(ts.isEnumDeclaration(parent) && parent.name === identifier) ||
|
|
208
|
+
(ts.isModuleDeclaration(parent) && parent.name === identifier) ||
|
|
209
|
+
(ts.isTypeParameterDeclaration(parent) && parent.name === identifier) ||
|
|
210
|
+
(ts.isVariableDeclaration(parent) && parent.name === identifier) ||
|
|
211
|
+
(ts.isParameter(parent) && parent.name === identifier) ||
|
|
212
|
+
(ts.isBindingElement(parent) && parent.name === identifier) ||
|
|
213
|
+
(ts.isImportClause(parent) && parent.name === identifier) ||
|
|
214
|
+
(ts.isImportSpecifier(parent) && parent.name === identifier) ||
|
|
215
|
+
(ts.isNamespaceImport(parent) && parent.name === identifier) ||
|
|
216
|
+
(ts.isImportEqualsDeclaration(parent) && parent.name === identifier) ||
|
|
217
|
+
(ts.isNamespaceExport(parent) && parent.name === identifier) ||
|
|
218
|
+
(ts.isEnumMember(parent) && parent.name === identifier) ||
|
|
219
|
+
(ts.isPropertyDeclaration(parent) && parent.name === identifier) ||
|
|
220
|
+
(ts.isPropertySignature(parent) && parent.name === identifier) ||
|
|
221
|
+
(ts.isMethodDeclaration(parent) && parent.name === identifier) ||
|
|
222
|
+
(ts.isMethodSignature(parent) && parent.name === identifier) ||
|
|
223
|
+
(ts.isGetAccessorDeclaration(parent) && parent.name === identifier) ||
|
|
224
|
+
(ts.isSetAccessorDeclaration(parent) && parent.name === identifier) ||
|
|
225
|
+
(ts.isPropertyAssignment(parent) && parent.name === identifier) ||
|
|
226
|
+
(ts.isShorthandPropertyAssignment(parent) && parent.name === identifier) ||
|
|
227
|
+
(ts.isLabeledStatement(parent) && parent.label === identifier)
|
|
228
|
+
) {
|
|
229
|
+
return true
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Property access (obj.fetch), qualified names (Ns.fetch), and type
|
|
233
|
+
// references (SomeType) — the right-hand identifier is not a standalone
|
|
234
|
+
// usage of the global name.
|
|
235
|
+
if (
|
|
236
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
|
|
237
|
+
(ts.isQualifiedName(parent) && parent.right === identifier) ||
|
|
238
|
+
(ts.isTypeReferenceNode(parent) && parent.typeName === identifier)
|
|
239
|
+
) {
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Walks the local import graph starting from `entryFilePath` and collects
|
|
248
|
+
* all reachable local source files. Also invokes an optional `onModuleSpecifier`
|
|
249
|
+
* callback for each import specifier found, allowing callers to collect
|
|
250
|
+
* module-level violations.
|
|
251
|
+
*
|
|
252
|
+
* Returns the set of absolute paths to all local source files reachable from
|
|
253
|
+
* the entry point.
|
|
254
|
+
*/
|
|
255
|
+
export const collectLocalSourceFiles = (
|
|
256
|
+
entryFilePath: string,
|
|
257
|
+
onModuleSpecifier?: (
|
|
258
|
+
specifier: string,
|
|
259
|
+
pos: number,
|
|
260
|
+
sourceFile: ts.SourceFile,
|
|
261
|
+
filePath: string,
|
|
262
|
+
) => void,
|
|
263
|
+
): Set<string> => {
|
|
264
|
+
const rootFile = toAbsolutePath(entryFilePath)
|
|
265
|
+
const filesToScan = [rootFile]
|
|
266
|
+
const scannedFiles = new Set<string>()
|
|
267
|
+
const localSourceFiles = new Set<string>()
|
|
268
|
+
|
|
269
|
+
while (filesToScan.length > 0) {
|
|
270
|
+
const currentFile = filesToScan.pop()
|
|
271
|
+
if (!currentFile || scannedFiles.has(currentFile)) continue
|
|
272
|
+
scannedFiles.add(currentFile)
|
|
273
|
+
|
|
274
|
+
if (!existsSync(currentFile)) continue
|
|
275
|
+
localSourceFiles.add(currentFile)
|
|
276
|
+
|
|
277
|
+
const fileContents = readFileSync(currentFile, 'utf-8')
|
|
278
|
+
const sourceFile = ts.createSourceFile(
|
|
279
|
+
currentFile,
|
|
280
|
+
fileContents,
|
|
281
|
+
ts.ScriptTarget.Latest,
|
|
282
|
+
true,
|
|
283
|
+
getScriptKind(currentFile),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
collectImports(sourceFile, currentFile, (specifier, pos) => {
|
|
287
|
+
onModuleSpecifier?.(specifier, pos, sourceFile, currentFile)
|
|
288
|
+
|
|
289
|
+
if (!isRelativeImport(specifier)) return
|
|
290
|
+
const resolved = resolveRelativeImport(currentFile, specifier)
|
|
291
|
+
if (resolved && !scannedFiles.has(resolved)) {
|
|
292
|
+
filesToScan.push(resolved)
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return localSourceFiles
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Walks the AST of a single source file and invokes `onSpecifier` for every
|
|
302
|
+
* module specifier found in import/export/require/dynamic-import syntax.
|
|
303
|
+
*/
|
|
304
|
+
const collectImports = (
|
|
305
|
+
sourceFile: ts.SourceFile,
|
|
306
|
+
filePath: string,
|
|
307
|
+
onSpecifier: (specifier: string, pos: number) => void,
|
|
308
|
+
) => {
|
|
309
|
+
const visit = (node: ts.Node) => {
|
|
310
|
+
// import ... from 'specifier'
|
|
311
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
312
|
+
onSpecifier(node.moduleSpecifier.text, node.moduleSpecifier.getStart(sourceFile))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// export ... from 'specifier'
|
|
316
|
+
if (
|
|
317
|
+
ts.isExportDeclaration(node) &&
|
|
318
|
+
node.moduleSpecifier &&
|
|
319
|
+
ts.isStringLiteral(node.moduleSpecifier)
|
|
320
|
+
) {
|
|
321
|
+
onSpecifier(node.moduleSpecifier.text, node.moduleSpecifier.getStart(sourceFile))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// import fs = require('specifier')
|
|
325
|
+
if (
|
|
326
|
+
ts.isImportEqualsDeclaration(node) &&
|
|
327
|
+
ts.isExternalModuleReference(node.moduleReference) &&
|
|
328
|
+
node.moduleReference.expression &&
|
|
329
|
+
ts.isStringLiteral(node.moduleReference.expression)
|
|
330
|
+
) {
|
|
331
|
+
onSpecifier(
|
|
332
|
+
node.moduleReference.expression.text,
|
|
333
|
+
node.moduleReference.expression.getStart(sourceFile),
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (ts.isCallExpression(node)) {
|
|
338
|
+
// require('specifier')
|
|
339
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
|
|
340
|
+
const requiredModule = getStringLiteralFromCall(node)
|
|
341
|
+
if (requiredModule) {
|
|
342
|
+
onSpecifier(requiredModule, node.arguments[0].getStart(sourceFile))
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// import('specifier')
|
|
347
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
348
|
+
const importedModule = getStringLiteralFromCall(node)
|
|
349
|
+
if (importedModule) {
|
|
350
|
+
onSpecifier(importedModule, node.arguments[0].getStart(sourceFile))
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
ts.forEachChild(node, visit)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
visit(sourceFile)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Creates a TypeScript program from the collected local source files,
|
|
363
|
+
* merging project compiler options with validation defaults.
|
|
364
|
+
*/
|
|
365
|
+
export const createValidationProgram = (
|
|
366
|
+
entryFilePath: string,
|
|
367
|
+
localSourceFiles: Set<string>,
|
|
368
|
+
): ts.Program => {
|
|
369
|
+
const projectCompilerOptions = loadClosestTsconfigCompilerOptions(entryFilePath) ?? {}
|
|
370
|
+
return ts.createProgram({
|
|
371
|
+
rootNames: [...localSourceFiles],
|
|
372
|
+
options: {
|
|
373
|
+
...defaultValidationCompilerOptions,
|
|
374
|
+
...projectCompilerOptions,
|
|
375
|
+
allowJs: true,
|
|
376
|
+
checkJs: true,
|
|
377
|
+
noEmit: true,
|
|
378
|
+
skipLibCheck: true,
|
|
379
|
+
},
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Sorts violations by file path, then line, then column, and formats them
|
|
385
|
+
* as a list of strings with relative paths.
|
|
386
|
+
*/
|
|
387
|
+
export const formatViolations = (violations: Violation[]): string => {
|
|
388
|
+
const sorted = [...violations].sort((a, b) => {
|
|
389
|
+
if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath)
|
|
390
|
+
if (a.line !== b.line) return a.line - b.line
|
|
391
|
+
return a.column - b.column
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
return sorted
|
|
395
|
+
.map((violation) => {
|
|
396
|
+
const relativePath = path.relative(process.cwd(), violation.filePath)
|
|
397
|
+
return `- ${relativePath}:${violation.line}:${violation.column} ${violation.message}`
|
|
398
|
+
})
|
|
399
|
+
.join('\n')
|
|
400
|
+
}
|