@step-forge/step-forge 0.0.7-beta.7 → 0.0.8
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/.claude/settings.local.json +17 -0
- package/.eslintignore +6 -0
- package/.eslintrc +18 -0
- package/.prettierignore +6 -0
- package/.prettierrc +15 -0
- package/CHANGELOG.md +28 -0
- package/CLAUDE.md +115 -0
- package/README.md +45 -39
- package/cucumber.mjs +39 -0
- package/dist/step-forge.cjs +1 -1
- package/dist/step-forge.js +143 -120
- package/dist/types/src/builderTypeUtils.d.ts +14 -0
- package/dist/types/src/common.d.ts +32 -0
- package/dist/types/src/given.d.ts +73 -0
- package/dist/types/src/index.d.ts +5 -0
- package/dist/types/src/then.d.ts +73 -0
- package/dist/types/src/utils.d.ts +5 -0
- package/dist/types/src/when.d.ts +72 -0
- package/dist/types/src/world.d.ts +21 -0
- package/docs/assets/state_deps.gif +0 -0
- package/dts-bundle-generator.config.cjs +19 -0
- package/features/TESTING.md +100 -0
- package/features/analyzer/analyzer.feature +83 -0
- package/features/analyzer/fixtures/ambiguous-step.feature +5 -0
- package/features/analyzer/fixtures/missing-given-dep.feature +5 -0
- package/features/analyzer/fixtures/missing-multiple-deps.feature +6 -0
- package/features/analyzer/fixtures/missing-when-dep.feature +5 -0
- package/features/analyzer/fixtures/steps.ts +113 -0
- package/features/analyzer/fixtures/undefined-step.feature +5 -0
- package/features/analyzer/fixtures/undefined-with-missing-dep.feature +5 -0
- package/features/analyzer/fixtures/valid-and-but-keywords.feature +8 -0
- package/features/analyzer/fixtures/valid-deps.feature +6 -0
- package/features/analyzer/fixtures/valid-no-deps.feature +6 -0
- package/features/analyzer/fixtures/valid-optional-deps.feature +6 -0
- package/features/analyzer/fixtures/valid-variables.feature +6 -0
- package/features/basic.feature +21 -0
- package/features/exported.feature +6 -0
- package/features/steps/analyzerSteps.ts +53 -0
- package/features/steps/commonSteps.ts +93 -0
- package/features/steps/exportedSteps.ts +42 -0
- package/features/steps/world.ts +29 -0
- package/package.json +41 -26
- package/rolldown.config.mjs +45 -0
- package/src/analyzer/cli.ts +96 -0
- package/src/analyzer/gherkinParser.ts +175 -0
- package/src/analyzer/index.ts +89 -0
- package/src/analyzer/rules/ambiguousStepRule.ts +36 -0
- package/src/analyzer/rules/dependencyRule.ts +71 -0
- package/src/analyzer/rules/index.ts +23 -0
- package/src/analyzer/rules/undefinedStepRule.ts +31 -0
- package/src/analyzer/stepExtractor.ts +432 -0
- package/src/analyzer/stepMatcher.ts +85 -0
- package/src/analyzer/types.ts +55 -0
- package/src/builderTypeUtils.ts +27 -0
- package/src/common.ts +138 -0
- package/src/given.ts +102 -0
- package/src/index.ts +46 -0
- package/src/then.ts +128 -0
- package/src/utils.ts +74 -0
- package/src/when.ts +118 -0
- package/src/world.ts +90 -0
- package/test/givenCompilationTests.ts +130 -0
- package/test/testUtils.ts +30 -0
- package/test/thenCompilationTests.ts +207 -0
- package/test/whenCompilationTests.ts +157 -0
- package/ts-node-esm-register.js +8 -0
- package/tsconfig.cucumber.json +14 -0
- package/tsconfig.json +29 -0
- package/dist/types/index.d.ts +0 -301
package/package.json
CHANGED
|
@@ -1,28 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@step-forge/step-forge",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
|
+
"module": "./dist/step-forge.js",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"exports": {
|
|
6
7
|
".": {
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
"require": {
|
|
9
|
+
"types": "./dist/index.d.cts",
|
|
10
|
+
"default": "./dist/step-forge.cjs"
|
|
11
|
+
},
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/step-forge.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"./analyzer": {
|
|
18
|
+
"types": "./dist/analyzer.d.ts",
|
|
19
|
+
"import": "./dist/analyzer.js"
|
|
10
20
|
},
|
|
11
|
-
"./
|
|
21
|
+
"./dist/": {
|
|
22
|
+
"import": "./dist/"
|
|
23
|
+
}
|
|
12
24
|
},
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"dist"
|
|
18
|
-
],
|
|
25
|
+
"bin": {
|
|
26
|
+
"step-forge-analyze": "./dist/analyzer-cli.js"
|
|
27
|
+
},
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
19
29
|
"scripts": {
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"test:
|
|
23
|
-
"test
|
|
24
|
-
"test": "NODE_ENV=test NODE_OPTIONS
|
|
25
|
-
"test:debug": "NODE_ENV=test PORT=7888 cucumber-js -p all",
|
|
30
|
+
"build": "rm -rf build && tsc && rolldown -c && dts-bundle-generator --config ./dts-bundle-generator.config.cjs && cp build/dist/index.d.ts build/dist/index.d.cts && cp package.json build/",
|
|
31
|
+
"test:cucumber": "NODE_ENV=test NODE_OPTIONS='--import tsx' cucumber-js -p default",
|
|
32
|
+
"test:ci": "NODE_ENV=test NODE_OPTIONS='--import tsx' cucumber-js -p ci",
|
|
33
|
+
"test": "NODE_ENV=test PORT=7888 LOG_LEVEL=none NODE_OPTIONS='--import tsx' cucumber-js -p all 2> /dev/null",
|
|
34
|
+
"test:debug": "NODE_ENV=test PORT=7888 NODE_OPTIONS='--import tsx' cucumber-js -p all",
|
|
26
35
|
"lint": "eslint . --ext .ts",
|
|
27
36
|
"format": "prettier . --write"
|
|
28
37
|
},
|
|
@@ -33,23 +42,29 @@
|
|
|
33
42
|
"@types/node": "^22.10.2",
|
|
34
43
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
35
44
|
"@typescript-eslint/parser": "^7.18.0",
|
|
36
|
-
"@vitest/coverage-v8": "^2.0.4",
|
|
37
|
-
"copyfiles": "^2.4.1",
|
|
38
45
|
"dts-bundle-generator": "^9.5.1",
|
|
39
46
|
"earl": "^1.3.0",
|
|
40
47
|
"eslint": "^8.57.0",
|
|
41
48
|
"eslint-config-prettier": "^9.1.0",
|
|
42
49
|
"eslint-plugin-prettier": "^5.2.1",
|
|
43
50
|
"prettier": "^3.3.3",
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
51
|
+
"tsx": "^4.19.0",
|
|
52
|
+
"rolldown": "^1.0.0-rc.3",
|
|
53
|
+
"typescript": "^5.7.2"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"typescript": "^5.0.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"typescript": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
50
62
|
},
|
|
51
63
|
"dependencies": {
|
|
52
|
-
"@cucumber/cucumber": "^
|
|
64
|
+
"@cucumber/cucumber": "^12.6.0",
|
|
65
|
+
"@cucumber/cucumber-expressions": "^18.1.0",
|
|
66
|
+
"@cucumber/gherkin": "^37.0.1",
|
|
67
|
+
"@cucumber/messages": "^24.1.0",
|
|
53
68
|
"lodash": "^4.17.21"
|
|
54
69
|
}
|
|
55
70
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineConfig } from "rolldown";
|
|
2
|
+
|
|
3
|
+
const allExternal = [
|
|
4
|
+
"@cucumber/cucumber",
|
|
5
|
+
"@cucumber/gherkin",
|
|
6
|
+
"@cucumber/messages",
|
|
7
|
+
"@cucumber/cucumber-expressions",
|
|
8
|
+
"lodash",
|
|
9
|
+
"typescript",
|
|
10
|
+
/^node:/,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export default defineConfig([
|
|
14
|
+
{
|
|
15
|
+
input: "./src/index.ts",
|
|
16
|
+
output: {
|
|
17
|
+
file: "./build/dist/step-forge.js",
|
|
18
|
+
format: "esm",
|
|
19
|
+
sourcemap: true,
|
|
20
|
+
},
|
|
21
|
+
external: allExternal,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
input: "./src/index.ts",
|
|
25
|
+
output: {
|
|
26
|
+
file: "./build/dist/step-forge.cjs",
|
|
27
|
+
format: "cjs",
|
|
28
|
+
sourcemap: true,
|
|
29
|
+
},
|
|
30
|
+
external: allExternal,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
input: {
|
|
34
|
+
analyzer: "./src/analyzer/index.ts",
|
|
35
|
+
"analyzer-cli": "./src/analyzer/cli.ts",
|
|
36
|
+
},
|
|
37
|
+
output: {
|
|
38
|
+
dir: "./build/dist",
|
|
39
|
+
format: "esm",
|
|
40
|
+
sourcemap: true,
|
|
41
|
+
entryFileNames: "[name].js",
|
|
42
|
+
},
|
|
43
|
+
external: allExternal,
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { analyze } from "./index.js";
|
|
4
|
+
import type { AnalyzerConfig, Diagnostic } from "./types.js";
|
|
5
|
+
|
|
6
|
+
function parseArgs(args: string[]): AnalyzerConfig {
|
|
7
|
+
const config: AnalyzerConfig = {
|
|
8
|
+
stepFiles: [],
|
|
9
|
+
featureFiles: [],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < args.length; i++) {
|
|
13
|
+
const arg = args[i];
|
|
14
|
+
const next = args[i + 1];
|
|
15
|
+
|
|
16
|
+
if ((arg === "--steps" || arg === "-s") && next) {
|
|
17
|
+
config.stepFiles.push(next);
|
|
18
|
+
i++;
|
|
19
|
+
} else if ((arg === "--features" || arg === "-f") && next) {
|
|
20
|
+
config.featureFiles.push(next);
|
|
21
|
+
i++;
|
|
22
|
+
} else if (arg === "--tsconfig" && next) {
|
|
23
|
+
config.tsConfigPath = next;
|
|
24
|
+
i++;
|
|
25
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
26
|
+
printUsage();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function printUsage() {
|
|
35
|
+
console.log(`Usage: step-forge-analyze [options]
|
|
36
|
+
|
|
37
|
+
Options:
|
|
38
|
+
-s, --steps <glob> Glob pattern for step definition files (repeatable)
|
|
39
|
+
-f, --features <glob> Glob pattern for feature files (repeatable)
|
|
40
|
+
--tsconfig <path> Path to tsconfig.json (default: auto-detect)
|
|
41
|
+
-h, --help Show this help message
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
step-forge-analyze --steps "features/steps/**/*.ts" --features "features/**/*.feature"
|
|
45
|
+
`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatDiagnostic(diag: Diagnostic): string {
|
|
49
|
+
const location = `${diag.file}:${diag.range.startLine}:${diag.range.startColumn}`;
|
|
50
|
+
return `${location} - ${diag.severity}: ${diag.message}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function main() {
|
|
54
|
+
const args = process.argv.slice(2);
|
|
55
|
+
const config = parseArgs(args);
|
|
56
|
+
|
|
57
|
+
if (config.stepFiles.length === 0 || config.featureFiles.length === 0) {
|
|
58
|
+
console.error(
|
|
59
|
+
"Error: Both --steps and --features are required.\n"
|
|
60
|
+
);
|
|
61
|
+
printUsage();
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const diagnostics = await analyze(config);
|
|
66
|
+
|
|
67
|
+
if (diagnostics.length === 0) {
|
|
68
|
+
console.log("No issues found.");
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const errors = diagnostics.filter((d) => d.severity === "error");
|
|
73
|
+
const warnings = diagnostics.filter((d) => d.severity === "warning");
|
|
74
|
+
|
|
75
|
+
for (const diag of diagnostics) {
|
|
76
|
+
console.log(formatDiagnostic(diag));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log(
|
|
80
|
+
`\nFound ${errors.length} error(s) and ${warnings.length} warning(s).`
|
|
81
|
+
);
|
|
82
|
+
process.exit(errors.length > 0 ? 1 : 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Only run when invoked directly (not when imported by Cucumber or other tools)
|
|
86
|
+
const isDirectRun =
|
|
87
|
+
import.meta.url === `file://${process.argv[1]}` ||
|
|
88
|
+
process.argv[1]?.endsWith("analyzer-cli.js") ||
|
|
89
|
+
process.argv[1]?.endsWith("analyzer-cli.ts");
|
|
90
|
+
|
|
91
|
+
if (isDirectRun) {
|
|
92
|
+
main().catch((err) => {
|
|
93
|
+
console.error("Analyzer failed:", err);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { GherkinClassicTokenMatcher, Parser, AstBuilder } from "@cucumber/gherkin";
|
|
3
|
+
import * as messages from "@cucumber/messages";
|
|
4
|
+
import { ParsedScenario, ParsedStep } from "./types.js";
|
|
5
|
+
|
|
6
|
+
type GherkinKeyword = "Given" | "When" | "Then" | "And" | "But";
|
|
7
|
+
|
|
8
|
+
export function parseFeatureFiles(filePaths: string[]): ParsedScenario[] {
|
|
9
|
+
const scenarios: ParsedScenario[] = [];
|
|
10
|
+
|
|
11
|
+
for (const filePath of filePaths) {
|
|
12
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
13
|
+
const parsed = parseFeatureContent(content, filePath);
|
|
14
|
+
scenarios.push(...parsed);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return scenarios;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseFeatureContent(
|
|
21
|
+
content: string,
|
|
22
|
+
filePath: string
|
|
23
|
+
): ParsedScenario[] {
|
|
24
|
+
const newId = messages.IdGenerator.uuid();
|
|
25
|
+
const builder = new AstBuilder(newId);
|
|
26
|
+
const matcher = new GherkinClassicTokenMatcher();
|
|
27
|
+
const parser = new Parser(builder, matcher);
|
|
28
|
+
|
|
29
|
+
const gherkinDocument: messages.GherkinDocument = parser.parse(content);
|
|
30
|
+
const feature = gherkinDocument.feature;
|
|
31
|
+
if (!feature) return [];
|
|
32
|
+
|
|
33
|
+
// Collect background steps at the feature level
|
|
34
|
+
const featureBackground: messages.Step[] = [];
|
|
35
|
+
const scenarios: ParsedScenario[] = [];
|
|
36
|
+
|
|
37
|
+
for (const child of feature.children) {
|
|
38
|
+
if (child.background) {
|
|
39
|
+
featureBackground.push(...child.background.steps);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (child.scenario) {
|
|
43
|
+
scenarios.push(
|
|
44
|
+
...expandScenario(child.scenario, featureBackground, filePath)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (child.rule) {
|
|
49
|
+
// Rules can have their own backgrounds
|
|
50
|
+
const ruleBackground: messages.Step[] = [...featureBackground];
|
|
51
|
+
for (const ruleChild of child.rule.children) {
|
|
52
|
+
if (ruleChild.background) {
|
|
53
|
+
ruleBackground.push(...ruleChild.background.steps);
|
|
54
|
+
}
|
|
55
|
+
if (ruleChild.scenario) {
|
|
56
|
+
scenarios.push(
|
|
57
|
+
...expandScenario(ruleChild.scenario, ruleBackground, filePath)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return scenarios;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function expandScenario(
|
|
68
|
+
scenario: messages.Scenario,
|
|
69
|
+
backgroundSteps: messages.Step[],
|
|
70
|
+
filePath: string
|
|
71
|
+
): ParsedScenario[] {
|
|
72
|
+
const hasExamples =
|
|
73
|
+
scenario.examples.length > 0 &&
|
|
74
|
+
scenario.examples.some((e) => e.tableBody.length > 0);
|
|
75
|
+
|
|
76
|
+
if (!hasExamples) {
|
|
77
|
+
// Regular scenario
|
|
78
|
+
const bgParsed = convertSteps(backgroundSteps);
|
|
79
|
+
const scenarioParsed = convertSteps(scenario.steps);
|
|
80
|
+
const allSteps = resolveEffectiveKeywords([...bgParsed, ...scenarioParsed]);
|
|
81
|
+
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
name: scenario.name,
|
|
85
|
+
file: filePath,
|
|
86
|
+
steps: allSteps,
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Scenario Outline — expand with each example row
|
|
92
|
+
const results: ParsedScenario[] = [];
|
|
93
|
+
for (const example of scenario.examples) {
|
|
94
|
+
if (!example.tableHeader || example.tableBody.length === 0) continue;
|
|
95
|
+
const headers = example.tableHeader.cells.map((c) => c.value);
|
|
96
|
+
|
|
97
|
+
for (const row of example.tableBody) {
|
|
98
|
+
const values = row.cells.map((c) => c.value);
|
|
99
|
+
const substitution: Record<string, string> = {};
|
|
100
|
+
headers.forEach((h, i) => {
|
|
101
|
+
substitution[h] = values[i];
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const bgParsed = convertSteps(backgroundSteps);
|
|
105
|
+
const scenarioSteps = convertSteps(scenario.steps).map((step) => ({
|
|
106
|
+
...step,
|
|
107
|
+
text: substituteExampleValues(step.text, substitution),
|
|
108
|
+
}));
|
|
109
|
+
const allSteps = resolveEffectiveKeywords([...bgParsed, ...scenarioSteps]);
|
|
110
|
+
|
|
111
|
+
results.push({
|
|
112
|
+
name: `${scenario.name} (${values.join(", ")})`,
|
|
113
|
+
file: filePath,
|
|
114
|
+
steps: allSteps,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function convertSteps(
|
|
123
|
+
steps: readonly messages.Step[]
|
|
124
|
+
): Omit<ParsedStep, "effectiveKeyword">[] {
|
|
125
|
+
return steps.map((step) => ({
|
|
126
|
+
keyword: normalizeKeyword(step.keyword),
|
|
127
|
+
text: step.text,
|
|
128
|
+
line: step.location.line,
|
|
129
|
+
column: step.location.column ?? 1,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeKeyword(keyword: string): GherkinKeyword {
|
|
134
|
+
const trimmed = keyword.trim();
|
|
135
|
+
// Gherkin keywords may include trailing space, e.g. "Given "
|
|
136
|
+
if (trimmed === "Given") return "Given";
|
|
137
|
+
if (trimmed === "When") return "When";
|
|
138
|
+
if (trimmed === "Then") return "Then";
|
|
139
|
+
if (trimmed === "And") return "And";
|
|
140
|
+
if (trimmed === "But") return "But";
|
|
141
|
+
// Fallback: treat as Given (shouldn't happen with valid Gherkin)
|
|
142
|
+
return "Given";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveEffectiveKeywords(
|
|
146
|
+
steps: Omit<ParsedStep, "effectiveKeyword">[]
|
|
147
|
+
): ParsedStep[] {
|
|
148
|
+
let lastEffective: "Given" | "When" | "Then" = "Given";
|
|
149
|
+
|
|
150
|
+
return steps.map((step) => {
|
|
151
|
+
let effectiveKeyword: "Given" | "When" | "Then";
|
|
152
|
+
if (step.keyword === "And" || step.keyword === "But") {
|
|
153
|
+
effectiveKeyword = lastEffective;
|
|
154
|
+
} else {
|
|
155
|
+
effectiveKeyword = step.keyword as "Given" | "When" | "Then";
|
|
156
|
+
}
|
|
157
|
+
lastEffective = effectiveKeyword;
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
...step,
|
|
161
|
+
effectiveKeyword,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function substituteExampleValues(
|
|
167
|
+
text: string,
|
|
168
|
+
substitution: Record<string, string>
|
|
169
|
+
): string {
|
|
170
|
+
let result = text;
|
|
171
|
+
for (const [key, value] of Object.entries(substitution)) {
|
|
172
|
+
result = result.replace(new RegExp(`<${key}>`, "g"), value);
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { glob } from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { extractStepDefinitions } from "./stepExtractor.js";
|
|
4
|
+
import { parseFeatureFiles, parseFeatureContent } from "./gherkinParser.js";
|
|
5
|
+
import {
|
|
6
|
+
matchScenarioSteps,
|
|
7
|
+
findMatchingDefinitions,
|
|
8
|
+
} from "./stepMatcher.js";
|
|
9
|
+
import { defaultRules, runRules } from "./rules/index.js";
|
|
10
|
+
import type {
|
|
11
|
+
AnalyzerConfig,
|
|
12
|
+
AnalysisRule,
|
|
13
|
+
Diagnostic,
|
|
14
|
+
StepDefinitionMeta,
|
|
15
|
+
ParsedScenario,
|
|
16
|
+
MatchedStep,
|
|
17
|
+
ParsedStep,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
AnalyzerConfig,
|
|
22
|
+
AnalysisRule,
|
|
23
|
+
Diagnostic,
|
|
24
|
+
StepDefinitionMeta,
|
|
25
|
+
ParsedScenario,
|
|
26
|
+
MatchedStep,
|
|
27
|
+
ParsedStep,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
extractStepDefinitions,
|
|
32
|
+
parseFeatureFiles,
|
|
33
|
+
parseFeatureContent,
|
|
34
|
+
matchScenarioSteps,
|
|
35
|
+
findMatchingDefinitions,
|
|
36
|
+
defaultRules,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface AnalyzeOptions {
|
|
40
|
+
rules?: AnalysisRule[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function analyze(
|
|
44
|
+
config: AnalyzerConfig,
|
|
45
|
+
options?: AnalyzeOptions
|
|
46
|
+
): Promise<Diagnostic[]> {
|
|
47
|
+
const rules = options?.rules ?? defaultRules;
|
|
48
|
+
|
|
49
|
+
// 1. Resolve file globs to paths
|
|
50
|
+
const stepFilePaths = await resolveGlobs(config.stepFiles);
|
|
51
|
+
const featureFilePaths = await resolveGlobs(config.featureFiles);
|
|
52
|
+
|
|
53
|
+
if (stepFilePaths.length === 0) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
if (featureFilePaths.length === 0) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Extract step metadata from step files
|
|
61
|
+
const stepDefinitions = extractStepDefinitions(
|
|
62
|
+
stepFilePaths,
|
|
63
|
+
config.tsConfigPath
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// 3. Parse feature files
|
|
67
|
+
const scenarios = parseFeatureFiles(featureFilePaths);
|
|
68
|
+
|
|
69
|
+
// 4. For each scenario, match steps and run rules
|
|
70
|
+
const diagnostics: Diagnostic[] = [];
|
|
71
|
+
for (const scenario of scenarios) {
|
|
72
|
+
const matchedSteps = matchScenarioSteps(scenario, stepDefinitions);
|
|
73
|
+
const scenarioDiags = runRules(rules, scenario, matchedSteps);
|
|
74
|
+
diagnostics.push(...scenarioDiags);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return diagnostics;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function resolveGlobs(patterns: string[]): Promise<string[]> {
|
|
81
|
+
const files: string[] = [];
|
|
82
|
+
for (const pattern of patterns) {
|
|
83
|
+
for await (const file of glob(pattern)) {
|
|
84
|
+
files.push(path.resolve(file));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Deduplicate
|
|
88
|
+
return [...new Set(files)];
|
|
89
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnalysisRule,
|
|
3
|
+
Diagnostic,
|
|
4
|
+
MatchedStep,
|
|
5
|
+
ParsedScenario,
|
|
6
|
+
} from "../types.js";
|
|
7
|
+
|
|
8
|
+
export const ambiguousStepRule: AnalysisRule = {
|
|
9
|
+
name: "ambiguous-step",
|
|
10
|
+
|
|
11
|
+
check(
|
|
12
|
+
scenario: ParsedScenario,
|
|
13
|
+
matchedSteps: MatchedStep[]
|
|
14
|
+
): Diagnostic[] {
|
|
15
|
+
return matchedSteps
|
|
16
|
+
.filter((step) => step.definitions.length > 1)
|
|
17
|
+
.map((step) => {
|
|
18
|
+
const locations = step.definitions
|
|
19
|
+
.map((d) => `${d.sourceFile}:${d.line}`)
|
|
20
|
+
.join(", ");
|
|
21
|
+
return {
|
|
22
|
+
file: scenario.file,
|
|
23
|
+
range: {
|
|
24
|
+
startLine: step.line,
|
|
25
|
+
startColumn: step.column,
|
|
26
|
+
endLine: step.line,
|
|
27
|
+
endColumn: step.column + step.text.length,
|
|
28
|
+
},
|
|
29
|
+
severity: "error" as const,
|
|
30
|
+
message: `Step "${step.text}" matches multiple step definitions: ${locations}`,
|
|
31
|
+
rule: "ambiguous-step",
|
|
32
|
+
source: "step-forge" as const,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnalysisRule,
|
|
3
|
+
Diagnostic,
|
|
4
|
+
MatchedStep,
|
|
5
|
+
ParsedScenario,
|
|
6
|
+
} from "../types.js";
|
|
7
|
+
|
|
8
|
+
export const dependencyRule: AnalysisRule = {
|
|
9
|
+
name: "dependency-check",
|
|
10
|
+
|
|
11
|
+
check(
|
|
12
|
+
scenario: ParsedScenario,
|
|
13
|
+
matchedSteps: MatchedStep[]
|
|
14
|
+
): Diagnostic[] {
|
|
15
|
+
const diagnostics: Diagnostic[] = [];
|
|
16
|
+
const produced = {
|
|
17
|
+
given: new Set<string>(),
|
|
18
|
+
when: new Set<string>(),
|
|
19
|
+
then: new Set<string>(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
for (const step of matchedSteps) {
|
|
23
|
+
if (step.definitions.length !== 1) continue;
|
|
24
|
+
|
|
25
|
+
const { dependencies, produces, stepType } = step.definitions[0];
|
|
26
|
+
|
|
27
|
+
// Collect all missing required dependencies for this step
|
|
28
|
+
const missing: Record<string, string[]> = {};
|
|
29
|
+
for (const phase of ["given", "when", "then"] as const) {
|
|
30
|
+
for (const [key, requirement] of Object.entries(dependencies[phase])) {
|
|
31
|
+
if (requirement === "required" && !produced[phase].has(key)) {
|
|
32
|
+
if (!missing[phase]) {
|
|
33
|
+
missing[phase] = [];
|
|
34
|
+
}
|
|
35
|
+
missing[phase].push(key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (Object.keys(missing).length > 0) {
|
|
41
|
+
const lines = Object.entries(missing).map(
|
|
42
|
+
([phase, keys]) =>
|
|
43
|
+
`${phase.charAt(0).toUpperCase() + phase.slice(1)}: ${keys.map((k) => `${phase}.${k}`).join(", ")}`
|
|
44
|
+
);
|
|
45
|
+
const message =
|
|
46
|
+
"Missing required dependencies:\n" + lines.join("\n");
|
|
47
|
+
|
|
48
|
+
diagnostics.push({
|
|
49
|
+
file: scenario.file,
|
|
50
|
+
range: {
|
|
51
|
+
startLine: step.line,
|
|
52
|
+
startColumn: step.column,
|
|
53
|
+
endLine: step.line,
|
|
54
|
+
endColumn: step.column + step.text.length,
|
|
55
|
+
},
|
|
56
|
+
severity: "error",
|
|
57
|
+
message,
|
|
58
|
+
rule: "dependency-check",
|
|
59
|
+
source: "step-forge",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Add produced keys for this step
|
|
64
|
+
for (const key of produces) {
|
|
65
|
+
produced[stepType].add(key);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return diagnostics;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnalysisRule,
|
|
3
|
+
Diagnostic,
|
|
4
|
+
MatchedStep,
|
|
5
|
+
ParsedScenario,
|
|
6
|
+
} from "../types.js";
|
|
7
|
+
import { ambiguousStepRule } from "./ambiguousStepRule.js";
|
|
8
|
+
import { dependencyRule } from "./dependencyRule.js";
|
|
9
|
+
import { undefinedStepRule } from "./undefinedStepRule.js";
|
|
10
|
+
|
|
11
|
+
export const defaultRules: AnalysisRule[] = [
|
|
12
|
+
undefinedStepRule,
|
|
13
|
+
ambiguousStepRule,
|
|
14
|
+
dependencyRule,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function runRules(
|
|
18
|
+
rules: AnalysisRule[],
|
|
19
|
+
scenario: ParsedScenario,
|
|
20
|
+
matchedSteps: MatchedStep[]
|
|
21
|
+
): Diagnostic[] {
|
|
22
|
+
return rules.flatMap((rule) => rule.check(scenario, matchedSteps));
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnalysisRule,
|
|
3
|
+
Diagnostic,
|
|
4
|
+
MatchedStep,
|
|
5
|
+
ParsedScenario,
|
|
6
|
+
} from "../types.js";
|
|
7
|
+
|
|
8
|
+
export const undefinedStepRule: AnalysisRule = {
|
|
9
|
+
name: "undefined-step",
|
|
10
|
+
|
|
11
|
+
check(
|
|
12
|
+
scenario: ParsedScenario,
|
|
13
|
+
matchedSteps: MatchedStep[]
|
|
14
|
+
): Diagnostic[] {
|
|
15
|
+
return matchedSteps
|
|
16
|
+
.filter((step) => step.definitions.length === 0)
|
|
17
|
+
.map((step) => ({
|
|
18
|
+
file: scenario.file,
|
|
19
|
+
range: {
|
|
20
|
+
startLine: step.line,
|
|
21
|
+
startColumn: step.column,
|
|
22
|
+
endLine: step.line,
|
|
23
|
+
endColumn: step.column + step.text.length,
|
|
24
|
+
},
|
|
25
|
+
severity: "error" as const,
|
|
26
|
+
message: `Step "${step.text}" does not match any step definition`,
|
|
27
|
+
rule: "undefined-step",
|
|
28
|
+
source: "step-forge" as const,
|
|
29
|
+
}));
|
|
30
|
+
},
|
|
31
|
+
};
|