@step-forge/step-forge 0.0.7-beta.7 → 0.0.7

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 +33 -26
  43. package/rolldown.config.mjs +34 -0
  44. package/src/analyzer/cli.ts +96 -0
  45. package/src/analyzer/gherkinParser.ts +175 -0
  46. package/src/analyzer/index.ts +77 -0
  47. package/src/analyzer/rules/ambiguousStepRule.ts +36 -0
  48. package/src/analyzer/rules/dependencyRule.ts +59 -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 +67 -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 +6 -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,29 @@
1
1
  {
2
2
  "name": "@step-forge/step-forge",
3
- "version": "0.0.7-beta.7",
3
+ "version": "0.0.7",
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
+ "import": "./dist/step-forge.js"
9
+ },
10
+ "./analyzer": {
11
+ "import": "./dist/analyzer.js"
10
12
  },
11
- "./package.json": "./package.json"
13
+ "./dist/": {
14
+ "import": "./dist/"
15
+ }
12
16
  },
13
- "main": "./dist/step-forge.cjs",
14
- "module": "./dist/step-forge.js",
15
- "types": "./dist/types/index.d.ts",
16
- "files": [
17
- "dist"
18
- ],
17
+ "bin": {
18
+ "step-forge-analyze": "./dist/analyzer-cli.js"
19
+ },
20
+ "types": "./dist/index.d.ts",
19
21
  "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",
22
+ "build": "rm -rf build && tsc && rolldown -c && dts-bundle-generator --config ./dts-bundle-generator.config.cjs && cp package.json build/",
23
+ "test:cucumber": "NODE_ENV=test NODE_OPTIONS='--import tsx' cucumber-js -p default",
24
+ "test:ci": "NODE_ENV=test NODE_OPTIONS='--import tsx' cucumber-js -p ci",
25
+ "test": "NODE_ENV=test PORT=7888 LOG_LEVEL=none NODE_OPTIONS='--import tsx' cucumber-js -p all 2> /dev/null",
26
+ "test:debug": "NODE_ENV=test PORT=7888 NODE_OPTIONS='--import tsx' cucumber-js -p all",
26
27
  "lint": "eslint . --ext .ts",
27
28
  "format": "prettier . --write"
28
29
  },
@@ -33,23 +34,29 @@
33
34
  "@types/node": "^22.10.2",
34
35
  "@typescript-eslint/eslint-plugin": "^7.18.0",
35
36
  "@typescript-eslint/parser": "^7.18.0",
36
- "@vitest/coverage-v8": "^2.0.4",
37
- "copyfiles": "^2.4.1",
38
37
  "dts-bundle-generator": "^9.5.1",
39
38
  "earl": "^1.3.0",
40
39
  "eslint": "^8.57.0",
41
40
  "eslint-config-prettier": "^9.1.0",
42
41
  "eslint-plugin-prettier": "^5.2.1",
43
42
  "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"
43
+ "tsx": "^4.19.0",
44
+ "rolldown": "^1.0.0-rc.3",
45
+ "typescript": "^5.7.2"
46
+ },
47
+ "peerDependencies": {
48
+ "typescript": "^5.0.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "typescript": {
52
+ "optional": true
53
+ }
50
54
  },
51
55
  "dependencies": {
52
- "@cucumber/cucumber": "^11.1.1",
56
+ "@cucumber/cucumber": "^12.6.0",
57
+ "@cucumber/cucumber-expressions": "^18.1.0",
58
+ "@cucumber/gherkin": "^37.0.1",
59
+ "@cucumber/messages": "^24.1.0",
53
60
  "lodash": "^4.17.21"
54
61
  }
55
62
  }
@@ -0,0 +1,34 @@
1
+ import { defineConfig } from "rolldown";
2
+
3
+ export default defineConfig([
4
+ {
5
+ input: "./src/index.ts",
6
+ output: {
7
+ file: "./build/dist/step-forge.js",
8
+ format: "esm",
9
+ sourcemap: true,
10
+ },
11
+ external: ["@cucumber/cucumber", "lodash"],
12
+ },
13
+ {
14
+ input: {
15
+ analyzer: "./src/analyzer/index.ts",
16
+ "analyzer-cli": "./src/analyzer/cli.ts",
17
+ },
18
+ output: {
19
+ dir: "./build/dist",
20
+ format: "esm",
21
+ sourcemap: true,
22
+ entryFileNames: "[name].js",
23
+ },
24
+ external: [
25
+ "@cucumber/cucumber",
26
+ "@cucumber/gherkin",
27
+ "@cucumber/messages",
28
+ "@cucumber/cucumber-expressions",
29
+ "lodash",
30
+ "typescript",
31
+ /^node:/,
32
+ ],
33
+ },
34
+ ]);
@@ -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
+ 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,77 @@
1
+ import { glob } from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { extractStepDefinitions } from "./stepExtractor.js";
4
+ import { parseFeatureFiles } from "./gherkinParser.js";
5
+ import { matchScenarioSteps } from "./stepMatcher.js";
6
+ import { defaultRules, runRules } from "./rules/index.js";
7
+ import type {
8
+ AnalyzerConfig,
9
+ AnalysisRule,
10
+ Diagnostic,
11
+ StepDefinitionMeta,
12
+ ParsedScenario,
13
+ MatchedStep,
14
+ ParsedStep,
15
+ } from "./types.js";
16
+
17
+ export type {
18
+ AnalyzerConfig,
19
+ AnalysisRule,
20
+ Diagnostic,
21
+ StepDefinitionMeta,
22
+ ParsedScenario,
23
+ MatchedStep,
24
+ ParsedStep,
25
+ };
26
+
27
+ export interface AnalyzeOptions {
28
+ rules?: AnalysisRule[];
29
+ }
30
+
31
+ export async function analyze(
32
+ config: AnalyzerConfig,
33
+ options?: AnalyzeOptions
34
+ ): Promise<Diagnostic[]> {
35
+ const rules = options?.rules ?? defaultRules;
36
+
37
+ // 1. Resolve file globs to paths
38
+ const stepFilePaths = await resolveGlobs(config.stepFiles);
39
+ const featureFilePaths = await resolveGlobs(config.featureFiles);
40
+
41
+ if (stepFilePaths.length === 0) {
42
+ return [];
43
+ }
44
+ if (featureFilePaths.length === 0) {
45
+ return [];
46
+ }
47
+
48
+ // 2. Extract step metadata from step files
49
+ const stepDefinitions = extractStepDefinitions(
50
+ stepFilePaths,
51
+ config.tsConfigPath
52
+ );
53
+
54
+ // 3. Parse feature files
55
+ const scenarios = parseFeatureFiles(featureFilePaths);
56
+
57
+ // 4. For each scenario, match steps and run rules
58
+ const diagnostics: Diagnostic[] = [];
59
+ for (const scenario of scenarios) {
60
+ const matchedSteps = matchScenarioSteps(scenario, stepDefinitions);
61
+ const scenarioDiags = runRules(rules, scenario, matchedSteps);
62
+ diagnostics.push(...scenarioDiags);
63
+ }
64
+
65
+ return diagnostics;
66
+ }
67
+
68
+ async function resolveGlobs(patterns: string[]): Promise<string[]> {
69
+ const files: string[] = [];
70
+ for (const pattern of patterns) {
71
+ for await (const file of glob(pattern)) {
72
+ files.push(path.resolve(file));
73
+ }
74
+ }
75
+ // Deduplicate
76
+ return [...new Set(files)];
77
+ }
@@ -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,59 @@
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
+ // Check required dependencies
28
+ for (const phase of ["given", "when", "then"] as const) {
29
+ for (const [key, requirement] of Object.entries(dependencies[phase])) {
30
+ if (requirement === "required" && !produced[phase].has(key)) {
31
+ const available = [...produced[phase]];
32
+ const availableStr =
33
+ available.length > 0 ? available.join(", ") : "(none)";
34
+ diagnostics.push({
35
+ file: scenario.file,
36
+ range: {
37
+ startLine: step.line,
38
+ startColumn: step.column,
39
+ endLine: step.line,
40
+ endColumn: step.column + step.text.length,
41
+ },
42
+ severity: "error",
43
+ message: `Step "${step.text}" requires '${phase}.${key}' but no preceding step produces it. Available ${phase} keys: ${availableStr}. Defined in: ${step.definitions[0].sourceFile}:${step.definitions[0].line}`,
44
+ rule: "dependency-check",
45
+ source: "step-forge",
46
+ });
47
+ }
48
+ }
49
+ }
50
+
51
+ // Add produced keys for this step
52
+ for (const key of produces) {
53
+ produced[stepType].add(key);
54
+ }
55
+ }
56
+
57
+ return diagnostics;
58
+ },
59
+ };
@@ -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
+ };