/
typescript-checker.ts
201 lines (186 loc) · 7.34 KB
/
typescript-checker.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
import { EOL } from 'os';
import path from 'path';
import ts from 'typescript';
import { Checker, CheckResult, CheckStatus } from '@stryker-mutator/api/check';
import { tokens, commonTokens, PluginContext, Injector, Scope } from '@stryker-mutator/api/plugin';
import { Logger, LoggerFactoryMethod } from '@stryker-mutator/api/logging';
import { Task, propertyPath } from '@stryker-mutator/util';
import { Mutant, StrykerOptions } from '@stryker-mutator/api/core';
import { HybridFileSystem } from './fs';
import { determineBuildModeEnabled, overrideOptions, retrieveReferencedProjects, guardTSVersion, toPosixFileName } from './tsconfig-helpers';
import * as pluginTokens from './plugin-tokens';
const diagnosticsHost: ts.FormatDiagnosticsHost = {
getCanonicalFileName: (fileName) => fileName,
getCurrentDirectory: process.cwd,
getNewLine: () => EOL,
};
const FILE_CHANGE_DETECTED_DIAGNOSTIC_CODE = 6032;
typescriptCheckerLoggerFactory.inject = tokens(commonTokens.getLogger, commonTokens.target);
// eslint-disable-next-line @typescript-eslint/ban-types
function typescriptCheckerLoggerFactory(loggerFactory: LoggerFactoryMethod, target: Function | undefined) {
const targetName = target?.name ?? TypescriptChecker.name;
const category = targetName === TypescriptChecker.name ? TypescriptChecker.name : `${TypescriptChecker.name}.${targetName}`;
return loggerFactory(category);
}
create.inject = tokens(commonTokens.injector);
export function create(injector: Injector<PluginContext>): TypescriptChecker {
return injector
.provideFactory(commonTokens.logger, typescriptCheckerLoggerFactory, Scope.Transient)
.provideClass(pluginTokens.fs, HybridFileSystem)
.injectClass(TypescriptChecker);
}
/**
* An in-memory type checker implementation which validates type errors of mutants.
*/
export class TypescriptChecker implements Checker {
private currentTask: Task<CheckResult> = new Task();
private readonly currentErrors: ts.Diagnostic[] = [];
/**
* Keep track of all tsconfig files which are read during compilation (for project references)
*/
private readonly allTSConfigFiles: Set<string>;
public static inject = tokens(commonTokens.logger, commonTokens.options, pluginTokens.fs);
private readonly tsconfigFile: string;
constructor(private readonly logger: Logger, options: StrykerOptions, private readonly fs: HybridFileSystem) {
this.tsconfigFile = toPosixFileName(options.tsconfigFile);
this.allTSConfigFiles = new Set([path.resolve(this.tsconfigFile)]);
}
/**
* Starts the typescript compiler and does a dry run
*/
public async init(): Promise<void> {
guardTSVersion();
this.guardTSConfigFileExists();
this.currentTask = new Task();
const buildModeEnabled = determineBuildModeEnabled(this.tsconfigFile);
const compiler = ts.createSolutionBuilderWithWatch(
ts.createSolutionBuilderWithWatchHost(
{
...ts.sys,
readFile: (fileName) => {
const content = this.fs.getFile(fileName)?.content;
if (content && this.allTSConfigFiles.has(path.resolve(fileName))) {
return this.adjustTSConfigFile(fileName, content, buildModeEnabled);
}
return content;
},
watchFile: (filePath: string, callback: ts.FileWatcherCallback) => {
this.fs.watchFile(filePath, callback);
return {
close: () => {
delete this.fs.getFile(filePath)!.watcher;
},
};
},
writeFile: (filePath, data) => {
this.fs.writeFile(filePath, data);
},
createDirectory: () => {
// Idle, no need to create directories in the hybrid fs
},
clearScreen() {
// idle, never clear the screen
},
getModifiedTime: (fileName) => {
return this.fs.getFile(fileName)!.modifiedTime;
},
watchDirectory: (): ts.FileWatcher => {
// this is used to see if new files are added to a directory. Can safely be ignored for mutation testing.
return {
// eslint-disable-next-line @typescript-eslint/no-empty-function
close() {},
};
},
},
undefined,
(error) => this.currentErrors.push(error),
(status) => this.logDiagnostic('status')(status),
(summary) => {
this.logDiagnostic('summary')(summary);
summary.code !== FILE_CHANGE_DETECTED_DIAGNOSTIC_CODE && this.resolveCheckResult();
}
),
[this.tsconfigFile],
{}
);
compiler.build();
const result = await this.currentTask.promise;
if (result.status === CheckStatus.CompileError) {
throw new Error(`TypeScript error(s) found in dry run compilation: ${result.reason}`);
}
}
private guardTSConfigFileExists() {
if (!ts.sys.fileExists(this.tsconfigFile)) {
throw new Error(
`The tsconfig file does not exist at: "${path.resolve(
this.tsconfigFile
)}". Please configure the tsconfig file in your stryker.conf file using "${propertyPath<StrykerOptions>('tsconfigFile')}"`
);
}
}
/**
* Checks whether or not a mutant results in a compile error.
* Will simply pass through if the file mutated isn't part of the typescript project
* @param mutant The mutant to check
*/
public async check(mutant: Mutant): Promise<CheckResult> {
if (this.fs.existsInMemory(mutant.fileName)) {
this.clearCheckState();
this.fs.mutate(mutant);
return this.currentTask.promise;
} else {
// We allow people to mutate files that are not included in this ts project
return {
status: CheckStatus.Passed,
};
}
}
/**
* Post processes the content of a tsconfig file. Adjusts some options for speed and alters quality options.
* @param fileName The tsconfig file name
* @param content The tsconfig content
* @param buildModeEnabled Whether or not `--build` mode is used
*/
private adjustTSConfigFile(fileName: string, content: string, buildModeEnabled: boolean) {
const parsedConfig = ts.parseConfigFileTextToJson(fileName, content);
if (parsedConfig.error) {
return content; // let the ts compiler deal with this error
} else {
for (const referencedProject of retrieveReferencedProjects(parsedConfig, path.dirname(fileName))) {
this.allTSConfigFiles.add(referencedProject);
}
return overrideOptions(parsedConfig, buildModeEnabled);
}
}
/**
* Resolves the task that is currently running. Will report back the check result.
*/
private resolveCheckResult(): void {
if (this.currentErrors.length) {
const errorText = ts.formatDiagnostics(this.currentErrors, {
getCanonicalFileName: (fileName) => fileName,
getCurrentDirectory: process.cwd,
getNewLine: () => EOL,
});
this.currentTask.resolve({
status: CheckStatus.CompileError,
reason: errorText,
});
}
this.currentTask.resolve({ status: CheckStatus.Passed });
}
/**
* Clear state between checks
*/
private clearCheckState() {
while (this.currentErrors.pop()) {
// Idle
}
this.currentTask = new Task();
}
private readonly logDiagnostic = (label: string) => {
return (d: ts.Diagnostic) => {
this.logger.trace(`${label} ${ts.formatDiagnostics([d], diagnosticsHost)}`);
};
};
}