@step-forge/step-forge 0.0.7 → 0.0.9

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.
@@ -81,3 +81,30 @@ Feature: Analyzer dependency verification
81
81
  And there are 2 errors for rule "dependency-check"
82
82
  And an error should mention "given.user"
83
83
  And an error should mention "given.account"
84
+
85
+ # --- Diagnostic range scenarios ---
86
+
87
+ Scenario: Undefined step diagnostic range covers only the step text
88
+ Given a feature file "undefined-step.feature"
89
+ When I analyze the files
90
+ Then there should be 1 error
91
+ And the error on line 4 should span columns 10 to 44
92
+
93
+ Scenario: Ambiguous step diagnostic range covers only the step text
94
+ Given a feature file "ambiguous-step.feature"
95
+ When I analyze the files
96
+ Then there should be 1 error
97
+ And the error on line 3 should span columns 11 to 30
98
+
99
+ Scenario: Missing dependency diagnostic range covers only the step text
100
+ Given a feature file "missing-given-dep.feature"
101
+ When I analyze the files
102
+ Then there should be 1 error
103
+ And the error on line 4 should span columns 10 to 25
104
+
105
+ Scenario: Each diagnostic range is correct with multiple errors
106
+ Given a feature file "missing-multiple-deps.feature"
107
+ When I analyze the files
108
+ Then there should be 2 errors
109
+ And the error on line 4 should span columns 10 to 25
110
+ And the error on line 5 should span columns 10 to 30
@@ -51,3 +51,17 @@ Then(
51
51
  expect(errors).toHaveLength(count);
52
52
  }
53
53
  );
54
+
55
+ Then(
56
+ "the error on line {int} should span columns {int} to {int}",
57
+ function (line: number, startCol: number, endCol: number) {
58
+ const errors = diagnostics.filter(
59
+ (d) => d.severity === "error" && d.range.startLine === line
60
+ );
61
+ expect(errors.length).toBeGreaterThanOrEqual(1);
62
+ const error = errors[0];
63
+ expect(error.range.startColumn).toEqual(startCol);
64
+ expect(error.range.endColumn).toEqual(endCol);
65
+ expect(error.range.endLine).toEqual(line);
66
+ }
67
+ );
package/package.json CHANGED
@@ -1,13 +1,21 @@
1
1
  {
2
2
  "name": "@step-forge/step-forge",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "module": "./dist/step-forge.js",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
8
- "import": "./dist/step-forge.js"
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
+ }
9
16
  },
10
17
  "./analyzer": {
18
+ "types": "./dist/analyzer.d.ts",
11
19
  "import": "./dist/analyzer.js"
12
20
  },
13
21
  "./dist/": {
@@ -19,7 +27,7 @@
19
27
  },
20
28
  "types": "./dist/index.d.ts",
21
29
  "scripts": {
22
- "build": "rm -rf build && tsc && rolldown -c && dts-bundle-generator --config ./dts-bundle-generator.config.cjs && cp package.json build/",
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/",
23
31
  "test:cucumber": "NODE_ENV=test NODE_OPTIONS='--import tsx' cucumber-js -p default",
24
32
  "test:ci": "NODE_ENV=test NODE_OPTIONS='--import tsx' cucumber-js -p ci",
25
33
  "test": "NODE_ENV=test PORT=7888 LOG_LEVEL=none NODE_OPTIONS='--import tsx' cucumber-js -p all 2> /dev/null",
@@ -1,5 +1,15 @@
1
1
  import { defineConfig } from "rolldown";
2
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
+
3
13
  export default defineConfig([
4
14
  {
5
15
  input: "./src/index.ts",
@@ -8,7 +18,16 @@ export default defineConfig([
8
18
  format: "esm",
9
19
  sourcemap: true,
10
20
  },
11
- external: ["@cucumber/cucumber", "lodash"],
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,
12
31
  },
13
32
  {
14
33
  input: {
@@ -21,14 +40,6 @@ export default defineConfig([
21
40
  sourcemap: true,
22
41
  entryFileNames: "[name].js",
23
42
  },
24
- external: [
25
- "@cucumber/cucumber",
26
- "@cucumber/gherkin",
27
- "@cucumber/messages",
28
- "@cucumber/cucumber-expressions",
29
- "lodash",
30
- "typescript",
31
- /^node:/,
32
- ],
43
+ external: allExternal,
33
44
  },
34
45
  ]);
@@ -17,7 +17,7 @@ export function parseFeatureFiles(filePaths: string[]): ParsedScenario[] {
17
17
  return scenarios;
18
18
  }
19
19
 
20
- function parseFeatureContent(
20
+ export function parseFeatureContent(
21
21
  content: string,
22
22
  filePath: string
23
23
  ): ParsedScenario[] {
@@ -126,7 +126,11 @@ function convertSteps(
126
126
  keyword: normalizeKeyword(step.keyword),
127
127
  text: step.text,
128
128
  line: step.location.line,
129
- column: step.location.column ?? 1,
129
+ // step.location.column points to the keyword start; shift past the
130
+ // keyword (which includes a trailing space) so column points to the
131
+ // start of the step text. This makes `column + text.length` produce
132
+ // the correct end position for diagnostic ranges.
133
+ column: (step.location.column ?? 1) + step.keyword.length,
130
134
  }));
131
135
  }
132
136
 
@@ -1,8 +1,11 @@
1
1
  import { glob } from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { extractStepDefinitions } from "./stepExtractor.js";
4
- import { parseFeatureFiles } from "./gherkinParser.js";
5
- import { matchScenarioSteps } from "./stepMatcher.js";
4
+ import { parseFeatureFiles, parseFeatureContent } from "./gherkinParser.js";
5
+ import {
6
+ matchScenarioSteps,
7
+ findMatchingDefinitions,
8
+ } from "./stepMatcher.js";
6
9
  import { defaultRules, runRules } from "./rules/index.js";
7
10
  import type {
8
11
  AnalyzerConfig,
@@ -24,6 +27,15 @@ export type {
24
27
  ParsedStep,
25
28
  };
26
29
 
30
+ export {
31
+ extractStepDefinitions,
32
+ parseFeatureFiles,
33
+ parseFeatureContent,
34
+ matchScenarioSteps,
35
+ findMatchingDefinitions,
36
+ defaultRules,
37
+ };
38
+
27
39
  export interface AnalyzeOptions {
28
40
  rules?: AnalysisRule[];
29
41
  }
@@ -24,30 +24,42 @@ export const dependencyRule: AnalysisRule = {
24
24
 
25
25
  const { dependencies, produces, stepType } = step.definitions[0];
26
26
 
27
- // Check required dependencies
27
+ // Collect all missing required dependencies for this step
28
+ const missing: Record<string, string[]> = {};
28
29
  for (const phase of ["given", "when", "then"] as const) {
29
30
  for (const [key, requirement] of Object.entries(dependencies[phase])) {
30
31
  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
- });
32
+ if (!missing[phase]) {
33
+ missing[phase] = [];
34
+ }
35
+ missing[phase].push(key);
47
36
  }
48
37
  }
49
38
  }
50
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
+
51
63
  // Add produced keys for this step
52
64
  for (const key of produces) {
53
65
  produced[stepType].add(key);
@@ -1,29 +1,40 @@
1
- import {
2
- CucumberExpression,
3
- ParameterTypeRegistry,
4
- } from "@cucumber/cucumber-expressions";
5
1
  import { MatchedStep, ParsedScenario, StepDefinitionMeta } from "./types.js";
6
2
 
7
- interface CompiledExpression {
8
- expression: CucumberExpression;
3
+ interface CompiledPattern {
4
+ regex: RegExp;
9
5
  definition: StepDefinitionMeta;
10
6
  }
11
7
 
12
- export function matchScenarioSteps(
13
- scenario: ParsedScenario,
8
+ function compileDefinitions(
14
9
  definitions: StepDefinitionMeta[]
15
- ): MatchedStep[] {
16
- const registry = new ParameterTypeRegistry();
17
-
18
- const compiled: CompiledExpression[] = [];
10
+ ): CompiledPattern[] {
11
+ const compiled: CompiledPattern[] = [];
19
12
  for (const def of definitions) {
20
13
  try {
21
- const expression = new CucumberExpression(def.expression, registry);
22
- compiled.push({ expression, definition: def });
14
+ // Replace {paramType} placeholders with (.+) to match any value,
15
+ // matching the Step Forge runtime behavior where all params use {string}
16
+ // and values are coerced at runtime via typeCoercer.
17
+ const placeholder = "###PLACEHOLDER###";
18
+ const regexStr = def.expression
19
+ .replace(/\{[^}]+\}/g, placeholder)
20
+ .replace(/[.*+?^$()|[\]\\]/g, "\\$&")
21
+ .replace(new RegExp(placeholder, "g"), "(.+)");
22
+ compiled.push({
23
+ regex: new RegExp(`^${regexStr}$`, "i"),
24
+ definition: def,
25
+ });
23
26
  } catch {
24
27
  // Skip definitions with invalid expressions
25
28
  }
26
29
  }
30
+ return compiled;
31
+ }
32
+
33
+ export function matchScenarioSteps(
34
+ scenario: ParsedScenario,
35
+ definitions: StepDefinitionMeta[]
36
+ ): MatchedStep[] {
37
+ const compiled = compileDefinitions(definitions);
27
38
 
28
39
  return scenario.steps.map((step) => {
29
40
  const matches = findMatches(step.text, step.effectiveKeyword, compiled);
@@ -34,10 +45,19 @@ export function matchScenarioSteps(
34
45
  });
35
46
  }
36
47
 
48
+ export function findMatchingDefinitions(
49
+ text: string,
50
+ effectiveKeyword: "Given" | "When" | "Then",
51
+ definitions: StepDefinitionMeta[]
52
+ ): StepDefinitionMeta[] {
53
+ const compiled = compileDefinitions(definitions);
54
+ return findMatches(text, effectiveKeyword, compiled);
55
+ }
56
+
37
57
  function findMatches(
38
58
  text: string,
39
59
  effectiveKeyword: "Given" | "When" | "Then",
40
- compiled: CompiledExpression[]
60
+ compiled: CompiledPattern[]
41
61
  ): StepDefinitionMeta[] {
42
62
  const keywordToStepType: Record<string, string> = {
43
63
  Given: "given",
@@ -48,19 +68,17 @@ function findMatches(
48
68
 
49
69
  // First try to match with the correct step type
50
70
  const typedMatches: StepDefinitionMeta[] = [];
51
- for (const { expression, definition } of compiled) {
71
+ for (const { regex, definition } of compiled) {
52
72
  if (definition.stepType !== expectedStepType) continue;
53
- const result = expression.match(text);
54
- if (result) typedMatches.push(definition);
73
+ if (regex.test(text)) typedMatches.push(definition);
55
74
  }
56
75
 
57
76
  if (typedMatches.length > 0) return typedMatches;
58
77
 
59
- // Fallback: match any step type (Cucumber itself doesn't enforce keyword-to-step-type)
78
+ // Fallback: match any step type
60
79
  const fallbackMatches: StepDefinitionMeta[] = [];
61
- for (const { expression, definition } of compiled) {
62
- const result = expression.match(text);
63
- if (result) fallbackMatches.push(definition);
80
+ for (const { regex, definition } of compiled) {
81
+ if (regex.test(text)) fallbackMatches.push(definition);
64
82
  }
65
83
 
66
84
  return fallbackMatches;
package/src/index.ts CHANGED
@@ -2,5 +2,45 @@ import { givenBuilder } from "./given";
2
2
  import { whenBuilder } from "./when";
3
3
  import { thenBuilder } from "./then";
4
4
  import { BasicWorld } from "./world";
5
+ import {
6
+ analyze,
7
+ extractStepDefinitions,
8
+ parseFeatureFiles,
9
+ parseFeatureContent,
10
+ matchScenarioSteps,
11
+ findMatchingDefinitions,
12
+ defaultRules,
13
+ } from "./analyzer/index";
14
+ import type {
15
+ AnalyzerConfig,
16
+ AnalyzeOptions,
17
+ AnalysisRule,
18
+ Diagnostic,
19
+ StepDefinitionMeta,
20
+ ParsedScenario,
21
+ ParsedStep,
22
+ MatchedStep,
23
+ } from "./analyzer/index";
5
24
 
6
25
  export { givenBuilder, whenBuilder, thenBuilder, BasicWorld };
26
+
27
+ export const analyzer = {
28
+ analyze,
29
+ extractStepDefinitions,
30
+ parseFeatureFiles,
31
+ parseFeatureContent,
32
+ matchScenarioSteps,
33
+ findMatchingDefinitions,
34
+ defaultRules,
35
+ };
36
+
37
+ export type {
38
+ AnalyzerConfig,
39
+ AnalyzeOptions,
40
+ AnalysisRule,
41
+ Diagnostic,
42
+ StepDefinitionMeta,
43
+ ParsedScenario,
44
+ ParsedStep,
45
+ MatchedStep,
46
+ };