@systemfsoftware/stryker-js-typescript-checker 1.4.1 → 2.0.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/dist/index.mjs CHANGED
@@ -1,14 +1,454 @@
1
1
  import { readFileSync } from "fs";
2
- import { PluginKind, Scope, commonTokens, declareFactoryPlugin, tokens } from "@systemfsoftware/stryker-js-plugin-api/plugin";
3
- import { Result, Schema } from "effect";
2
+ import { CheckStatus, Checker, CheckerFailed } from "@systemfsoftware/stryker-js-plugin-api/check";
3
+ import { PluginKind, RunConfiguration, declarePlugin } from "@systemfsoftware/stryker-js-plugin-api/plugin";
4
+ import * as Effect from "effect/Effect";
5
+ import * as FileSystem from "effect/FileSystem";
6
+ import * as Layer from "effect/Layer";
7
+ import * as Path from "effect/Path";
8
+ import * as S from "effect/Schema";
9
+ import { StrykerOptionsSchema, normalizeFileName, strykerReportBugUrl } from "@systemfsoftware/stryker-js-plugin-api/core";
10
+ import * as Ref from "effect/Ref";
4
11
  import { EOL } from "os";
5
- import { CheckStatus } from "@systemfsoftware/stryker-js-plugin-api/check";
6
- import { split, strykerReportBugUrl } from "@systemfsoftware/stryker-js-util";
12
+ import { Predicate, Result, Schema } from "effect";
13
+ import * as Match from "effect/Match";
7
14
  import { API, DiagnosticCategory } from "typescript/unstable/sync";
8
- import { createRequire } from "module";
9
- import path from "path";
10
- import semver from "semver";
15
+ import * as Context from "effect/Context";
11
16
  import { SyntaxKind } from "typescript/unstable/ast";
17
+ import { createRequire } from "module";
18
+ //#region src/project/hybrid-file-system.schema.ts
19
+ var HybridFileNotFoundError = class extends S.TaggedError()("HybridFileNotFoundError", { fileName: S.String }) {};
20
+ //#endregion
21
+ //#region src/project/script-file.ts
22
+ function makeScriptFile(content, fileName, modifiedTime = /* @__PURE__ */ new Date()) {
23
+ return {
24
+ content,
25
+ fileName,
26
+ originalContent: content,
27
+ modifiedTime
28
+ };
29
+ }
30
+ function withContent(file, content) {
31
+ return {
32
+ ...file,
33
+ content,
34
+ modifiedTime: /* @__PURE__ */ new Date()
35
+ };
36
+ }
37
+ function mutateScriptFile(file, mutant) {
38
+ const start = getOffset(file, mutant.location.start);
39
+ const end = getOffset(file, mutant.location.end);
40
+ const content = `${file.originalContent.slice(0, start)}${mutant.replacement}${file.originalContent.slice(end)}`;
41
+ return {
42
+ ...file,
43
+ content,
44
+ modifiedTime: /* @__PURE__ */ new Date()
45
+ };
46
+ }
47
+ function resetScriptFile(file) {
48
+ return {
49
+ ...file,
50
+ content: file.originalContent,
51
+ modifiedTime: /* @__PURE__ */ new Date()
52
+ };
53
+ }
54
+ function getOffset(file, pos) {
55
+ const lines = file.originalContent.split("\n");
56
+ const lineCount = Math.min(pos.line, lines.length);
57
+ let offset = 0;
58
+ for (let i = 0; i < lineCount; i++) {
59
+ const line = lines[i];
60
+ if (line === void 0) break;
61
+ offset += line.length + 1;
62
+ }
63
+ offset += pos.column;
64
+ return offset;
65
+ }
66
+ //#endregion
67
+ //#region src/project/hybrid-file-system.ts
68
+ const makeEmptyFilesMap = () => /* @__PURE__ */ new Map();
69
+ const makeEmptyOverridesMap = () => /* @__PURE__ */ new Map();
70
+ const copyAndSet = (map, key, value) => {
71
+ const next = new Map(map);
72
+ next.set(key, value);
73
+ return next;
74
+ };
75
+ const makeHybridFileSystem = (fsService) => Effect.gen(function* () {
76
+ const filesRef = yield* Ref.make(makeEmptyFilesMap());
77
+ const overridesRef = yield* Ref.make(makeEmptyOverridesMap());
78
+ const fileNameIsBuildInfo = (fileName) => fileName.endsWith(".tsbuildinfo");
79
+ const fileSystem = {
80
+ readFile: (fileName) => {
81
+ const normalized = normalizeFileName(fileName);
82
+ if (fileNameIsBuildInfo(normalized)) return null;
83
+ const override = overridesRef.ref.current.get(normalized);
84
+ if (override !== void 0) return override;
85
+ const files = filesRef.ref.current;
86
+ const file = files.get(normalized);
87
+ if (file) return file.content;
88
+ if (file === void 0 && files.has(normalized)) return null;
89
+ },
90
+ fileExists: (fileName) => {
91
+ const normalized = normalizeFileName(fileName);
92
+ if (fileNameIsBuildInfo(normalized)) return false;
93
+ if (overridesRef.ref.current.has(normalized)) return true;
94
+ const files = filesRef.ref.current;
95
+ if (files.has(normalized)) return files.get(normalized) !== void 0;
96
+ },
97
+ directoryExists: () => void 0,
98
+ getAccessibleEntries: () => void 0,
99
+ realpath: () => void 0
100
+ };
101
+ const getFile = (fileName) => Effect.gen(function* () {
102
+ const normalized = normalizeFileName(fileName);
103
+ const files = yield* Ref.get(filesRef);
104
+ if (files.has(normalized)) return files.get(normalized);
105
+ const content = yield* fsService.readFileString(normalized).pipe(Effect.orElseSucceed(() => void 0));
106
+ if (content === void 0) {
107
+ yield* Ref.update(filesRef, (m) => copyAndSet(m, normalized, void 0));
108
+ return;
109
+ }
110
+ const file = makeScriptFile(content, normalized);
111
+ yield* Ref.update(filesRef, (m) => copyAndSet(m, normalized, file));
112
+ return file;
113
+ });
114
+ const writeFile = (fileName, data) => Effect.gen(function* () {
115
+ const normalized = normalizeFileName(fileName);
116
+ const existing = (yield* Ref.get(filesRef)).get(normalized);
117
+ if (existing) {
118
+ const next = withContent(existing, data);
119
+ yield* Ref.update(filesRef, (m) => copyAndSet(m, normalized, next));
120
+ } else {
121
+ const file = makeScriptFile(data, normalized);
122
+ yield* Ref.update(filesRef, (m) => copyAndSet(m, normalized, file));
123
+ }
124
+ });
125
+ const mutateFile = (fileName, mutant) => Effect.gen(function* () {
126
+ const file = yield* getFile(fileName);
127
+ if (!file) return yield* new HybridFileNotFoundError({ fileName });
128
+ const next = mutateScriptFile(file, mutant);
129
+ const normalized = normalizeFileName(fileName);
130
+ yield* Ref.update(filesRef, (m) => copyAndSet(m, normalized, next));
131
+ });
132
+ const resetFile = (fileName) => Effect.gen(function* () {
133
+ const normalized = normalizeFileName(fileName);
134
+ const file = (yield* Ref.get(filesRef)).get(normalized);
135
+ if (file) {
136
+ const next = resetScriptFile(file);
137
+ yield* Ref.update(filesRef, (m) => copyAndSet(m, normalized, next));
138
+ }
139
+ });
140
+ const existsInMemory = (fileName) => Effect.gen(function* () {
141
+ return (yield* Ref.get(filesRef)).get(normalizeFileName(fileName)) !== void 0;
142
+ });
143
+ const setTsConfigOverrides = (overrides) => Ref.set(overridesRef, overrides);
144
+ return {
145
+ fileSystem,
146
+ getFile,
147
+ writeFile,
148
+ mutateFile,
149
+ resetFile,
150
+ existsInMemory,
151
+ setTsConfigOverrides
152
+ };
153
+ });
154
+ //#endregion
155
+ //#region src/grouping/ts-file-node.ts
156
+ function makeTSFileNode(fileName) {
157
+ return {
158
+ fileName,
159
+ parents: [],
160
+ children: []
161
+ };
162
+ }
163
+ function getAllParentReferencesIncludingSelf(node, allParentReferences = /* @__PURE__ */ new Set()) {
164
+ allParentReferences.add(node);
165
+ for (const parent of node.parents) if (!allParentReferences.has(parent)) getAllParentReferencesIncludingSelf(parent, allParentReferences);
166
+ return allParentReferences;
167
+ }
168
+ function getMutantsWithReferenceToChildrenOrSelf(node, mutants, nodesChecked = []) {
169
+ if (nodesChecked.includes(node.fileName)) return [];
170
+ nodesChecked.push(node.fileName);
171
+ const relatedMutants = mutants.filter((m) => normalizeFileName(m.fileName) === node.fileName);
172
+ const childResult = node.children.flatMap((c) => getMutantsWithReferenceToChildrenOrSelf(c, mutants, nodesChecked));
173
+ return [...relatedMutants, ...childResult];
174
+ }
175
+ //#endregion
176
+ //#region src/diagnostics.schema.ts
177
+ /**
178
+ * The compiler returned a diagnostic without a file name. The checker cannot
179
+ * attribute it to a mutant and the run should be failed rather than silently
180
+ * marking every mutant as a compile error.
181
+ */
182
+ var DiagnosticWithoutFileError = class extends S.TaggedError()("DiagnosticWithoutFileError", { text: S.String }) {};
183
+ /**
184
+ * The compiler reported a diagnostic in a file that is not part of the
185
+ * project graph built from the tsconfig. This indicates a graph or FS
186
+ * mismatch and must be surfaced as a checker failure rather than ignored.
187
+ */
188
+ var DiagnosticInUnrelatedFileError = class extends S.TaggedError()("DiagnosticInUnrelatedFileError", {
189
+ text: S.String,
190
+ fileName: S.String
191
+ }) {};
192
+ //#endregion
193
+ //#region src/diagnostics.ts
194
+ /**
195
+ * Pure decision: given a batch of diagnostics and the file graph, decide
196
+ * which mutants are definitely responsible and which need an individual
197
+ * re-check.
198
+ *
199
+ * No I/O, no clock, no throwing — diagnostics in, verdict out. The caller
200
+ * (executor) performs the compiler call and, if `needsRetest` is non-empty,
201
+ * re-invokes the compiler for each of those mutants individually.
202
+ */
203
+ function classifyDiagnostics(diagnostics, mutants, nodes) {
204
+ const definitive = /* @__PURE__ */ new Map();
205
+ const needsRetest = /* @__PURE__ */ new Map();
206
+ if (diagnostics.length > 0 && mutants.length === 1) {
207
+ const only = mutants[0];
208
+ if (only !== void 0) definitive.set(only.id, [...diagnostics]);
209
+ return Result.succeed({
210
+ definitive,
211
+ needsRetest: []
212
+ });
213
+ }
214
+ for (const diagnostic of diagnostics) {
215
+ if (!diagnostic.fileName) return Result.fail(new DiagnosticWithoutFileError({ text: diagnostic.text }));
216
+ const node = nodes.get(diagnostic.fileName);
217
+ if (!node) return Result.fail(new DiagnosticInUnrelatedFileError({
218
+ text: diagnostic.text,
219
+ fileName: diagnostic.fileName
220
+ }));
221
+ const related = getMutantsWithReferenceToChildrenOrSelf(node, [...mutants]);
222
+ if (related.length === 0) for (const m of mutants) needsRetest.set(m.id, m);
223
+ else if (related.length === 1) {
224
+ const only = related[0];
225
+ if (only !== void 0) {
226
+ const existing = definitive.get(only.id);
227
+ if (existing) existing.push(diagnostic);
228
+ else definitive.set(only.id, [diagnostic]);
229
+ }
230
+ } else for (const m of related) needsRetest.set(m.id, m);
231
+ }
232
+ const filteredRetest = [...needsRetest.values()].filter((m) => !definitive.has(m.id));
233
+ return Result.succeed({
234
+ definitive,
235
+ needsRetest: filteredRetest
236
+ });
237
+ }
238
+ /**
239
+ * Pure grouping decision: delegates to the existing `createGroups` but
240
+ * expressed as a kernel entry so the checker can state the boundary.
241
+ * `nodes` is the file graph; mutants outside the project are separated
242
+ * before grouping, matching the executor's `split` that was previously
243
+ * inline in `TypescriptChecker.group`.
244
+ */
245
+ function partitionMutantsForGrouping(mutants, nodes, prioritizePerformanceOverAccuracy) {
246
+ if (!prioritizePerformanceOverAccuracy) return {
247
+ inside: [],
248
+ outside: [...mutants]
249
+ };
250
+ const outside = [];
251
+ const inside = [];
252
+ for (const m of mutants) if (nodes.get(normalizeFileName(m.fileName)) == null) outside.push(m);
253
+ else inside.push(m);
254
+ return {
255
+ inside,
256
+ outside
257
+ };
258
+ }
259
+ //#endregion
260
+ //#region src/grouping/create-groups.ts
261
+ /**
262
+ * To speed up the type-checking we want to check multiple mutants at once.
263
+ * When multiple mutants in different files don't have overlap in affected files (or have small overlap), we can type-check them simultaneously.
264
+ * These mutants who can be tested at the same time are called a group.
265
+ * Therefore, the return type is an array of arrays, in other words: an array of groups.
266
+ *
267
+ * @param mutants All the mutants of the test project.
268
+ * @param nodes A graph representation of the test project.
269
+ */
270
+ function createGroups(mutants, nodes) {
271
+ const groups = [];
272
+ const mutantsToGroup = new Set(mutants);
273
+ while (mutantsToGroup.size) {
274
+ const group = [];
275
+ const groupNodes = /* @__PURE__ */ new Set();
276
+ const nodesToIgnore = /* @__PURE__ */ new Set();
277
+ for (const currentMutant of mutantsToGroup) {
278
+ const currentNode = findNode(currentMutant.fileName, nodes);
279
+ if (!nodesToIgnore.has(currentNode) && !parentsHaveOverlapWith(currentNode, groupNodes)) {
280
+ group.push(currentMutant.id);
281
+ groupNodes.add(currentNode);
282
+ mutantsToGroup.delete(currentMutant);
283
+ addRangeOfNodesToSet(nodesToIgnore, getAllParentReferencesIncludingSelf(currentNode));
284
+ }
285
+ }
286
+ groups.push(group);
287
+ }
288
+ return groups;
289
+ }
290
+ function addRangeOfNodesToSet(nodes, nodesToAdd) {
291
+ for (const parent of nodesToAdd) nodes.add(parent);
292
+ }
293
+ function findNode(fileName, nodes) {
294
+ const node = nodes.get(normalizeFileName(fileName));
295
+ if (node == null) throw new Error(`Node not in graph: ${fileName}`);
296
+ return node;
297
+ }
298
+ function parentsHaveOverlapWith(currentNode, groupNodes) {
299
+ for (const parentNode of getAllParentReferencesIncludingSelf(currentNode)) if (groupNodes.has(parentNode)) return true;
300
+ return false;
301
+ }
302
+ //#endregion
303
+ //#region src/typescript-checker.ts
304
+ const emptyCheckResultMap = () => /* @__PURE__ */ new Map();
305
+ const emptyDiagnosticMap = () => /* @__PURE__ */ new Map();
306
+ function getPrioritize(options) {
307
+ if (!Predicate.hasProperty(options, "typescriptChecker")) return false;
308
+ const tc = options["typescriptChecker"];
309
+ if (typeof tc !== "object" || tc === null) return false;
310
+ if (!Predicate.hasProperty(tc, "prioritizePerformanceOverAccuracy")) return false;
311
+ const val = tc["prioritizePerformanceOverAccuracy"];
312
+ return typeof val === "boolean" ? val : false;
313
+ }
314
+ function makeCheckerService({ options, compiler }) {
315
+ const formatDiagnostic = (error) => Effect.gen(function* () {
316
+ const severity = error.category === DiagnosticCategory.Error ? "error" : error.category === DiagnosticCategory.Warning ? "warning" : error.category === DiagnosticCategory.Suggestion ? "suggestion" : "message";
317
+ let location = "";
318
+ if (error.fileName) {
319
+ const lineAndCharacter = yield* compiler.getLineAndCharacterOfPosition(error.fileName, error.pos).pipe(Effect.orElseSucceed(() => void 0));
320
+ const line = (lineAndCharacter?.line ?? 0) + 1;
321
+ const character = (lineAndCharacter?.character ?? 0) + 1;
322
+ location = `${error.fileName}(${line},${character}): `;
323
+ }
324
+ return `${location}${severity} TS${error.code}: ${error.text}`;
325
+ });
326
+ const createErrorText = (errors) => Effect.gen(function* () {
327
+ return (yield* Effect.forEach(errors, formatDiagnostic)).join(EOL);
328
+ });
329
+ const checkErrors = (mutants, errorsMap, nodes) => Effect.gen(function* () {
330
+ const classified = classifyDiagnostics(yield* compiler.check([...mutants]).pipe(Effect.mapError((cause) => new CheckerFailed({
331
+ checkerName: "typescript",
332
+ mutantIds: mutants.map((m) => m.id),
333
+ cause
334
+ }))), mutants, nodes);
335
+ if (Result.isFailure(classified)) {
336
+ const failure = classified.failure;
337
+ const message = Match.value(failure).pipe(Match.tag("DiagnosticWithoutFileError", (f) => `Typescript error: '${f.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: ${f.text}`)}`), Match.tag("DiagnosticInUnrelatedFileError", (f) => `Typescript error: '${f.text}' was reported in an unrelated file (${f.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: ${f.text}`)}`), Match.exhaustive);
338
+ return yield* new CheckerFailed({
339
+ checkerName: "typescript",
340
+ mutantIds: mutants.map((m) => m.id),
341
+ cause: new Error(message)
342
+ });
343
+ }
344
+ const { definitive, needsRetest } = classified.success;
345
+ for (const [id, errors] of definitive.entries()) {
346
+ const existing = errorsMap.get(id);
347
+ if (existing) existing.push(...errors);
348
+ else errorsMap.set(id, [...errors]);
349
+ }
350
+ if (needsRetest.length > 0) {
351
+ yield* compiler.check([]).pipe(Effect.mapError((cause) => new CheckerFailed({
352
+ checkerName: "typescript",
353
+ mutantIds: needsRetest.map((m) => m.id),
354
+ cause
355
+ })));
356
+ for (const mutant of needsRetest) {
357
+ if (errorsMap.has(mutant.id)) continue;
358
+ yield* checkErrors([mutant], errorsMap, nodes);
359
+ }
360
+ }
361
+ });
362
+ return {
363
+ init: Effect.gen(function* () {
364
+ const errors = yield* compiler.init.pipe(Effect.mapError((cause) => new CheckerFailed({
365
+ checkerName: "typescript",
366
+ mutantIds: [],
367
+ cause
368
+ })));
369
+ if (errors.length > 0) {
370
+ const text = yield* createErrorText(errors);
371
+ return yield* new CheckerFailed({
372
+ checkerName: "typescript",
373
+ mutantIds: [],
374
+ cause: /* @__PURE__ */ new Error(`Typescript error(s) found in dry run compilation: ${text}`)
375
+ });
376
+ }
377
+ }),
378
+ check: (mutants) => Effect.gen(function* () {
379
+ const nodes = yield* compiler.nodes.pipe(Effect.mapError((cause) => new CheckerFailed({
380
+ checkerName: "typescript",
381
+ mutantIds: mutants.map((m) => m.id),
382
+ cause
383
+ })));
384
+ const result = emptyCheckResultMap();
385
+ for (const mutant of mutants) result.set(mutant.id, { status: CheckStatus.Passed });
386
+ const firstMutant = mutants[0];
387
+ if (!firstMutant || !nodes.get(normalizeFileName(firstMutant.fileName))) return result;
388
+ const errorsMap = emptyDiagnosticMap();
389
+ yield* checkErrors(mutants, errorsMap, nodes);
390
+ for (const [id, errors] of errorsMap.entries()) {
391
+ const text = yield* createErrorText(errors);
392
+ result.set(id, {
393
+ status: CheckStatus.CompileError,
394
+ reason: text
395
+ });
396
+ }
397
+ return result;
398
+ }),
399
+ group: (mutants) => Effect.gen(function* () {
400
+ const nodes = yield* compiler.nodes.pipe(Effect.mapError((cause) => new CheckerFailed({
401
+ checkerName: "typescript",
402
+ mutantIds: mutants.map((m) => m.id),
403
+ cause
404
+ })));
405
+ const { inside, outside } = partitionMutantsForGrouping(mutants, nodes, getPrioritize(options));
406
+ if (inside.length === 0) return mutants.map((m) => [m.id]);
407
+ const groups = createGroups([...inside], nodes);
408
+ if (outside.length > 0) return [outside.map((m) => m.id), ...groups];
409
+ return groups;
410
+ })
411
+ };
412
+ }
413
+ //#endregion
414
+ //#region src/compiler-error.schema.ts
415
+ const TypeId = "~stryker/typescript-checker/CompilerFailed";
416
+ /**
417
+ * Every way the TypeScript compiler can fail while serving a check.
418
+ *
419
+ * One tagged error rather than four, because nothing branches on which of these
420
+ * happened — they all reach the caller as a failed check. The `reason` keeps the
421
+ * cases distinguishable in a report without inventing four types no consumer
422
+ * discriminates.
423
+ */
424
+ var CompilerFailed = class extends S.TaggedError(TypeId)("CompilerFailed", {
425
+ reason: S.Literals([
426
+ "not-initialized",
427
+ "no-projects",
428
+ "unknown-file-node",
429
+ "file-not-in-project"
430
+ ]),
431
+ /** The file or tsconfig the failure is about, when it is about one. */
432
+ subject: S.optional(S.String)
433
+ }) {
434
+ [TypeId] = TypeId;
435
+ get message() {
436
+ switch (this.reason) {
437
+ case "not-initialized": return "The TypeScript compiler was used before it was initialized";
438
+ case "no-projects": return `No projects were found for ${this.subject ?? "the tsconfig"}`;
439
+ case "unknown-file-node": return `The file graph has no node for '${this.subject ?? "a file"}', which should not happen`;
440
+ case "file-not-in-project": return `'${this.subject ?? "a file"}' is part of your TypeScript project but could not be found on disk`;
441
+ }
442
+ }
443
+ };
444
+ //#endregion
445
+ //#region src/declaration-source-mapping.ts
446
+ const findSourceMapRegex = /\/\/# sourceMappingURL=(.+)$/m;
447
+ function getSourceMappingURL(content) {
448
+ findSourceMapRegex.lastIndex = 0;
449
+ return findSourceMapRegex.exec(content)?.[1];
450
+ }
451
+ //#endregion
12
452
  //#region ../../../../../node_modules/.pnpm/@jsr+std__jsonc@1.0.2/node_modules/@jsr/std__jsonc/parse.js
13
453
  /**
14
454
  * Converts a JSON with Comments (JSONC) string into an object.
@@ -229,7 +669,7 @@ function buildErrorMessage({ type, sourceText, position }) {
229
669
  return `Cannot parse JSONC: unexpected token "${token}" in JSONC at position ${position}`;
230
670
  }
231
671
  //#endregion
232
- //#region src/tsconfig-helpers.schema.ts
672
+ //#region src/tsconfig.schema.ts
233
673
  /**
234
674
  * Error returned when a tsconfig file fails to parse or does not match the
235
675
  * shape this package consumes.
@@ -238,13 +678,21 @@ var TsConfigParseError = class extends Schema.TaggedError()("TsConfigParseError"
238
678
  file: Schema.String,
239
679
  reason: Schema.String
240
680
  }) {};
681
+ /**
682
+ * The configured tsconfig file could not be read from disk.
683
+ */
684
+ var TsConfigNotFoundError = class extends Schema.TaggedError()("TsConfigNotFoundError", { file: Schema.String }) {
685
+ get message() {
686
+ return `The tsconfig file does not exist at: "${this.file}". Please configure the tsconfig file in your stryker.conf file using "tsconfigFile"`;
687
+ }
688
+ };
241
689
  const JsonRecord = Schema.Record(Schema.String, Schema.Unknown);
242
690
  const TsConfigSchema = Schema.StructWithRest(Schema.Struct({
243
691
  references: Schema.optional(Schema.Array(Schema.StructWithRest(Schema.Struct({ path: Schema.String }), [JsonRecord]))),
244
692
  compilerOptions: Schema.optional(JsonRecord)
245
693
  }), [JsonRecord]);
246
694
  //#endregion
247
- //#region src/tsconfig-helpers.ts
695
+ //#region src/tsconfig.ts
248
696
  const COMPILER_OPTIONS_OVERRIDES = Object.freeze({
249
697
  allowUnreachableCode: true,
250
698
  noUnusedLocals: false,
@@ -262,17 +710,6 @@ const LOW_EMIT_OPTIONS_FOR_PROJECT_REFERENCES = Object.freeze({
262
710
  declarationMap: true,
263
711
  declaration: true
264
712
  });
265
- let cachedTSVersion;
266
- function getTSVersion() {
267
- if (cachedTSVersion === void 0) {
268
- const require = createRequire(import.meta.url);
269
- cachedTSVersion = Schema.decodeUnknownSync(Schema.Struct({ version: Schema.String }))(JSON.parse(readFileSync(require.resolve("typescript/package.json"), "utf-8"))).version;
270
- }
271
- return cachedTSVersion;
272
- }
273
- function guardTSVersion(version = getTSVersion()) {
274
- 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}`);
275
- }
276
713
  /**
277
714
  * Parses the raw text of a tsconfig file into a typed config, rejecting shapes this package cannot consume.
278
715
  * @param fileName The tsconfig file name, used for error reporting
@@ -296,13 +733,13 @@ function parseTsConfig(fileName, jsonText) {
296
733
  * Determines whether or not to use `--build` mode based on "references" being there in the config file
297
734
  * @param tsconfigFileName The tsconfig file to parse
298
735
  */
299
- function determineBuildModeEnabled(tsconfigFileName) {
300
- const parsed = parseTsConfig(tsconfigFileName, readFileSync(tsconfigFileName, "utf-8"));
736
+ const determineBuildModeEnabled = (tsconfigFileName, fsService) => Effect.gen(function* () {
737
+ const parsed = parseTsConfig(tsconfigFileName, yield* fsService.readFileString(tsconfigFileName));
301
738
  return Result.match(parsed, {
302
739
  onFailure: () => false,
303
740
  onSuccess: (config) => config.references !== void 0
304
741
  });
305
- }
742
+ });
306
743
  /**
307
744
  * Overrides some options to speed up compilation and disable some code quality checks we don't want during mutation testing
308
745
  * @param config The parsed config file
@@ -332,354 +769,161 @@ function overrideOptions(config, useBuildMode) {
332
769
  * @param config The parsed config file
333
770
  * @param fromDirName The directory where to resolve from
334
771
  */
335
- function retrieveReferencedProjects(config, fromDirName) {
772
+ function retrieveReferencedProjects(config, fromDirName, pathService) {
336
773
  return (config.references ?? []).map((reference) => {
337
- let resolved = path.resolve(fromDirName, reference.path);
338
- if (!path.basename(resolved).endsWith(".json")) resolved = path.join(resolved, "tsconfig.json");
339
- return toPosixFileName(resolved);
774
+ let resolved = pathService.resolve(fromDirName, reference.path);
775
+ if (!pathService.basename(resolved).endsWith(".json")) resolved = pathService.join(resolved, "tsconfig.json");
776
+ return normalizeFileName(resolved);
340
777
  });
341
778
  }
779
+ //#endregion
780
+ //#region src/typescript-version.schema.ts
342
781
  /**
343
- * Replaces backslashes with forward slashes (used by typescript)
344
- * @param fileName The file name that may contain backslashes `\`
345
- * @returns posix and ts complaint file name (with `/`)
346
- */
347
- function toPosixFileName(fileName) {
348
- return fileName.replace(/\\/g, "/");
349
- }
350
- /**
351
- * Find source file in declaration file
352
- * @param content The content of the declaration file
353
- * @returns URL of the source file or undefined if not found
782
+ * The installed TypeScript version is below the supported floor.
354
783
  */
355
- const findSourceMapRegex = /\/\/# sourceMappingURL=(.+)$/m;
356
- function getSourceMappingURL(content) {
357
- findSourceMapRegex.lastIndex = 0;
358
- return findSourceMapRegex.exec(content)?.[1];
359
- }
360
- //#endregion
361
- //#region src/grouping/ts-file-node.ts
362
- var TSFileNode = class {
363
- fileName;
364
- parents;
365
- children;
366
- constructor(fileName, parents, children) {
367
- this.fileName = fileName;
368
- this.parents = parents;
369
- this.children = children;
370
- }
371
- getAllParentReferencesIncludingSelf(allParentReferences = /* @__PURE__ */ new Set()) {
372
- allParentReferences.add(this);
373
- this.parents.forEach((parent) => {
374
- if (!allParentReferences.has(parent)) parent.getAllParentReferencesIncludingSelf(allParentReferences);
375
- });
376
- return allParentReferences;
377
- }
378
- getAllChildReferencesIncludingSelf(allChildReferences = /* @__PURE__ */ new Set()) {
379
- allChildReferences.add(this);
380
- this.children.forEach((child) => {
381
- if (!allChildReferences.has(child)) child.getAllChildReferencesIncludingSelf(allChildReferences);
382
- });
383
- return allChildReferences;
384
- }
385
- getMutantsWithReferenceToChildrenOrSelf(mutants, nodesChecked = []) {
386
- if (nodesChecked.includes(this.fileName)) return [];
387
- nodesChecked.push(this.fileName);
388
- const relatedMutants = mutants.filter((m) => toPosixFileName(m.fileName) === this.fileName);
389
- const childResult = this.children.flatMap((c) => c.getMutantsWithReferenceToChildrenOrSelf(mutants, nodesChecked));
390
- return [...relatedMutants, ...childResult];
784
+ var UnsupportedTypeScriptVersionError = class extends Schema.TaggedError()("UnsupportedTypeScriptVersionError", { version: Schema.String }) {
785
+ get message() {
786
+ return `@systemfsoftware/stryker-js-typescript-checker only supports typescript@7.0.0 or higher. Found typescript@${this.version}`;
391
787
  }
392
788
  };
393
789
  //#endregion
394
- //#region src/grouping/create-groups.ts
790
+ //#region src/typescript-version.ts
791
+ let cachedTSVersion;
792
+ const getTSVersion = (fsService) => Effect.gen(function* () {
793
+ if (cachedTSVersion !== void 0) return cachedTSVersion;
794
+ const pkgPath = createRequire(import.meta.url).resolve("typescript/package.json");
795
+ const text = yield* fsService.readFileString(pkgPath);
796
+ const raw = JSON.parse(text);
797
+ let version = "";
798
+ if (Predicate.hasProperty(raw, "version") && typeof raw.version === "string") version = raw.version;
799
+ cachedTSVersion = version;
800
+ return version;
801
+ });
395
802
  /**
396
- * To speed up the type-checking we want to check multiple mutants at once.
397
- * When multiple mutants in different files don't have overlap in affected files (or have small overlap), we can type-check them simultaneously.
398
- * These mutants who can be tested at the same time are called a group.
399
- * Therefore, the return type is an array of arrays, in other words: an array of groups.
803
+ * Whether a TypeScript version satisfies the supported floor `>=7.0.0`.
400
804
  *
401
- * @param mutants All the mutants of the test project.
402
- * @param nodes A graph representation of the test project.
805
+ * Prerelease handling: the suffix after `-` (e.g. `7.0.0-beta`, `7.0.0-rc.1`) and any
806
+ * `+` build metadata is stripped before numeric comparison, so a `7.0.0` prerelease
807
+ * compares as `7.0.0` and satisfies the floor. This preserves the prior
808
+ * `satisfies(version, '>=7.0.0', { includePrerelease: true })` behaviour
809
+ * where a TypeScript 7 prerelease must still pass the guard.
403
810
  */
404
- function createGroups(mutants, nodes) {
405
- const groups = [];
406
- const mutantsToGroup = new Set(mutants);
407
- while (mutantsToGroup.size) {
408
- const group = [];
409
- const groupNodes = /* @__PURE__ */ new Set();
410
- const nodesToIgnore = /* @__PURE__ */ new Set();
411
- for (const currentMutant of mutantsToGroup) {
412
- const currentNode = findNode(currentMutant.fileName, nodes);
413
- if (!nodesToIgnore.has(currentNode) && !parentsHaveOverlapWith(currentNode, groupNodes)) {
414
- group.push(currentMutant.id);
415
- groupNodes.add(currentNode);
416
- mutantsToGroup.delete(currentMutant);
417
- addRangeOfNodesToSet(nodesToIgnore, currentNode.getAllParentReferencesIncludingSelf());
418
- }
419
- }
420
- groups.push(group);
421
- }
422
- return groups;
423
- }
424
- function addRangeOfNodesToSet(nodes, nodesToAdd) {
425
- for (const parent of nodesToAdd) nodes.add(parent);
811
+ function isSupportedTypescriptVersion(version) {
812
+ const dashBase = version.split("-")[0] ?? version;
813
+ const parts = (dashBase.split("+")[0] ?? dashBase).split(".").map((p) => Number.parseInt(p, 10));
814
+ const major = parts[0] ?? 0;
815
+ const minor = parts[1] ?? 0;
816
+ const patch = parts[2] ?? 0;
817
+ if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) return false;
818
+ if (major !== 7) return major > 7;
819
+ if (minor !== 0) return minor > 0;
820
+ return patch >= 0;
426
821
  }
427
- function findNode(fileName, nodes) {
428
- const node = nodes.get(toPosixFileName(fileName));
429
- if (node == null) throw new Error(`Node not in graph: ${fileName}`);
430
- return node;
431
- }
432
- function parentsHaveOverlapWith(currentNode, groupNodes) {
433
- for (const parentNode of currentNode.getAllParentReferencesIncludingSelf()) if (groupNodes.has(parentNode)) return true;
434
- return false;
435
- }
436
- const tsCompiler = "tsCompiler";
437
- //#endregion
438
- //#region src/project/script-file.ts
439
- var ScriptFile = class {
440
- content;
441
- fileName;
442
- modifiedTime;
443
- originalContent;
444
- constructor(content, fileName, modifiedTime = /* @__PURE__ */ new Date()) {
445
- this.content = content;
446
- this.fileName = fileName;
447
- this.modifiedTime = modifiedTime;
448
- this.originalContent = content;
449
- }
450
- write(content) {
451
- this.content = content;
452
- this.touch();
453
- }
454
- mutate(mutant) {
455
- const start = this.getOffset(mutant.location.start);
456
- const end = this.getOffset(mutant.location.end);
457
- this.content = `${this.originalContent.slice(0, start)}${mutant.replacement}${this.originalContent.slice(end)}`;
458
- this.touch();
459
- }
460
- getOffset(pos) {
461
- const lines = this.originalContent.split("\n");
462
- const lineCount = Math.min(pos.line, lines.length);
463
- let offset = 0;
464
- for (let i = 0; i < lineCount; i++) {
465
- const line = lines[i];
466
- if (line === void 0) break;
467
- offset += line.length + 1;
468
- }
469
- offset += pos.column;
470
- return offset;
471
- }
472
- resetMutant() {
473
- this.content = this.originalContent;
474
- this.touch();
475
- }
476
- touch() {
477
- this.modifiedTime = /* @__PURE__ */ new Date();
478
- }
479
- };
822
+ const guardTSVersion = (fsService) => Effect.gen(function* () {
823
+ const version = yield* getTSVersion(fsService);
824
+ if (!isSupportedTypescriptVersion(version)) return yield* new UnsupportedTypeScriptVersionError({ version });
825
+ });
480
826
  //#endregion
481
- //#region src/project/hybrid-file-system.ts
482
- /**
483
- * A very simple hybrid file system.
484
- * * Readonly from disk
485
- * * Writes in-memory
486
- * * Hard caching
487
- * * Ability to mutate one file
488
- */
489
- var HybridFileSystem = class {
490
- files = /* @__PURE__ */ new Map();
491
- /**
492
- * Map of absolute tsconfig file paths to their adjusted JSON content.
493
- * This allows the TS7 API to read overridden compiler options.
494
- */
495
- tsConfigOverrides = /* @__PURE__ */ new Map();
496
- readFile = (fileName) => {
497
- const normalized = toPosixFileName(fileName);
498
- if (this.fileNameIsBuildInfo(normalized)) return null;
499
- const override = this.tsConfigOverrides.get(normalized);
500
- if (override !== void 0) return override;
501
- const file = this.files.get(normalized);
502
- if (file) return file.content;
503
- if (file === void 0 && this.files.has(normalized)) return null;
827
+ //#region src/typescript-compiler.ts
828
+ var TypeScriptCompiler = class extends Context.Service()("@systemfsoftware/stryker-js-typescript-checker/TypeScriptCompiler") {};
829
+ const emptySourceFiles = () => /* @__PURE__ */ new Map();
830
+ const emptyNodes = () => /* @__PURE__ */ new Map();
831
+ const emptyStringMap = () => /* @__PURE__ */ new Map();
832
+ const emptyStringSet = () => /* @__PURE__ */ new Set();
833
+ const setFromArray = (arr) => new Set(arr);
834
+ const unique = (arr) => [...new Set(arr)];
835
+ const cloneMap = (m) => new Map(m);
836
+ const makeDummy = Effect.gen(function* () {
837
+ const stateRef = yield* Ref.make({
838
+ api: void 0,
839
+ snapshot: void 0,
840
+ sourceFiles: emptySourceFiles(),
841
+ nodes: emptyNodes(),
842
+ lastMutants: [],
843
+ lastMutatedFileNames: [],
844
+ allTSConfigFiles: setFromArray(["tsconfig.json"]),
845
+ tsconfigFile: "tsconfig.json"
846
+ });
847
+ yield* Effect.addFinalizer(() => Effect.gen(function* () {
848
+ const s = yield* Ref.get(stateRef);
849
+ yield* Effect.sync(() => s.snapshot?.dispose());
850
+ yield* Effect.sync(() => s.api?.close());
851
+ }));
852
+ return {
853
+ init: Effect.succeed([]),
854
+ check: () => Effect.succeed([]),
855
+ nodes: Ref.get(stateRef).pipe(Effect.map((s) => s.nodes)),
856
+ close: Effect.gen(function* () {
857
+ const s = yield* Ref.get(stateRef);
858
+ yield* Effect.sync(() => s.snapshot?.dispose());
859
+ yield* Effect.sync(() => s.api?.close());
860
+ yield* Ref.update(stateRef, (prev) => ({
861
+ ...prev,
862
+ snapshot: void 0,
863
+ api: void 0
864
+ }));
865
+ }),
866
+ getLineAndCharacterOfPosition: () => Effect.succeed(void 0)
504
867
  };
505
- fileExists = (fileName) => {
506
- const normalized = toPosixFileName(fileName);
507
- if (this.fileNameIsBuildInfo(normalized)) return false;
508
- if (this.tsConfigOverrides.has(normalized)) return true;
509
- if (this.files.has(normalized)) return this.files.get(normalized) !== void 0;
868
+ });
869
+ Layer.effect(TypeScriptCompiler)(makeDummy);
870
+ function makeTypescriptCompiler(options, fs, fsService, pathService) {
871
+ if (!S.is(StrykerOptionsSchema)(options)) throw new Error("Invalid StrykerOptions");
872
+ const rawTsconfigFile = normalizeFileName(options.tsconfigFile);
873
+ const initialState = {
874
+ api: void 0,
875
+ snapshot: void 0,
876
+ sourceFiles: emptySourceFiles(),
877
+ nodes: emptyNodes(),
878
+ lastMutants: [],
879
+ lastMutatedFileNames: [],
880
+ allTSConfigFiles: setFromArray([rawTsconfigFile]),
881
+ tsconfigFile: rawTsconfigFile
510
882
  };
511
- directoryExists = () => {};
512
- getAccessibleEntries = () => {};
513
- realpath = () => {};
514
- writeFile(fileName, data) {
515
- const normalized = toPosixFileName(fileName);
516
- const existingFile = this.files.get(normalized);
517
- if (existingFile) existingFile.write(data);
518
- else this.files.set(normalized, new ScriptFile(data, normalized));
519
- }
520
- getFile(fileName) {
521
- const normalized = toPosixFileName(fileName);
522
- if (!this.files.has(normalized)) try {
523
- const content = readFileSync(normalized, "utf-8");
524
- this.files.set(normalized, new ScriptFile(content, normalized));
525
- } catch {
526
- this.files.set(normalized, void 0);
527
- }
528
- return this.files.get(normalized);
529
- }
530
- mutateFile(fileName, mutant) {
531
- const file = this.getFile(fileName);
532
- if (!file) throw new Error(`Tried to mutate file "${fileName}" but it could not be found.`);
533
- file.mutate(mutant);
534
- }
535
- resetFile(fileName) {
536
- this.getFile(fileName)?.resetMutant();
537
- }
538
- existsInMemory(fileName) {
539
- return this.files.get(toPosixFileName(fileName)) !== void 0;
540
- }
541
- fileNameIsBuildInfo(fileName) {
542
- return fileName.endsWith(".tsbuildinfo");
543
- }
544
- };
545
- //#endregion
546
- //#region src/typescript-compiler.ts
547
- var TypescriptCompiler = class {
548
- log;
549
- options;
550
- fs;
551
- static inject = tokens(commonTokens.logger, commonTokens.options, "fs");
552
- allTSConfigFiles;
553
- tsconfigFile;
554
- api;
555
- snapshot;
556
- sourceFiles = /* @__PURE__ */ new Map();
557
- _nodes = /* @__PURE__ */ new Map();
558
- lastMutants = [];
559
- lastMutatedFileNames = [];
560
- constructor(log, options, fs) {
561
- this.log = log;
562
- this.options = options;
563
- this.fs = fs;
564
- this.tsconfigFile = toPosixFileName(path.resolve(toPosixFileName(this.options.tsconfigFile)));
565
- this.allTSConfigFiles = /* @__PURE__ */ new Set([this.tsconfigFile]);
566
- }
567
- async init() {
568
- guardTSVersion();
569
- this.guardTSConfigFileExists();
570
- const buildModeEnabled = determineBuildModeEnabled(this.tsconfigFile);
571
- this.collectAllTSConfigFiles(buildModeEnabled);
572
- this.api = new API({ fs: this.fs });
573
- this.snapshot = this.api.updateSnapshot({ openProjects: [...this.allTSConfigFiles] });
574
- const programs = this.getPrograms();
575
- this.buildDependencyGraph(programs);
576
- return this.check([]);
577
- }
578
- async check(mutants) {
579
- for (const mutant of this.lastMutants) this.fs.resetFile(mutant.fileName);
580
- for (const mutant of mutants) {
581
- const file = this.fs.getFile(mutant.fileName);
582
- if (!file) throw new Error(`Tried to check file "${mutant.fileName}" (which is part of your typescript project), but it could not be found.`);
583
- file.mutate(mutant);
584
- }
585
- const mutatedFileNames = [...new Set(mutants.map((m) => toPosixFileName(m.fileName)))];
586
- const changedFiles = [.../* @__PURE__ */ new Set([...this.lastMutatedFileNames, ...mutatedFileNames])];
587
- if (this.api && this.snapshot) {
588
- const oldSnapshot = this.snapshot;
589
- this.snapshot = this.api.updateSnapshot({
590
- openProjects: [...this.allTSConfigFiles],
591
- fileChanges: { changed: changedFiles }
592
- });
593
- oldSnapshot.dispose();
594
- }
595
- this.lastMutants = mutants;
596
- this.lastMutatedFileNames = mutatedFileNames;
597
- return this.getPrograms().flatMap((program) => [
598
- ...program.getConfigFileParsingDiagnostics(),
599
- ...program.getSemanticDiagnostics(),
600
- ...program.getProgramDiagnostics()
601
- ]).filter((diagnostic) => diagnostic.category === DiagnosticCategory.Error);
602
- }
603
- get nodes() {
604
- if (!this._nodes.size) {
605
- for (const [fileName] of this.sourceFiles) {
606
- const node = new TSFileNode(fileName, [], []);
607
- this._nodes.set(fileName, node);
608
- }
609
- for (const [fileName, file] of this.sourceFiles) {
610
- const node = this._nodes.get(fileName);
611
- if (node == null) throw new Error(`Node for file '${fileName}' could not be found. This should not happen.`);
612
- node.children = [...file.imports].map((importName) => this._nodes.get(importName)).filter((n) => n != null);
613
- }
614
- for (const [, node] of this._nodes) {
615
- node.parents = [];
616
- for (const [, n] of this._nodes) if (n.children.includes(node)) node.parents.push(n);
617
- }
618
- }
619
- return this._nodes;
620
- }
621
- close() {
622
- this.snapshot?.dispose();
623
- this.api?.close();
624
- }
625
- getLineAndCharacterOfPosition(fileName, position) {
626
- for (const program of this.getPrograms()) {
627
- const sourceFile = program.getSourceFile(fileName);
628
- if (sourceFile) return sourceFile.getLineAndCharacterOfPosition(position);
629
- }
630
- }
631
- getPrograms() {
632
- if (!this.snapshot) throw new Error("TypescriptCompiler not initialized");
633
- const projects = this.snapshot.getProjects();
634
- if (projects.length === 0) throw new Error(`No projects found for ${this.tsconfigFile}`);
883
+ const stateRef = Ref.makeUnsafe(initialState);
884
+ const getProgramsEffect = () => Effect.gen(function* () {
885
+ const s = yield* Ref.get(stateRef);
886
+ if (!s.snapshot) return yield* new CompilerFailed({ reason: "not-initialized" });
887
+ const projects = s.snapshot.getProjects();
888
+ if (projects.length === 0) return yield* new CompilerFailed({
889
+ reason: "no-projects",
890
+ subject: s.tsconfigFile
891
+ });
635
892
  return projects.map((project) => project.program);
636
- }
637
- collectAllTSConfigFiles(buildModeEnabled) {
638
- const tsConfigOverrides = /* @__PURE__ */ new Map();
639
- const toProcess = [this.tsconfigFile];
640
- const processed = /* @__PURE__ */ new Set();
893
+ });
894
+ const guardTSConfigFileExistsEffect = Effect.gen(function* () {
895
+ const s = yield* Ref.get(stateRef);
896
+ yield* fsService.readFileString(s.tsconfigFile).pipe(Effect.mapError(() => new TsConfigNotFoundError({ file: s.tsconfigFile })));
897
+ });
898
+ const collectAllTSConfigFiles = (buildModeEnabled) => Effect.gen(function* () {
899
+ const s = yield* Ref.get(stateRef);
900
+ const tsConfigOverrides = emptyStringMap();
901
+ const toProcess = [s.tsconfigFile];
902
+ const processed = emptyStringSet();
641
903
  while (toProcess.length > 0) {
642
904
  const current = toProcess.pop();
643
905
  if (!current || processed.has(current)) continue;
644
906
  processed.add(current);
645
- const content = readFileSync(current, "utf-8");
907
+ const content = yield* fsService.readFileString(current);
646
908
  const parsed = parseTsConfig(current, content);
647
909
  if (Result.isFailure(parsed)) {
648
- this.log.warn(`Could not parse tsconfig file "%s": %s. Compiler-option overrides and project-reference walking were skipped for this file, so mutants may be misreported as compile errors.`, current, parsed.failure.reason);
649
910
  tsConfigOverrides.set(current, content);
650
911
  continue;
651
912
  }
652
913
  tsConfigOverrides.set(current, overrideOptions(parsed.success, buildModeEnabled));
653
- for (const referenced of retrieveReferencedProjects(parsed.success, path.dirname(current))) {
654
- this.allTSConfigFiles.add(referenced);
914
+ for (const referenced of retrieveReferencedProjects(parsed.success, pathService.dirname(current), pathService)) {
915
+ const normalized = normalizeFileName(referenced);
916
+ s.allTSConfigFiles.add(normalized);
655
917
  toProcess.push(referenced);
656
918
  }
657
919
  }
658
- this.fs.tsConfigOverrides = tsConfigOverrides;
659
- }
660
- buildDependencyGraph(programs) {
661
- for (const program of programs) for (const fileName of program.getSourceFileNames()) {
662
- if (fileName.endsWith(".d.ts") || fileName.includes("node_modules")) continue;
663
- const normalized = toPosixFileName(fileName);
664
- this.sourceFiles.set(normalized, {
665
- fileName: normalized,
666
- imports: /* @__PURE__ */ new Set()
667
- });
668
- }
669
- for (const [fileName] of this.sourceFiles) {
670
- const sourceFile = programs.map((p) => p.getSourceFile(fileName)).find((sf) => sf != null);
671
- if (!sourceFile) continue;
672
- const imports = this.extractImports(sourceFile);
673
- for (const specifier of imports) {
674
- const resolved = this.resolveModuleSpecifier(fileName, specifier);
675
- if (resolved) {
676
- const sourceFileName = this.resolveTSInputFile(resolved);
677
- if (this.sourceFiles.has(sourceFileName)) this.sourceFiles.get(fileName)?.imports.add(sourceFileName);
678
- }
679
- }
680
- }
681
- }
682
- extractImports(sourceFile) {
920
+ yield* fs.setTsConfigOverrides(tsConfigOverrides);
921
+ yield* Ref.update(stateRef, (prev) => ({
922
+ ...prev,
923
+ allTSConfigFiles: setFromArray([...s.allTSConfigFiles])
924
+ }));
925
+ });
926
+ const extractImports = (sourceFile) => {
683
927
  const result = [];
684
928
  for (const statement of sourceFile.statements) if (statement.kind === SyntaxKind.ImportDeclaration) {
685
929
  let spec;
@@ -695,17 +939,9 @@ var TypescriptCompiler = class {
695
939
  for (const ref of sourceFile.referencedFiles) result.push(ref.fileName);
696
940
  for (const ref of sourceFile.typeReferenceDirectives) result.push(ref.fileName);
697
941
  return result;
698
- }
699
- resolveModuleSpecifier(sourceFileName, specifier) {
700
- const cleaned = specifier.replace(/^['"]|['"]$/g, "");
701
- if (!cleaned.startsWith("./") && !cleaned.startsWith("../")) return;
702
- const baseDir = path.dirname(sourceFileName);
703
- const resolved = toPosixFileName(path.resolve(baseDir, cleaned));
704
- const candidates = this.getResolutionCandidates(resolved);
705
- for (const candidate of candidates) if (this.sourceFiles.has(candidate)) return candidate;
706
- }
707
- getResolutionCandidates(resolved) {
708
- const extension = path.extname(resolved);
942
+ };
943
+ const getResolutionCandidates = (resolved, pathService) => {
944
+ const extension = pathService.extname(resolved);
709
945
  if (extension) {
710
946
  const withoutExt = resolved.slice(0, -extension.length);
711
947
  return [
@@ -736,124 +972,198 @@ var TypescriptCompiler = class {
736
972
  `${resolved}/index.mjs`,
737
973
  `${resolved}/index.cjs`
738
974
  ];
739
- }
740
- resolveTSInputFile(dependencyFileName) {
975
+ };
976
+ const resolveModuleSpecifier = (sourceFileName, specifier, sourceFiles, pathService) => {
977
+ const cleaned = specifier.replace(/^['"]|['"]$/g, "");
978
+ if (!cleaned.startsWith("./") && !cleaned.startsWith("../")) return;
979
+ const baseDir = pathService.dirname(sourceFileName);
980
+ const resolved = normalizeFileName(pathService.resolve(baseDir, cleaned));
981
+ const candidates = getResolutionCandidates(resolved, pathService);
982
+ for (const candidate of candidates) if (sourceFiles.has(candidate)) return candidate;
983
+ };
984
+ const resolveTSInputFile = (dependencyFileName, pathService) => {
741
985
  if (!dependencyFileName.endsWith(".d.ts")) return dependencyFileName;
742
- const file = this.fs.getFile(dependencyFileName);
743
- if (!file) return dependencyFileName;
744
- const sourceMappingURL = getSourceMappingURL(file.content);
986
+ const content = fs.fileSystem.readFile?.(dependencyFileName);
987
+ if (typeof content !== "string") return dependencyFileName;
988
+ const sourceMappingURL = getSourceMappingURL(content);
745
989
  if (!sourceMappingURL) return dependencyFileName;
746
- const sourceMapFileName = toPosixFileName(path.resolve(path.dirname(dependencyFileName), sourceMappingURL));
747
- const sourceMap = this.fs.getFile(sourceMapFileName);
748
- if (!sourceMap) {
749
- this.log.warn(`Could not find sourcemap ${sourceMapFileName}`);
750
- return dependencyFileName;
751
- }
752
- const sources = Schema.decodeUnknownSync(Schema.Struct({ sources: Schema.optional(Schema.Array(Schema.String)) }))(JSON.parse(sourceMap.content)).sources;
990
+ const sourceMapFileName = normalizeFileName(pathService.resolve(pathService.dirname(dependencyFileName), sourceMappingURL));
991
+ const sourceMapContent = fs.fileSystem.readFile?.(sourceMapFileName);
992
+ if (typeof sourceMapContent !== "string") return dependencyFileName;
993
+ const rawMap = JSON.parse(sourceMapContent);
994
+ let sources;
995
+ if (Predicate.hasProperty(rawMap, "sources") && Array.isArray(rawMap.sources)) sources = rawMap.sources.filter((s) => typeof s === "string");
753
996
  if (sources?.length === 1) {
754
997
  const sourcePath = sources[0];
755
998
  if (sourcePath === void 0) return dependencyFileName;
756
- return toPosixFileName(path.resolve(path.dirname(sourceMapFileName), sourcePath));
999
+ return normalizeFileName(pathService.resolve(pathService.dirname(sourceMapFileName), sourcePath));
757
1000
  }
758
1001
  return dependencyFileName;
759
- }
760
- guardTSConfigFileExists() {
761
- try {
762
- readFileSync(this.tsconfigFile, "utf-8");
763
- } catch {
764
- throw new Error(`The tsconfig file does not exist at: "${this.tsconfigFile}". Please configure the tsconfig file in your stryker.conf file using "tsconfigFile"`);
765
- }
766
- }
767
- };
768
- //#endregion
769
- //#region src/typescript-checker.ts
770
- const typescriptCheckerLoggerFactory = Object.assign((loggerFactory, target) => {
771
- const targetName = target?.name ?? TypescriptChecker.name;
772
- return loggerFactory(targetName === TypescriptChecker.name ? TypescriptChecker.name : `${TypescriptChecker.name}.${targetName}`);
773
- }, { inject: tokens(commonTokens.getLogger, commonTokens.target) });
774
- const create = Object.assign((injector) => injector.provideFactory(commonTokens.logger, typescriptCheckerLoggerFactory, Scope.Transient).provideClass("fs", HybridFileSystem).provideClass(tsCompiler, TypescriptCompiler).injectClass(TypescriptChecker), { inject: tokens(commonTokens.injector) });
775
- var TypescriptChecker = class {
776
- logger;
777
- tsCompiler;
778
- static inject = tokens(commonTokens.logger, commonTokens.options, tsCompiler);
779
- options;
780
- constructor(logger, options, tsCompiler) {
781
- this.logger = logger;
782
- this.tsCompiler = tsCompiler;
783
- this.options = options;
784
- }
785
- async init() {
786
- const errors = await this.tsCompiler.init();
787
- if (errors.length) throw new Error(`Typescript error(s) found in dry run compilation: ${this.createErrorText(errors)}`);
788
- }
789
- async check(mutants) {
790
- const result = Object.fromEntries(mutants.map((mutant) => [mutant.id, { status: CheckStatus.Passed }]));
791
- const firstMutant = mutants[0];
792
- if (!firstMutant || !this.tsCompiler.nodes.get(toPosixFileName(firstMutant.fileName))) return result;
793
- const mutantErrorRelationMap = await this.checkErrors(mutants, {}, this.tsCompiler.nodes);
794
- for (const [id, errors] of Object.entries(mutantErrorRelationMap)) result[id] = {
795
- status: CheckStatus.CompileError,
796
- reason: this.createErrorText(errors)
797
- };
798
- return result;
799
- }
800
- group(mutants) {
801
- if (!this.options.typescriptChecker?.prioritizePerformanceOverAccuracy) return Promise.resolve(mutants.map((m) => [m.id]));
802
- const { nodes } = this.tsCompiler;
803
- const [mutantsOutsideProject, mutantsInProject] = split(mutants, (m) => nodes.get(toPosixFileName(m.fileName)) == null);
804
- const groups = createGroups(mutantsInProject, nodes);
805
- if (mutantsOutsideProject.length) return Promise.resolve([mutantsOutsideProject.map((m) => m.id), ...groups]);
806
- else return Promise.resolve(groups);
807
- }
808
- async checkErrors(mutants, errorsMap, nodes) {
809
- const errors = await this.tsCompiler.check(mutants);
810
- const mutantsThatCouldNotBeTestedInGroups = /* @__PURE__ */ new Set();
811
- if (errors.length && mutants.length === 1) {
812
- const onlyMutant = mutants[0];
813
- if (onlyMutant !== void 0) errorsMap[onlyMutant.id] = errors;
814
- return errorsMap;
1002
+ };
1003
+ const buildDependencyGraph = (programs) => Effect.gen(function* () {
1004
+ const s = yield* Ref.get(stateRef);
1005
+ for (const program of programs) for (const fileName of program.getSourceFileNames()) {
1006
+ if (fileName.endsWith(".d.ts") || fileName.includes("node_modules")) continue;
1007
+ const normalized = normalizeFileName(fileName);
1008
+ s.sourceFiles.set(normalized, {
1009
+ fileName: normalized,
1010
+ imports: emptyStringSet()
1011
+ });
815
1012
  }
816
- for (const error of errors) {
817
- 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}`)}`);
818
- const nodeErrorWasThrownIn = nodes.get(error.fileName);
819
- 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}`)}`);
820
- const mutantsRelatedToError = nodeErrorWasThrownIn.getMutantsWithReferenceToChildrenOrSelf(mutants);
821
- if (mutantsRelatedToError.length === 0) for (const mutant of mutants) mutantsThatCouldNotBeTestedInGroups.add(mutant);
822
- else if (mutantsRelatedToError.length === 1) {
823
- const onlyRelatedMutant = mutantsRelatedToError[0];
824
- if (onlyRelatedMutant !== void 0) {
825
- const mutantId = onlyRelatedMutant.id;
826
- if (errorsMap[mutantId]) errorsMap[mutantId].push(error);
827
- else errorsMap[mutantId] = [error];
1013
+ for (const [fileName] of s.sourceFiles) {
1014
+ const sourceFile = programs.map((p) => p.getSourceFile(fileName)).find((sf) => sf != null);
1015
+ if (!sourceFile) continue;
1016
+ const imports = extractImports(sourceFile);
1017
+ for (const specifier of imports) {
1018
+ const resolved = resolveModuleSpecifier(fileName, specifier, s.sourceFiles, pathService);
1019
+ if (resolved) {
1020
+ const sourceFileName = resolveTSInputFile(resolved, pathService);
1021
+ if (s.sourceFiles.has(sourceFileName)) s.sourceFiles.get(fileName)?.imports.add(sourceFileName);
828
1022
  }
829
- } else for (const mutant of mutantsRelatedToError) mutantsThatCouldNotBeTestedInGroups.add(mutant);
1023
+ }
830
1024
  }
831
- if (mutantsThatCouldNotBeTestedInGroups.size) await this.tsCompiler.check([]);
832
- for (const mutant of mutantsThatCouldNotBeTestedInGroups) {
833
- if (errorsMap[mutant.id]) continue;
834
- await this.checkErrors([mutant], errorsMap, nodes);
1025
+ yield* Ref.update(stateRef, (prev) => ({
1026
+ ...prev,
1027
+ sourceFiles: cloneMap(s.sourceFiles)
1028
+ }));
1029
+ });
1030
+ const getNodesEffect = Effect.gen(function* () {
1031
+ const s = yield* Ref.get(stateRef);
1032
+ if (s.nodes.size > 0) return s.nodes;
1033
+ for (const [fileName] of s.sourceFiles) {
1034
+ const node = makeTSFileNode(fileName);
1035
+ s.nodes.set(fileName, node);
835
1036
  }
836
- return errorsMap;
837
- }
838
- createErrorText(errors) {
839
- return errors.map((error) => this.formatDiagnostic(error)).join(EOL);
840
- }
841
- formatDiagnostic(error) {
842
- const severity = error.category === DiagnosticCategory.Error ? "error" : error.category === DiagnosticCategory.Warning ? "warning" : error.category === DiagnosticCategory.Suggestion ? "suggestion" : "message";
843
- let location = "";
844
- if (error.fileName) {
845
- const lineAndCharacter = this.tsCompiler.getLineAndCharacterOfPosition(error.fileName, error.pos);
846
- const line = (lineAndCharacter?.line ?? 0) + 1;
847
- const character = (lineAndCharacter?.character ?? 0) + 1;
848
- location = `${error.fileName}(${line},${character}): `;
1037
+ const withChildren = emptyNodes();
1038
+ for (const [fileName, file] of s.sourceFiles) {
1039
+ const node = s.nodes.get(fileName);
1040
+ if (node == null) return yield* new CompilerFailed({
1041
+ reason: "unknown-file-node",
1042
+ subject: fileName
1043
+ });
1044
+ const children = [...file.imports].map((importName) => s.nodes.get(importName)).filter((n) => n !== void 0);
1045
+ withChildren.set(fileName, {
1046
+ ...node,
1047
+ children,
1048
+ parents: []
1049
+ });
849
1050
  }
850
- return `${location}${severity} TS${error.code}: ${error.text}`;
851
- }
852
- };
1051
+ s.nodes.clear();
1052
+ for (const [k, v] of withChildren) s.nodes.set(k, v);
1053
+ const withParents = emptyNodes();
1054
+ for (const [fileName, node] of s.nodes) {
1055
+ const parents = [];
1056
+ for (const [, n] of s.nodes) if (n.children.includes(node)) parents.push(n);
1057
+ withParents.set(fileName, {
1058
+ ...node,
1059
+ parents
1060
+ });
1061
+ }
1062
+ s.nodes.clear();
1063
+ for (const [k, v] of withParents) s.nodes.set(k, v);
1064
+ yield* Ref.update(stateRef, (prev) => ({
1065
+ ...prev,
1066
+ nodes: cloneMap(s.nodes)
1067
+ }));
1068
+ return s.nodes;
1069
+ });
1070
+ const check = (mutants) => Effect.gen(function* () {
1071
+ const state = yield* Ref.get(stateRef);
1072
+ for (const mutant of state.lastMutants) yield* fs.resetFile(mutant.fileName);
1073
+ for (const mutant of mutants) {
1074
+ if (!(yield* fs.getFile(mutant.fileName))) return yield* new CompilerFailed({
1075
+ reason: "file-not-in-project",
1076
+ subject: mutant.fileName
1077
+ });
1078
+ yield* fs.mutateFile(mutant.fileName, mutant);
1079
+ }
1080
+ const mutatedFileNames = unique(mutants.map((m) => normalizeFileName(m.fileName)));
1081
+ const changedFiles = unique([...state.lastMutatedFileNames, ...mutatedFileNames]);
1082
+ const current = yield* Ref.get(stateRef);
1083
+ if (current.api && current.snapshot) {
1084
+ const oldSnapshot = current.snapshot;
1085
+ const nextSnapshot = current.api.updateSnapshot({
1086
+ openProjects: [...current.allTSConfigFiles],
1087
+ fileChanges: { changed: changedFiles }
1088
+ });
1089
+ yield* Effect.sync(() => oldSnapshot.dispose());
1090
+ yield* Ref.update(stateRef, (prev) => ({
1091
+ ...prev,
1092
+ snapshot: nextSnapshot
1093
+ }));
1094
+ }
1095
+ yield* Ref.update(stateRef, (prev) => ({
1096
+ ...prev,
1097
+ lastMutants: [...mutants],
1098
+ lastMutatedFileNames: mutatedFileNames
1099
+ }));
1100
+ return (yield* getProgramsEffect()).flatMap((program) => [
1101
+ ...program.getConfigFileParsingDiagnostics(),
1102
+ ...program.getSemanticDiagnostics(),
1103
+ ...program.getProgramDiagnostics()
1104
+ ]).filter((diagnostic) => diagnostic.category === DiagnosticCategory.Error);
1105
+ });
1106
+ const init = Effect.gen(function* () {
1107
+ yield* guardTSVersion(fsService);
1108
+ const absoluteTsconfigFile = normalizeFileName(pathService.resolve(rawTsconfigFile));
1109
+ yield* Ref.update(stateRef, (prev) => ({
1110
+ ...prev,
1111
+ tsconfigFile: absoluteTsconfigFile,
1112
+ allTSConfigFiles: setFromArray([absoluteTsconfigFile])
1113
+ }));
1114
+ yield* guardTSConfigFileExistsEffect;
1115
+ const buildModeEnabled = yield* determineBuildModeEnabled(absoluteTsconfigFile, fsService);
1116
+ yield* collectAllTSConfigFiles(buildModeEnabled);
1117
+ const s = yield* Ref.get(stateRef);
1118
+ const api = new API({ fs: fs.fileSystem });
1119
+ const snapshot = api.updateSnapshot({ openProjects: [...s.allTSConfigFiles] });
1120
+ yield* Ref.update(stateRef, (prev) => ({
1121
+ ...prev,
1122
+ api,
1123
+ snapshot
1124
+ }));
1125
+ const programs = yield* getProgramsEffect();
1126
+ yield* buildDependencyGraph(programs);
1127
+ return yield* check([]);
1128
+ });
1129
+ const close = Effect.gen(function* () {
1130
+ const s = yield* Ref.get(stateRef);
1131
+ yield* Effect.sync(() => s.snapshot?.dispose());
1132
+ yield* Effect.sync(() => s.api?.close());
1133
+ yield* Ref.update(stateRef, (prev) => ({
1134
+ ...prev,
1135
+ snapshot: void 0,
1136
+ api: void 0
1137
+ }));
1138
+ });
1139
+ const getLineAndCharacterOfPosition = (fileName, position) => Effect.gen(function* () {
1140
+ const programs = yield* getProgramsEffect();
1141
+ for (const program of programs) {
1142
+ const sourceFile = program.getSourceFile(fileName);
1143
+ if (sourceFile) return sourceFile.getLineAndCharacterOfPosition(position);
1144
+ }
1145
+ });
1146
+ return {
1147
+ init,
1148
+ check,
1149
+ nodes: getNodesEffect,
1150
+ close,
1151
+ getLineAndCharacterOfPosition
1152
+ };
1153
+ }
853
1154
  //#endregion
854
1155
  //#region src/index.ts
855
- const strykerPlugins = [declareFactoryPlugin(PluginKind.Checker, "typescript", create)];
856
- const createTypescriptChecker = create;
857
- const strykerValidationSchema = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Unknown))(JSON.parse(readFileSync(new URL("../schema/typescript-checker-options.json", import.meta.url), "utf-8")));
1156
+ const strykerPlugins = [declarePlugin(PluginKind.Checker, "typescript", Layer.effect(Checker, Effect.gen(function* () {
1157
+ const options = yield* RunConfiguration;
1158
+ const fsService = yield* FileSystem.FileSystem;
1159
+ const pathService = yield* Path.Path;
1160
+ return makeCheckerService({
1161
+ options,
1162
+ compiler: makeTypescriptCompiler(options, yield* makeHybridFileSystem(fsService), fsService, pathService)
1163
+ });
1164
+ })))];
1165
+ const rawSchema = JSON.parse(readFileSync(new URL("../schema/typescript-checker-options.json", import.meta.url), "utf-8"));
1166
+ if (!S.is(S.Record(S.String, S.Unknown))(rawSchema)) throw new Error("Invalid typescript-checker schema file");
1167
+ const strykerValidationSchema = rawSchema;
858
1168
  //#endregion
859
- export { createTypescriptChecker, strykerPlugins, strykerValidationSchema };
1169
+ export { strykerPlugins, strykerValidationSchema };