@poleski/quality-tools 0.1.3 → 0.2.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/CHANGELOG.md +12 -0
- package/README.md +4 -0
- package/dist/cli/main.js +361 -23
- package/package.json +3 -2
- package/stryker/quality-tools-vitest-runner.mjs +4 -0
- package/stryker.config.cjs +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @poleski/quality-tools
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 5bab67a: Add an acceptance spec compiler that turns Gherkin-ish Markdown into Playwright tests.
|
|
8
|
+
|
|
9
|
+
## 0.1.4
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 289c605: Stop host projects from seeing Stryker warnings about the upstream Vitest runner plugin when using the bundled mutation config, and throttle Stryker mutation progress into the existing one-minute heartbeat.
|
|
14
|
+
|
|
3
15
|
## 0.1.3
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ pnpm add -D link:/absolute/path/to/quality-tools
|
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
26
|
pnpm exec quality-tools organize .
|
|
27
|
+
pnpm exec quality-tools acceptance compile --spec "tests/acceptance/specs/**/*.md" --steps "tests/acceptance/steps.ts" --out "tests/playwright/generated/acceptance.spec.ts"
|
|
27
28
|
pnpm exec quality-tools boundaries . --strict
|
|
28
29
|
pnpm exec quality-tools reachability . --strict
|
|
29
30
|
pnpm exec quality-tools scrap ./tests
|
|
@@ -244,12 +245,15 @@ root, then falls back to the bundled base config.
|
|
|
244
245
|
## Other Tools
|
|
245
246
|
|
|
246
247
|
```bash
|
|
248
|
+
pnpm exec quality-tools acceptance compile --spec "tests/acceptance/specs/**/*.md" --steps "tests/acceptance/steps.ts" --out "tests/playwright/generated/acceptance.spec.ts"
|
|
247
249
|
pnpm exec quality-tools organize ./src
|
|
248
250
|
pnpm exec quality-tools boundaries parser --strict
|
|
249
251
|
pnpm exec quality-tools reachability parser --strict
|
|
250
252
|
pnpm exec quality-tools scrap ./tests --strict
|
|
251
253
|
```
|
|
252
254
|
|
|
255
|
+
- `acceptance` compiles human-authored Gherkin-ish Markdown specs into
|
|
256
|
+
executable Playwright specs that import host-owned step bindings.
|
|
253
257
|
- `organize` checks directory size, depth, naming, barrels, and cohesion. Use
|
|
254
258
|
`--write-baseline` and `--compare <path>` for baseline workflows.
|
|
255
259
|
- `boundaries` checks configured layers, entrypoints, dead surfaces, and dead
|
package/dist/cli/main.js
CHANGED
|
@@ -1,5 +1,154 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/acceptance/command.ts
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { glob } from "glob";
|
|
7
|
+
|
|
8
|
+
// src/acceptance/parser.ts
|
|
9
|
+
var FEATURE_PATTERN = /^#{0,6}\s*Feature:\s*(.+)$/;
|
|
10
|
+
var SCENARIO_PATTERN = /^#{0,6}\s*Scenario:\s*(.+)$/;
|
|
11
|
+
var STEP_PATTERN = /^(Given|When|Then|And|But)\s+(.+)$/;
|
|
12
|
+
function parseAcceptanceMarkdown(markdown, sourcePath) {
|
|
13
|
+
const lines = markdown.split(/\r?\n/);
|
|
14
|
+
let feature;
|
|
15
|
+
const scenarios = [];
|
|
16
|
+
lines.forEach((rawLine, index) => {
|
|
17
|
+
const lineNumber = index + 1;
|
|
18
|
+
const line = rawLine.trim();
|
|
19
|
+
if (line === "") {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const featureMatch = FEATURE_PATTERN.exec(line);
|
|
23
|
+
if (featureMatch) {
|
|
24
|
+
feature = {
|
|
25
|
+
name: featureMatch[1].trim(),
|
|
26
|
+
line: lineNumber
|
|
27
|
+
};
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const scenarioMatch = SCENARIO_PATTERN.exec(line);
|
|
31
|
+
if (scenarioMatch) {
|
|
32
|
+
scenarios.push({
|
|
33
|
+
name: scenarioMatch[1].trim(),
|
|
34
|
+
line: lineNumber,
|
|
35
|
+
steps: []
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const stepMatch = STEP_PATTERN.exec(line);
|
|
40
|
+
if (stepMatch) {
|
|
41
|
+
const scenario = scenarios.at(-1);
|
|
42
|
+
if (!scenario) {
|
|
43
|
+
throw new Error(`${sourcePath}:${lineNumber} Step appears before a Scenario`);
|
|
44
|
+
}
|
|
45
|
+
scenario.steps.push({
|
|
46
|
+
keyword: stepMatch[1],
|
|
47
|
+
text: stepMatch[2].trim(),
|
|
48
|
+
line: lineNumber
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
if (!feature) {
|
|
53
|
+
throw new Error(`${sourcePath}: Expected a Feature heading`);
|
|
54
|
+
}
|
|
55
|
+
if (scenarios.length === 0) {
|
|
56
|
+
throw new Error(`${sourcePath}: Expected at least one Scenario`);
|
|
57
|
+
}
|
|
58
|
+
const emptyScenario = scenarios.find((scenario) => scenario.steps.length === 0);
|
|
59
|
+
if (emptyScenario) {
|
|
60
|
+
throw new Error(`${sourcePath}:${emptyScenario.line} Scenario "${emptyScenario.name}" must contain at least one step`);
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
sourcePath,
|
|
64
|
+
feature,
|
|
65
|
+
scenarios
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/acceptance/playwright/generator.ts
|
|
70
|
+
function generatePlaywrightAcceptanceSpec(documents, options) {
|
|
71
|
+
const sections = documents.flatMap((document) => generateDocumentSections(document));
|
|
72
|
+
return [
|
|
73
|
+
"/* Generated by quality-tools acceptance compile. Do not edit. */",
|
|
74
|
+
"/* eslint-disable playwright/expect-expect */",
|
|
75
|
+
"import { test } from '@playwright/test';",
|
|
76
|
+
`import { acceptanceSteps, createAcceptanceContext } from ${quote(options.stepsImportPath)};`,
|
|
77
|
+
"",
|
|
78
|
+
"type AcceptanceContext = Awaited<ReturnType<typeof createAcceptanceContext>> & { cleanup?: () => unknown | Promise<unknown> };",
|
|
79
|
+
"type AcceptanceRuntimeStep = { keyword: string; text: string; sourcePath: string; line: number };",
|
|
80
|
+
"type AcceptanceStepImplementation = (context: AcceptanceContext, step: AcceptanceRuntimeStep) => unknown | Promise<unknown>;",
|
|
81
|
+
"type AcceptanceStepRegistry = Record<string, AcceptanceStepImplementation>;",
|
|
82
|
+
"",
|
|
83
|
+
"async function runAcceptanceStep(",
|
|
84
|
+
" context: AcceptanceContext,",
|
|
85
|
+
" stepText: string,",
|
|
86
|
+
" step: AcceptanceRuntimeStep",
|
|
87
|
+
"): Promise<void> {",
|
|
88
|
+
" const registry = acceptanceSteps as AcceptanceStepRegistry;",
|
|
89
|
+
" const implementation = registry[stepText] ?? registry[`${step.keyword} ${stepText}`];",
|
|
90
|
+
"",
|
|
91
|
+
" if (!implementation) {",
|
|
92
|
+
' throw new Error(`Missing acceptance step "${step.keyword} ${step.text}" at ${step.sourcePath}:${step.line}`);',
|
|
93
|
+
" }",
|
|
94
|
+
"",
|
|
95
|
+
" await implementation(context, step);",
|
|
96
|
+
"}",
|
|
97
|
+
"",
|
|
98
|
+
...sections,
|
|
99
|
+
""
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
102
|
+
function generateDocumentSections(document) {
|
|
103
|
+
const scenarios = document.scenarios.flatMap((scenario) => generateScenario(document.sourcePath, scenario));
|
|
104
|
+
return [
|
|
105
|
+
`test.describe(${quote(document.feature.name)}, () => {`,
|
|
106
|
+
...indentLines(scenarios, 2),
|
|
107
|
+
"});",
|
|
108
|
+
""
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
function generateScenario(sourcePath, scenario) {
|
|
112
|
+
const steps = indentLines(scenario.steps.flatMap((step) => generateStep(sourcePath, step)), 4);
|
|
113
|
+
return [
|
|
114
|
+
`test(${quote(scenario.name)}, async ({}, testInfo) => {`,
|
|
115
|
+
" const context = await createAcceptanceContext({",
|
|
116
|
+
" testInfo,",
|
|
117
|
+
` sourcePath: ${quote(sourcePath)},`,
|
|
118
|
+
` scenario: ${quote(scenario.name)}`,
|
|
119
|
+
" });",
|
|
120
|
+
"",
|
|
121
|
+
" try {",
|
|
122
|
+
...steps,
|
|
123
|
+
" } finally {",
|
|
124
|
+
" await context.cleanup?.();",
|
|
125
|
+
" }",
|
|
126
|
+
"});"
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
function generateStep(sourcePath, step) {
|
|
130
|
+
const label = `${step.keyword} ${step.text}`;
|
|
131
|
+
return [
|
|
132
|
+
`// ${sourcePath}:${step.line}`,
|
|
133
|
+
`await test.step(${quote(label)}, async () => {`,
|
|
134
|
+
` await runAcceptanceStep(context, ${quote(step.text)}, {`,
|
|
135
|
+
` keyword: ${quote(step.keyword)},`,
|
|
136
|
+
` text: ${quote(step.text)},`,
|
|
137
|
+
` sourcePath: ${quote(sourcePath)},`,
|
|
138
|
+
` line: ${step.line}`,
|
|
139
|
+
" });",
|
|
140
|
+
"});",
|
|
141
|
+
""
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
function quote(value) {
|
|
145
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\r/g, "\\r").replace(/\n/g, "\\n")}'`;
|
|
146
|
+
}
|
|
147
|
+
function indentLines(lines, spaces) {
|
|
148
|
+
const prefix = " ".repeat(spaces);
|
|
149
|
+
return lines.map((line) => line === "" ? line : `${prefix}${line}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
3
152
|
// src/shared/flagValue.ts
|
|
4
153
|
function flagValue(args2, name) {
|
|
5
154
|
const inlineFlag = args2.find((arg) => arg.startsWith(`${name}=`));
|
|
@@ -31,6 +180,83 @@ function cleanCliArgs(args2) {
|
|
|
31
180
|
return args2.filter((arg) => arg !== "--");
|
|
32
181
|
}
|
|
33
182
|
|
|
183
|
+
// src/acceptance/command.ts
|
|
184
|
+
async function runAcceptanceCli(rawArgs, options = {}) {
|
|
185
|
+
const args2 = cleanCliArgs(rawArgs);
|
|
186
|
+
const [command2, ...commandArgs] = args2;
|
|
187
|
+
if (command2 !== "compile") {
|
|
188
|
+
throw new Error("Usage: quality-tools acceptance compile --spec <glob> --steps <path> --out <path>");
|
|
189
|
+
}
|
|
190
|
+
await compileAcceptance(commandArgs, options.cwd ?? process.cwd());
|
|
191
|
+
}
|
|
192
|
+
async function compileAcceptance(args2, cwd) {
|
|
193
|
+
const options = parseCompileOptions(args2);
|
|
194
|
+
const specFiles = await findSpecFiles(cwd, options.specPatterns);
|
|
195
|
+
if (specFiles.length === 0) {
|
|
196
|
+
throw new Error(`No acceptance specs matched: ${options.specPatterns.join(", ")}`);
|
|
197
|
+
}
|
|
198
|
+
const documents = specFiles.map((specFile) => {
|
|
199
|
+
const source = fs.readFileSync(specFile, "utf8");
|
|
200
|
+
return parseAcceptanceMarkdown(source, toPosixPath(path.relative(cwd, specFile)));
|
|
201
|
+
});
|
|
202
|
+
const outPath = path.resolve(cwd, options.outPath);
|
|
203
|
+
const stepsImportPath = createStepsImportPath(outPath, path.resolve(cwd, options.stepsPath));
|
|
204
|
+
const generated = generatePlaywrightAcceptanceSpec(documents, { stepsImportPath });
|
|
205
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
206
|
+
fs.writeFileSync(outPath, generated);
|
|
207
|
+
}
|
|
208
|
+
function parseCompileOptions(args2) {
|
|
209
|
+
const specPatterns = collectFlagValues(args2, "--spec");
|
|
210
|
+
const stepsPath = requireFlagValue(args2, "--steps");
|
|
211
|
+
const outPath = requireFlagValue(args2, "--out");
|
|
212
|
+
if (specPatterns.length === 0) {
|
|
213
|
+
throw new Error("Missing required --spec <glob>");
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
specPatterns,
|
|
217
|
+
stepsPath,
|
|
218
|
+
outPath
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function findSpecFiles(cwd, patterns) {
|
|
222
|
+
const files = await Promise.all(
|
|
223
|
+
patterns.map((pattern) => glob(pattern, { absolute: true, cwd, nodir: true }))
|
|
224
|
+
);
|
|
225
|
+
return files.flat().sort((left, right) => left.localeCompare(right));
|
|
226
|
+
}
|
|
227
|
+
function createStepsImportPath(outPath, stepsPath) {
|
|
228
|
+
const relativePath = toPosixPath(path.relative(path.dirname(outPath), stepsPath));
|
|
229
|
+
const extension = path.extname(relativePath);
|
|
230
|
+
const extensionlessPath = extension ? relativePath.slice(0, -extension.length) : relativePath;
|
|
231
|
+
if (extensionlessPath.startsWith(".")) {
|
|
232
|
+
return extensionlessPath;
|
|
233
|
+
}
|
|
234
|
+
return `./${extensionlessPath}`;
|
|
235
|
+
}
|
|
236
|
+
function collectFlagValues(args2, flag) {
|
|
237
|
+
const values = [];
|
|
238
|
+
args2.forEach((arg, index) => {
|
|
239
|
+
if (arg === flag) {
|
|
240
|
+
const value = args2[index + 1];
|
|
241
|
+
if (!value || value.startsWith("--")) {
|
|
242
|
+
throw new Error(`Missing value for ${flag}`);
|
|
243
|
+
}
|
|
244
|
+
values.push(value);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
return values;
|
|
248
|
+
}
|
|
249
|
+
function requireFlagValue(args2, flag) {
|
|
250
|
+
const value = collectFlagValues(args2, flag).at(0);
|
|
251
|
+
if (!value) {
|
|
252
|
+
throw new Error(`Missing required ${flag} <path>`);
|
|
253
|
+
}
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
function toPosixPath(value) {
|
|
257
|
+
return value.split(path.sep).join(path.posix.sep);
|
|
258
|
+
}
|
|
259
|
+
|
|
34
260
|
// src/shared/resolve/repoRoot.ts
|
|
35
261
|
import { resolve as resolve3 } from "node:path";
|
|
36
262
|
|
|
@@ -872,7 +1098,7 @@ function runBoundariesCli(rawArgs, dependencies = DEFAULT_DEPENDENCIES) {
|
|
|
872
1098
|
|
|
873
1099
|
// src/crap/analysis/run.ts
|
|
874
1100
|
import { existsSync as existsSync6 } from "fs";
|
|
875
|
-
import * as
|
|
1101
|
+
import * as path3 from "path";
|
|
876
1102
|
|
|
877
1103
|
// src/crap/analysis/calculate.ts
|
|
878
1104
|
function calculateCrap(complexity, coverage) {
|
|
@@ -998,7 +1224,7 @@ function extractFunctions(sourceFile) {
|
|
|
998
1224
|
|
|
999
1225
|
// src/crap/analysis/fileSelection.ts
|
|
1000
1226
|
import { readFileSync as readFileSync4 } from "fs";
|
|
1001
|
-
import * as
|
|
1227
|
+
import * as path2 from "path";
|
|
1002
1228
|
import * as ts7 from "typescript";
|
|
1003
1229
|
function matchesFilterScope(relativePath, filterScope) {
|
|
1004
1230
|
if (!filterScope) {
|
|
@@ -1010,7 +1236,7 @@ function matchesFilterScope(relativePath, filterScope) {
|
|
|
1010
1236
|
return relativePath.startsWith(`${filterScope}/`);
|
|
1011
1237
|
}
|
|
1012
1238
|
function shouldIncludeFile(filePath, filterScope, repoRoot) {
|
|
1013
|
-
const relativePath = toPosix(
|
|
1239
|
+
const relativePath = toPosix(path2.relative(repoRoot, filePath));
|
|
1014
1240
|
if (!matchesFilterScope(relativePath, filterScope)) {
|
|
1015
1241
|
return false;
|
|
1016
1242
|
}
|
|
@@ -1022,7 +1248,7 @@ function shouldIncludeFile(filePath, filterScope, repoRoot) {
|
|
|
1022
1248
|
repoRoot,
|
|
1023
1249
|
workspacePackage.name,
|
|
1024
1250
|
"crap",
|
|
1025
|
-
toPosix(
|
|
1251
|
+
toPosix(path2.relative(workspacePackage.root, filePath))
|
|
1026
1252
|
);
|
|
1027
1253
|
}
|
|
1028
1254
|
function createSourceFile3(filePath) {
|
|
@@ -1058,7 +1284,7 @@ function analyzeCoverageEntry(filePath, fileCoverage, repoRoot, threshold) {
|
|
|
1058
1284
|
complexity: fn.complexity,
|
|
1059
1285
|
coverage: Math.round(coverage),
|
|
1060
1286
|
crap: Math.round(crap * 100) / 100,
|
|
1061
|
-
file: toPosix(
|
|
1287
|
+
file: toPosix(path3.relative(repoRoot, fn.file)),
|
|
1062
1288
|
line: fn.line,
|
|
1063
1289
|
name: fn.name
|
|
1064
1290
|
};
|
|
@@ -1181,11 +1407,11 @@ function createCoverageProfiles(repoRoot, target) {
|
|
|
1181
1407
|
|
|
1182
1408
|
// src/crap/coverage/read.ts
|
|
1183
1409
|
import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
|
|
1184
|
-
function readCoverageReport(
|
|
1185
|
-
if (!existsSync7(
|
|
1186
|
-
throw new Error(`Coverage data not found: ${
|
|
1410
|
+
function readCoverageReport(path4) {
|
|
1411
|
+
if (!existsSync7(path4)) {
|
|
1412
|
+
throw new Error(`Coverage data not found: ${path4}`);
|
|
1187
1413
|
}
|
|
1188
|
-
return JSON.parse(readFileSync5(
|
|
1414
|
+
return JSON.parse(readFileSync5(path4, "utf-8"));
|
|
1189
1415
|
}
|
|
1190
1416
|
|
|
1191
1417
|
// src/crap/report.ts
|
|
@@ -1472,6 +1698,99 @@ function buildMutationEnv(options = {}) {
|
|
|
1472
1698
|
};
|
|
1473
1699
|
}
|
|
1474
1700
|
|
|
1701
|
+
// src/mutation/runner/progress.ts
|
|
1702
|
+
var ANSI_PATTERN = new RegExp(
|
|
1703
|
+
`${String.fromCharCode(27)}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])`,
|
|
1704
|
+
"g"
|
|
1705
|
+
);
|
|
1706
|
+
var PROGRESS_PATTERN = /Mutation testing\s+(?:\[(?<bracketStatus>[^\]]*)\]\s*)?(?<percent>\d+%)\s+\((?<timing>elapsed:[^)]+)\)\s+(?<count>\d+\/\d+)\s+(?:Mutants?|tested)(?:\s+\((?<tailStatus>\d+\s+survived,\s*\d+\s+timed out)\))?/i;
|
|
1707
|
+
var STATUS_TAIL_PATTERN = /(?:^|\s)tested\s+\((?<status>\d+\s+survived,\s*\d+\s+timed out)\)\s*$/i;
|
|
1708
|
+
function cleanProgressText(text) {
|
|
1709
|
+
return text.replace(ANSI_PATTERN, "").trim();
|
|
1710
|
+
}
|
|
1711
|
+
function normalizeStatus(status) {
|
|
1712
|
+
const trimmed = status?.trim();
|
|
1713
|
+
return trimmed && trimmed.length > 0 ? trimmed : void 0;
|
|
1714
|
+
}
|
|
1715
|
+
var MutationProgressTracker = class {
|
|
1716
|
+
latest;
|
|
1717
|
+
formatLatest() {
|
|
1718
|
+
if (!this.latest) {
|
|
1719
|
+
return void 0;
|
|
1720
|
+
}
|
|
1721
|
+
return [
|
|
1722
|
+
"Mutation testing",
|
|
1723
|
+
`[${this.latest.status ?? ""}]`,
|
|
1724
|
+
this.latest.percent,
|
|
1725
|
+
`(${this.latest.timing})`,
|
|
1726
|
+
this.latest.count,
|
|
1727
|
+
"Mutants"
|
|
1728
|
+
].join(" ");
|
|
1729
|
+
}
|
|
1730
|
+
observe(text) {
|
|
1731
|
+
const cleanText = cleanProgressText(text);
|
|
1732
|
+
if (cleanText.length === 0) {
|
|
1733
|
+
return false;
|
|
1734
|
+
}
|
|
1735
|
+
const progressMatch = PROGRESS_PATTERN.exec(cleanText);
|
|
1736
|
+
if (progressMatch?.groups) {
|
|
1737
|
+
this.latest = {
|
|
1738
|
+
count: progressMatch.groups.count,
|
|
1739
|
+
percent: progressMatch.groups.percent,
|
|
1740
|
+
timing: progressMatch.groups.timing,
|
|
1741
|
+
status: normalizeStatus(progressMatch.groups.tailStatus) ?? normalizeStatus(progressMatch.groups.bracketStatus) ?? this.latest?.status
|
|
1742
|
+
};
|
|
1743
|
+
return true;
|
|
1744
|
+
}
|
|
1745
|
+
const statusMatch = STATUS_TAIL_PATTERN.exec(cleanText);
|
|
1746
|
+
if (statusMatch?.groups && this.latest) {
|
|
1747
|
+
this.latest = {
|
|
1748
|
+
...this.latest,
|
|
1749
|
+
status: normalizeStatus(statusMatch.groups.status) ?? this.latest.status
|
|
1750
|
+
};
|
|
1751
|
+
return true;
|
|
1752
|
+
}
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
};
|
|
1756
|
+
function createMutationProgressOutputForwarder(tracker, writeOutput) {
|
|
1757
|
+
let pending = "";
|
|
1758
|
+
const handleSegment = (segment, delimiter) => {
|
|
1759
|
+
if (tracker.observe(segment)) {
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
writeOutput(`${segment}${delimiter}`);
|
|
1763
|
+
};
|
|
1764
|
+
const flushPendingProgress = () => {
|
|
1765
|
+
if (tracker.observe(pending)) {
|
|
1766
|
+
pending = "";
|
|
1767
|
+
return true;
|
|
1768
|
+
}
|
|
1769
|
+
return false;
|
|
1770
|
+
};
|
|
1771
|
+
return {
|
|
1772
|
+
flush() {
|
|
1773
|
+
if (pending.length === 0 || flushPendingProgress()) {
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
writeOutput(pending);
|
|
1777
|
+
pending = "";
|
|
1778
|
+
},
|
|
1779
|
+
write(text) {
|
|
1780
|
+
pending += text;
|
|
1781
|
+
let delimiterIndex = pending.search(/[\r\n]/);
|
|
1782
|
+
while (delimiterIndex >= 0) {
|
|
1783
|
+
const segment = pending.slice(0, delimiterIndex);
|
|
1784
|
+
const delimiter = pending[delimiterIndex] === "\n" ? "\n" : "";
|
|
1785
|
+
pending = pending.slice(delimiterIndex + 1);
|
|
1786
|
+
handleSegment(segment, delimiter);
|
|
1787
|
+
delimiterIndex = pending.search(/[\r\n]/);
|
|
1788
|
+
}
|
|
1789
|
+
flushPendingProgress();
|
|
1790
|
+
}
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1475
1794
|
// src/mutation/runner/run.ts
|
|
1476
1795
|
var MUTATION_PROGRESS_INTERVAL_MS = 6e4;
|
|
1477
1796
|
function formatElapsedDuration(durationMs) {
|
|
@@ -1483,18 +1802,35 @@ function formatElapsedDuration(durationMs) {
|
|
|
1483
1802
|
function runStryker(args2, env, target) {
|
|
1484
1803
|
return new Promise((resolve8, reject) => {
|
|
1485
1804
|
const startedAt = Date.now();
|
|
1805
|
+
const progressTracker = new MutationProgressTracker();
|
|
1806
|
+
const stdoutForwarder = createMutationProgressOutputForwarder(
|
|
1807
|
+
progressTracker,
|
|
1808
|
+
(text) => process.stdout.write(text)
|
|
1809
|
+
);
|
|
1810
|
+
const stderrForwarder = createMutationProgressOutputForwarder(
|
|
1811
|
+
progressTracker,
|
|
1812
|
+
(text) => process.stderr.write(text)
|
|
1813
|
+
);
|
|
1486
1814
|
const child = spawn(process.execPath, [strykerBinPath(), ...args2], {
|
|
1487
1815
|
cwd: REPO_ROOT,
|
|
1488
1816
|
env,
|
|
1489
|
-
stdio: "inherit"
|
|
1817
|
+
stdio: ["inherit", "pipe", "pipe"]
|
|
1818
|
+
});
|
|
1819
|
+
child.stdout?.on("data", (chunk) => {
|
|
1820
|
+
stdoutForwarder.write(String(chunk));
|
|
1821
|
+
});
|
|
1822
|
+
child.stderr?.on("data", (chunk) => {
|
|
1823
|
+
stderrForwarder.write(String(chunk));
|
|
1490
1824
|
});
|
|
1491
1825
|
const progressTimer = setInterval(() => {
|
|
1492
1826
|
console.error(
|
|
1493
|
-
`[mutation] Still running ${target.relativePath} after ${formatElapsedDuration(Date.now() - startedAt)}...`
|
|
1827
|
+
progressTracker.formatLatest() ?? `[mutation] Still running ${target.relativePath} after ${formatElapsedDuration(Date.now() - startedAt)}...`
|
|
1494
1828
|
);
|
|
1495
1829
|
}, MUTATION_PROGRESS_INTERVAL_MS);
|
|
1496
1830
|
const clearProgressTimer = () => {
|
|
1497
1831
|
clearInterval(progressTimer);
|
|
1832
|
+
stdoutForwarder.flush();
|
|
1833
|
+
stderrForwarder.flush();
|
|
1498
1834
|
};
|
|
1499
1835
|
child.once("error", (error) => {
|
|
1500
1836
|
clearProgressTimer();
|
|
@@ -1555,7 +1891,7 @@ function parseBareMutationTargetArg(args2) {
|
|
|
1555
1891
|
}
|
|
1556
1892
|
return void 0;
|
|
1557
1893
|
}
|
|
1558
|
-
function
|
|
1894
|
+
function collectFlagValues2(args2, name) {
|
|
1559
1895
|
const values = [];
|
|
1560
1896
|
for (let index = 0; index < args2.length; index += 1) {
|
|
1561
1897
|
const arg = args2[index];
|
|
@@ -1582,11 +1918,11 @@ function parseJsonStringArray(value, flagName) {
|
|
|
1582
1918
|
}
|
|
1583
1919
|
function mutationRunOptions(args2) {
|
|
1584
1920
|
const mutateGlobs = [
|
|
1585
|
-
...
|
|
1921
|
+
...collectFlagValues2(args2, "--mutate-glob"),
|
|
1586
1922
|
...parseJsonStringArray(flagValue(args2, "--mutate-globs-json"), "--mutate-globs-json")
|
|
1587
1923
|
];
|
|
1588
1924
|
const testIncludes = [
|
|
1589
|
-
...
|
|
1925
|
+
...collectFlagValues2(args2, "--test-include"),
|
|
1590
1926
|
...parseJsonStringArray(flagValue(args2, "--test-includes-json"), "--test-includes-json")
|
|
1591
1927
|
];
|
|
1592
1928
|
return {
|
|
@@ -2644,7 +2980,7 @@ function runReachabilityCli(rawArgs, dependencies = DEFAULT_DEPENDENCIES4) {
|
|
|
2644
2980
|
}
|
|
2645
2981
|
|
|
2646
2982
|
// src/scrap/analysis/pipeline/run.ts
|
|
2647
|
-
import * as
|
|
2983
|
+
import * as fs2 from "fs";
|
|
2648
2984
|
import * as ts25 from "typescript";
|
|
2649
2985
|
|
|
2650
2986
|
// src/scrap/test/discovery/files.ts
|
|
@@ -3990,8 +4326,8 @@ function compareBlockSummaries(left, right) {
|
|
|
3990
4326
|
|
|
3991
4327
|
// src/scrap/structure/blocks/groups.ts
|
|
3992
4328
|
var BLOCK_SEPARATOR = "";
|
|
3993
|
-
function blockPathKey(
|
|
3994
|
-
return
|
|
4329
|
+
function blockPathKey(path4) {
|
|
4330
|
+
return path4.join(BLOCK_SEPARATOR);
|
|
3995
4331
|
}
|
|
3996
4332
|
function blockPathFromKey(key) {
|
|
3997
4333
|
return key.split(BLOCK_SEPARATOR);
|
|
@@ -4017,7 +4353,7 @@ function averageScore2(examples) {
|
|
|
4017
4353
|
function countExamples2(examples, predicate) {
|
|
4018
4354
|
return examples.filter(predicate).length;
|
|
4019
4355
|
}
|
|
4020
|
-
function summarizeBlock(
|
|
4356
|
+
function summarizeBlock(path4, examples) {
|
|
4021
4357
|
const meanScore = averageScore2(examples);
|
|
4022
4358
|
const maxScore2 = examples.reduce((max, example) => Math.max(max, example.score), 0);
|
|
4023
4359
|
const hotExampleCount2 = countExamples2(examples, (example) => example.score >= 8);
|
|
@@ -4032,8 +4368,8 @@ function summarizeBlock(path3, examples) {
|
|
|
4032
4368
|
hotExampleCount: hotExampleCount2,
|
|
4033
4369
|
lowAssertionExampleCount: countExamples2(examples, (example) => example.assertionCount <= 1),
|
|
4034
4370
|
maxScore: maxScore2,
|
|
4035
|
-
name:
|
|
4036
|
-
path:
|
|
4371
|
+
name: path4[path4.length - 1],
|
|
4372
|
+
path: path4,
|
|
4037
4373
|
remediationMode: remediationMode(examples.length, meanScore, hotExampleCount2, maxScore2),
|
|
4038
4374
|
zeroAssertionExampleCount: countExamples2(examples, (example) => example.assertionCount === 0)
|
|
4039
4375
|
};
|
|
@@ -4411,7 +4747,7 @@ function analyzeScrapFile(sourceFile) {
|
|
|
4411
4747
|
// src/scrap/analysis/pipeline/run.ts
|
|
4412
4748
|
function analyzeScrap(target) {
|
|
4413
4749
|
return discoverTestFiles(target).map((filePath) => {
|
|
4414
|
-
const source =
|
|
4750
|
+
const source = fs2.readFileSync(filePath, "utf-8");
|
|
4415
4751
|
const sourceFile = ts25.createSourceFile(
|
|
4416
4752
|
filePath,
|
|
4417
4753
|
source,
|
|
@@ -4567,8 +4903,8 @@ function resolveScrapPolicy(args2) {
|
|
|
4567
4903
|
}
|
|
4568
4904
|
|
|
4569
4905
|
// src/scrap/report/blocks/format.ts
|
|
4570
|
-
function formatBlockPath(
|
|
4571
|
-
return
|
|
4906
|
+
function formatBlockPath(path4) {
|
|
4907
|
+
return path4.join(" > ");
|
|
4572
4908
|
}
|
|
4573
4909
|
function interestingBlocks(metric) {
|
|
4574
4910
|
return metric.blockSummaries.filter((block) => block.remediationMode !== "STABLE").slice(0, 5);
|
|
@@ -4786,6 +5122,7 @@ function runScrapCli(rawArgs, dependencies = DEFAULT_DEPENDENCIES5) {
|
|
|
4786
5122
|
|
|
4787
5123
|
// src/cli/main.ts
|
|
4788
5124
|
var COMMANDS = {
|
|
5125
|
+
acceptance: runAcceptanceCli,
|
|
4789
5126
|
boundaries: runBoundariesCli,
|
|
4790
5127
|
crap: runCrapCli,
|
|
4791
5128
|
init: runInitCli,
|
|
@@ -4799,6 +5136,7 @@ function printHelp() {
|
|
|
4799
5136
|
|
|
4800
5137
|
Commands:
|
|
4801
5138
|
init Create a starter quality.config.json
|
|
5139
|
+
acceptance Compile human-authored acceptance specs into executable tests
|
|
4802
5140
|
organize Check folder structure, naming, and cohesion
|
|
4803
5141
|
boundaries Check package/layer boundaries
|
|
4804
5142
|
reachability Check dead surfaces and dead ends
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poleski/quality-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Portable TypeScript quality checks for project structure, complexity, mutation, and test health",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,10 +43,11 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "esbuild src/cli/main.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/cli/main.js",
|
|
45
45
|
"ci": "pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
|
|
46
|
-
"release": "pnpm run ci && pnpm publish --
|
|
46
|
+
"release": "pnpm run ci && pnpm publish --no-git-checks",
|
|
47
47
|
"changeset": "changeset",
|
|
48
48
|
"cli": "tsx src/cli/main.ts",
|
|
49
49
|
"quality-tools": "pnpm run cli",
|
|
50
|
+
"acceptance": "tsx src/cli/acceptance.ts",
|
|
50
51
|
"boundaries": "tsx src/cli/boundaries.ts",
|
|
51
52
|
"reachability": "tsx src/cli/reachability.ts",
|
|
52
53
|
"crap": "tsx src/cli/crap.ts",
|
|
@@ -13,6 +13,10 @@ const { vitestWrapper } = await import(path.join(vitestRunnerRoot, 'dist/src/vit
|
|
|
13
13
|
const STRYKER_SETUP = path.join(vitestRunnerRoot, 'dist/src/stryker-setup.js');
|
|
14
14
|
const STRYKER_SETUP_SOURCE_MAP = 'stryker-setup.js.map';
|
|
15
15
|
|
|
16
|
+
export const strykerValidationSchema = JSON.parse(
|
|
17
|
+
fs.readFileSync(path.join(vitestRunnerRoot, 'dist/schema/vitest-runner-options.json'), 'utf-8'),
|
|
18
|
+
);
|
|
19
|
+
|
|
16
20
|
function createStrykerSetupSourceMap(setupFilePath) {
|
|
17
21
|
return JSON.stringify({
|
|
18
22
|
version: 3,
|
package/stryker.config.cjs
CHANGED
|
@@ -21,7 +21,6 @@ module.exports = {
|
|
|
21
21
|
testRunner: 'quality-tools-vitest',
|
|
22
22
|
plugins: [
|
|
23
23
|
path.join(packageRoot, 'stryker/quality-tools-vitest-runner.mjs'),
|
|
24
|
-
'@stryker-mutator/vitest-runner',
|
|
25
24
|
],
|
|
26
25
|
vitest: {
|
|
27
26
|
configFile: path.isAbsolute(vitestConfig) ? vitestConfig : path.join(hostRoot, vitestConfig),
|