1 | import path from 'path';
|
2 | import { isDeepStrictEqual } from 'util';
|
3 |
|
4 | import minimatch, { type Minimatch as IMinimatch } from 'minimatch';
|
5 | import { StrykerOptions, FileDescriptions, FileDescription, Location, Position } from '@stryker-mutator/api/core';
|
6 | import { Logger } from '@stryker-mutator/api/logging';
|
7 | import { commonTokens, tokens } from '@stryker-mutator/api/plugin';
|
8 | import { ERROR_CODES, I, isErrnoException } from '@stryker-mutator/util';
|
9 | import type { MutationTestResult } from 'mutation-testing-report-schema/api';
|
10 |
|
11 | import { OpenEndLocation } from 'mutation-testing-report-schema';
|
12 |
|
13 | import { defaultOptions, FileMatcher } from '../config/index.js';
|
14 | import { coreTokens } from '../di/index.js';
|
15 |
|
16 | import { Project } from './project.js';
|
17 | import { FileSystem } from './file-system.js';
|
18 |
|
19 | const { Minimatch } = minimatch;
|
20 | const ALWAYS_IGNORE = Object.freeze(['node_modules', '.git', '*.tsbuildinfo', '/stryker.log']);
|
21 |
|
22 | export const IGNORE_PATTERN_CHARACTER = '!';
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 | export const MUTATION_RANGE_REGEX = /(.*?):((\d+)(?::(\d+))?-(\d+)(?::(\d+))?)$/;
|
31 |
|
32 | export 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 |
|
62 |
|
63 |
|
64 |
|
65 | private resolveFileDescriptions(inputFileNames: string[]): FileDescriptions {
|
66 |
|
67 | const logAboutUselessPatterns = !isDeepStrictEqual(this.mutatePatterns, defaultOptions.mutate);
|
68 |
|
69 |
|
70 | const mutateInputFileMap = new Map<string, FileDescription>();
|
71 | inputFileNames.forEach((fileName) => mutateInputFileMap.set(fileName, { mutate: false }));
|
72 |
|
73 |
|
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 |
|
113 |
|
114 |
|
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 |
|
145 |
|
146 | const matchesDirectoryPartially = (entryPath: string, rule: IMinimatch) => {
|
147 | return rule.match(`/${entryPath}`, true) || rule.match(entryPath, true);
|
148 | };
|
149 |
|
150 |
|
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 |
|
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 |
|
236 | function reportOpenEndLocationToStrykerLocation({ start, end }: OpenEndLocation): OpenEndLocation {
|
237 | return {
|
238 | start: reportPositionToStrykerPosition(start),
|
239 | end: end && reportPositionToStrykerPosition(end),
|
240 | };
|
241 | }
|
242 |
|
243 | function reportLocationToStrykerLocation({ start, end }: Location): Location {
|
244 | return {
|
245 | start: reportPositionToStrykerPosition(start),
|
246 | end: reportPositionToStrykerPosition(end),
|
247 | };
|
248 | }
|
249 |
|
250 | function 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 |