@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.
Files changed (69) hide show
  1. package/.claude/settings.local.json +17 -0
  2. package/.eslintignore +6 -0
  3. package/.eslintrc +18 -0
  4. package/.prettierignore +6 -0
  5. package/.prettierrc +15 -0
  6. package/CHANGELOG.md +28 -0
  7. package/CLAUDE.md +115 -0
  8. package/README.md +45 -39
  9. package/cucumber.mjs +39 -0
  10. package/dist/step-forge.cjs +1 -1
  11. package/dist/step-forge.js +143 -120
  12. package/dist/types/src/builderTypeUtils.d.ts +14 -0
  13. package/dist/types/src/common.d.ts +32 -0
  14. package/dist/types/src/given.d.ts +73 -0
  15. package/dist/types/src/index.d.ts +5 -0
  16. package/dist/types/src/then.d.ts +73 -0
  17. package/dist/types/src/utils.d.ts +5 -0
  18. package/dist/types/src/when.d.ts +72 -0
  19. package/dist/types/src/world.d.ts +21 -0
  20. package/docs/assets/state_deps.gif +0 -0
  21. package/dts-bundle-generator.config.cjs +19 -0
  22. package/features/TESTING.md +100 -0
  23. package/features/analyzer/analyzer.feature +83 -0
  24. package/features/analyzer/fixtures/ambiguous-step.feature +5 -0
  25. package/features/analyzer/fixtures/missing-given-dep.feature +5 -0
  26. package/features/analyzer/fixtures/missing-multiple-deps.feature +6 -0
  27. package/features/analyzer/fixtures/missing-when-dep.feature +5 -0
  28. package/features/analyzer/fixtures/steps.ts +113 -0
  29. package/features/analyzer/fixtures/undefined-step.feature +5 -0
  30. package/features/analyzer/fixtures/undefined-with-missing-dep.feature +5 -0
  31. package/features/analyzer/fixtures/valid-and-but-keywords.feature +8 -0
  32. package/features/analyzer/fixtures/valid-deps.feature +6 -0
  33. package/features/analyzer/fixtures/valid-no-deps.feature +6 -0
  34. package/features/analyzer/fixtures/valid-optional-deps.feature +6 -0
  35. package/features/analyzer/fixtures/valid-variables.feature +6 -0
  36. package/features/basic.feature +21 -0
  37. package/features/exported.feature +6 -0
  38. package/features/steps/analyzerSteps.ts +53 -0
  39. package/features/steps/commonSteps.ts +93 -0
  40. package/features/steps/exportedSteps.ts +42 -0
  41. package/features/steps/world.ts +29 -0
  42. package/package.json +41 -26
  43. package/rolldown.config.mjs +45 -0
  44. package/src/analyzer/cli.ts +96 -0
  45. package/src/analyzer/gherkinParser.ts +175 -0
  46. package/src/analyzer/index.ts +89 -0
  47. package/src/analyzer/rules/ambiguousStepRule.ts +36 -0
  48. package/src/analyzer/rules/dependencyRule.ts +71 -0
  49. package/src/analyzer/rules/index.ts +23 -0
  50. package/src/analyzer/rules/undefinedStepRule.ts +31 -0
  51. package/src/analyzer/stepExtractor.ts +432 -0
  52. package/src/analyzer/stepMatcher.ts +85 -0
  53. package/src/analyzer/types.ts +55 -0
  54. package/src/builderTypeUtils.ts +27 -0
  55. package/src/common.ts +138 -0
  56. package/src/given.ts +102 -0
  57. package/src/index.ts +46 -0
  58. package/src/then.ts +128 -0
  59. package/src/utils.ts +74 -0
  60. package/src/when.ts +118 -0
  61. package/src/world.ts +90 -0
  62. package/test/givenCompilationTests.ts +130 -0
  63. package/test/testUtils.ts +30 -0
  64. package/test/thenCompilationTests.ts +207 -0
  65. package/test/whenCompilationTests.ts +157 -0
  66. package/ts-node-esm-register.js +8 -0
  67. package/tsconfig.cucumber.json +14 -0
  68. package/tsconfig.json +29 -0
  69. 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.7-beta.7",
3
+ "version": "0.0.8",
4
+ "module": "./dist/step-forge.js",
4
5
  "type": "module",
5
6
  "exports": {
6
7
  ".": {
7
- "types": "./dist/types/index.d.ts",
8
- "import": "./dist/step-forge.js",
9
- "require": "./dist/step-forge.cjs"
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
- "./package.json": "./package.json"
21
+ "./dist/": {
22
+ "import": "./dist/"
23
+ }
12
24
  },
13
- "main": "./dist/step-forge.cjs",
14
- "module": "./dist/step-forge.js",
15
- "types": "./dist/types/index.d.ts",
16
- "files": [
17
- "dist"
18
- ],
25
+ "bin": {
26
+ "step-forge-analyze": "./dist/analyzer-cli.js"
27
+ },
28
+ "types": "./dist/index.d.ts",
19
29
  "scripts": {
20
- "dev": "vite --host",
21
- "build": "rimraf dist && tsc && vite build && dts-bundle-generator --config ./dts-bundle-generator.config.cjs",
22
- "test:cucumber": "NODE_ENV=test NODE_OPTIONS=\"--experimental-specifier-resolution=node --loader ts-node/esm\" TS_NODE_PROJECT=./tsconfig.cucumber.json cucumber-js -p default",
23
- "test:ci": "NODE_ENV=test cucumber-js -p ci",
24
- "test": "NODE_ENV=test NODE_OPTIONS=\"--experimental-specifier-resolution=node --loader ts-node/esm\" TS_NODE_PROJECT=./tsconfig.cucumber.json cucumber-js -p default 2> /dev/null",
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
- "rimraf": "^6.0.1",
45
- "ts-node": "^10.9.2",
46
- "tsconfig-paths": "^4.2.0",
47
- "typescript": "^5.7.2",
48
- "vite": "^5.3.5",
49
- "vitest": "^2.0.4"
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": "^11.1.1",
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
+ };