@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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +15 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/LICENSE +21 -0
  4. package/dist/index.d.mts +122 -0
  5. package/dist/index.mjs +602 -0
  6. package/oxlint.config.ts +9 -0
  7. package/package.json +37 -0
  8. package/schema/typescript-checker-options.json +22 -0
  9. package/src/fs/hybrid-file-system.ts +118 -0
  10. package/src/fs/index.ts +2 -0
  11. package/src/fs/script-file.ts +44 -0
  12. package/src/grouping/create-groups.ts +77 -0
  13. package/src/grouping/ts-file-node.ts +54 -0
  14. package/src/index.ts +18 -0
  15. package/src/plugin-tokens.ts +2 -0
  16. package/src/tsconfig-helpers.ts +168 -0
  17. package/src/typescript-checker-options-with-stryker-options.ts +9 -0
  18. package/src/typescript-checker.ts +223 -0
  19. package/src/typescript-compiler.ts +406 -0
  20. package/test/integration/e2e-plugin-entry.it.spec.ts +153 -0
  21. package/test/integration/project-references.it.spec.ts +146 -0
  22. package/test/integration/project-with-ts-buildinfo.it.spec.ts +92 -0
  23. package/test/integration/single-project.it.spec.ts +263 -0
  24. package/test/integration/typescript-checkers-errors.it.spec.ts +87 -0
  25. package/test/unit/fs/hybrid-file-system.spec.ts +109 -0
  26. package/test/unit/grouping/create-groups.spec.ts +122 -0
  27. package/test/unit/grouping/ts-file-node.spec.ts +132 -0
  28. package/testResources/errors/compile-error/add.ts +3 -0
  29. package/testResources/errors/compile-error/tsconfig.json +6 -0
  30. package/testResources/errors/empty-dir/.gitkeep +0 -0
  31. package/testResources/errors/invalid-tsconfig/tsconfig.json +1 -0
  32. package/testResources/project-references/src/index.ts +5 -0
  33. package/testResources/project-references/src/job.ts +6 -0
  34. package/testResources/project-references/src/src.tsbuildinfo +1 -0
  35. package/testResources/project-references/src/tsconfig.json +10 -0
  36. package/testResources/project-references/tsconfig.root.json +7 -0
  37. package/testResources/project-references/tsconfig.settings.json +17 -0
  38. package/testResources/project-references/utils/math.ts +3 -0
  39. package/testResources/project-references/utils/text.ts +3 -0
  40. package/testResources/project-references/utils/tsconfig.json +7 -0
  41. package/testResources/project-references/utils/utils.tsbuildinfo +1 -0
  42. package/testResources/project-with-ts-buildinfo/do-not-delete.tsbuildinfo +1 -0
  43. package/testResources/project-with-ts-buildinfo/src/index.ts +3 -0
  44. package/testResources/project-with-ts-buildinfo/tsconfig.json +17 -0
  45. package/testResources/single-project/src/counter.ts +9 -0
  46. package/testResources/single-project/src/errorInFileAbove2Mutants/counter.ts +9 -0
  47. package/testResources/single-project/src/errorInFileAbove2Mutants/todo-counter.ts +7 -0
  48. package/testResources/single-project/src/errorInFileAbove2Mutants/todo.spec.ts +11 -0
  49. package/testResources/single-project/src/errorInFileAbove2Mutants/todo.ts +22 -0
  50. package/testResources/single-project/src/not-type-checked.js +1 -0
  51. package/testResources/single-project/src/todo.spec.ts +11 -0
  52. package/testResources/single-project/src/todo.ts +22 -0
  53. package/testResources/single-project/tsconfig.json +15 -0
  54. package/tsconfig.json +10 -0
  55. package/vitest.config.ts +11 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,602 @@
1
+ import { readFileSync } from "fs";
2
+ import { PluginKind, Scope, commonTokens, declareFactoryPlugin, tokens } from "@stryker-mutator/api/plugin";
3
+ import { EOL } from "os";
4
+ import { CheckStatus } from "@stryker-mutator/api/check";
5
+ import { split, strykerReportBugUrl } from "@stryker-mutator/util";
6
+ import { API, DiagnosticCategory } from "typescript/unstable/sync";
7
+ import { createRequire } from "module";
8
+ import path from "path";
9
+ import semver from "semver";
10
+ import { SyntaxKind } from "typescript/unstable/ast";
11
+ //#region src/tsconfig-helpers.ts
12
+ const COMPILER_OPTIONS_OVERRIDES = Object.freeze({
13
+ allowUnreachableCode: true,
14
+ noUnusedLocals: false,
15
+ noUnusedParameters: false
16
+ });
17
+ const NO_EMIT_OPTIONS_FOR_SINGLE_PROJECT = Object.freeze({
18
+ noEmit: true,
19
+ incremental: false,
20
+ tsBuildInfoFile: void 0,
21
+ composite: false
22
+ });
23
+ const LOW_EMIT_OPTIONS_FOR_PROJECT_REFERENCES = Object.freeze({
24
+ emitDeclarationOnly: true,
25
+ noEmit: false,
26
+ declarationMap: true,
27
+ declaration: true
28
+ });
29
+ let cachedTSVersion;
30
+ function getTSVersion() {
31
+ if (cachedTSVersion === void 0) {
32
+ const require = createRequire(import.meta.url);
33
+ cachedTSVersion = JSON.parse(readFileSync(require.resolve("typescript/package.json"), "utf-8")).version;
34
+ }
35
+ return cachedTSVersion;
36
+ }
37
+ function guardTSVersion(version = getTSVersion()) {
38
+ if (!semver.satisfies(version, ">=7.0.0", { includePrerelease: true })) throw new Error(`@systemfsoftware/stryker-js-typescript-checker only supports typescript@7.0.0 or higher. Found typescript@${version}`);
39
+ }
40
+ function stripJsonComments(json) {
41
+ return json.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
42
+ }
43
+ function parseConfigFileTextToJson(fileName, jsonText) {
44
+ try {
45
+ const stripped = stripJsonComments(jsonText);
46
+ return { config: JSON.parse(stripped) };
47
+ } catch (error) {
48
+ return { error };
49
+ }
50
+ }
51
+ function determineBuildModeEnabled(tsconfigFileName) {
52
+ const parsed = parseConfigFileTextToJson(tsconfigFileName, readFileSync(tsconfigFileName, "utf-8"));
53
+ if (parsed.error) return false;
54
+ return "references" in parsed.config;
55
+ }
56
+ /**
57
+ * Overrides some options to speed up compilation and disable some code quality checks we don't want during mutation testing
58
+ * @param parsedConfig The parsed config file
59
+ * @param useBuildMode whether or not `--build` mode is used
60
+ */
61
+ function overrideOptions(parsedConfig, useBuildMode) {
62
+ const config = parsedConfig.config ?? {};
63
+ const compilerOptions = {
64
+ ...config.compilerOptions,
65
+ ...COMPILER_OPTIONS_OVERRIDES,
66
+ ...useBuildMode ? LOW_EMIT_OPTIONS_FOR_PROJECT_REFERENCES : NO_EMIT_OPTIONS_FOR_SINGLE_PROJECT,
67
+ target: "es2022",
68
+ moduleResolution: "bundler"
69
+ };
70
+ if (!useBuildMode && compilerOptions["declarationDir"] !== void 0 && compilerOptions["declarationDir"] !== null) delete compilerOptions["declarationDir"];
71
+ if (useBuildMode) {
72
+ delete compilerOptions["inlineSourceMap"];
73
+ delete compilerOptions["inlineSources"];
74
+ delete compilerOptions["mapRoute"];
75
+ delete compilerOptions["sourceRoot"];
76
+ delete compilerOptions["outFile"];
77
+ }
78
+ return JSON.stringify({
79
+ ...config,
80
+ compilerOptions
81
+ });
82
+ }
83
+ /**
84
+ * Retrieves the referenced config files based on parsed configuration
85
+ * @param parsedConfig The parsed config file
86
+ * @param fromDirName The directory where to resolve from
87
+ */
88
+ function retrieveReferencedProjects(parsedConfig, fromDirName) {
89
+ const config = parsedConfig.config;
90
+ if (Array.isArray(config?.references)) return config.references.map((reference) => {
91
+ let resolved = path.resolve(fromDirName, reference.path);
92
+ if (!path.basename(resolved).endsWith(".json")) resolved = path.join(resolved, "tsconfig.json");
93
+ return toPosixFileName(resolved);
94
+ });
95
+ return [];
96
+ }
97
+ /**
98
+ * Replaces backslashes with forward slashes (used by typescript)
99
+ * @param fileName The file name that may contain backslashes `\`
100
+ * @returns posix and ts complaint file name (with `/`)
101
+ */
102
+ function toPosixFileName(fileName) {
103
+ return fileName.replace(/\\/g, "/");
104
+ }
105
+ /**
106
+ * Find source file in declaration file
107
+ * @param content The content of the declaration file
108
+ * @returns URL of the source file or undefined if not found
109
+ */
110
+ const findSourceMapRegex = /\/\/# sourceMappingURL=(.+)$/m;
111
+ function getSourceMappingURL(content) {
112
+ findSourceMapRegex.lastIndex = 0;
113
+ return findSourceMapRegex.exec(content)?.[1];
114
+ }
115
+ //#endregion
116
+ //#region src/fs/script-file.ts
117
+ var ScriptFile = class {
118
+ content;
119
+ fileName;
120
+ modifiedTime;
121
+ originalContent;
122
+ constructor(content, fileName, modifiedTime = /* @__PURE__ */ new Date()) {
123
+ this.content = content;
124
+ this.fileName = fileName;
125
+ this.modifiedTime = modifiedTime;
126
+ this.originalContent = content;
127
+ }
128
+ write(content) {
129
+ this.content = content;
130
+ this.touch();
131
+ }
132
+ mutate(mutant) {
133
+ const start = this.getOffset(mutant.location.start);
134
+ const end = this.getOffset(mutant.location.end);
135
+ this.content = `${this.originalContent.slice(0, start)}${mutant.replacement}${this.originalContent.slice(end)}`;
136
+ this.touch();
137
+ }
138
+ getOffset(pos) {
139
+ const lines = this.originalContent.split("\n");
140
+ let offset = 0;
141
+ for (let i = 0; i < pos.line && i < lines.length; i++) offset += lines[i].length + 1;
142
+ offset += pos.column;
143
+ return offset;
144
+ }
145
+ resetMutant() {
146
+ this.content = this.originalContent;
147
+ this.touch();
148
+ }
149
+ touch() {
150
+ this.modifiedTime = /* @__PURE__ */ new Date();
151
+ }
152
+ };
153
+ //#endregion
154
+ //#region src/fs/hybrid-file-system.ts
155
+ /**
156
+ * A very simple hybrid file system.
157
+ * * Readonly from disk
158
+ * * Writes in-memory
159
+ * * Hard caching
160
+ * * Ability to mutate one file
161
+ */
162
+ var HybridFileSystem = class {
163
+ files = /* @__PURE__ */ new Map();
164
+ /**
165
+ * Map of absolute tsconfig file paths to their adjusted JSON content.
166
+ * This allows the TS7 API to read overridden compiler options.
167
+ */
168
+ tsConfigOverrides = /* @__PURE__ */ new Map();
169
+ readFile = (fileName) => {
170
+ const normalized = toPosixFileName(fileName);
171
+ if (this.fileNameIsBuildInfo(normalized)) return null;
172
+ const override = this.tsConfigOverrides.get(normalized);
173
+ if (override !== void 0) return override;
174
+ const file = this.files.get(normalized);
175
+ if (file) return file.content;
176
+ if (file === void 0 && this.files.has(normalized)) return null;
177
+ };
178
+ fileExists = (fileName) => {
179
+ const normalized = toPosixFileName(fileName);
180
+ if (this.fileNameIsBuildInfo(normalized)) return false;
181
+ if (this.tsConfigOverrides.has(normalized)) return true;
182
+ if (this.files.has(normalized)) return this.files.get(normalized) !== void 0;
183
+ };
184
+ directoryExists = () => {};
185
+ getAccessibleEntries = () => {};
186
+ realpath = () => {};
187
+ writeFile(fileName, data) {
188
+ const normalized = toPosixFileName(fileName);
189
+ const existingFile = this.files.get(normalized);
190
+ if (existingFile) existingFile.write(data);
191
+ else this.files.set(normalized, new ScriptFile(data, normalized));
192
+ }
193
+ getFile(fileName) {
194
+ const normalized = toPosixFileName(fileName);
195
+ if (!this.files.has(normalized)) try {
196
+ const content = readFileSync(normalized, "utf-8");
197
+ this.files.set(normalized, new ScriptFile(content, normalized));
198
+ } catch {
199
+ this.files.set(normalized, void 0);
200
+ }
201
+ return this.files.get(normalized);
202
+ }
203
+ mutateFile(fileName, mutant) {
204
+ const file = this.getFile(fileName);
205
+ if (!file) throw new Error(`Tried to mutate file "${fileName}" but it could not be found.`);
206
+ file.mutate(mutant);
207
+ }
208
+ resetFile(fileName) {
209
+ this.getFile(fileName)?.resetMutant();
210
+ }
211
+ existsInMemory(fileName) {
212
+ return this.files.get(toPosixFileName(fileName)) !== void 0;
213
+ }
214
+ fileNameIsBuildInfo(fileName) {
215
+ return fileName.endsWith(".tsbuildinfo");
216
+ }
217
+ };
218
+ //#endregion
219
+ //#region src/grouping/ts-file-node.ts
220
+ var TSFileNode = class {
221
+ fileName;
222
+ parents;
223
+ children;
224
+ constructor(fileName, parents, children) {
225
+ this.fileName = fileName;
226
+ this.parents = parents;
227
+ this.children = children;
228
+ }
229
+ getAllParentReferencesIncludingSelf(allParentReferences = /* @__PURE__ */ new Set()) {
230
+ allParentReferences.add(this);
231
+ this.parents.forEach((parent) => {
232
+ if (!allParentReferences.has(parent)) parent.getAllParentReferencesIncludingSelf(allParentReferences);
233
+ });
234
+ return allParentReferences;
235
+ }
236
+ getAllChildReferencesIncludingSelf(allChildReferences = /* @__PURE__ */ new Set()) {
237
+ allChildReferences.add(this);
238
+ this.children.forEach((child) => {
239
+ if (!allChildReferences.has(child)) child.getAllChildReferencesIncludingSelf(allChildReferences);
240
+ });
241
+ return allChildReferences;
242
+ }
243
+ getMutantsWithReferenceToChildrenOrSelf(mutants, nodesChecked = []) {
244
+ if (nodesChecked.includes(this.fileName)) return [];
245
+ nodesChecked.push(this.fileName);
246
+ const relatedMutants = mutants.filter((m) => toPosixFileName(m.fileName) === this.fileName);
247
+ const childResult = this.children.flatMap((c) => c.getMutantsWithReferenceToChildrenOrSelf(mutants, nodesChecked));
248
+ return [...relatedMutants, ...childResult];
249
+ }
250
+ };
251
+ //#endregion
252
+ //#region src/grouping/create-groups.ts
253
+ /**
254
+ * To speed up the type-checking we want to check multiple mutants at once.
255
+ * When multiple mutants in different files don't have overlap in affected files (or have small overlap), we can type-check them simultaneously.
256
+ * These mutants who can be tested at the same time are called a group.
257
+ * Therefore, the return type is an array of arrays, in other words: an array of groups.
258
+ *
259
+ * @param mutants All the mutants of the test project.
260
+ * @param nodes A graph representation of the test project.
261
+ */
262
+ function createGroups(mutants, nodes) {
263
+ const groups = [];
264
+ const mutantsToGroup = new Set(mutants);
265
+ while (mutantsToGroup.size) {
266
+ const group = [];
267
+ const groupNodes = /* @__PURE__ */ new Set();
268
+ const nodesToIgnore = /* @__PURE__ */ new Set();
269
+ for (const currentMutant of mutantsToGroup) {
270
+ const currentNode = findNode(currentMutant.fileName, nodes);
271
+ if (!nodesToIgnore.has(currentNode) && !parentsHaveOverlapWith(currentNode, groupNodes)) {
272
+ group.push(currentMutant.id);
273
+ groupNodes.add(currentNode);
274
+ mutantsToGroup.delete(currentMutant);
275
+ addRangeOfNodesToSet(nodesToIgnore, currentNode.getAllParentReferencesIncludingSelf());
276
+ }
277
+ }
278
+ groups.push(group);
279
+ }
280
+ return groups;
281
+ }
282
+ function addRangeOfNodesToSet(nodes, nodesToAdd) {
283
+ for (const parent of nodesToAdd) nodes.add(parent);
284
+ }
285
+ function findNode(fileName, nodes) {
286
+ const node = nodes.get(toPosixFileName(fileName));
287
+ if (node == null) throw new Error(`Node not in graph: ${fileName}`);
288
+ return node;
289
+ }
290
+ function parentsHaveOverlapWith(currentNode, groupNodes) {
291
+ for (const parentNode of currentNode.getAllParentReferencesIncludingSelf()) if (groupNodes.has(parentNode)) return true;
292
+ return false;
293
+ }
294
+ const tsCompiler = "tsCompiler";
295
+ //#endregion
296
+ //#region src/typescript-compiler.ts
297
+ var TypescriptCompiler = class {
298
+ log;
299
+ options;
300
+ fs;
301
+ static inject = tokens(commonTokens.logger, commonTokens.options, "fs");
302
+ allTSConfigFiles;
303
+ tsconfigFile;
304
+ api;
305
+ snapshot;
306
+ sourceFiles = /* @__PURE__ */ new Map();
307
+ _nodes = /* @__PURE__ */ new Map();
308
+ lastMutants = [];
309
+ lastMutatedFileNames = [];
310
+ constructor(log, options, fs) {
311
+ this.log = log;
312
+ this.options = options;
313
+ this.fs = fs;
314
+ this.tsconfigFile = toPosixFileName(path.resolve(toPosixFileName(this.options.tsconfigFile)));
315
+ this.allTSConfigFiles = /* @__PURE__ */ new Set([this.tsconfigFile]);
316
+ }
317
+ async init() {
318
+ guardTSVersion();
319
+ this.guardTSConfigFileExists();
320
+ const buildModeEnabled = determineBuildModeEnabled(this.tsconfigFile);
321
+ this.collectAllTSConfigFiles(buildModeEnabled);
322
+ this.api = new API({ fs: this.fs });
323
+ this.snapshot = this.api.updateSnapshot({ openProjects: [...this.allTSConfigFiles] });
324
+ const programs = this.getPrograms();
325
+ this.buildDependencyGraph(programs);
326
+ return this.check([]);
327
+ }
328
+ async check(mutants) {
329
+ for (const mutant of this.lastMutants) this.fs.resetFile(mutant.fileName);
330
+ for (const mutant of mutants) {
331
+ const file = this.fs.getFile(mutant.fileName);
332
+ if (!file) throw new Error(`Tried to check file "${mutant.fileName}" (which is part of your typescript project), but it could not be found.`);
333
+ file.mutate(mutant);
334
+ }
335
+ const mutatedFileNames = [...new Set(mutants.map((m) => toPosixFileName(m.fileName)))];
336
+ const changedFiles = [.../* @__PURE__ */ new Set([...this.lastMutatedFileNames, ...mutatedFileNames])];
337
+ if (this.api && this.snapshot) {
338
+ const oldSnapshot = this.snapshot;
339
+ this.snapshot = this.api.updateSnapshot({
340
+ openProjects: [...this.allTSConfigFiles],
341
+ fileChanges: { changed: changedFiles }
342
+ });
343
+ oldSnapshot.dispose();
344
+ }
345
+ this.lastMutants = mutants;
346
+ this.lastMutatedFileNames = mutatedFileNames;
347
+ return this.getPrograms().flatMap((program) => [
348
+ ...program.getConfigFileParsingDiagnostics(),
349
+ ...program.getSemanticDiagnostics(),
350
+ ...program.getProgramDiagnostics()
351
+ ]);
352
+ }
353
+ get nodes() {
354
+ if (!this._nodes.size) {
355
+ for (const [fileName] of this.sourceFiles) {
356
+ const node = new TSFileNode(fileName, [], []);
357
+ this._nodes.set(fileName, node);
358
+ }
359
+ for (const [fileName, file] of this.sourceFiles) {
360
+ const node = this._nodes.get(fileName);
361
+ if (node == null) throw new Error(`Node for file '${fileName}' could not be found. This should not happen.`);
362
+ node.children = [...file.imports].map((importName) => this._nodes.get(importName)).filter((n) => n != null);
363
+ }
364
+ for (const [, node] of this._nodes) {
365
+ node.parents = [];
366
+ for (const [, n] of this._nodes) if (n.children.includes(node)) node.parents.push(n);
367
+ }
368
+ }
369
+ return this._nodes;
370
+ }
371
+ close() {
372
+ this.snapshot?.dispose();
373
+ this.api?.close();
374
+ }
375
+ getLineAndCharacterOfPosition(fileName, position) {
376
+ for (const program of this.getPrograms()) {
377
+ const sourceFile = program.getSourceFile(fileName);
378
+ if (sourceFile) return sourceFile.getLineAndCharacterOfPosition(position);
379
+ }
380
+ }
381
+ getPrograms() {
382
+ if (!this.snapshot) throw new Error("TypescriptCompiler not initialized");
383
+ const projects = this.snapshot.getProjects();
384
+ if (projects.length === 0) throw new Error(`No projects found for ${this.tsconfigFile}`);
385
+ return projects.map((project) => project.program);
386
+ }
387
+ collectAllTSConfigFiles(buildModeEnabled) {
388
+ const tsConfigOverrides = /* @__PURE__ */ new Map();
389
+ const toProcess = [this.tsconfigFile];
390
+ const processed = /* @__PURE__ */ new Set();
391
+ while (toProcess.length > 0) {
392
+ const current = toProcess.pop();
393
+ if (!current || processed.has(current)) continue;
394
+ processed.add(current);
395
+ const content = readFileSync(current, "utf-8");
396
+ const parsed = parseConfigFileTextToJson(current, content);
397
+ if (parsed.error) {
398
+ tsConfigOverrides.set(current, content);
399
+ continue;
400
+ }
401
+ tsConfigOverrides.set(current, overrideOptions(parsed, buildModeEnabled));
402
+ for (const referenced of retrieveReferencedProjects(parsed, path.dirname(current))) {
403
+ this.allTSConfigFiles.add(referenced);
404
+ toProcess.push(referenced);
405
+ }
406
+ }
407
+ this.fs.tsConfigOverrides = tsConfigOverrides;
408
+ }
409
+ buildDependencyGraph(programs) {
410
+ for (const program of programs) for (const fileName of program.getSourceFileNames()) {
411
+ if (fileName.endsWith(".d.ts") || fileName.includes("node_modules")) continue;
412
+ const normalized = toPosixFileName(fileName);
413
+ this.sourceFiles.set(normalized, {
414
+ fileName: normalized,
415
+ imports: /* @__PURE__ */ new Set()
416
+ });
417
+ }
418
+ for (const [fileName] of this.sourceFiles) {
419
+ const sourceFile = programs.map((p) => p.getSourceFile(fileName)).find((sf) => sf != null);
420
+ if (!sourceFile) continue;
421
+ const imports = this.extractImports(sourceFile);
422
+ for (const specifier of imports) {
423
+ const resolved = this.resolveModuleSpecifier(fileName, specifier);
424
+ if (resolved) {
425
+ const sourceFileName = this.resolveTSInputFile(resolved);
426
+ if (this.sourceFiles.has(sourceFileName)) this.sourceFiles.get(fileName)?.imports.add(sourceFileName);
427
+ }
428
+ }
429
+ }
430
+ }
431
+ extractImports(sourceFile) {
432
+ const result = [];
433
+ for (const statement of sourceFile.statements) if (statement.kind === SyntaxKind.ImportDeclaration) {
434
+ let spec;
435
+ statement.forEachChild((child) => {
436
+ if (child.kind === SyntaxKind.StringLiteral) spec = child;
437
+ });
438
+ if (spec) result.push(spec.getText(sourceFile));
439
+ } else if (statement.kind === SyntaxKind.ImportEqualsDeclaration) statement.forEachChild((child) => {
440
+ if (child.kind === SyntaxKind.ExternalModuleReference) child.forEachChild((refChild) => {
441
+ if (refChild.kind === SyntaxKind.StringLiteral) result.push(refChild.getText(sourceFile));
442
+ });
443
+ });
444
+ for (const ref of sourceFile.referencedFiles) result.push(ref.fileName);
445
+ for (const ref of sourceFile.typeReferenceDirectives) result.push(ref.fileName);
446
+ return result;
447
+ }
448
+ resolveModuleSpecifier(sourceFileName, specifier) {
449
+ const cleaned = specifier.replace(/^['"]|['"]$/g, "");
450
+ if (!cleaned.startsWith("./") && !cleaned.startsWith("../")) return;
451
+ const baseDir = path.dirname(sourceFileName);
452
+ const resolved = toPosixFileName(path.resolve(baseDir, cleaned));
453
+ const candidates = this.getResolutionCandidates(resolved);
454
+ for (const candidate of candidates) if (this.sourceFiles.has(candidate)) return candidate;
455
+ }
456
+ getResolutionCandidates(resolved) {
457
+ const extension = path.extname(resolved);
458
+ if (extension) {
459
+ const withoutExt = resolved.slice(0, -extension.length);
460
+ return [
461
+ resolved,
462
+ `${withoutExt}.ts`,
463
+ `${withoutExt}.tsx`,
464
+ `${withoutExt}.d.ts`,
465
+ `${withoutExt}.js`,
466
+ `${withoutExt}.jsx`,
467
+ `${withoutExt}.mjs`,
468
+ `${withoutExt}.cjs`
469
+ ];
470
+ }
471
+ return [
472
+ resolved,
473
+ `${resolved}.ts`,
474
+ `${resolved}.tsx`,
475
+ `${resolved}.d.ts`,
476
+ `${resolved}/index.ts`,
477
+ `${resolved}/index.tsx`,
478
+ `${resolved}/index.d.ts`,
479
+ `${resolved}.js`,
480
+ `${resolved}.jsx`,
481
+ `${resolved}.mjs`,
482
+ `${resolved}.cjs`,
483
+ `${resolved}/index.js`,
484
+ `${resolved}/index.jsx`,
485
+ `${resolved}/index.mjs`,
486
+ `${resolved}/index.cjs`
487
+ ];
488
+ }
489
+ resolveTSInputFile(dependencyFileName) {
490
+ if (!dependencyFileName.endsWith(".d.ts")) return dependencyFileName;
491
+ const file = this.fs.getFile(dependencyFileName);
492
+ if (!file) return dependencyFileName;
493
+ const sourceMappingURL = getSourceMappingURL(file.content);
494
+ if (!sourceMappingURL) return dependencyFileName;
495
+ const sourceMapFileName = toPosixFileName(path.resolve(path.dirname(dependencyFileName), sourceMappingURL));
496
+ const sourceMap = this.fs.getFile(sourceMapFileName);
497
+ if (!sourceMap) {
498
+ this.log.warn(`Could not find sourcemap ${sourceMapFileName}`);
499
+ return dependencyFileName;
500
+ }
501
+ const sources = JSON.parse(sourceMap.content).sources;
502
+ if (sources?.length === 1) {
503
+ const [sourcePath] = sources;
504
+ return toPosixFileName(path.resolve(path.dirname(sourceMapFileName), sourcePath));
505
+ }
506
+ return dependencyFileName;
507
+ }
508
+ guardTSConfigFileExists() {
509
+ try {
510
+ readFileSync(this.tsconfigFile, "utf-8");
511
+ } catch {
512
+ throw new Error(`The tsconfig file does not exist at: "${this.tsconfigFile}". Please configure the tsconfig file in your stryker.conf file using "tsconfigFile"`);
513
+ }
514
+ }
515
+ };
516
+ //#endregion
517
+ //#region src/typescript-checker.ts
518
+ const typescriptCheckerLoggerFactory = Object.assign((loggerFactory, target) => {
519
+ const targetName = target?.name ?? TypescriptChecker.name;
520
+ return loggerFactory(targetName === TypescriptChecker.name ? TypescriptChecker.name : `${TypescriptChecker.name}.${targetName}`);
521
+ }, { inject: tokens(commonTokens.getLogger, commonTokens.target) });
522
+ const create = Object.assign((injector) => injector.provideFactory(commonTokens.logger, typescriptCheckerLoggerFactory, Scope.Transient).provideClass("fs", HybridFileSystem).provideClass(tsCompiler, TypescriptCompiler).injectClass(TypescriptChecker), { inject: tokens(commonTokens.injector) });
523
+ var TypescriptChecker = class {
524
+ logger;
525
+ tsCompiler;
526
+ static inject = tokens(commonTokens.logger, commonTokens.options, tsCompiler);
527
+ options;
528
+ constructor(logger, options, tsCompiler) {
529
+ this.logger = logger;
530
+ this.tsCompiler = tsCompiler;
531
+ this.options = options;
532
+ }
533
+ async init() {
534
+ const errors = await this.tsCompiler.init();
535
+ if (errors.length) throw new Error(`Typescript error(s) found in dry run compilation: ${this.createErrorText(errors)}`);
536
+ }
537
+ async check(mutants) {
538
+ const result = Object.fromEntries(mutants.map((mutant) => [mutant.id, { status: CheckStatus.Passed }]));
539
+ if (!this.tsCompiler.nodes.get(toPosixFileName(mutants[0].fileName))) return result;
540
+ const mutantErrorRelationMap = await this.checkErrors(mutants, {}, this.tsCompiler.nodes);
541
+ for (const [id, errors] of Object.entries(mutantErrorRelationMap)) result[id] = {
542
+ status: CheckStatus.CompileError,
543
+ reason: this.createErrorText(errors)
544
+ };
545
+ return result;
546
+ }
547
+ group(mutants) {
548
+ if (!this.options.typescriptChecker?.prioritizePerformanceOverAccuracy) return Promise.resolve(mutants.map((m) => [m.id]));
549
+ const { nodes } = this.tsCompiler;
550
+ const [mutantsOutsideProject, mutantsInProject] = split(mutants, (m) => nodes.get(toPosixFileName(m.fileName)) == null);
551
+ const groups = createGroups(mutantsInProject, nodes);
552
+ if (mutantsOutsideProject.length) return Promise.resolve([mutantsOutsideProject.map((m) => m.id), ...groups]);
553
+ else return Promise.resolve(groups);
554
+ }
555
+ async checkErrors(mutants, errorsMap, nodes) {
556
+ const errors = await this.tsCompiler.check(mutants);
557
+ const mutantsThatCouldNotBeTestedInGroups = /* @__PURE__ */ new Set();
558
+ if (errors.length && mutants.length === 1) {
559
+ errorsMap[mutants[0].id] = errors;
560
+ return errorsMap;
561
+ }
562
+ for (const error of errors) {
563
+ if (!error.fileName) throw new Error(`Typescript error: '${error.text}' was reported without a corresponding file. This shouldn't happen. Please open an issue using this link: ${strykerReportBugUrl(`[BUG]: TypeScript checker reports compile error without a corresponding file: ${error.text}`)}`);
564
+ const nodeErrorWasThrownIn = nodes.get(error.fileName);
565
+ if (!nodeErrorWasThrownIn) throw new Error(`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: ${strykerReportBugUrl(`[BUG]: TypeScript checker reports compile error in an unrelated file: ${error.text}`)}`);
566
+ const mutantsRelatedToError = nodeErrorWasThrownIn.getMutantsWithReferenceToChildrenOrSelf(mutants);
567
+ if (mutantsRelatedToError.length === 0) for (const mutant of mutants) mutantsThatCouldNotBeTestedInGroups.add(mutant);
568
+ else if (mutantsRelatedToError.length === 1) {
569
+ const mutantId = mutantsRelatedToError[0].id;
570
+ if (errorsMap[mutantId]) errorsMap[mutantId].push(error);
571
+ else errorsMap[mutantId] = [error];
572
+ } else for (const mutant of mutantsRelatedToError) mutantsThatCouldNotBeTestedInGroups.add(mutant);
573
+ }
574
+ if (mutantsThatCouldNotBeTestedInGroups.size) await this.tsCompiler.check([]);
575
+ for (const mutant of mutantsThatCouldNotBeTestedInGroups) {
576
+ if (errorsMap[mutant.id]) continue;
577
+ await this.checkErrors([mutant], errorsMap, nodes);
578
+ }
579
+ return errorsMap;
580
+ }
581
+ createErrorText(errors) {
582
+ return errors.map((error) => this.formatDiagnostic(error)).join(EOL);
583
+ }
584
+ formatDiagnostic(error) {
585
+ const severity = error.category === DiagnosticCategory.Error ? "error" : error.category === DiagnosticCategory.Warning ? "warning" : error.category === DiagnosticCategory.Suggestion ? "suggestion" : "message";
586
+ let location = "";
587
+ if (error.fileName) {
588
+ const lineAndCharacter = this.tsCompiler.getLineAndCharacterOfPosition(error.fileName, error.pos);
589
+ const line = (lineAndCharacter?.line ?? 0) + 1;
590
+ const character = (lineAndCharacter?.character ?? 0) + 1;
591
+ location = `${error.fileName}(${line},${character}): `;
592
+ }
593
+ return `${location}${severity} TS${error.code}: ${error.text}`;
594
+ }
595
+ };
596
+ //#endregion
597
+ //#region src/index.ts
598
+ const strykerPlugins = [declareFactoryPlugin(PluginKind.Checker, "typescript", create)];
599
+ const createTypescriptChecker = create;
600
+ const strykerValidationSchema = JSON.parse(readFileSync(new URL("../schema/typescript-checker-options.json", import.meta.url), "utf-8"));
601
+ //#endregion
602
+ export { createTypescriptChecker, strykerPlugins, strykerValidationSchema };
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'oxlint'
2
+
3
+ export default defineConfig({
4
+ rules: {
5
+ // TypeScript already reports unused locals; avoids false positives in test files.
6
+ 'no-unused-vars': 'off',
7
+ },
8
+ ignorePatterns: ['**/testResources/**'],
9
+ })
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@systemfsoftware/stryker-js-typescript-checker",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript checker plugin for Stryker — TS7 native",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "@systemfsoftware/source": "./src/index.ts",
9
+ "default": "./dist/index.mjs"
10
+ },
11
+ "./package.json": "./package.json"
12
+ },
13
+ "license": "Apache-2.0",
14
+ "dependencies": {
15
+ "@stryker-mutator/api": "^9.6.1",
16
+ "@stryker-mutator/util": "^9.6.1",
17
+ "semver": "^7.7.0",
18
+ "tslib": "~2.8.0",
19
+ "typescript": "^7"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^24",
23
+ "@types/semver": "^7.5.8",
24
+ "rimraf": "^6.1.3",
25
+ "tsdown": "^0.22.7",
26
+ "vitest": "^4",
27
+ "@systemfsoftware/tsconfig": "^1.0.0",
28
+ "@systemfsoftware/vitest-config": "^0.1.0"
29
+ },
30
+ "scripts": {
31
+ "clean": "rimraf dist",
32
+ "build": "tsdown",
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "vitest run",
35
+ "lint": "oxlint ."
36
+ }
37
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema",
3
+ "title": "TypescriptCheckerPluginOptions",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {
7
+ "typescriptChecker": {
8
+ "description": "Configuration for @systemfsoftware/stryker-js-typescript-checker",
9
+ "title": "TypescriptCheckerOptions",
10
+ "additionalProperties": false,
11
+ "type": "object",
12
+ "default": {},
13
+ "properties": {
14
+ "prioritizePerformanceOverAccuracy": {
15
+ "description": "Configures the performance of the TypescriptChecker. Setting this to false results in a slower, but more accurate result.",
16
+ "type": "boolean",
17
+ "default": true
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }