@systemfsoftware/stryker-js-typescript-checker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +15 -0
- package/.turbo/turbo-lint.log +3 -0
- package/LICENSE +21 -0
- package/dist/index.d.mts +122 -0
- package/dist/index.mjs +602 -0
- package/oxlint.config.ts +9 -0
- package/package.json +37 -0
- package/schema/typescript-checker-options.json +22 -0
- package/src/fs/hybrid-file-system.ts +118 -0
- package/src/fs/index.ts +2 -0
- package/src/fs/script-file.ts +44 -0
- package/src/grouping/create-groups.ts +77 -0
- package/src/grouping/ts-file-node.ts +54 -0
- package/src/index.ts +18 -0
- package/src/plugin-tokens.ts +2 -0
- package/src/tsconfig-helpers.ts +168 -0
- package/src/typescript-checker-options-with-stryker-options.ts +9 -0
- package/src/typescript-checker.ts +223 -0
- package/src/typescript-compiler.ts +406 -0
- package/test/integration/e2e-plugin-entry.it.spec.ts +153 -0
- package/test/integration/project-references.it.spec.ts +146 -0
- package/test/integration/project-with-ts-buildinfo.it.spec.ts +92 -0
- package/test/integration/single-project.it.spec.ts +263 -0
- package/test/integration/typescript-checkers-errors.it.spec.ts +87 -0
- package/test/unit/fs/hybrid-file-system.spec.ts +109 -0
- package/test/unit/grouping/create-groups.spec.ts +122 -0
- package/test/unit/grouping/ts-file-node.spec.ts +132 -0
- package/testResources/errors/compile-error/add.ts +3 -0
- package/testResources/errors/compile-error/tsconfig.json +6 -0
- package/testResources/errors/empty-dir/.gitkeep +0 -0
- package/testResources/errors/invalid-tsconfig/tsconfig.json +1 -0
- package/testResources/project-references/src/index.ts +5 -0
- package/testResources/project-references/src/job.ts +6 -0
- package/testResources/project-references/src/src.tsbuildinfo +1 -0
- package/testResources/project-references/src/tsconfig.json +10 -0
- package/testResources/project-references/tsconfig.root.json +7 -0
- package/testResources/project-references/tsconfig.settings.json +17 -0
- package/testResources/project-references/utils/math.ts +3 -0
- package/testResources/project-references/utils/text.ts +3 -0
- package/testResources/project-references/utils/tsconfig.json +7 -0
- package/testResources/project-references/utils/utils.tsbuildinfo +1 -0
- package/testResources/project-with-ts-buildinfo/do-not-delete.tsbuildinfo +1 -0
- package/testResources/project-with-ts-buildinfo/src/index.ts +3 -0
- package/testResources/project-with-ts-buildinfo/tsconfig.json +17 -0
- package/testResources/single-project/src/counter.ts +9 -0
- package/testResources/single-project/src/errorInFileAbove2Mutants/counter.ts +9 -0
- package/testResources/single-project/src/errorInFileAbove2Mutants/todo-counter.ts +7 -0
- package/testResources/single-project/src/errorInFileAbove2Mutants/todo.spec.ts +11 -0
- package/testResources/single-project/src/errorInFileAbove2Mutants/todo.ts +22 -0
- package/testResources/single-project/src/not-type-checked.js +1 -0
- package/testResources/single-project/src/todo.spec.ts +11 -0
- package/testResources/single-project/src/todo.ts +22 -0
- package/testResources/single-project/tsconfig.json +15 -0
- package/tsconfig.json +10 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { EOL } from 'os'
|
|
2
|
+
|
|
3
|
+
import type { Checker, CheckResult } from '@stryker-mutator/api/check'
|
|
4
|
+
import { CheckStatus } from '@stryker-mutator/api/check'
|
|
5
|
+
import type { Mutant, StrykerOptions } from '@stryker-mutator/api/core'
|
|
6
|
+
import type { Logger, LoggerFactoryMethod } from '@stryker-mutator/api/logging'
|
|
7
|
+
import { commonTokens, Scope, tokens } from '@stryker-mutator/api/plugin'
|
|
8
|
+
import type { Injector, PluginContext } from '@stryker-mutator/api/plugin'
|
|
9
|
+
import { split, strykerReportBugUrl } from '@stryker-mutator/util'
|
|
10
|
+
import { DiagnosticCategory } from 'typescript/unstable/sync'
|
|
11
|
+
import type { Diagnostic } from 'typescript/unstable/sync'
|
|
12
|
+
|
|
13
|
+
import { HybridFileSystem } from './fs/hybrid-file-system.js'
|
|
14
|
+
import { createGroups } from './grouping/create-groups.js'
|
|
15
|
+
import { TSFileNode } from './grouping/ts-file-node.js'
|
|
16
|
+
import * as pluginTokens from './plugin-tokens.js'
|
|
17
|
+
import { toPosixFileName } from './tsconfig-helpers.js'
|
|
18
|
+
import type { TypescriptCheckerOptionsWithStrykerOptions } from './typescript-checker-options-with-stryker-options.js'
|
|
19
|
+
import { TypescriptCompiler } from './typescript-compiler.js'
|
|
20
|
+
|
|
21
|
+
const typescriptCheckerLoggerFactory = Object.assign(
|
|
22
|
+
(
|
|
23
|
+
loggerFactory: LoggerFactoryMethod,
|
|
24
|
+
target: Function | undefined,
|
|
25
|
+
): Logger => {
|
|
26
|
+
const targetName = target?.name ?? TypescriptChecker.name
|
|
27
|
+
const category = targetName === TypescriptChecker.name
|
|
28
|
+
? TypescriptChecker.name
|
|
29
|
+
: `${TypescriptChecker.name}.${targetName}`
|
|
30
|
+
return loggerFactory(category)
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
inject: tokens(commonTokens.getLogger, commonTokens.target),
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
export const create = Object.assign(
|
|
38
|
+
(injector: Injector<PluginContext>): TypescriptChecker =>
|
|
39
|
+
injector
|
|
40
|
+
.provideFactory(
|
|
41
|
+
commonTokens.logger,
|
|
42
|
+
typescriptCheckerLoggerFactory,
|
|
43
|
+
Scope.Transient,
|
|
44
|
+
)
|
|
45
|
+
.provideClass(pluginTokens.fs, HybridFileSystem)
|
|
46
|
+
.provideClass(pluginTokens.tsCompiler, TypescriptCompiler)
|
|
47
|
+
.injectClass(TypescriptChecker),
|
|
48
|
+
{
|
|
49
|
+
inject: tokens(commonTokens.injector),
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
export class TypescriptChecker implements Checker {
|
|
54
|
+
public static inject = tokens(
|
|
55
|
+
commonTokens.logger,
|
|
56
|
+
commonTokens.options,
|
|
57
|
+
pluginTokens.tsCompiler,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
private readonly options: TypescriptCheckerOptionsWithStrykerOptions
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
private readonly logger: Logger,
|
|
64
|
+
options: StrykerOptions,
|
|
65
|
+
private readonly tsCompiler: TypescriptCompiler,
|
|
66
|
+
) {
|
|
67
|
+
this.options = options as TypescriptCheckerOptionsWithStrykerOptions
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public async init(): Promise<void> {
|
|
71
|
+
const errors = await this.tsCompiler.init()
|
|
72
|
+
|
|
73
|
+
if (errors.length) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Typescript error(s) found in dry run compilation: ${this.createErrorText(errors)}`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public async check(mutants: Mutant[]): Promise<Record<string, CheckResult>> {
|
|
81
|
+
const result: Record<string, CheckResult> = Object.fromEntries(
|
|
82
|
+
mutants.map((mutant) => [mutant.id, { status: CheckStatus.Passed }]),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// Check if this is the group with unrelated files and return check status passed if so
|
|
86
|
+
if (!this.tsCompiler.nodes.get(toPosixFileName(mutants[0]!.fileName))) {
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const mutantErrorRelationMap = await this.checkErrors(
|
|
91
|
+
mutants,
|
|
92
|
+
{},
|
|
93
|
+
this.tsCompiler.nodes,
|
|
94
|
+
)
|
|
95
|
+
for (const [id, errors] of Object.entries(mutantErrorRelationMap)) {
|
|
96
|
+
result[id] = {
|
|
97
|
+
status: CheckStatus.CompileError,
|
|
98
|
+
reason: this.createErrorText(errors),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return result
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public group(mutants: Mutant[]): Promise<string[][]> {
|
|
106
|
+
if (!this.options.typescriptChecker?.prioritizePerformanceOverAccuracy) {
|
|
107
|
+
return Promise.resolve(mutants.map((m) => [m.id]))
|
|
108
|
+
}
|
|
109
|
+
const { nodes } = this.tsCompiler
|
|
110
|
+
const [mutantsOutsideProject, mutantsInProject] = split(
|
|
111
|
+
mutants,
|
|
112
|
+
(m) => nodes.get(toPosixFileName(m.fileName)) == null,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const groups = createGroups(mutantsInProject, nodes)
|
|
116
|
+
if (mutantsOutsideProject.length) {
|
|
117
|
+
return Promise.resolve([
|
|
118
|
+
mutantsOutsideProject.map((m) => m.id),
|
|
119
|
+
...groups,
|
|
120
|
+
])
|
|
121
|
+
} else {
|
|
122
|
+
return Promise.resolve(groups)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async checkErrors(
|
|
127
|
+
mutants: Mutant[],
|
|
128
|
+
errorsMap: Record<string, Diagnostic[]>,
|
|
129
|
+
nodes: Map<string, TSFileNode>,
|
|
130
|
+
): Promise<Record<string, Diagnostic[]>> {
|
|
131
|
+
const errors = await this.tsCompiler.check(mutants)
|
|
132
|
+
const mutantsThatCouldNotBeTestedInGroups = new Set<Mutant>()
|
|
133
|
+
|
|
134
|
+
// If there is only a single mutant the error has to originate from the single mutant
|
|
135
|
+
if (errors.length && mutants.length === 1) {
|
|
136
|
+
errorsMap[mutants[0]!.id] = errors
|
|
137
|
+
return errorsMap
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const error of errors) {
|
|
141
|
+
if (!error.fileName) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Typescript error: '${error.text}' was reported without a corresponding file. This shouldn't happen. Please open an issue using this link: ${
|
|
144
|
+
strykerReportBugUrl(
|
|
145
|
+
`[BUG]: TypeScript checker reports compile error without a corresponding file: ${error.text}`,
|
|
146
|
+
)
|
|
147
|
+
}`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
const nodeErrorWasThrownIn = nodes.get(error.fileName)
|
|
151
|
+
if (!nodeErrorWasThrownIn) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Typescript error: '${error.text}' was reported in an unrelated file (${error.fileName}). This file is not part of your project, or referenced from your project. This shouldn't happen, please open an issue using this link: ${
|
|
154
|
+
strykerReportBugUrl(
|
|
155
|
+
`[BUG]: TypeScript checker reports compile error in an unrelated file: ${error.text}`,
|
|
156
|
+
)
|
|
157
|
+
}`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
const mutantsRelatedToError = nodeErrorWasThrownIn.getMutantsWithReferenceToChildrenOrSelf(mutants)
|
|
161
|
+
|
|
162
|
+
if (mutantsRelatedToError.length === 0) {
|
|
163
|
+
// In rare cases there are no mutants related to the typescript error
|
|
164
|
+
// Having to test all mutants individually to know which mutant thrown the error
|
|
165
|
+
for (const mutant of mutants) {
|
|
166
|
+
mutantsThatCouldNotBeTestedInGroups.add(mutant)
|
|
167
|
+
}
|
|
168
|
+
} else if (mutantsRelatedToError.length === 1) {
|
|
169
|
+
// There is only one mutant related to the typescript error so we can add it to the errorsRelatedToMutant
|
|
170
|
+
const mutantId = mutantsRelatedToError[0]!.id
|
|
171
|
+
if (errorsMap[mutantId]) {
|
|
172
|
+
errorsMap[mutantId]!.push(error)
|
|
173
|
+
} else {
|
|
174
|
+
errorsMap[mutantId] = [error]
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
// If there are more than one mutants related to the error we should check them individually
|
|
178
|
+
for (const mutant of mutantsRelatedToError) {
|
|
179
|
+
mutantsThatCouldNotBeTestedInGroups.add(mutant)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (mutantsThatCouldNotBeTestedInGroups.size) {
|
|
185
|
+
// Because at this point the filesystem contains all the mutants from the group we need to reset back
|
|
186
|
+
// to the original state of the files to make it possible to test the first mutant
|
|
187
|
+
// if we wouldn't do this the first mutant would not be noticed by the compiler because it was already in the filesystem
|
|
188
|
+
await this.tsCompiler.check([])
|
|
189
|
+
}
|
|
190
|
+
for (const mutant of mutantsThatCouldNotBeTestedInGroups) {
|
|
191
|
+
if (errorsMap[mutant.id]) continue
|
|
192
|
+
await this.checkErrors([mutant], errorsMap, nodes)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return errorsMap
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private createErrorText(errors: Diagnostic[]): string {
|
|
199
|
+
return errors
|
|
200
|
+
.map((error) => this.formatDiagnostic(error))
|
|
201
|
+
.join(EOL)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private formatDiagnostic(error: Diagnostic): string {
|
|
205
|
+
const severity = error.category === DiagnosticCategory.Error
|
|
206
|
+
? 'error'
|
|
207
|
+
: error.category === DiagnosticCategory.Warning
|
|
208
|
+
? 'warning'
|
|
209
|
+
: error.category === DiagnosticCategory.Suggestion
|
|
210
|
+
? 'suggestion'
|
|
211
|
+
: 'message'
|
|
212
|
+
|
|
213
|
+
let location = ''
|
|
214
|
+
if (error.fileName) {
|
|
215
|
+
const lineAndCharacter = this.tsCompiler.getLineAndCharacterOfPosition(error.fileName, error.pos)
|
|
216
|
+
const line = (lineAndCharacter?.line ?? 0) + 1
|
|
217
|
+
const character = (lineAndCharacter?.character ?? 0) + 1
|
|
218
|
+
location = `${error.fileName}(${line},${character}): `
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return `${location}${severity} TS${error.code}: ${error.text}`
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { readFileSync } from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
import type { Mutant, StrykerOptions } from '@stryker-mutator/api/core'
|
|
5
|
+
import type { Logger } from '@stryker-mutator/api/logging'
|
|
6
|
+
import { commonTokens, tokens } from '@stryker-mutator/api/plugin'
|
|
7
|
+
import { type SourceFile, SyntaxKind } from 'typescript/unstable/ast'
|
|
8
|
+
import type { FileSystem } from 'typescript/unstable/fs'
|
|
9
|
+
import { API, type Diagnostic, type DocumentIdentifier, type Program, type Snapshot } from 'typescript/unstable/sync'
|
|
10
|
+
|
|
11
|
+
import { HybridFileSystem } from './fs/index.js'
|
|
12
|
+
import { TSFileNode } from './grouping/ts-file-node.js'
|
|
13
|
+
import * as pluginTokens from './plugin-tokens.js'
|
|
14
|
+
import {
|
|
15
|
+
determineBuildModeEnabled,
|
|
16
|
+
getSourceMappingURL,
|
|
17
|
+
guardTSVersion,
|
|
18
|
+
overrideOptions,
|
|
19
|
+
parseConfigFileTextToJson,
|
|
20
|
+
retrieveReferencedProjects,
|
|
21
|
+
toPosixFileName,
|
|
22
|
+
} from './tsconfig-helpers.js'
|
|
23
|
+
|
|
24
|
+
export interface ITypescriptCompiler {
|
|
25
|
+
init(): Promise<Diagnostic[]>
|
|
26
|
+
check(mutants: Mutant[]): Promise<Diagnostic[]>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IFileRelationCreator {
|
|
30
|
+
get nodes(): Map<string, TSFileNode>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type SourceFiles = Map<
|
|
34
|
+
string,
|
|
35
|
+
{
|
|
36
|
+
fileName: string
|
|
37
|
+
imports: Set<string>
|
|
38
|
+
}
|
|
39
|
+
>
|
|
40
|
+
|
|
41
|
+
export class TypescriptCompiler implements ITypescriptCompiler, IFileRelationCreator {
|
|
42
|
+
public static inject = tokens(
|
|
43
|
+
commonTokens.logger,
|
|
44
|
+
commonTokens.options,
|
|
45
|
+
pluginTokens.fs,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
private readonly allTSConfigFiles: Set<string>
|
|
49
|
+
private readonly tsconfigFile: string
|
|
50
|
+
private api?: API
|
|
51
|
+
private snapshot?: Snapshot
|
|
52
|
+
private readonly sourceFiles: SourceFiles = new Map()
|
|
53
|
+
private readonly _nodes = new Map<string, TSFileNode>()
|
|
54
|
+
private lastMutants: Mutant[] = []
|
|
55
|
+
private lastMutatedFileNames: string[] = []
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly log: Logger,
|
|
59
|
+
private readonly options: StrykerOptions,
|
|
60
|
+
private readonly fs: HybridFileSystem,
|
|
61
|
+
) {
|
|
62
|
+
this.tsconfigFile = toPosixFileName(
|
|
63
|
+
path.resolve(toPosixFileName(this.options.tsconfigFile)),
|
|
64
|
+
)
|
|
65
|
+
this.allTSConfigFiles = new Set<string>([this.tsconfigFile])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public async init(): Promise<Diagnostic[]> {
|
|
69
|
+
guardTSVersion()
|
|
70
|
+
this.guardTSConfigFileExists()
|
|
71
|
+
const buildModeEnabled = determineBuildModeEnabled(this.tsconfigFile)
|
|
72
|
+
|
|
73
|
+
this.collectAllTSConfigFiles(buildModeEnabled)
|
|
74
|
+
this.api = new API({ fs: this.fs as FileSystem })
|
|
75
|
+
this.snapshot = this.api.updateSnapshot({
|
|
76
|
+
openProjects: [...this.allTSConfigFiles],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const programs = this.getPrograms()
|
|
80
|
+
this.buildDependencyGraph(programs)
|
|
81
|
+
|
|
82
|
+
return this.check([])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public async check(mutants: Mutant[]): Promise<Diagnostic[]> {
|
|
86
|
+
// Reset previous mutations
|
|
87
|
+
for (const mutant of this.lastMutants) {
|
|
88
|
+
this.fs.resetFile(mutant.fileName)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Apply new mutations
|
|
92
|
+
for (const mutant of mutants) {
|
|
93
|
+
const file = this.fs.getFile(mutant.fileName)
|
|
94
|
+
if (!file) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Tried to check file "${mutant.fileName}" (which is part of your typescript project), but it could not be found.`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
file.mutate(mutant)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mutatedFileNames = [
|
|
103
|
+
...new Set(mutants.map((m) => toPosixFileName(m.fileName))),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
const changedFiles = [
|
|
107
|
+
...new Set([...this.lastMutatedFileNames, ...mutatedFileNames]),
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
if (this.api && this.snapshot) {
|
|
111
|
+
const oldSnapshot = this.snapshot
|
|
112
|
+
this.snapshot = this.api.updateSnapshot({
|
|
113
|
+
openProjects: [...this.allTSConfigFiles],
|
|
114
|
+
fileChanges: { changed: changedFiles },
|
|
115
|
+
})
|
|
116
|
+
oldSnapshot.dispose()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.lastMutants = mutants
|
|
120
|
+
this.lastMutatedFileNames = mutatedFileNames
|
|
121
|
+
|
|
122
|
+
const programs = this.getPrograms()
|
|
123
|
+
return programs.flatMap((program) => [
|
|
124
|
+
...program.getConfigFileParsingDiagnostics(),
|
|
125
|
+
...program.getSemanticDiagnostics(),
|
|
126
|
+
...program.getProgramDiagnostics(),
|
|
127
|
+
])
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public get nodes(): Map<string, TSFileNode> {
|
|
131
|
+
if (!this._nodes.size) {
|
|
132
|
+
// create nodes
|
|
133
|
+
for (const [fileName] of this.sourceFiles) {
|
|
134
|
+
const node = new TSFileNode(fileName, [], [])
|
|
135
|
+
this._nodes.set(fileName, node)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// set children
|
|
139
|
+
for (const [fileName, file] of this.sourceFiles) {
|
|
140
|
+
const node = this._nodes.get(fileName)
|
|
141
|
+
if (node == null) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Node for file '${fileName}' could not be found. This should not happen.`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
node.children = [...file.imports]
|
|
148
|
+
.map((importName) => this._nodes.get(importName))
|
|
149
|
+
.filter((n): n is TSFileNode => n != null)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// set parents
|
|
153
|
+
for (const [, node] of this._nodes) {
|
|
154
|
+
node.parents = []
|
|
155
|
+
for (const [, n] of this._nodes) {
|
|
156
|
+
if (n.children.includes(node)) {
|
|
157
|
+
node.parents.push(n)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return this._nodes
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public close(): void {
|
|
167
|
+
this.snapshot?.dispose()
|
|
168
|
+
this.api?.close()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public getLineAndCharacterOfPosition(
|
|
172
|
+
fileName: string,
|
|
173
|
+
position: number,
|
|
174
|
+
): { line: number; character: number } | undefined {
|
|
175
|
+
for (const program of this.getPrograms()) {
|
|
176
|
+
const sourceFile = program.getSourceFile(fileName as DocumentIdentifier)
|
|
177
|
+
if (sourceFile) {
|
|
178
|
+
return sourceFile.getLineAndCharacterOfPosition(position)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return undefined
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private getPrograms(): Program[] {
|
|
185
|
+
if (!this.snapshot) {
|
|
186
|
+
throw new Error('TypescriptCompiler not initialized')
|
|
187
|
+
}
|
|
188
|
+
const projects = this.snapshot.getProjects()
|
|
189
|
+
if (projects.length === 0) {
|
|
190
|
+
throw new Error(`No projects found for ${this.tsconfigFile}`)
|
|
191
|
+
}
|
|
192
|
+
return projects.map((project) => project.program)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private collectAllTSConfigFiles(buildModeEnabled: boolean): void {
|
|
196
|
+
const tsConfigOverrides = new Map<string, string>()
|
|
197
|
+
const toProcess = [this.tsconfigFile]
|
|
198
|
+
const processed = new Set<string>()
|
|
199
|
+
|
|
200
|
+
while (toProcess.length > 0) {
|
|
201
|
+
const current = toProcess.pop()
|
|
202
|
+
if (!current || processed.has(current)) {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
processed.add(current)
|
|
206
|
+
|
|
207
|
+
const content = readFileSync(current, 'utf-8')
|
|
208
|
+
const parsed = parseConfigFileTextToJson(current, content)
|
|
209
|
+
if (parsed.error) {
|
|
210
|
+
tsConfigOverrides.set(current, content)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
tsConfigOverrides.set(current, overrideOptions(parsed, buildModeEnabled))
|
|
214
|
+
|
|
215
|
+
for (
|
|
216
|
+
const referenced of retrieveReferencedProjects(
|
|
217
|
+
parsed,
|
|
218
|
+
path.dirname(current),
|
|
219
|
+
)
|
|
220
|
+
) {
|
|
221
|
+
this.allTSConfigFiles.add(referenced)
|
|
222
|
+
toProcess.push(referenced)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.fs.tsConfigOverrides = tsConfigOverrides
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private buildDependencyGraph(programs: Program[]): void {
|
|
230
|
+
for (const program of programs) {
|
|
231
|
+
for (const fileName of program.getSourceFileNames()) {
|
|
232
|
+
if (
|
|
233
|
+
fileName.endsWith('.d.ts') ||
|
|
234
|
+
fileName.includes('node_modules')
|
|
235
|
+
) {
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
const normalized = toPosixFileName(fileName)
|
|
239
|
+
this.sourceFiles.set(normalized, {
|
|
240
|
+
fileName: normalized,
|
|
241
|
+
imports: new Set(),
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const [fileName] of this.sourceFiles) {
|
|
247
|
+
const sourceFile = programs
|
|
248
|
+
.map((p) => p.getSourceFile(fileName as DocumentIdentifier))
|
|
249
|
+
.find((sf) => sf != null)
|
|
250
|
+
if (!sourceFile) {
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
const imports = this.extractImports(sourceFile)
|
|
254
|
+
for (const specifier of imports) {
|
|
255
|
+
const resolved = this.resolveModuleSpecifier(fileName, specifier)
|
|
256
|
+
if (resolved) {
|
|
257
|
+
const sourceFileName = this.resolveTSInputFile(resolved)
|
|
258
|
+
if (this.sourceFiles.has(sourceFileName)) {
|
|
259
|
+
this.sourceFiles.get(fileName)?.imports.add(sourceFileName)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private extractImports(sourceFile: SourceFile): string[] {
|
|
267
|
+
const result: string[] = []
|
|
268
|
+
|
|
269
|
+
for (const statement of sourceFile.statements) {
|
|
270
|
+
if (statement.kind === SyntaxKind.ImportDeclaration) {
|
|
271
|
+
let spec: SourceFile['imports'][number] | undefined
|
|
272
|
+
statement.forEachChild((child) => {
|
|
273
|
+
if (child.kind === SyntaxKind.StringLiteral) {
|
|
274
|
+
spec = child
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
if (spec) {
|
|
278
|
+
result.push(spec.getText(sourceFile))
|
|
279
|
+
}
|
|
280
|
+
} else if (statement.kind === SyntaxKind.ImportEqualsDeclaration) {
|
|
281
|
+
statement.forEachChild((child) => {
|
|
282
|
+
if (child.kind === SyntaxKind.ExternalModuleReference) {
|
|
283
|
+
child.forEachChild((refChild) => {
|
|
284
|
+
if (refChild.kind === SyntaxKind.StringLiteral) {
|
|
285
|
+
result.push(refChild.getText(sourceFile))
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const ref of sourceFile.referencedFiles) {
|
|
294
|
+
result.push(ref.fileName)
|
|
295
|
+
}
|
|
296
|
+
for (const ref of sourceFile.typeReferenceDirectives) {
|
|
297
|
+
result.push(ref.fileName)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return result
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private resolveModuleSpecifier(
|
|
304
|
+
sourceFileName: string,
|
|
305
|
+
specifier: string,
|
|
306
|
+
): string | undefined {
|
|
307
|
+
const cleaned = specifier.replace(/^['"]|['"]$/g, '')
|
|
308
|
+
if (!cleaned.startsWith('./') && !cleaned.startsWith('../')) {
|
|
309
|
+
return undefined
|
|
310
|
+
}
|
|
311
|
+
const baseDir = path.dirname(sourceFileName)
|
|
312
|
+
const resolved = toPosixFileName(path.resolve(baseDir, cleaned))
|
|
313
|
+
|
|
314
|
+
const candidates = this.getResolutionCandidates(resolved)
|
|
315
|
+
for (const candidate of candidates) {
|
|
316
|
+
if (this.sourceFiles.has(candidate)) {
|
|
317
|
+
return candidate
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return undefined
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private getResolutionCandidates(resolved: string): string[] {
|
|
325
|
+
const extension = path.extname(resolved)
|
|
326
|
+
if (extension) {
|
|
327
|
+
const withoutExt = resolved.slice(0, -extension.length)
|
|
328
|
+
return [
|
|
329
|
+
resolved,
|
|
330
|
+
`${withoutExt}.ts`,
|
|
331
|
+
`${withoutExt}.tsx`,
|
|
332
|
+
`${withoutExt}.d.ts`,
|
|
333
|
+
`${withoutExt}.js`,
|
|
334
|
+
`${withoutExt}.jsx`,
|
|
335
|
+
`${withoutExt}.mjs`,
|
|
336
|
+
`${withoutExt}.cjs`,
|
|
337
|
+
]
|
|
338
|
+
}
|
|
339
|
+
return [
|
|
340
|
+
resolved,
|
|
341
|
+
`${resolved}.ts`,
|
|
342
|
+
`${resolved}.tsx`,
|
|
343
|
+
`${resolved}.d.ts`,
|
|
344
|
+
`${resolved}/index.ts`,
|
|
345
|
+
`${resolved}/index.tsx`,
|
|
346
|
+
`${resolved}/index.d.ts`,
|
|
347
|
+
`${resolved}.js`,
|
|
348
|
+
`${resolved}.jsx`,
|
|
349
|
+
`${resolved}.mjs`,
|
|
350
|
+
`${resolved}.cjs`,
|
|
351
|
+
`${resolved}/index.js`,
|
|
352
|
+
`${resolved}/index.jsx`,
|
|
353
|
+
`${resolved}/index.mjs`,
|
|
354
|
+
`${resolved}/index.cjs`,
|
|
355
|
+
]
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private resolveTSInputFile(dependencyFileName: string): string {
|
|
359
|
+
if (!dependencyFileName.endsWith('.d.ts')) {
|
|
360
|
+
return dependencyFileName
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const file = this.fs.getFile(dependencyFileName)
|
|
364
|
+
if (!file) {
|
|
365
|
+
return dependencyFileName
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const sourceMappingURL = getSourceMappingURL(file.content)
|
|
369
|
+
if (!sourceMappingURL) {
|
|
370
|
+
return dependencyFileName
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const sourceMapFileName = toPosixFileName(
|
|
374
|
+
path.resolve(path.dirname(dependencyFileName), sourceMappingURL),
|
|
375
|
+
)
|
|
376
|
+
const sourceMap = this.fs.getFile(sourceMapFileName)
|
|
377
|
+
if (!sourceMap) {
|
|
378
|
+
this.log.warn(`Could not find sourcemap ${sourceMapFileName}`)
|
|
379
|
+
return dependencyFileName
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const sourceMapParsed = JSON.parse(sourceMap.content) as {
|
|
383
|
+
sources?: string[]
|
|
384
|
+
}
|
|
385
|
+
const sources = sourceMapParsed.sources
|
|
386
|
+
|
|
387
|
+
if (sources?.length === 1) {
|
|
388
|
+
const [sourcePath] = sources
|
|
389
|
+
return toPosixFileName(
|
|
390
|
+
path.resolve(path.dirname(sourceMapFileName), sourcePath!),
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return dependencyFileName
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private guardTSConfigFileExists(): void {
|
|
398
|
+
try {
|
|
399
|
+
readFileSync(this.tsconfigFile, 'utf-8')
|
|
400
|
+
} catch {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`The tsconfig file does not exist at: "${this.tsconfigFile}". Please configure the tsconfig file in your stryker.conf file using "tsconfigFile"`,
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|