@poleski/quality-tools 0.1.4 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 0.1.4
4
10
 
5
11
  ### 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 path2 from "path";
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 path from "path";
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(path.relative(repoRoot, filePath));
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(path.relative(workspacePackage.root, filePath))
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(path2.relative(repoRoot, fn.file)),
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(path3) {
1185
- if (!existsSync7(path3)) {
1186
- throw new Error(`Coverage data not found: ${path3}`);
1410
+ function readCoverageReport(path4) {
1411
+ if (!existsSync7(path4)) {
1412
+ throw new Error(`Coverage data not found: ${path4}`);
1187
1413
  }
1188
- return JSON.parse(readFileSync5(path3, "utf-8"));
1414
+ return JSON.parse(readFileSync5(path4, "utf-8"));
1189
1415
  }
1190
1416
 
1191
1417
  // src/crap/report.ts
@@ -1665,7 +1891,7 @@ function parseBareMutationTargetArg(args2) {
1665
1891
  }
1666
1892
  return void 0;
1667
1893
  }
1668
- function collectFlagValues(args2, name) {
1894
+ function collectFlagValues2(args2, name) {
1669
1895
  const values = [];
1670
1896
  for (let index = 0; index < args2.length; index += 1) {
1671
1897
  const arg = args2[index];
@@ -1692,11 +1918,11 @@ function parseJsonStringArray(value, flagName) {
1692
1918
  }
1693
1919
  function mutationRunOptions(args2) {
1694
1920
  const mutateGlobs = [
1695
- ...collectFlagValues(args2, "--mutate-glob"),
1921
+ ...collectFlagValues2(args2, "--mutate-glob"),
1696
1922
  ...parseJsonStringArray(flagValue(args2, "--mutate-globs-json"), "--mutate-globs-json")
1697
1923
  ];
1698
1924
  const testIncludes = [
1699
- ...collectFlagValues(args2, "--test-include"),
1925
+ ...collectFlagValues2(args2, "--test-include"),
1700
1926
  ...parseJsonStringArray(flagValue(args2, "--test-includes-json"), "--test-includes-json")
1701
1927
  ];
1702
1928
  return {
@@ -2754,7 +2980,7 @@ function runReachabilityCli(rawArgs, dependencies = DEFAULT_DEPENDENCIES4) {
2754
2980
  }
2755
2981
 
2756
2982
  // src/scrap/analysis/pipeline/run.ts
2757
- import * as fs from "fs";
2983
+ import * as fs2 from "fs";
2758
2984
  import * as ts25 from "typescript";
2759
2985
 
2760
2986
  // src/scrap/test/discovery/files.ts
@@ -4100,8 +4326,8 @@ function compareBlockSummaries(left, right) {
4100
4326
 
4101
4327
  // src/scrap/structure/blocks/groups.ts
4102
4328
  var BLOCK_SEPARATOR = "";
4103
- function blockPathKey(path3) {
4104
- return path3.join(BLOCK_SEPARATOR);
4329
+ function blockPathKey(path4) {
4330
+ return path4.join(BLOCK_SEPARATOR);
4105
4331
  }
4106
4332
  function blockPathFromKey(key) {
4107
4333
  return key.split(BLOCK_SEPARATOR);
@@ -4127,7 +4353,7 @@ function averageScore2(examples) {
4127
4353
  function countExamples2(examples, predicate) {
4128
4354
  return examples.filter(predicate).length;
4129
4355
  }
4130
- function summarizeBlock(path3, examples) {
4356
+ function summarizeBlock(path4, examples) {
4131
4357
  const meanScore = averageScore2(examples);
4132
4358
  const maxScore2 = examples.reduce((max, example) => Math.max(max, example.score), 0);
4133
4359
  const hotExampleCount2 = countExamples2(examples, (example) => example.score >= 8);
@@ -4142,8 +4368,8 @@ function summarizeBlock(path3, examples) {
4142
4368
  hotExampleCount: hotExampleCount2,
4143
4369
  lowAssertionExampleCount: countExamples2(examples, (example) => example.assertionCount <= 1),
4144
4370
  maxScore: maxScore2,
4145
- name: path3[path3.length - 1],
4146
- path: path3,
4371
+ name: path4[path4.length - 1],
4372
+ path: path4,
4147
4373
  remediationMode: remediationMode(examples.length, meanScore, hotExampleCount2, maxScore2),
4148
4374
  zeroAssertionExampleCount: countExamples2(examples, (example) => example.assertionCount === 0)
4149
4375
  };
@@ -4521,7 +4747,7 @@ function analyzeScrapFile(sourceFile) {
4521
4747
  // src/scrap/analysis/pipeline/run.ts
4522
4748
  function analyzeScrap(target) {
4523
4749
  return discoverTestFiles(target).map((filePath) => {
4524
- const source = fs.readFileSync(filePath, "utf-8");
4750
+ const source = fs2.readFileSync(filePath, "utf-8");
4525
4751
  const sourceFile = ts25.createSourceFile(
4526
4752
  filePath,
4527
4753
  source,
@@ -4677,8 +4903,8 @@ function resolveScrapPolicy(args2) {
4677
4903
  }
4678
4904
 
4679
4905
  // src/scrap/report/blocks/format.ts
4680
- function formatBlockPath(path3) {
4681
- return path3.join(" > ");
4906
+ function formatBlockPath(path4) {
4907
+ return path4.join(" > ");
4682
4908
  }
4683
4909
  function interestingBlocks(metric) {
4684
4910
  return metric.blockSummaries.filter((block) => block.remediationMode !== "STABLE").slice(0, 5);
@@ -4896,6 +5122,7 @@ function runScrapCli(rawArgs, dependencies = DEFAULT_DEPENDENCIES5) {
4896
5122
 
4897
5123
  // src/cli/main.ts
4898
5124
  var COMMANDS = {
5125
+ acceptance: runAcceptanceCli,
4899
5126
  boundaries: runBoundariesCli,
4900
5127
  crap: runCrapCli,
4901
5128
  init: runInitCli,
@@ -4909,6 +5136,7 @@ function printHelp() {
4909
5136
 
4910
5137
  Commands:
4911
5138
  init Create a starter quality.config.json
5139
+ acceptance Compile human-authored acceptance specs into executable tests
4912
5140
  organize Check folder structure, naming, and cohesion
4913
5141
  boundaries Check package/layer boundaries
4914
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.1.4",
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",
@@ -47,6 +47,7 @@
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",