UNPKG

10.5 kBPlain TextView Raw
1import path from 'path';
2import { isDeepStrictEqual } from 'util';
3
4import minimatch, { type Minimatch as IMinimatch } from 'minimatch';
5import { StrykerOptions, FileDescriptions, FileDescription, Location, Position } from '@stryker-mutator/api/core';
6import { Logger } from '@stryker-mutator/api/logging';
7import { commonTokens, tokens } from '@stryker-mutator/api/plugin';
8import { ERROR_CODES, I, isErrnoException } from '@stryker-mutator/util';
9import type { MutationTestResult } from 'mutation-testing-report-schema/api';
10
11import { OpenEndLocation } from 'mutation-testing-report-schema';
12
13import { defaultOptions, FileMatcher } from '../config/index.js';
14import { coreTokens } from '../di/index.js';
15
16import { Project } from './project.js';
17import { FileSystem } from './file-system.js';
18
19const { Minimatch } = minimatch;
20const ALWAYS_IGNORE = Object.freeze(['node_modules', '.git', '*.tsbuildinfo', '/stryker.log']);
21
22export const IGNORE_PATTERN_CHARACTER = '!';
23/**
24 * @see https://stryker-mutator.io/docs/stryker-js/configuration/#mutate-string
25 * @example
26 * * "src/app.js:1-11" will mutate lines 1 through 11 inside app.js.
27 * * "src/app.js:5:4-6:4" will mutate from line 5, column 4 through line 6 column 4 inside app.js (columns 4 are included).
28 * * "src/app.js:5-6:4" will mutate from line 5, column 0 through line 6 column 4 inside app.js (column 4 is included).
29 */
30export const MUTATION_RANGE_REGEX = /(.*?):((\d+)(?::(\d+))?-(\d+)(?::(\d+))?)$/;
31
32export class ProjectReader {
33 private readonly mutatePatterns: readonly string[];
34 private readonly ignoreRules: readonly string[];
35 private readonly incremental: boolean;
36 private readonly force: boolean;
37 private readonly incrementalFile: string;
38
39 public static inject = tokens(coreTokens.fs, commonTokens.logger, commonTokens.options);
40 constructor(
41 private readonly fs: I<FileSystem>,
42 private readonly log: Logger,
43 { mutate, tempDirName, ignorePatterns, incremental, incrementalFile, force, htmlReporter, jsonReporter }: StrykerOptions
44 ) {
45 this.mutatePatterns = mutate;
46 this.ignoreRules = [...ALWAYS_IGNORE, tempDirName, incrementalFile, htmlReporter.fileName, jsonReporter.fileName, ...ignorePatterns];
47 this.incremental = incremental;
48 this.incrementalFile = incrementalFile;
49 this.force = force;
50 }
51
52 public async read(): Promise<Project> {
53 const inputFileNames = await this.resolveInputFileNames();
54 const fileDescriptions = this.resolveFileDescriptions(inputFileNames);
55 const project = new Project(this.fs, fileDescriptions, await this.readIncrementalReport());
56 project.logFiles(this.log, this.ignoreRules, this.force);
57 return project;
58 }
59
60 /**
61 * Takes the list of file names and creates file description object from it, containing logic about wether or not it needs to be mutated.
62 * If a mutate pattern starts with a `!`, it negates the pattern.
63 * @param inputFileNames the file names to filter
64 */
65 private resolveFileDescriptions(inputFileNames: string[]): FileDescriptions {
66 // Only log about useless patterns when the user actually configured it
67 const logAboutUselessPatterns = !isDeepStrictEqual(this.mutatePatterns, defaultOptions.mutate);
68
69 // Start out without files to mutate
70 const mutateInputFileMap = new Map<string, FileDescription>();
71 inputFileNames.forEach((fileName) => mutateInputFileMap.set(fileName, { mutate: false }));
72
73 // Now lets see what we need to mutate
74 for (const pattern of this.mutatePatterns) {
75 if (pattern.startsWith(IGNORE_PATTERN_CHARACTER)) {
76 const files = this.filterMutatePattern(mutateInputFileMap.keys(), pattern.substring(1));
77 if (logAboutUselessPatterns && files.size === 0) {
78 this.log.warn(`Glob pattern "${pattern}" did not exclude any files.`);
79 }
80 for (const fileName of files.keys()) {
81 mutateInputFileMap.set(fileName, { mutate: false });
82 }
83 } else {
84 const files = this.filterMutatePattern(inputFileNames, pattern);
85 if (logAboutUselessPatterns && files.size === 0) {
86 this.log.warn(`Glob pattern "${pattern}" did not result in any files.`);
87 }
88 for (const [fileName, file] of files) {
89 mutateInputFileMap.set(fileName, this.mergeFileDescriptions(file, mutateInputFileMap.get(fileName)));
90 }
91 }
92 }
93 return Object.fromEntries(mutateInputFileMap);
94 }
95
96 private mergeFileDescriptions(first: FileDescription, second?: FileDescription): FileDescription {
97 if (second) {
98 if (Array.isArray(first.mutate) && Array.isArray(second.mutate)) {
99 return { mutate: [...second.mutate, ...first.mutate] };
100 } else if (first.mutate && !second.mutate) {
101 return first;
102 } else if (!first.mutate && second.mutate) {
103 return second;
104 } else {
105 return { mutate: false };
106 }
107 }
108 return first;
109 }
110
111 /**
112 * Filters a given list of file names given a mutate pattern.
113 * @param fileNames the file names to match to the pattern
114 * @param mutatePattern the pattern to match with
115 */
116 private filterMutatePattern(fileNames: Iterable<string>, mutatePattern: string): Map<string, FileDescription> {
117 const mutationRangeMatch = MUTATION_RANGE_REGEX.exec(mutatePattern);
118 let mutate: FileDescription['mutate'] = true;
119 if (mutationRangeMatch) {
120 const [_, newPattern, _mutationRange, startLine, startColumn = '0', endLine, endColumn = Number.MAX_SAFE_INTEGER.toString()] =
121 mutationRangeMatch;
122 mutatePattern = newPattern;
123 mutate = [
124 {
125 start: { line: parseInt(startLine) - 1, column: parseInt(startColumn) },
126 end: { line: parseInt(endLine) - 1, column: parseInt(endColumn) },
127 },
128 ];
129 }
130 const matcher = new FileMatcher(mutatePattern);
131 const inputFiles = new Map<string, FileDescription>();
132 for (const fileName of fileNames) {
133 if (matcher.matches(fileName)) {
134 inputFiles.set(fileName, { mutate });
135 }
136 }
137 return inputFiles;
138 }
139
140 private async resolveInputFileNames(): Promise<string[]> {
141 const ignoreRules = this.ignoreRules.map((pattern) => new Minimatch(pattern, { dot: true, flipNegate: true, nocase: true }));
142
143 /**
144 * Rewrite of: https://github.com/npm/ignore-walk/blob/0e4f87adccb3e16f526d2e960ed04bdc77fd6cca/index.js#L213-L215
145 */
146 const matchesDirectoryPartially = (entryPath: string, rule: IMinimatch) => {
147 return rule.match(`/${entryPath}`, true) || rule.match(entryPath, true);
148 };
149
150 // Inspired by https://github.com/npm/ignore-walk/blob/0e4f87adccb3e16f526d2e960ed04bdc77fd6cca/index.js#L124
151 const matchesDirectory = (entryName: string, entryPath: string, rule: IMinimatch) => {
152 return (
153 matchesFile(entryName, entryPath, rule) ||
154 rule.match(`/${entryPath}/`) ||
155 rule.match(`${entryPath}/`) ||
156 (rule.negate && matchesDirectoryPartially(entryPath, rule))
157 );
158 };
159
160 // Inspired by https://github.com/npm/ignore-walk/blob/0e4f87adccb3e16f526d2e960ed04bdc77fd6cca/index.js#L123
161 const matchesFile = (entryName: string, entryPath: string, rule: IMinimatch) => {
162 return rule.match(entryName) || rule.match(entryPath) || rule.match(`/${entryPath}`);
163 };
164
165 const crawlDir = async (dir: string, rootDir = dir): Promise<string[]> => {
166 const dirEntries = await this.fs.readdir(dir, { withFileTypes: true });
167 const relativeName = path.relative(rootDir, dir);
168 const files = await Promise.all(
169 dirEntries
170 .filter((dirEntry) => {
171 let included = true;
172 const entryPath = `${relativeName.length ? `${relativeName}/` : ''}${dirEntry.name}`;
173 ignoreRules.forEach((rule) => {
174 if (rule.negate !== included) {
175 const match = dirEntry.isDirectory() ? matchesDirectory(dirEntry.name, entryPath, rule) : matchesFile(dirEntry.name, entryPath, rule);
176 if (match) {
177 included = rule.negate;
178 }
179 }
180 });
181 return included;
182 })
183 .map(async (dirent) => {
184 if (dirent.isDirectory()) {
185 return crawlDir(path.resolve(rootDir, relativeName, dirent.name), rootDir);
186 } else {
187 return path.resolve(rootDir, relativeName, dirent.name);
188 }
189 })
190 );
191 return files.flat();
192 };
193 const files = await crawlDir(process.cwd());
194 return files;
195 }
196
197 private async readIncrementalReport(): Promise<MutationTestResult | undefined> {
198 if (!this.incremental) {
199 return;
200 }
201 try {
202 // TODO: Validate against the schema or stryker version?
203 const contents = await this.fs.readFile(this.incrementalFile, 'utf-8');
204 const result: MutationTestResult = JSON.parse(contents);
205 return {
206 ...result,
207 files: Object.fromEntries(
208 Object.entries(result.files).map(([fileName, file]) => [
209 fileName,
210 { ...file, mutants: file.mutants.map((mutant) => ({ ...mutant, location: reportLocationToStrykerLocation(mutant.location) })) },
211 ])
212 ),
213 testFiles:
214 result.testFiles &&
215 Object.fromEntries(
216 Object.entries(result.testFiles).map(([fileName, file]) => [
217 fileName,
218 {
219 ...file,
220 tests: file.tests.map((test) => ({ ...test, location: test.location && reportOpenEndLocationToStrykerLocation(test.location) })),
221 },
222 ])
223 ),
224 };
225 } catch (err: unknown) {
226 if (isErrnoException(err) && err.code === ERROR_CODES.NoSuchFileOrDirectory) {
227 this.log.info('No incremental result file found at %s, a full mutation testing run will be performed.', this.incrementalFile);
228 return;
229 }
230 // Whoops, didn't mean to catch this one!
231 throw err;
232 }
233 }
234}
235
236function reportOpenEndLocationToStrykerLocation({ start, end }: OpenEndLocation): OpenEndLocation {
237 return {
238 start: reportPositionToStrykerPosition(start),
239 end: end && reportPositionToStrykerPosition(end),
240 };
241}
242
243function reportLocationToStrykerLocation({ start, end }: Location): Location {
244 return {
245 start: reportPositionToStrykerPosition(start),
246 end: reportPositionToStrykerPosition(end),
247 };
248}
249
250function reportPositionToStrykerPosition({ line, column }: Position): Position {
251 // stryker's positions are 0-based
252 return {
253 line: line - 1,
254 column: column - 1,
255 };
256}
257
\No newline at end of file