@poleski/quality-tools 0.2.0 → 0.2.1
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 +6 -0
- package/README.md +37 -3
- package/dist/cli/main.js +707 -166
- package/dist/cli/templates/playwright-acceptance-runtime.ts.template +170 -0
- package/package.json +2 -2
package/dist/cli/main.js
CHANGED
|
@@ -5,149 +5,544 @@ import fs from "node:fs";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { glob } from "glob";
|
|
7
7
|
|
|
8
|
+
// src/acceptance/dryChecker.ts
|
|
9
|
+
var NEAR_DUPLICATE_THRESHOLD = 0.72;
|
|
10
|
+
var POSSIBLE_SYNONYM_THRESHOLD = 0.45;
|
|
11
|
+
var IGNORED_TOKENS = /* @__PURE__ */ new Set([
|
|
12
|
+
"a",
|
|
13
|
+
"an",
|
|
14
|
+
"and",
|
|
15
|
+
"are",
|
|
16
|
+
"be",
|
|
17
|
+
"i",
|
|
18
|
+
"in",
|
|
19
|
+
"is",
|
|
20
|
+
"of",
|
|
21
|
+
"the",
|
|
22
|
+
"then",
|
|
23
|
+
"to",
|
|
24
|
+
"when"
|
|
25
|
+
]);
|
|
26
|
+
function analyzeAcceptanceIrDryness(document, options = {}) {
|
|
27
|
+
const occurrences = document.scenarios.flatMap(
|
|
28
|
+
(scenario, scenarioIndex) => scenario.steps.map((step, stepIndex) => ({
|
|
29
|
+
step,
|
|
30
|
+
location: createLocation(scenario, scenarioIndex, step, stepIndex)
|
|
31
|
+
}))
|
|
32
|
+
);
|
|
33
|
+
const scenarioShapeGroups = findRepeatedScenarioShapes(document.scenarios);
|
|
34
|
+
const repeatedStepPatterns = findRepeatedStepPatterns(occurrences);
|
|
35
|
+
const findings = [
|
|
36
|
+
...findDuplicateInScenario(document.scenarios),
|
|
37
|
+
...options.includeExact ? findExactDuplicates(occurrences) : [],
|
|
38
|
+
...findPlaceholderVariants(occurrences),
|
|
39
|
+
...repeatedStepPatterns,
|
|
40
|
+
...options.includeSimilar ? findSimilarSteps(occurrences) : []
|
|
41
|
+
];
|
|
42
|
+
return {
|
|
43
|
+
schema_version: 1,
|
|
44
|
+
source_path: document.source_path,
|
|
45
|
+
feature_name: document.feature.name,
|
|
46
|
+
summary: {
|
|
47
|
+
scenarios: document.scenarios.length,
|
|
48
|
+
step_occurrences: occurrences.length,
|
|
49
|
+
unique_steps: new Set(occurrences.map((occurrence) => occurrence.step.text)).size,
|
|
50
|
+
repeated_step_patterns: repeatedStepPatterns.length,
|
|
51
|
+
repeated_scenario_shapes: scenarioShapeGroups.length,
|
|
52
|
+
findings: findings.length
|
|
53
|
+
},
|
|
54
|
+
repeated_scenario_shapes: scenarioShapeGroups,
|
|
55
|
+
findings
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function findDuplicateInScenario(scenarios) {
|
|
59
|
+
return scenarios.flatMap((scenario, scenarioIndex) => {
|
|
60
|
+
const groups = groupOccurrences(scenario.steps.map((step, stepIndex) => ({
|
|
61
|
+
step,
|
|
62
|
+
location: createLocation(scenario, scenarioIndex, step, stepIndex)
|
|
63
|
+
})), (occurrence) => occurrence.step.text);
|
|
64
|
+
return [...groups.entries()].filter(([, members]) => containsDuplicateWithoutInterveningAction(scenario.steps, members)).map(([text, members]) => createFinding({
|
|
65
|
+
kind: "duplicate-in-scenario",
|
|
66
|
+
confidence: "high",
|
|
67
|
+
canonicalCandidate: text,
|
|
68
|
+
members,
|
|
69
|
+
reason: "the same step text appears more than once in one scenario",
|
|
70
|
+
suggestedAction: "Review the scenario and remove the repeated step if it is accidental."
|
|
71
|
+
}));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function containsDuplicateWithoutInterveningAction(steps, members) {
|
|
75
|
+
const stepIndexes = members.map((member) => member.location.step_index).sort((left, right) => left - right);
|
|
76
|
+
return stepIndexes.some((stepIndex, index) => {
|
|
77
|
+
const nextStepIndex = stepIndexes[index + 1];
|
|
78
|
+
return nextStepIndex !== void 0 && !hasInterveningAction(steps, stepIndex, nextStepIndex);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function hasInterveningAction(steps, leftIndex, rightIndex) {
|
|
82
|
+
const effectiveKeywords = getEffectiveKeywords(steps);
|
|
83
|
+
return steps.slice(leftIndex + 1, rightIndex).some((_, offset) => {
|
|
84
|
+
const effectiveKeyword = effectiveKeywords[leftIndex + offset + 1];
|
|
85
|
+
return effectiveKeyword === "Given" || effectiveKeyword === "When";
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function getEffectiveKeywords(steps) {
|
|
89
|
+
let currentKeyword = "Given";
|
|
90
|
+
return steps.map((step) => {
|
|
91
|
+
if (step.keyword !== "And" && step.keyword !== "But") {
|
|
92
|
+
currentKeyword = step.keyword;
|
|
93
|
+
}
|
|
94
|
+
return currentKeyword;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function findExactDuplicates(occurrences) {
|
|
98
|
+
return [...groupOccurrences(occurrences, (occurrence) => occurrence.step.text).entries()].filter(([, members]) => members.length > 1).map(([text, members]) => createFinding({
|
|
99
|
+
kind: "exact-duplicate",
|
|
100
|
+
confidence: "medium",
|
|
101
|
+
canonicalCandidate: text,
|
|
102
|
+
members,
|
|
103
|
+
reason: "the same step text appears more than once in the feature",
|
|
104
|
+
suggestedAction: "Keep repeated setup vocabulary when intentional; normalize only accidental drift."
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
function findPlaceholderVariants(occurrences) {
|
|
108
|
+
return [...groupOccurrences(occurrences, (occurrence) => normalizePlaceholders(occurrence.step.text)).entries()].filter(([, members]) => members.length > 1 && new Set(members.map((member) => member.step.text)).size > 1).map(([text, members]) => createFinding({
|
|
109
|
+
kind: "placeholder-variant",
|
|
110
|
+
confidence: "high",
|
|
111
|
+
canonicalCandidate: text,
|
|
112
|
+
patternCandidate: patternFromPlaceholderText(text),
|
|
113
|
+
members,
|
|
114
|
+
reason: "step text is identical after replacing placeholder names with generic slots",
|
|
115
|
+
suggestedAction: "Normalize the Gherkin if the different placeholder names do not add reader meaning."
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
function findRepeatedStepPatterns(occurrences) {
|
|
119
|
+
return [...groupOccurrences(occurrences, (occurrence) => normalizeStepPattern(occurrence.step.text)).entries()].filter(([, members]) => members.length > 1 && new Set(members.map((member) => member.step.text)).size > 1).map(([pattern, members]) => createFinding({
|
|
120
|
+
kind: "repeated-step-pattern",
|
|
121
|
+
confidence: "high",
|
|
122
|
+
canonicalCandidate: pattern,
|
|
123
|
+
patternCandidate: pattern,
|
|
124
|
+
members,
|
|
125
|
+
reason: "step text follows the same generalized pattern with different example values",
|
|
126
|
+
suggestedAction: "Consider a Scenario Outline, data table, or shared Background if the repeated shape is intentional."
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
function findSimilarSteps(occurrences) {
|
|
130
|
+
return findSimilarStepCandidates(occurrences).map(createSimilarStepFinding);
|
|
131
|
+
}
|
|
132
|
+
function findSimilarStepCandidates(occurrences) {
|
|
133
|
+
return occurrences.flatMap(
|
|
134
|
+
(left, leftIndex) => occurrences.slice(leftIndex + 1).map((right) => createSimilarStepCandidate(left, right)).filter((candidate) => candidate !== void 0)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
function createSimilarStepCandidate(left, right) {
|
|
138
|
+
if (!shouldCompareSimilarSteps(left, right)) {
|
|
139
|
+
return void 0;
|
|
140
|
+
}
|
|
141
|
+
const score = tokenSimilarity(left.step.text, right.step.text);
|
|
142
|
+
return score >= POSSIBLE_SYNONYM_THRESHOLD ? { left, right, score } : void 0;
|
|
143
|
+
}
|
|
144
|
+
function shouldCompareSimilarSteps(left, right) {
|
|
145
|
+
return left.step.text !== right.step.text && normalizePlaceholders(left.step.text) !== normalizePlaceholders(right.step.text);
|
|
146
|
+
}
|
|
147
|
+
function createSimilarStepFinding(candidate) {
|
|
148
|
+
return createFinding({
|
|
149
|
+
kind: similarStepKind(candidate.score),
|
|
150
|
+
confidence: similarStepConfidence(candidate.score),
|
|
151
|
+
canonicalCandidate: candidate.left.step.text,
|
|
152
|
+
members: [candidate.left, candidate.right],
|
|
153
|
+
reason: "step texts share similar normalized tokens",
|
|
154
|
+
score: Number(candidate.score.toFixed(3)),
|
|
155
|
+
suggestedAction: "Review whether these steps express the same behavior before changing feature wording."
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function similarStepKind(score) {
|
|
159
|
+
return score >= NEAR_DUPLICATE_THRESHOLD ? "near-duplicate" : "possible-synonym";
|
|
160
|
+
}
|
|
161
|
+
function similarStepConfidence(score) {
|
|
162
|
+
return score >= NEAR_DUPLICATE_THRESHOLD ? "medium" : "low";
|
|
163
|
+
}
|
|
164
|
+
function createLocation(scenario, scenarioIndex, step, stepIndex) {
|
|
165
|
+
return {
|
|
166
|
+
section: "scenario",
|
|
167
|
+
scenario_index: scenarioIndex,
|
|
168
|
+
scenario_name: scenario.name,
|
|
169
|
+
step_index: stepIndex,
|
|
170
|
+
keyword: step.keyword,
|
|
171
|
+
line: step.line
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function findRepeatedScenarioShapes(scenarios) {
|
|
175
|
+
const exactShapeGroups = [...groupOccurrences(
|
|
176
|
+
scenarios.map((scenario, scenarioIndex) => ({ scenario, scenarioIndex })),
|
|
177
|
+
({ scenario }) => scenario.steps.map((step) => normalizeStepPattern(step.text)).join("\n")
|
|
178
|
+
).entries()].filter(([, members]) => members.length > 1).map(([, members]) => createScenarioShapeGroup(members, members[0]?.scenario.steps.length ?? 0, "high"));
|
|
179
|
+
const prefixShapeGroups = [...groupOccurrences(
|
|
180
|
+
scenarios.map((scenario, scenarioIndex) => ({
|
|
181
|
+
scenario,
|
|
182
|
+
scenarioIndex,
|
|
183
|
+
prefix: longestSetupPrefix(scenario)
|
|
184
|
+
})).filter((entry) => entry.prefix.length >= 3),
|
|
185
|
+
({ prefix }) => prefix.join("\n")
|
|
186
|
+
).entries()].filter(([, members]) => members.length > 1).map(([, members]) => createScenarioShapeGroup(members, members[0]?.prefix.length ?? 0, "medium"));
|
|
187
|
+
return dedupeScenarioShapeGroups([...exactShapeGroups, ...prefixShapeGroups]);
|
|
188
|
+
}
|
|
189
|
+
function createScenarioShapeGroup(members, sharedStepCount, confidence) {
|
|
190
|
+
const pattern = members[0]?.prefix ?? members[0]?.scenario.steps.slice(0, sharedStepCount).map((step) => normalizeStepPattern(step.text)) ?? [];
|
|
191
|
+
return {
|
|
192
|
+
confidence,
|
|
193
|
+
scenario_count: members.length,
|
|
194
|
+
shared_step_count: sharedStepCount,
|
|
195
|
+
pattern,
|
|
196
|
+
scenarios: members.map(({ scenario, scenarioIndex }) => ({
|
|
197
|
+
scenario_index: scenarioIndex,
|
|
198
|
+
scenario_name: scenario.name,
|
|
199
|
+
line: scenario.line,
|
|
200
|
+
examples: scenario.examples.length
|
|
201
|
+
})),
|
|
202
|
+
reason: confidence === "high" ? "multiple scenarios have the same generalized step sequence" : "multiple scenarios share the same setup or action prefix before diverging into specific assertions",
|
|
203
|
+
suggested_action: confidence === "high" ? "Consider collapsing these scenarios into a Scenario Outline or a data-driven runner." : "Consider moving the shared prefix into Background or a host fixture if it does not need to be repeated in every scenario."
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function longestSetupPrefix(scenario) {
|
|
207
|
+
const normalizedSteps = scenario.steps.map((step) => normalizeStepPattern(step.text));
|
|
208
|
+
const assertionIndex = scenario.steps.findIndex((step) => step.keyword === "Then");
|
|
209
|
+
const prefixLength = assertionIndex === -1 ? normalizedSteps.length : assertionIndex;
|
|
210
|
+
return normalizedSteps.slice(0, prefixLength);
|
|
211
|
+
}
|
|
212
|
+
function dedupeScenarioShapeGroups(groups) {
|
|
213
|
+
const seen = /* @__PURE__ */ new Set();
|
|
214
|
+
const sorted = groups.sort(
|
|
215
|
+
(left, right) => right.scenario_count - left.scenario_count || right.shared_step_count - left.shared_step_count || left.confidence.localeCompare(right.confidence)
|
|
216
|
+
);
|
|
217
|
+
return sorted.filter((group) => {
|
|
218
|
+
const scenarioKey = group.scenarios.map((scenario) => scenario.scenario_index).join(",");
|
|
219
|
+
const key = `${scenarioKey}:${group.shared_step_count}:${group.pattern.join("\n")}`;
|
|
220
|
+
if (seen.has(key)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
seen.add(key);
|
|
224
|
+
return true;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function createFinding(input) {
|
|
228
|
+
return {
|
|
229
|
+
kind: input.kind,
|
|
230
|
+
confidence: input.confidence,
|
|
231
|
+
canonical_candidate: input.canonicalCandidate,
|
|
232
|
+
...input.patternCandidate ? { pattern_candidate: input.patternCandidate } : {},
|
|
233
|
+
...input.score === void 0 ? {} : { score: input.score },
|
|
234
|
+
members: [...groupOccurrences(input.members, (member) => member.step.text).entries()].map(([text, members]) => ({
|
|
235
|
+
text,
|
|
236
|
+
locations: members.map((member) => member.location)
|
|
237
|
+
})),
|
|
238
|
+
reason: input.reason,
|
|
239
|
+
suggested_action: input.suggestedAction
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function groupOccurrences(values, keySelector) {
|
|
243
|
+
const groups = /* @__PURE__ */ new Map();
|
|
244
|
+
values.forEach((value) => {
|
|
245
|
+
const key = keySelector(value);
|
|
246
|
+
groups.set(key, [...groups.get(key) ?? [], value]);
|
|
247
|
+
});
|
|
248
|
+
return groups;
|
|
249
|
+
}
|
|
250
|
+
function normalizePlaceholders(text) {
|
|
251
|
+
let index = 0;
|
|
252
|
+
return text.replace(/<[^>]+>/g, () => {
|
|
253
|
+
index += 1;
|
|
254
|
+
return `<_${index}>`;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
function normalizeStepPattern(text) {
|
|
258
|
+
return normalizePlaceholders(text).replace(/\b\d+\b/g, "<number>").replace(/\bexamples\/[A-Za-z0-9_-]+\b/g, "<workspace>").replace(/\b[A-Za-z0-9._/-]+\.[A-Za-z0-9]+\b/g, "<path>").replace(/\b(File|Folder|Package|Symbol|Namespace|Function|Callable|Method|Constructor|Prototype|Class|Interface|Record|Delegate|Property|Event|Type|Struct|Union|Enum|Alias|Template|Typedef|Variable|Constant|Global|Field|Parameter|Local|Godot class_name)\b/g, "<node-type>").replace(/\b(Include|Imports|References|Calls|Type imports|Inherits|Using|Call|Implements|Loads|Nests|Contains|Overrides|TypeScript Alias Import)\b/g, "<edge-type>").replace(/\s+/g, " ").trim();
|
|
259
|
+
}
|
|
260
|
+
function patternFromPlaceholderText(text) {
|
|
261
|
+
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
262
|
+
return `^${escaped.replace(/<_[0-9]+>/g, "(.+)")}$`;
|
|
263
|
+
}
|
|
264
|
+
function tokenSimilarity(left, right) {
|
|
265
|
+
const leftTokens = tokenize(left);
|
|
266
|
+
const rightTokens = tokenize(right);
|
|
267
|
+
const union = /* @__PURE__ */ new Set([...leftTokens, ...rightTokens]);
|
|
268
|
+
if (union.size === 0) {
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
const intersection = [...leftTokens].filter((token) => rightTokens.has(token));
|
|
272
|
+
return intersection.length / union.size;
|
|
273
|
+
}
|
|
274
|
+
function tokenize(text) {
|
|
275
|
+
return new Set(
|
|
276
|
+
text.replace(/<[^>]+>/g, " ").toLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length > 0 && !IGNORED_TOKENS.has(token))
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/acceptance/ir.ts
|
|
281
|
+
function toAcceptanceIr(document) {
|
|
282
|
+
return {
|
|
283
|
+
schema_version: 1,
|
|
284
|
+
source_path: document.sourcePath,
|
|
285
|
+
feature: {
|
|
286
|
+
name: document.feature.name,
|
|
287
|
+
line: document.feature.line
|
|
288
|
+
},
|
|
289
|
+
...document.background ? {
|
|
290
|
+
background: {
|
|
291
|
+
line: document.background.line,
|
|
292
|
+
steps: document.background.steps.map(toAcceptanceIrStep)
|
|
293
|
+
}
|
|
294
|
+
} : {},
|
|
295
|
+
scenarios: document.scenarios.map((scenario) => ({
|
|
296
|
+
name: scenario.name,
|
|
297
|
+
line: scenario.line,
|
|
298
|
+
steps: scenario.steps.map(toAcceptanceIrStep),
|
|
299
|
+
examples: scenario.examples.map((example) => ({
|
|
300
|
+
line: example.line,
|
|
301
|
+
values: example.values
|
|
302
|
+
}))
|
|
303
|
+
}))
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function toAcceptanceIrStep(documentStep) {
|
|
307
|
+
return {
|
|
308
|
+
keyword: documentStep.keyword,
|
|
309
|
+
text: documentStep.text,
|
|
310
|
+
line: documentStep.line,
|
|
311
|
+
parameters: documentStep.parameters
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
8
315
|
// src/acceptance/parser.ts
|
|
9
316
|
var FEATURE_PATTERN = /^#{0,6}\s*Feature:\s*(.+)$/;
|
|
317
|
+
var BACKGROUND_PATTERN = /^#{0,6}\s*Background:\s*$/;
|
|
10
318
|
var SCENARIO_PATTERN = /^#{0,6}\s*Scenario:\s*(.+)$/;
|
|
319
|
+
var SCENARIO_OUTLINE_PATTERN = /^#{0,6}\s*Scenario Outline:\s*(.+)$/;
|
|
320
|
+
var EXAMPLES_PATTERN = /^#{0,6}\s*Examples:\s*$/;
|
|
11
321
|
var STEP_PATTERN = /^(Given|When|Then|And|But)\s+(.+)$/;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
322
|
+
var PARAMETER_PATTERN = /<([A-Za-z0-9_]+)>/g;
|
|
323
|
+
var LINE_PARSERS = [
|
|
324
|
+
parseFeatureHeading,
|
|
325
|
+
parseBackgroundHeading,
|
|
326
|
+
parseScenarioHeading,
|
|
327
|
+
parseExamplesHeading,
|
|
328
|
+
parseExamplesRowLine,
|
|
329
|
+
parseStepLine
|
|
330
|
+
];
|
|
331
|
+
function parseAcceptanceFeature(featureSource, sourcePath) {
|
|
332
|
+
const state = createParserState(sourcePath);
|
|
333
|
+
parseFeatureLines(state, featureSource);
|
|
334
|
+
validateDocument(state);
|
|
335
|
+
return createDocument(state);
|
|
336
|
+
}
|
|
337
|
+
function createParserState(sourcePath) {
|
|
338
|
+
return {
|
|
339
|
+
sourcePath,
|
|
340
|
+
scenarios: [],
|
|
341
|
+
scenariosWithExamples: /* @__PURE__ */ new Set()
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function parseFeatureLines(state, featureSource) {
|
|
345
|
+
featureSource.split(/\r?\n/).forEach((rawLine, index) => {
|
|
17
346
|
const lineNumber = index + 1;
|
|
18
347
|
const line = rawLine.trim();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
});
|
|
348
|
+
parseFeatureLine(state, line, lineNumber);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
function parseFeatureLine(state, line, lineNumber) {
|
|
352
|
+
if (line === "") {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
for (const parseLine of LINE_PARSERS) {
|
|
356
|
+
if (parseLine(state, line, lineNumber)) {
|
|
37
357
|
return;
|
|
38
358
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function parseFeatureHeading(state, line, lineNumber) {
|
|
362
|
+
const featureMatch = FEATURE_PATTERN.exec(line);
|
|
363
|
+
if (!featureMatch) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
state.feature = {
|
|
367
|
+
name: featureMatch[1].trim(),
|
|
368
|
+
line: lineNumber
|
|
369
|
+
};
|
|
370
|
+
clearActiveTargets(state);
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
function parseBackgroundHeading(state, line, lineNumber) {
|
|
374
|
+
if (!BACKGROUND_PATTERN.test(line)) {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
if (state.background) {
|
|
378
|
+
throw new Error(`${state.sourcePath}:${lineNumber} Feature has more than one Background`);
|
|
379
|
+
}
|
|
380
|
+
state.background = {
|
|
381
|
+
line: lineNumber,
|
|
382
|
+
steps: []
|
|
383
|
+
};
|
|
384
|
+
state.stepTarget = state.background;
|
|
385
|
+
clearExamplesTarget(state);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
function parseScenarioHeading(state, line, lineNumber) {
|
|
389
|
+
const scenarioMatch = SCENARIO_PATTERN.exec(line) ?? SCENARIO_OUTLINE_PATTERN.exec(line);
|
|
390
|
+
if (!scenarioMatch) {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
const scenario = {
|
|
394
|
+
name: scenarioMatch[1].trim(),
|
|
395
|
+
line: lineNumber,
|
|
396
|
+
steps: [],
|
|
397
|
+
examples: []
|
|
398
|
+
};
|
|
399
|
+
state.scenarios.push(scenario);
|
|
400
|
+
state.stepTarget = scenario;
|
|
401
|
+
clearExamplesTarget(state);
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
function parseExamplesHeading(state, line, lineNumber) {
|
|
405
|
+
if (!EXAMPLES_PATTERN.test(line)) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
const scenario = state.scenarios.at(-1);
|
|
409
|
+
if (!scenario) {
|
|
410
|
+
throw new Error(`${state.sourcePath}:${lineNumber} Examples appear before a Scenario`);
|
|
411
|
+
}
|
|
412
|
+
state.examplesTarget = scenario;
|
|
413
|
+
state.scenariosWithExamples.add(scenario);
|
|
414
|
+
state.stepTarget = void 0;
|
|
415
|
+
state.examplesHeaders = void 0;
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
function parseExamplesRowLine(state, line, lineNumber) {
|
|
419
|
+
if (!line.startsWith("|")) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
if (!state.examplesTarget) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
const values = parseExamplesRow(line);
|
|
426
|
+
if (!state.examplesHeaders) {
|
|
427
|
+
setExamplesHeaders(state, values, lineNumber);
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
if (values.length !== state.examplesHeaders.length) {
|
|
431
|
+
throw new Error(`${state.sourcePath}:${lineNumber} Examples row has ${values.length} cells; expected ${state.examplesHeaders.length}`);
|
|
432
|
+
}
|
|
433
|
+
state.examplesTarget.examples.push({
|
|
434
|
+
line: lineNumber,
|
|
435
|
+
values: Object.fromEntries(state.examplesHeaders.map((header, cellIndex) => [header, values[cellIndex] ?? ""]))
|
|
51
436
|
});
|
|
52
|
-
|
|
53
|
-
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
function setExamplesHeaders(state, values, lineNumber) {
|
|
440
|
+
state.examplesHeaders = values;
|
|
441
|
+
if (state.examplesHeaders.length === 0) {
|
|
442
|
+
throw new Error(`${state.sourcePath}:${lineNumber} Examples header must contain at least one column`);
|
|
54
443
|
}
|
|
55
|
-
|
|
56
|
-
|
|
444
|
+
}
|
|
445
|
+
function parseStepLine(state, line, lineNumber) {
|
|
446
|
+
const stepMatch = STEP_PATTERN.exec(line);
|
|
447
|
+
if (!stepMatch) {
|
|
448
|
+
return false;
|
|
57
449
|
}
|
|
58
|
-
|
|
450
|
+
if (!state.stepTarget) {
|
|
451
|
+
throw new Error(`${state.sourcePath}:${lineNumber} Step appears before a Scenario or Background`);
|
|
452
|
+
}
|
|
453
|
+
const text = stepMatch[2].trim();
|
|
454
|
+
state.stepTarget.steps.push({
|
|
455
|
+
keyword: stepMatch[1],
|
|
456
|
+
text,
|
|
457
|
+
line: lineNumber,
|
|
458
|
+
parameters: extractParameters(text)
|
|
459
|
+
});
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
function clearActiveTargets(state) {
|
|
463
|
+
state.stepTarget = void 0;
|
|
464
|
+
clearExamplesTarget(state);
|
|
465
|
+
}
|
|
466
|
+
function clearExamplesTarget(state) {
|
|
467
|
+
state.examplesTarget = void 0;
|
|
468
|
+
state.examplesHeaders = void 0;
|
|
469
|
+
}
|
|
470
|
+
function validateDocument(state) {
|
|
471
|
+
assertFeatureExists(state);
|
|
472
|
+
assertHasScenarios(state);
|
|
473
|
+
assertBackgroundHasSteps(state);
|
|
474
|
+
assertScenariosHaveSteps(state);
|
|
475
|
+
assertExamplesHaveRows(state);
|
|
476
|
+
}
|
|
477
|
+
function assertFeatureExists(state) {
|
|
478
|
+
if (!state.feature) {
|
|
479
|
+
throw new Error(`${state.sourcePath}: Expected a Feature heading`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function assertHasScenarios(state) {
|
|
483
|
+
if (state.scenarios.length === 0) {
|
|
484
|
+
throw new Error(`${state.sourcePath}: Expected at least one Scenario`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function assertBackgroundHasSteps(state) {
|
|
488
|
+
if (state.background && state.background.steps.length === 0) {
|
|
489
|
+
throw new Error(`${state.sourcePath}:${state.background.line} Background must contain at least one step`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function assertScenariosHaveSteps(state) {
|
|
493
|
+
const emptyScenario = state.scenarios.find((scenario) => scenario.steps.length === 0);
|
|
59
494
|
if (emptyScenario) {
|
|
60
|
-
throw new Error(`${sourcePath}:${emptyScenario.line} Scenario "${emptyScenario.name}" must contain at least one step`);
|
|
495
|
+
throw new Error(`${state.sourcePath}:${emptyScenario.line} Scenario "${emptyScenario.name}" must contain at least one step`);
|
|
61
496
|
}
|
|
497
|
+
}
|
|
498
|
+
function assertExamplesHaveRows(state) {
|
|
499
|
+
const emptyExamples = state.scenarios.find(
|
|
500
|
+
(scenario) => state.scenariosWithExamples.has(scenario) && scenario.examples.length === 0
|
|
501
|
+
);
|
|
502
|
+
if (emptyExamples) {
|
|
503
|
+
throw new Error(`${state.sourcePath}:${emptyExamples.line} Scenario "${emptyExamples.name}" has Examples without rows`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function createDocument(state) {
|
|
62
507
|
return {
|
|
63
|
-
sourcePath,
|
|
64
|
-
feature,
|
|
65
|
-
|
|
508
|
+
sourcePath: state.sourcePath,
|
|
509
|
+
feature: state.feature,
|
|
510
|
+
...state.background ? { background: state.background } : {},
|
|
511
|
+
scenarios: state.scenarios
|
|
66
512
|
};
|
|
67
513
|
}
|
|
514
|
+
function extractParameters(text) {
|
|
515
|
+
return [...text.matchAll(PARAMETER_PATTERN)].map((match) => match[1] ?? "");
|
|
516
|
+
}
|
|
517
|
+
function parseExamplesRow(line) {
|
|
518
|
+
return line.slice(1, line.endsWith("|") ? -1 : void 0).split("|").map((cell) => cell.trim());
|
|
519
|
+
}
|
|
68
520
|
|
|
69
521
|
// src/acceptance/playwright/generator.ts
|
|
70
|
-
|
|
71
|
-
|
|
522
|
+
import { readFileSync } from "node:fs";
|
|
523
|
+
var runtimeTemplateUrl = new URL("./templates/playwright-acceptance-runtime.ts.template", import.meta.url);
|
|
524
|
+
function generatePlaywrightAcceptanceSpec(options) {
|
|
72
525
|
return [
|
|
73
|
-
"/* Generated by quality-tools acceptance
|
|
74
|
-
"/* eslint-disable playwright/expect-expect */",
|
|
526
|
+
"/* Generated by quality-tools acceptance generate. Do not edit. */",
|
|
75
527
|
"import { test } from '@playwright/test';",
|
|
76
528
|
`import { acceptanceSteps, createAcceptanceContext } from ${quote(options.stepsImportPath)};`,
|
|
529
|
+
`import { loadAcceptanceIr, runAcceptanceFeature } from ${quote(options.runtimeImportPath)};`,
|
|
77
530
|
"",
|
|
78
|
-
|
|
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}`];",
|
|
531
|
+
`const feature = loadAcceptanceIr(${quote(options.irImportPath)});`,
|
|
90
532
|
"",
|
|
91
|
-
"
|
|
92
|
-
|
|
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),
|
|
533
|
+
"runAcceptanceFeature(test, feature, {",
|
|
534
|
+
" acceptanceSteps,",
|
|
535
|
+
" createAcceptanceContext",
|
|
107
536
|
"});",
|
|
108
537
|
""
|
|
109
|
-
];
|
|
538
|
+
].join("\n");
|
|
110
539
|
}
|
|
111
|
-
function
|
|
112
|
-
|
|
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
|
-
];
|
|
540
|
+
function generatePlaywrightAcceptanceRuntime() {
|
|
541
|
+
return readFileSync(runtimeTemplateUrl, "utf8");
|
|
143
542
|
}
|
|
144
543
|
function quote(value) {
|
|
145
544
|
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\r/g, "\\r").replace(/\n/g, "\\n")}'`;
|
|
146
545
|
}
|
|
147
|
-
function indentLines(lines, spaces) {
|
|
148
|
-
const prefix = " ".repeat(spaces);
|
|
149
|
-
return lines.map((line) => line === "" ? line : `${prefix}${line}`);
|
|
150
|
-
}
|
|
151
546
|
|
|
152
547
|
// src/shared/flagValue.ts
|
|
153
548
|
function flagValue(args2, name) {
|
|
@@ -181,51 +576,191 @@ function cleanCliArgs(args2) {
|
|
|
181
576
|
}
|
|
182
577
|
|
|
183
578
|
// src/acceptance/command.ts
|
|
579
|
+
var ACCEPTANCE_COMMANDS = /* @__PURE__ */ new Map([
|
|
580
|
+
["parse", parseCommand],
|
|
581
|
+
["dry-check", dryCheckCommand],
|
|
582
|
+
["generate", generateCommand],
|
|
583
|
+
["compile", compileCommand]
|
|
584
|
+
]);
|
|
585
|
+
var COMPILE_VALUE_FLAGS = [
|
|
586
|
+
"--steps",
|
|
587
|
+
"--ir",
|
|
588
|
+
"--dry",
|
|
589
|
+
"--spec",
|
|
590
|
+
"--out-dir",
|
|
591
|
+
"--ir-dir",
|
|
592
|
+
"--dry-report-dir"
|
|
593
|
+
];
|
|
184
594
|
async function runAcceptanceCli(rawArgs, options = {}) {
|
|
185
595
|
const args2 = cleanCliArgs(rawArgs);
|
|
186
596
|
const [command2, ...commandArgs] = args2;
|
|
187
|
-
|
|
188
|
-
|
|
597
|
+
const cwd = options.cwd ?? process.cwd();
|
|
598
|
+
if (isHelpCommand(command2)) {
|
|
599
|
+
console.log(acceptanceUsage());
|
|
600
|
+
return;
|
|
189
601
|
}
|
|
190
|
-
await
|
|
602
|
+
await requireAcceptanceCommand(command2)(commandArgs, cwd);
|
|
603
|
+
}
|
|
604
|
+
function acceptanceUsage() {
|
|
605
|
+
return [
|
|
606
|
+
"Usage:",
|
|
607
|
+
" quality-tools acceptance parse <feature-file> <json-output>",
|
|
608
|
+
" quality-tools acceptance dry-check [--include-exact] [--include-similar] <json-ir> <report-output>",
|
|
609
|
+
" quality-tools acceptance generate <json-ir> <generated-test-output> --steps <path>",
|
|
610
|
+
" quality-tools acceptance compile <spec-glob> <generated-output-dir> --steps <path> [--ir <dir>] [--dry <dir>]"
|
|
611
|
+
].join("\n");
|
|
191
612
|
}
|
|
192
|
-
|
|
613
|
+
function parseCommand(args2, cwd) {
|
|
614
|
+
const [featurePath, jsonOutputPath, ...extraArgs] = args2;
|
|
615
|
+
if (!featurePath || !jsonOutputPath || extraArgs.length > 0) {
|
|
616
|
+
throw new Error("Usage: quality-tools acceptance parse <feature-file> <json-output>");
|
|
617
|
+
}
|
|
618
|
+
const ir = parseIrFile(cwd, featurePath);
|
|
619
|
+
writeJsonFile(path.resolve(cwd, jsonOutputPath), ir);
|
|
620
|
+
}
|
|
621
|
+
function dryCheckCommand(args2, cwd) {
|
|
622
|
+
const options = parseDryCheckOptions(args2);
|
|
623
|
+
const report = analyzeAcceptanceIrDryness(readIrFile(cwd, options.irPath), {
|
|
624
|
+
includeExact: options.includeExact,
|
|
625
|
+
includeSimilar: options.includeSimilar
|
|
626
|
+
});
|
|
627
|
+
writeJsonFile(path.resolve(cwd, options.reportPath), report);
|
|
628
|
+
}
|
|
629
|
+
function generateCommand(args2, cwd) {
|
|
630
|
+
const options = parseGenerateOptions(args2);
|
|
631
|
+
writeGeneratedPlaywrightSpec(cwd, options.irPath, options.outPath, options.stepsPath);
|
|
632
|
+
}
|
|
633
|
+
async function compileCommand(args2, cwd) {
|
|
193
634
|
const options = parseCompileOptions(args2);
|
|
194
635
|
const specFiles = await findSpecFiles(cwd, options.specPatterns);
|
|
195
636
|
if (specFiles.length === 0) {
|
|
196
637
|
throw new Error(`No acceptance specs matched: ${options.specPatterns.join(", ")}`);
|
|
197
638
|
}
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
639
|
+
const generatedDir = path.resolve(cwd, options.generatedDir);
|
|
640
|
+
writePlaywrightRuntime(generatedDir);
|
|
641
|
+
specFiles.forEach((specFile) => {
|
|
642
|
+
const ir = parseIrFile(cwd, specFile);
|
|
643
|
+
const slug = sourcePathSlug(ir.source_path);
|
|
644
|
+
const irPath = path.join(path.resolve(cwd, options.irDir), `${slug}.json`);
|
|
645
|
+
const generatedPath = path.join(generatedDir, `${slug}.spec.ts`);
|
|
646
|
+
writeJsonFile(irPath, ir);
|
|
647
|
+
if (options.dryDir) {
|
|
648
|
+
writeJsonFile(path.join(path.resolve(cwd, options.dryDir), `${slug}.json`), analyzeAcceptanceIrDryness(ir, {
|
|
649
|
+
includeExact: options.includeExact,
|
|
650
|
+
includeSimilar: options.includeSimilar
|
|
651
|
+
}));
|
|
652
|
+
}
|
|
653
|
+
writeGeneratedPlaywrightSpec(cwd, irPath, generatedPath, options.stepsPath);
|
|
201
654
|
});
|
|
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
655
|
}
|
|
208
|
-
function
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
656
|
+
function parseDryCheckOptions(args2) {
|
|
657
|
+
const positional = args2.filter((arg) => !arg.startsWith("--"));
|
|
658
|
+
if (positional.length !== 2) {
|
|
659
|
+
throw new Error("Usage: quality-tools acceptance dry-check [--include-exact] [--include-similar] <json-ir> <report-output>");
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
irPath: positional[0] ?? "",
|
|
663
|
+
reportPath: positional[1] ?? "",
|
|
664
|
+
includeExact: args2.includes("--include-exact"),
|
|
665
|
+
includeSimilar: args2.includes("--include-similar")
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
function parseGenerateOptions(args2) {
|
|
669
|
+
const positional = args2.filter((arg, index) => arg !== "--steps" && args2[index - 1] !== "--steps" && !arg.startsWith("--"));
|
|
670
|
+
if (positional.length !== 2) {
|
|
671
|
+
throw new Error("Usage: quality-tools acceptance generate <json-ir> <generated-test-output> --steps <path>");
|
|
214
672
|
}
|
|
673
|
+
return {
|
|
674
|
+
irPath: positional[0] ?? "",
|
|
675
|
+
outPath: positional[1] ?? "",
|
|
676
|
+
stepsPath: requireFlagValue(args2, "--steps")
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
function parseCompileOptions(args2) {
|
|
680
|
+
const positional = collectPositionalArgs(args2, COMPILE_VALUE_FLAGS);
|
|
681
|
+
const specPatterns = resolveCompileSpecPatterns(args2, positional);
|
|
682
|
+
const generatedDir = requireCompileGeneratedDir(specPatterns, resolveGeneratedDir(args2, positional));
|
|
215
683
|
return {
|
|
216
684
|
specPatterns,
|
|
217
|
-
stepsPath,
|
|
218
|
-
|
|
685
|
+
stepsPath: requireFlagValue(args2, "--steps"),
|
|
686
|
+
generatedDir,
|
|
687
|
+
irDir: resolveIrDir(args2, generatedDir),
|
|
688
|
+
dryDir: resolveDryDir(args2),
|
|
689
|
+
includeExact: args2.includes("--include-exact"),
|
|
690
|
+
includeSimilar: args2.includes("--include-similar")
|
|
219
691
|
};
|
|
220
692
|
}
|
|
693
|
+
function isHelpCommand(command2) {
|
|
694
|
+
return !command2 || command2 === "--help" || command2 === "-h";
|
|
695
|
+
}
|
|
696
|
+
function requireAcceptanceCommand(command2) {
|
|
697
|
+
const runner = command2 ? ACCEPTANCE_COMMANDS.get(command2) : void 0;
|
|
698
|
+
if (!runner) {
|
|
699
|
+
throw new Error(acceptanceUsage());
|
|
700
|
+
}
|
|
701
|
+
return runner;
|
|
702
|
+
}
|
|
703
|
+
function collectPositionalArgs(args2, valueFlags) {
|
|
704
|
+
return args2.filter(
|
|
705
|
+
(arg, index) => !arg.startsWith("--") && !isFlagValue(args2, index, valueFlags)
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
function resolveCompileSpecPatterns(args2, positional) {
|
|
709
|
+
const specFlags = collectFlagValues(args2, "--spec");
|
|
710
|
+
return specFlags.length > 0 ? specFlags : positional.slice(0, 1);
|
|
711
|
+
}
|
|
712
|
+
function resolveGeneratedDir(args2, positional) {
|
|
713
|
+
return collectFlagValues(args2, "--out-dir").at(0) ?? positional[1];
|
|
714
|
+
}
|
|
715
|
+
function requireCompileGeneratedDir(specPatterns, generatedDir) {
|
|
716
|
+
if (specPatterns.length === 0 || !generatedDir) {
|
|
717
|
+
throw new Error("Usage: quality-tools acceptance compile <spec-glob> <generated-output-dir> --steps <path> [--ir <dir>] [--dry <dir>]");
|
|
718
|
+
}
|
|
719
|
+
return generatedDir;
|
|
720
|
+
}
|
|
721
|
+
function resolveIrDir(args2, generatedDir) {
|
|
722
|
+
return collectFlagValues(args2, "--ir").at(0) ?? collectFlagValues(args2, "--ir-dir").at(0) ?? path.join(generatedDir, "..", "generated-ir");
|
|
723
|
+
}
|
|
724
|
+
function resolveDryDir(args2) {
|
|
725
|
+
return collectFlagValues(args2, "--dry").at(0) ?? collectFlagValues(args2, "--dry-report-dir").at(0);
|
|
726
|
+
}
|
|
727
|
+
function parseIrFile(cwd, featurePath) {
|
|
728
|
+
const resolvedPath = path.resolve(cwd, featurePath);
|
|
729
|
+
const source = fs.readFileSync(resolvedPath, "utf8");
|
|
730
|
+
return toAcceptanceIr(parseAcceptanceFeature(source, toPosixPath(path.relative(cwd, resolvedPath))));
|
|
731
|
+
}
|
|
732
|
+
function readIrFile(cwd, irPath) {
|
|
733
|
+
return JSON.parse(fs.readFileSync(path.resolve(cwd, irPath), "utf8"));
|
|
734
|
+
}
|
|
735
|
+
function writeGeneratedPlaywrightSpec(cwd, irPath, outPath, stepsPath) {
|
|
736
|
+
const resolvedOutPath = path.resolve(cwd, outPath);
|
|
737
|
+
const generatedDir = path.dirname(resolvedOutPath);
|
|
738
|
+
writePlaywrightRuntime(generatedDir);
|
|
739
|
+
writeFile(resolvedOutPath, generatePlaywrightAcceptanceSpec({
|
|
740
|
+
irImportPath: toPosixPath(path.relative(cwd, path.resolve(cwd, irPath))),
|
|
741
|
+
runtimeImportPath: createExtensionlessImportPath(resolvedOutPath, path.join(generatedDir, "runtime.ts")),
|
|
742
|
+
stepsImportPath: createExtensionlessImportPath(resolvedOutPath, path.resolve(cwd, stepsPath))
|
|
743
|
+
}));
|
|
744
|
+
}
|
|
745
|
+
function writePlaywrightRuntime(generatedDir) {
|
|
746
|
+
writeFile(path.join(generatedDir, "runtime.ts"), generatePlaywrightAcceptanceRuntime());
|
|
747
|
+
}
|
|
748
|
+
function writeJsonFile(filePath, data) {
|
|
749
|
+
writeFile(filePath, `${JSON.stringify(data, null, 2)}
|
|
750
|
+
`);
|
|
751
|
+
}
|
|
752
|
+
function writeFile(filePath, content) {
|
|
753
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
754
|
+
fs.writeFileSync(filePath, content);
|
|
755
|
+
}
|
|
221
756
|
async function findSpecFiles(cwd, patterns) {
|
|
222
757
|
const files = await Promise.all(
|
|
223
758
|
patterns.map((pattern) => glob(pattern, { absolute: true, cwd, nodir: true }))
|
|
224
759
|
);
|
|
225
|
-
return files.flat().sort((left, right) => left.localeCompare(right));
|
|
760
|
+
return [...new Set(files.flat())].sort((left, right) => left.localeCompare(right));
|
|
226
761
|
}
|
|
227
|
-
function
|
|
228
|
-
const relativePath = toPosixPath(path.relative(path.dirname(outPath),
|
|
762
|
+
function createExtensionlessImportPath(outPath, targetPath) {
|
|
763
|
+
const relativePath = toPosixPath(path.relative(path.dirname(outPath), targetPath));
|
|
229
764
|
const extension = path.extname(relativePath);
|
|
230
765
|
const extensionlessPath = extension ? relativePath.slice(0, -extension.length) : relativePath;
|
|
231
766
|
if (extensionlessPath.startsWith(".")) {
|
|
@@ -253,9 +788,15 @@ function requireFlagValue(args2, flag) {
|
|
|
253
788
|
}
|
|
254
789
|
return value;
|
|
255
790
|
}
|
|
791
|
+
function isFlagValue(args2, index, flags) {
|
|
792
|
+
return flags.includes(args2[index - 1] ?? "");
|
|
793
|
+
}
|
|
256
794
|
function toPosixPath(value) {
|
|
257
795
|
return value.split(path.sep).join(path.posix.sep);
|
|
258
796
|
}
|
|
797
|
+
function sourcePathSlug(sourcePath) {
|
|
798
|
+
return sourcePath.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
799
|
+
}
|
|
259
800
|
|
|
260
801
|
// src/shared/resolve/repoRoot.ts
|
|
261
802
|
import { resolve as resolve3 } from "node:path";
|
|
@@ -364,12 +905,12 @@ import { existsSync as existsSync4, statSync } from "fs";
|
|
|
364
905
|
import { isAbsolute, resolve as resolve4 } from "path";
|
|
365
906
|
|
|
366
907
|
// src/shared/util/workspacePackages.ts
|
|
367
|
-
import { existsSync as existsSync3, readFileSync } from "fs";
|
|
908
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
368
909
|
import { basename, dirname as dirname4, join as join3 } from "path";
|
|
369
910
|
import { globSync } from "glob";
|
|
370
911
|
function workspacePackageFromPackageJson(repoRoot, relativePackageJson) {
|
|
371
912
|
const packageJsonPath = join3(repoRoot, relativePackageJson);
|
|
372
|
-
const manifest = JSON.parse(
|
|
913
|
+
const manifest = JSON.parse(readFileSync2(packageJsonPath, "utf-8"));
|
|
373
914
|
const relativeRoot = toPosix(dirname4(relativePackageJson));
|
|
374
915
|
return {
|
|
375
916
|
...manifest.name ? { manifestName: manifest.name } : {},
|
|
@@ -383,7 +924,7 @@ function pnpmWorkspaceGlobs(repoRoot) {
|
|
|
383
924
|
if (!existsSync3(workspacePath)) {
|
|
384
925
|
return [];
|
|
385
926
|
}
|
|
386
|
-
const lines =
|
|
927
|
+
const lines = readFileSync2(workspacePath, "utf-8").split(/\r?\n/);
|
|
387
928
|
const packageGlobs = [];
|
|
388
929
|
let inPackagesBlock = false;
|
|
389
930
|
for (const line of lines) {
|
|
@@ -409,7 +950,7 @@ function packageJsonWorkspaceGlobs(repoRoot) {
|
|
|
409
950
|
if (!existsSync3(packageJsonPath)) {
|
|
410
951
|
return [];
|
|
411
952
|
}
|
|
412
|
-
const manifest = JSON.parse(
|
|
953
|
+
const manifest = JSON.parse(readFileSync2(packageJsonPath, "utf-8"));
|
|
413
954
|
if (Array.isArray(manifest.workspaces)) {
|
|
414
955
|
return manifest.workspaces;
|
|
415
956
|
}
|
|
@@ -442,7 +983,7 @@ function listWorkspacePackages(repoRoot) {
|
|
|
442
983
|
const packages = [];
|
|
443
984
|
const packageJsons = discoverWorkspacePackageJsons(repoRoot);
|
|
444
985
|
if (shouldIncludeRootPackage(repoRoot, packageJsons)) {
|
|
445
|
-
const packageJson = JSON.parse(
|
|
986
|
+
const packageJson = JSON.parse(readFileSync2(join3(repoRoot, "package.json"), "utf-8"));
|
|
446
987
|
packages.push({
|
|
447
988
|
...packageJson.name ? { manifestName: packageJson.name } : {},
|
|
448
989
|
name: packageJson.name?.split("/").pop() ?? "root",
|
|
@@ -560,7 +1101,7 @@ import { basename as basename3, join as join8 } from "path";
|
|
|
560
1101
|
import { basename as basename2 } from "path";
|
|
561
1102
|
|
|
562
1103
|
// src/organize/cohesion/imports/parse.ts
|
|
563
|
-
import { readFileSync as
|
|
1104
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
564
1105
|
import * as ts2 from "typescript";
|
|
565
1106
|
|
|
566
1107
|
// src/organize/cohesion/imports/scriptKind.ts
|
|
@@ -601,7 +1142,7 @@ function extractImports(sourceFile) {
|
|
|
601
1142
|
}
|
|
602
1143
|
function parseFileImports(filePath, fileName) {
|
|
603
1144
|
try {
|
|
604
|
-
const fileContent =
|
|
1145
|
+
const fileContent = readFileSync3(filePath, "utf-8");
|
|
605
1146
|
const scriptKind = getScriptKind(fileName);
|
|
606
1147
|
const sourceFile = ts2.createSourceFile(
|
|
607
1148
|
fileName,
|
|
@@ -638,7 +1179,7 @@ function resolveImportTarget(fromFile, specifier, candidatePaths) {
|
|
|
638
1179
|
}
|
|
639
1180
|
|
|
640
1181
|
// src/config/quality.ts
|
|
641
|
-
import { readFileSync as
|
|
1182
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
642
1183
|
import { isAbsolute as isAbsolute2, join as join6, matchesGlob, relative as relative2, resolve as resolve5 } from "path";
|
|
643
1184
|
|
|
644
1185
|
// src/config/patterns.ts
|
|
@@ -676,7 +1217,7 @@ var DEFAULT_REPORTS_DIR = "reports/quality-tools";
|
|
|
676
1217
|
function loadQualityConfig(repoRoot) {
|
|
677
1218
|
const configPath = join6(repoRoot, CONFIG_FILE);
|
|
678
1219
|
try {
|
|
679
|
-
return JSON.parse(
|
|
1220
|
+
return JSON.parse(readFileSync4(configPath, "utf-8"));
|
|
680
1221
|
} catch {
|
|
681
1222
|
return {};
|
|
682
1223
|
}
|
|
@@ -1223,7 +1764,7 @@ function extractFunctions(sourceFile) {
|
|
|
1223
1764
|
}
|
|
1224
1765
|
|
|
1225
1766
|
// src/crap/analysis/fileSelection.ts
|
|
1226
|
-
import { readFileSync as
|
|
1767
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
1227
1768
|
import * as path2 from "path";
|
|
1228
1769
|
import * as ts7 from "typescript";
|
|
1229
1770
|
function matchesFilterScope(relativePath, filterScope) {
|
|
@@ -1254,7 +1795,7 @@ function shouldIncludeFile(filePath, filterScope, repoRoot) {
|
|
|
1254
1795
|
function createSourceFile3(filePath) {
|
|
1255
1796
|
return ts7.createSourceFile(
|
|
1256
1797
|
filePath,
|
|
1257
|
-
|
|
1798
|
+
readFileSync5(filePath, "utf-8"),
|
|
1258
1799
|
ts7.ScriptTarget.Latest,
|
|
1259
1800
|
true,
|
|
1260
1801
|
filePath.endsWith(".tsx") ? ts7.ScriptKind.TSX : ts7.ScriptKind.TS
|
|
@@ -1406,12 +1947,12 @@ function createCoverageProfiles(repoRoot, target) {
|
|
|
1406
1947
|
}
|
|
1407
1948
|
|
|
1408
1949
|
// src/crap/coverage/read.ts
|
|
1409
|
-
import { existsSync as existsSync7, readFileSync as
|
|
1950
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
|
|
1410
1951
|
function readCoverageReport(path4) {
|
|
1411
1952
|
if (!existsSync7(path4)) {
|
|
1412
1953
|
throw new Error(`Coverage data not found: ${path4}`);
|
|
1413
1954
|
}
|
|
1414
|
-
return JSON.parse(
|
|
1955
|
+
return JSON.parse(readFileSync6(path4, "utf-8"));
|
|
1415
1956
|
}
|
|
1416
1957
|
|
|
1417
1958
|
// src/crap/report.ts
|
|
@@ -1592,9 +2133,9 @@ function copySharedMutationReports(reportKey, repoRoot = process.cwd()) {
|
|
|
1592
2133
|
}
|
|
1593
2134
|
|
|
1594
2135
|
// src/mutation/reporting/check.ts
|
|
1595
|
-
import { readFileSync as
|
|
2136
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
1596
2137
|
function findMutationSiteViolations(reportPath, threshold = 50) {
|
|
1597
|
-
const report = JSON.parse(
|
|
2138
|
+
const report = JSON.parse(readFileSync7(reportPath, "utf-8"));
|
|
1598
2139
|
return Object.entries(report.files ?? {}).map(([file, entry]) => ({ count: (entry.mutants ?? []).length, file })).filter((entry) => entry.count > threshold).sort((left, right) => right.count - left.count);
|
|
1599
2140
|
}
|
|
1600
2141
|
function reportMutationSiteViolations(reportPath, threshold = 50) {
|
|
@@ -1952,7 +2493,7 @@ import { join as join16 } from "path";
|
|
|
1952
2493
|
import { relative as relative7 } from "path";
|
|
1953
2494
|
|
|
1954
2495
|
// src/organize/rules.ts
|
|
1955
|
-
import { readFileSync as
|
|
2496
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
1956
2497
|
import { join as join14 } from "path";
|
|
1957
2498
|
var DEFAULT_CONFIG2 = {
|
|
1958
2499
|
lowInfoNames: {
|
|
@@ -1979,7 +2520,7 @@ function mergeConfig(defaults, overrides) {
|
|
|
1979
2520
|
function loadOrganizeConfig(repoRoot, packageName) {
|
|
1980
2521
|
const configPath = join14(repoRoot, CONFIG_FILE3);
|
|
1981
2522
|
try {
|
|
1982
|
-
const rawConfig = JSON.parse(
|
|
2523
|
+
const rawConfig = JSON.parse(readFileSync8(configPath, "utf-8"));
|
|
1983
2524
|
const defaultConfig = rawConfig.defaults?.organize;
|
|
1984
2525
|
const packageConfig = packageName ? rawConfig.packages?.[packageName]?.organize : void 0;
|
|
1985
2526
|
const mergedDefaults = defaultConfig ? mergeConfig(DEFAULT_CONFIG2, defaultConfig) : DEFAULT_CONFIG2;
|
|
@@ -2164,7 +2705,7 @@ function shouldStartNewToken(previous, current, next) {
|
|
|
2164
2705
|
}
|
|
2165
2706
|
|
|
2166
2707
|
// src/organize/naming/tokenize.ts
|
|
2167
|
-
function
|
|
2708
|
+
function tokenize2(name) {
|
|
2168
2709
|
const withoutExtension = stripExtension(name);
|
|
2169
2710
|
const characters = Array.from(withoutExtension);
|
|
2170
2711
|
const tokens = [];
|
|
@@ -2195,7 +2736,7 @@ function tokenize(name) {
|
|
|
2195
2736
|
function buildPrefixGroups(fileNames) {
|
|
2196
2737
|
const groups = /* @__PURE__ */ new Map();
|
|
2197
2738
|
for (const fileName of fileNames) {
|
|
2198
|
-
const tokens =
|
|
2739
|
+
const tokens = tokenize2(fileName);
|
|
2199
2740
|
if (tokens.length > 0) {
|
|
2200
2741
|
const prefix = tokens[0];
|
|
2201
2742
|
if (!groups.has(prefix)) {
|
|
@@ -2209,7 +2750,7 @@ function buildPrefixGroups(fileNames) {
|
|
|
2209
2750
|
function countFirstTokens(fileNames) {
|
|
2210
2751
|
const tokenCounts = /* @__PURE__ */ new Map();
|
|
2211
2752
|
for (const fileName of fileNames) {
|
|
2212
|
-
const tokens =
|
|
2753
|
+
const tokens = tokenize2(fileName);
|
|
2213
2754
|
if (tokens.length > 0) {
|
|
2214
2755
|
const token = tokens[0];
|
|
2215
2756
|
tokenCounts.set(token, (tokenCounts.get(token) ?? 0) + 1);
|
|
@@ -2413,9 +2954,9 @@ function isConventionalEntryFile(filePath, ancestorFolders) {
|
|
|
2413
2954
|
return false;
|
|
2414
2955
|
}
|
|
2415
2956
|
const hookName = fileStem.slice(3);
|
|
2416
|
-
const hookTokens =
|
|
2957
|
+
const hookTokens = tokenize2(hookName);
|
|
2417
2958
|
return hookTokens.some((hookToken) => ancestorFolders.some((folder) => {
|
|
2418
|
-
const folderTokens =
|
|
2959
|
+
const folderTokens = tokenize2(folder);
|
|
2419
2960
|
return folderTokens.includes(hookToken);
|
|
2420
2961
|
}));
|
|
2421
2962
|
}
|
|
@@ -2426,13 +2967,13 @@ function pathRedundancy(filePath, ancestorFolders) {
|
|
|
2426
2967
|
return 0;
|
|
2427
2968
|
}
|
|
2428
2969
|
const fileName = basename5(filePath);
|
|
2429
|
-
const fileTokens =
|
|
2970
|
+
const fileTokens = tokenize2(fileName);
|
|
2430
2971
|
if (fileTokens.length === 0) {
|
|
2431
2972
|
return 0;
|
|
2432
2973
|
}
|
|
2433
2974
|
const ancestorTokens = /* @__PURE__ */ new Set();
|
|
2434
2975
|
for (const folder of ancestorFolders) {
|
|
2435
|
-
const folderTokens =
|
|
2976
|
+
const folderTokens = tokenize2(folder);
|
|
2436
2977
|
for (const token of folderTokens) {
|
|
2437
2978
|
ancestorTokens.add(token);
|
|
2438
2979
|
}
|
|
@@ -2458,7 +2999,7 @@ function computeAverageRedundancy(fileNames, ancestorFolders) {
|
|
|
2458
2999
|
}
|
|
2459
3000
|
|
|
2460
3001
|
// src/organize/analyze/issues.ts
|
|
2461
|
-
import { readFileSync as
|
|
3002
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
2462
3003
|
|
|
2463
3004
|
// src/organize/metric/naming/details.ts
|
|
2464
3005
|
var LOW_INFO_NAME_DETAILS = {
|
|
@@ -2600,7 +3141,7 @@ function collectFileIssues(fileNames, directoryPath, ancestorFolders, lowInfoNam
|
|
|
2600
3141
|
}
|
|
2601
3142
|
try {
|
|
2602
3143
|
const filePath = `${directoryPath}/${fileName}`;
|
|
2603
|
-
const fileContent =
|
|
3144
|
+
const fileContent = readFileSync9(filePath, "utf-8");
|
|
2604
3145
|
const barrelIssue = checkBarrelFile(fileName, fileContent);
|
|
2605
3146
|
if (barrelIssue) {
|
|
2606
3147
|
issues.push(barrelIssue);
|
|
@@ -2653,7 +3194,7 @@ function analyze(target) {
|
|
|
2653
3194
|
}
|
|
2654
3195
|
|
|
2655
3196
|
// src/organize/compare/baseline.ts
|
|
2656
|
-
import { readFileSync as
|
|
3197
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
2657
3198
|
|
|
2658
3199
|
// src/organize/compare/verdict.ts
|
|
2659
3200
|
function verdictFromDeltas(fileFanOutDelta, folderFanOutDelta, clusterCountDelta, issueCountDelta, redundancyDelta) {
|
|
@@ -2689,7 +3230,7 @@ function baselineMetricsByPath(baseline2) {
|
|
|
2689
3230
|
return new Map(baseline2.map((metric) => [metric.directoryPath, metric]));
|
|
2690
3231
|
}
|
|
2691
3232
|
function compareBaseline(current, baselinePath) {
|
|
2692
|
-
const baselineData = JSON.parse(
|
|
3233
|
+
const baselineData = JSON.parse(readFileSync10(baselinePath, "utf-8"));
|
|
2693
3234
|
const previousByPath = baselineMetricsByPath(baselineData);
|
|
2694
3235
|
const comparisons = /* @__PURE__ */ new Map();
|
|
2695
3236
|
for (const metric of current) {
|
|
@@ -2984,7 +3525,7 @@ import * as fs2 from "fs";
|
|
|
2984
3525
|
import * as ts25 from "typescript";
|
|
2985
3526
|
|
|
2986
3527
|
// src/scrap/test/discovery/files.ts
|
|
2987
|
-
import { readFileSync as
|
|
3528
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
2988
3529
|
|
|
2989
3530
|
// src/scrap/test/discovery/globs.ts
|
|
2990
3531
|
import { globSync as globSync2 } from "glob";
|
|
@@ -3092,7 +3633,7 @@ function discoverTestFiles(target) {
|
|
|
3092
3633
|
return packageNamesForTarget(target, REPO_ROOT).flatMap((packageName) => discoverPackageTestFiles(packageName, REPO_ROOT)).filter((filePath) => isInsideTarget(target, REPO_ROOT, filePath));
|
|
3093
3634
|
}
|
|
3094
3635
|
function readBaselineMetrics(baselinePath) {
|
|
3095
|
-
return JSON.parse(
|
|
3636
|
+
return JSON.parse(readFileSync11(baselinePath, "utf-8"));
|
|
3096
3637
|
}
|
|
3097
3638
|
function baselineMetricsByPath2(baseline2) {
|
|
3098
3639
|
return new Map(
|