@g_package/jest-cucumber-fusion 2.0.0 → 3.0.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.
Files changed (71) hide show
  1. package/README.md +444 -38
  2. package/dist/THIRD_PARTY_LICENSES.txt +91 -0
  3. package/dist/index.cjs +9917 -0
  4. package/dist/index.d.cts +145 -0
  5. package/package.json +60 -10
  6. package/scripts/prepare-hooks.js +23 -0
  7. package/src/code-suggestion.js +240 -0
  8. package/src/configuration.js +147 -0
  9. package/src/feature-source.js +322 -0
  10. package/src/index.d.ts +123 -13
  11. package/src/index.js +153 -420
  12. package/src/keywords.js +56 -0
  13. package/src/scenario-name.js +128 -0
  14. package/src/shared-state.js +36 -0
  15. package/src/step-argument.js +56 -0
  16. package/src/step-matching.js +89 -0
  17. package/src/tag-filter.js +90 -0
  18. package/src/test-registration.js +178 -0
  19. package/src/value-description.js +26 -0
  20. package/.prettierignore +0 -16
  21. package/.prettierrc.json +0 -0
  22. package/codecov +0 -0
  23. package/codecov.SHA256SUM +0 -1
  24. package/codecov.SHA256SUM.sig +0 -16
  25. package/docs/AdditionalConfiguration.md +0 -155
  26. package/docs/GherkinTables.md +0 -61
  27. package/docs/Language.md +0 -76
  28. package/docs/ReusingStepDefinitions.md +0 -110
  29. package/docs/RunningTheExamples.md +0 -16
  30. package/docs/ScenarioOutlines.md +0 -43
  31. package/docs/StepDefinitionArguments.md +0 -37
  32. package/docs/product/expectations/fix-l2-outline-regex/outline-regex-binds.md +0 -148
  33. package/docs/product/expectations/fix-l4-escaped-parens-outline/escaped-parens-bind-in-outlines.md +0 -106
  34. package/docs/product/expectations/fix-m3-singleton-reset/clean-slate-per-feature.md +0 -133
  35. package/test/specs/features/basic-scenarios.feature +0 -28
  36. package/test/specs/features/l3-step-argument-delivery.feature +0 -44
  37. package/test/specs/features/language.feature +0 -41
  38. package/test/specs/features/m6-hooks-once-per-test.feature +0 -21
  39. package/test/specs/features/m6-hooks-outline-only.feature +0 -12
  40. package/test/specs/features/reuse-definition.feature +0 -13
  41. package/test/specs/features/scenario-outline2.feature +0 -73
  42. package/test/specs/features/scenario-outlines.feature +0 -88
  43. package/test/specs/features/step-definitions/ambiguous-step-shadowing.steps.js +0 -92
  44. package/test/specs/features/step-definitions/basic-scenarios.steps.js +0 -62
  45. package/test/specs/features/step-definitions/fuzz-properties.steps.js +0 -170
  46. package/test/specs/features/step-definitions/hook-error.steps.js +0 -59
  47. package/test/specs/features/step-definitions/l2-outline-edge-cases.steps.js +0 -327
  48. package/test/specs/features/step-definitions/l3-step-argument-delivery.steps.js +0 -191
  49. package/test/specs/features/step-definitions/l4-escaped-parens-outline.steps.js +0 -256
  50. package/test/specs/features/step-definitions/language.steps.js +0 -86
  51. package/test/specs/features/step-definitions/m1-before-hooks-clobber.steps.js +0 -64
  52. package/test/specs/features/step-definitions/m2-duplicate-matcher.steps.js +0 -60
  53. package/test/specs/features/step-definitions/m3-singleton-reset.steps.js +0 -275
  54. package/test/specs/features/step-definitions/m4-callsite-resolution.steps.js +0 -76
  55. package/test/specs/features/step-definitions/m5-errors-false-silent-skip.steps.js +0 -80
  56. package/test/specs/features/step-definitions/m6-hooks-once-per-test.steps.js +0 -90
  57. package/test/specs/features/step-definitions/missing-feature-file.steps.js +0 -23
  58. package/test/specs/features/step-definitions/reuse-code.js +0 -22
  59. package/test/specs/features/step-definitions/reuse-definition.steps.js +0 -30
  60. package/test/specs/features/step-definitions/scenario-outline2.steps.js +0 -57
  61. package/test/specs/features/step-definitions/scenario-outlines.steps.js +0 -110
  62. package/test/specs/features/step-definitions/undefined-step.steps.js +0 -39
  63. package/test/specs/features/step-definitions/using-dynamic-values.steps.js +0 -70
  64. package/test/specs/features/step-definitions/using-gherkin-tables.steps.js +0 -42
  65. package/test/specs/features/undefined-step.feature +0 -4
  66. package/test/specs/features/using-dynamic-values.feature +0 -34
  67. package/test/specs/features/using-gherkin-tables.feature +0 -16
  68. package/test/src/bank-account.js +0 -17
  69. package/test/src/online-sales.js +0 -33
  70. package/test/src/rocket.js +0 -13
  71. package/test/src/todo-list.js +0 -20
@@ -0,0 +1,145 @@
1
+ // Type definitions for @g_package/jest-cucumber-fusion
2
+ // Project: https://github.com/gotreasa/jest-cucumber-fusion#readme
3
+ // Originally written by Pelle Johnsen <https://github.com/pjoe> for DefinitelyTyped.
4
+
5
+ /** What Fusion passes a step: a capture or a docstring as a string, a data table as its rows. */
6
+ export type StepArgument = string | Array<Record<string, string>>;
7
+
8
+ // Declared through a method so its parameters are checked bivariantly: a step may declare
9
+ // the narrower type it receives, such as `(count: string) => ...`, which a plain function type
10
+ // refuses under strictFunctionTypes. A type Fusion never passes, such as `number`, is still
11
+ // refused, and an undeclared parameter is still a StepArgument.
12
+ export type CallBack = {
13
+ step(...args: ReadonlyArray<StepArgument>): void | Promise<void>;
14
+ }["step"];
15
+
16
+ export interface StepChain {
17
+ stepSentence: string | RegExp;
18
+ stepFnDefinition: CallBack;
19
+ }
20
+
21
+ export function Given(name: string | RegExp, callback: CallBack): StepChain;
22
+ export function Given(chain: StepChain): StepChain;
23
+ export function When(name: string | RegExp, callback: CallBack): StepChain;
24
+ export function When(chain: StepChain): StepChain;
25
+ export function Then(name: string | RegExp, callback: CallBack): StepChain;
26
+ export function Then(chain: StepChain): StepChain;
27
+ export function And(name: string | RegExp, callback: CallBack): StepChain;
28
+ export function And(chain: StepChain): StepChain;
29
+ export function But(name: string | RegExp, callback: CallBack): StepChain;
30
+ export function But(chain: StepChain): StepChain;
31
+
32
+ export function Before(callback: () => void | Promise<void>): void;
33
+ export function After(callback: () => void | Promise<void>): void;
34
+
35
+ /**
36
+ * Which of Fusion's validations are on.
37
+ *
38
+ * - `stepsMustMatchFeatureFile` (default on): a feature step that no registered definition
39
+ * matches is refused at collection, in one message naming every unbound step of the feature
40
+ * with starter code for each. Off, the scenarios holding those steps are registered through
41
+ * `test.skip`, so Jest reports them as skipped and never as passed.
42
+ * - `scenariosMustMatchFeatureFile` (default on): two scenarios of one feature file declared
43
+ * with the same title, ignoring case and counting a scenario inside a Rule, are refused at
44
+ * collection. A Scenario Outline counts once however many Examples rows it has, so repeated
45
+ * row names are never a duplicate. Off, the file is accepted as written.
46
+ * - `allowScenariosNotInFeatureFile`: accepted and vestigial. Fusion generates the scenario
47
+ * definitions from the feature file itself, so there is no scenario outside it to allow.
48
+ *
49
+ * As a boolean, `errors` is shorthand: `true` turns every key on, `false` turns every key off.
50
+ * As an object it merges KEY-WISE over the defaults, so naming one key says nothing about the
51
+ * others. Switching the step check off leaves the duplicate check exactly as it was.
52
+ */
53
+ export interface FusionErrorOptions {
54
+ stepsMustMatchFeatureFile?: boolean;
55
+ scenariosMustMatchFeatureFile?: boolean;
56
+ allowScenariosNotInFeatureFile?: boolean;
57
+ }
58
+
59
+ /**
60
+ * What a scenarioNameTemplate is handed, once per test. Unchanged from the shape consumers
61
+ * already write: these four variables and no others.
62
+ *
63
+ * `scenarioTitle` is the title of the individual test being named. For a Scenario Outline row
64
+ * that is the row's OWN substituted title, so each row gets its own name.
65
+ *
66
+ * `featureTags` are the feature's declared tags. `scenarioTags` are the rest of the tags that
67
+ * reached the scenario, which for an Examples row includes that Examples set's tags. The two
68
+ * lists are disjoint, and every tag carries its leading `@` and is lowercased. A tag declared
69
+ * on both the feature and the scenario appears in `featureTags` only.
70
+ */
71
+ export interface ScenarioNameTemplateVars {
72
+ featureTitle: string;
73
+ scenarioTitle: string;
74
+ scenarioTags: string[];
75
+ featureTags: string[];
76
+ }
77
+
78
+ export interface FusionOptions {
79
+ /**
80
+ * Accepted and ignored. Fusion always resolves a relative feature path against the
81
+ * directory of the file that called it, and hands an absolute path on from there, so there
82
+ * is nothing left for this to switch.
83
+ */
84
+ loadRelativePath?: boolean;
85
+ errors?: boolean | FusionErrorOptions;
86
+ /**
87
+ * Which scenarios to run, as a tag expression: a tag name written `@name`, combined with
88
+ * the operators `and`, `or` and `not` and grouped with parentheses. For example
89
+ * `@smoke and not @slow`, or `@shop and (@included or @draft)`.
90
+ *
91
+ * Matching ignores case on both sides: the expression and every tag are lowercased before
92
+ * they are compared, so the operators may be written in any case too.
93
+ *
94
+ * A scenario is selected on the tags its compiled pickle carries, which is the union of the
95
+ * scenario's own tags, its feature's tags and, for a Scenario Outline row, that Examples
96
+ * set's tags. A scenario the expression excludes is registered through `test.skip` under its
97
+ * own unannotated name, so Jest reports it as skipped: never run, and never absent from the
98
+ * report. Its steps are also exempt from the unmatched-step check, because a scenario you
99
+ * excluded is not one you are asking to have wired. They are still read, so a step with an
100
+ * unsupported keyword (`*`) or one that matches two definitions is refused all the same.
101
+ *
102
+ * An expression that cannot be parsed is refused at collection, before anything is
103
+ * registered, and the refusal names the expression you wrote. So is one with an operand
104
+ * that is not a tag, such as `smoke` written for `@smoke`: it could never match.
105
+ */
106
+ tagFilter?: string;
107
+ /**
108
+ * Renames every test Fusion registers, Scenario Outline rows included. It is called once per
109
+ * test while Fusion registers, never again while the tests run, and it names a test whatever
110
+ * its status: a scenario skipped because a tag filter excluded it, or because its steps do
111
+ * not bind, carries the same templated name it would have carried had it run.
112
+ *
113
+ * It must return a non-empty string. A template that throws, or that answers with anything
114
+ * else, is refused at collection rather than naming a test something Jest cannot report.
115
+ */
116
+ scenarioNameTemplate?: (vars: ScenarioNameTemplateVars) => string;
117
+ }
118
+
119
+ export function Fusion(feature: string, options?: FusionOptions): void;
120
+
121
+ /**
122
+ * Sets options for every `Fusion()` call in the current test file, so they need not be repeated
123
+ * in each one. Meant for a script listed in Jest's `setupFiles`:
124
+ *
125
+ * ```js
126
+ * // jest.config.js -> setupFiles: ["<rootDir>/jest.setup.js"]
127
+ * // jest.setup.js
128
+ * const { setFusionConfiguration } = require("@g_package/jest-cucumber-fusion");
129
+ * setFusionConfiguration({ tagFilter: "@smoke and not @slow" });
130
+ * ```
131
+ *
132
+ * Options are merged lowest to highest: the defaults, then whatever this setter holds, then the
133
+ * options passed to one `Fusion()` call. A per-call option therefore still wins for its own
134
+ * file, and `errors` merges key-wise at every layer, so naming one validation never switches
135
+ * off another. An option set to `undefined`, such as an unset environment variable forwarded
136
+ * as `{ tagFilter: process.env.TAGS }`, counts as not set and leaves the layer below in place.
137
+ *
138
+ * A second call REPLACES what the first set rather than merging into it, which is what lets a
139
+ * global be cleared or redefined. Jest gives each test file its own module registry and runs
140
+ * `setupFiles` inside it, so what is set here cannot reach another file.
141
+ *
142
+ * An argument that is not an options object is refused where it is called, before any step
143
+ * definition file has loaded. An unknown key is accepted and ignored, exactly as it is per call.
144
+ */
145
+ export function setFusionConfiguration(options: FusionOptions): void;
package/package.json CHANGED
@@ -1,16 +1,32 @@
1
1
  {
2
2
  "name": "@g_package/jest-cucumber-fusion",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Write cucumber test as part of a jest run (including coverage)",
5
- "main": "src/index.js",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
6
7
  "types": "src/index.d.ts",
8
+ "files": [
9
+ "src/",
10
+ "dist/",
11
+ "scripts/prepare-hooks.js"
12
+ ],
13
+ "engines": {
14
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
15
+ },
7
16
  "scripts": {
8
- "test": "jest --color",
17
+ "pretest": "npm run build",
18
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --color",
9
19
  "test-d": "tsd",
10
- "coverage": "jest --coverage",
20
+ "test:baseline": "node test/specs/baseline/run-all.js",
21
+ "coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
22
+ "lint": "eslint . --max-warnings 0",
23
+ "lint:secrets": "secretlint \"**/*\"",
11
24
  "commit": "git-cz",
12
25
  "semantic-release": "semantic-release",
13
- "fmt": "npx prettier --write ."
26
+ "fmt": "npx prettier --write .",
27
+ "prepare": "node scripts/prepare-hooks.js",
28
+ "build": "node scripts/build-cjs.js",
29
+ "prepack": "npm run build"
14
30
  },
15
31
  "repository": {
16
32
  "type": "git",
@@ -23,14 +39,28 @@
23
39
  "gherkin"
24
40
  ],
25
41
  "dependencies": {
26
- "callsites": "~3.1.0",
27
- "jest": "^30.4.2",
28
- "jest-cucumber": "^4.5.0"
42
+ "@cucumber/gherkin": "42.0.1",
43
+ "@cucumber/messages": "34.2.1",
44
+ "@cucumber/tag-expressions": "11.0.1",
45
+ "callsites": "4.2.0"
29
46
  },
30
47
  "devDependencies": {
48
+ "@commitlint/cli": "^21.2.3",
49
+ "@commitlint/config-conventional": "^21.2.3",
50
+ "@eslint/js": "^9.39.5",
51
+ "@secretlint/secretlint-rule-pattern": "^13.0.7",
52
+ "@secretlint/secretlint-rule-preset-recommend": "^13.0.7",
31
53
  "cz-conventional-changelog": "^3.3.0",
54
+ "esbuild": "0.28.2",
55
+ "eslint": "^9.39.5",
56
+ "eslint-plugin-jest": "^29.16.6",
32
57
  "fast-check": "^4.10.2",
33
- "prettier": "2.3.2",
58
+ "globals": "^17.13.0",
59
+ "husky": "^9.1.7",
60
+ "jest": "^30.4.2",
61
+ "lint-staged": "^16.4.0",
62
+ "prettier": "3.9.9",
63
+ "secretlint": "^13.0.7",
34
64
  "semantic-release": "^25.0.5",
35
65
  "tsd": "^0.33.0"
36
66
  },
@@ -56,7 +86,8 @@
56
86
  [
57
87
  "@semantic-release/npm",
58
88
  {
59
- "npmPublish": true
89
+ "npmPublish": false,
90
+ "tarballDir": "release"
60
91
  }
61
92
  ]
62
93
  ],
@@ -78,11 +109,30 @@
78
109
  "/node_modules/",
79
110
  "<rootDir>/.claude/"
80
111
  ],
112
+ "collectCoverageFrom": [
113
+ "src/**/*.js"
114
+ ],
81
115
  "coveragePathIgnorePatterns": [
82
116
  "/node_modules/",
83
117
  "/test/"
84
118
  ],
85
119
  "coverageDirectory": "./coverage/",
86
120
  "collectCoverage": true
121
+ },
122
+ "exports": {
123
+ ".": {
124
+ "import": {
125
+ "types": "./src/index.d.ts",
126
+ "default": "./src/index.js"
127
+ },
128
+ "require": {
129
+ "types": "./dist/index.d.cts",
130
+ "default": "./dist/index.cjs"
131
+ }
132
+ },
133
+ "./package.json": "./package.json"
134
+ },
135
+ "peerDependencies": {
136
+ "jest": ">=27"
87
137
  }
88
138
  }
@@ -0,0 +1,23 @@
1
+ // The prepare script: installs the git hooks, exactly as `husky` does, but writes husky's
2
+ // message to stderr instead of stdout.
3
+ //
4
+ // npm runs `prepare` during `npm pack`, and `npm pack --json` prints its JSON on stdout. husky's
5
+ // own command writes its message to stdout, so outside a git checkout (a source archive, for
6
+ // one) ".git can't be found" broke that JSON (finding F6 of PR #16 review round 4;
7
+ // test/specs/packaging/prepare-script.steps.js). husky returns the message from its function,
8
+ // so it is still shown, on stderr, and the exit code stays 0 as husky's own command keeps it.
9
+ //
10
+ // npm also runs `prepare` when a consumer installs this package from a directory or a `file:`
11
+ // dependency. That install has none of this package's devDependencies, husky included, and no
12
+ // hooks to install, so a missing husky is skipped quietly. Any other failure still fails. This
13
+ // file ships in the package for that reason (fresh fuzz of PR #16, 2026-10-10, finding B4).
14
+ let husky;
15
+ try {
16
+ ({ default: husky } = await import("husky"));
17
+ } catch (failure) {
18
+ if (failure && failure.code === "ERR_MODULE_NOT_FOUND") process.exit(0);
19
+ throw failure;
20
+ }
21
+
22
+ const message = husky();
23
+ if (message) console.error(`husky: ${message}`);
@@ -0,0 +1,240 @@
1
+ // What to tell a consumer whose step binds nothing: the starter code for one step, and the
2
+ // one numbered refusal for every unbound step of a feature.
3
+ //
4
+ // The refusal is the whole value here. "No step definition matches" on its own is the message
5
+ // the consumer already had; code they can paste is the message they wanted. So each entry
6
+ // carries a verb call in FUSION's own idiom, keyed on that step's recovered keyword, with a
7
+ // matcher built from the step's own text and one parameter for every argument the step
8
+ // implies.
9
+ //
10
+ // Pure: no filesystem, no parser, no Jest global. The argument detection, the escaping and the
11
+ // parameter naming are carried over from the removed intermediary's code generator
12
+ // (code-generation/step-generation.js:19-61); only its output form is not, because it emitted
13
+ // a test-callback idiom Fusion has no verb for.
14
+
15
+ // A number, or a double-quoted substring. The intermediary's pattern had a third alternative
16
+ // for an angle-bracket placeholder; it is dead for Fusion, because the step text reaching a
17
+ // suggestion is already substituted for its Examples row and no angle brackets survive.
18
+ const ARGUMENT_IN_STEP_TEXT = /([-+]?[0-9]*\.?[0-9]+)|"([^"<]+)"/g;
19
+
20
+ // A plain unsigned integer keeps the familiar (\d+). Any other number the detection above
21
+ // accepts (a sign, a decimal point, a leading dot) gets a capture that matches that same shape,
22
+ // because (\d+) cannot match "3.14", "-5" or ".5" and the suggested matcher would then fail to
23
+ // bind the very step it was suggested for. Inherited from jest-cucumber's generator; found by
24
+ // fuzzing on 2026-10-08.
25
+ const INTEGER_CAPTURE = "(\\d+)";
26
+ const NUMBER_CAPTURE = "([-+]?\\d*\\.?\\d+)";
27
+ // A quoted value never holds a double quote (the detection above stops at one), so the capture
28
+ // stops at one too. jest-cucumber's "(.*)" is greedy: it spans several quoted arguments and the
29
+ // text between them, so the matcher for `"a" "b"` also matched `"a" "b" 3 "c"` and two pasted
30
+ // definitions were refused as ambiguous. And "." matches no line terminator, which a quoted
31
+ // value may hold (a lone \r, U+2028 and U+2029 survive Gherkin's \r?\n line split); a negated
32
+ // class matches every character but the quote. Found by the review of the PR #16 fixes.
33
+ const QUOTED_CAPTURE = '"([^"]*)"';
34
+
35
+ // One capture for every value an argument position takes across the steps it must bind, so
36
+ // one definition binds them all: the integer capture only if every value is an unsigned
37
+ // integer.
38
+ const captureForNumbers = (numberTexts) =>
39
+ numberTexts.every((numberText) => /^\d+$/.test(numberText))
40
+ ? INTEGER_CAPTURE
41
+ : NUMBER_CAPTURE;
42
+
43
+ const captureFor = (kind, valueTexts) =>
44
+ kind === "number" ? captureForNumbers(valueTexts) : QUOTED_CAPTURE;
45
+
46
+ const VERB_FOR_BUCKET = {
47
+ given: "Given",
48
+ when: "When",
49
+ then: "Then",
50
+ and: "And",
51
+ but: "But",
52
+ };
53
+
54
+ // The matcher is emitted as a regex LITERAL, so "/" must be escaped too: unescaped it ends the
55
+ // literal early and the suggested code is not valid JavaScript. The same holds for the two
56
+ // Unicode line terminators, U+2028 and U+2029: Gherkin splits lines on \r?\n only, so they can
57
+ // reach step text, and JavaScript forbids a line terminator inside a regex literal. They are
58
+ // written as \u escapes, which match the same character. A lone \r reaches step text the same
59
+ // way and ends a regex or a string literal just as early, so it is written \r in both.
60
+ const escapedLineTerminators = (text) =>
61
+ text
62
+ .replace(/\r/g, "\\r")
63
+ .replace(/\u2028/g, "\\u2028")
64
+ .replace(/\u2029/g, "\\u2029");
65
+
66
+ const escapedForRegex = (text) =>
67
+ escapedLineTerminators(text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&"));
68
+
69
+ const escapedForDoubleQuotes = (text) =>
70
+ escapedLineTerminators(text.replace(/[\\"]/g, "\\$&"));
71
+
72
+ // Every argument the step text carries, as {start, end, kind, value} in text order. One pass
73
+ // with indices rather than a sequence of string replacements: a replacement would rewrite the
74
+ // first occurrence of the matched text wherever it sat, which is the wrong one as soon as a
75
+ // step says the same number twice.
76
+ //
77
+ // matchAll clones the pattern internally, so a shared lastIndex cannot leak between calls and
78
+ // the module-level regex needs no defensive copy of its own.
79
+ const argumentsInStepText = (stepText) =>
80
+ [...stepText.matchAll(ARGUMENT_IN_STEP_TEXT)].map((match) => ({
81
+ start: match.index,
82
+ end: match.index + match[0].length,
83
+ kind: match[1] === undefined ? "quoted" : "number",
84
+ value: match[1] === undefined ? match[2] : match[1],
85
+ }));
86
+
87
+ // A step's SHAPE: its verb and its literal text, with each argument reduced to its kind. Steps
88
+ // of one shape are bound by one definition, whatever their values.
89
+ const shapeOf = (step) => {
90
+ const argumentsFound = argumentsInStepText(step.stepText);
91
+ const shape = argumentsFound.reduce(
92
+ (built, argument) => ({
93
+ text:
94
+ built.text +
95
+ step.stepText.slice(built.consumedTo, argument.start) +
96
+ `\u0000${argument.kind}\u0000`,
97
+ consumedTo: argument.end,
98
+ }),
99
+ { text: "", consumedTo: 0 },
100
+ );
101
+
102
+ return `${step.keyword}\u0000${
103
+ shape.text + step.stepText.slice(shape.consumedTo)
104
+ }`;
105
+ };
106
+
107
+ // An ANCHORED regex literal over the step's own text, with each detected argument replaced by
108
+ // a capture group and everything around it escaped so it matches literally.
109
+ const matcherRegexFor = (stepText, argumentsFound) => {
110
+ const pattern = argumentsFound.reduce(
111
+ (built, argument) => ({
112
+ source:
113
+ built.source +
114
+ escapedForRegex(stepText.slice(built.consumedTo, argument.start)) +
115
+ argument.capture,
116
+ consumedTo: argument.end,
117
+ }),
118
+ { source: "", consumedTo: 0 },
119
+ );
120
+
121
+ return `/^${
122
+ pattern.source + escapedForRegex(stepText.slice(pattern.consumedTo))
123
+ }$/`;
124
+ };
125
+
126
+ // One parameter per detected argument, then the Gherkin argument LAST if the step carries one,
127
+ // named for the shape it actually is. Fusion hands a step its captures and then its Gherkin
128
+ // argument, so this order is the order the values arrive in.
129
+ //
130
+ // The Gherkin argument is read by PRESENCE: an empty docstring is "" and still earns a
131
+ // parameter, because the step really does receive it.
132
+ const parametersFor = (argumentsFound, stepArgument) => {
133
+ const captures = argumentsFound.map((each, index) => `arg${index}`);
134
+
135
+ if (stepArgument == null) return captures;
136
+
137
+ return captures.concat([
138
+ typeof stepArgument === "string" ? "docString" : "table",
139
+ ]);
140
+ };
141
+
142
+ const matcherFor = (stepText, argumentsFound) =>
143
+ argumentsFound.length > 0
144
+ ? matcherRegexFor(stepText, argumentsFound)
145
+ : `"${escapedForDoubleQuotes(stepText)}"`;
146
+
147
+ // The starter code for steps of ONE shape: the verb for their keyword, a matcher built from the
148
+ // first step's text with each capture wide enough for every step's value at that position, and
149
+ // a step function with the parameters they imply and an empty body to fill in. The Gherkin
150
+ // argument parameter is named for the first step that carries one.
151
+ const starterCodeForShape = (steps) => {
152
+ const valuesByStep = steps.map((step) => argumentsInStepText(step.stepText));
153
+ const argumentsFound = valuesByStep[0].map((argument, position) => ({
154
+ start: argument.start,
155
+ end: argument.end,
156
+ capture: captureFor(
157
+ argument.kind,
158
+ valuesByStep.map((values) => values[position].value),
159
+ ),
160
+ }));
161
+ const stepWithArgument = steps.find((step) => step.stepArgument != null);
162
+ const parameters = parametersFor(
163
+ argumentsFound,
164
+ stepWithArgument ? stepWithArgument.stepArgument : null,
165
+ );
166
+
167
+ return `${VERB_FOR_BUCKET[steps[0].keyword]}(${matcherFor(
168
+ steps[0].stepText,
169
+ argumentsFound,
170
+ )}, (${parameters.join(", ")}) => {});`;
171
+ };
172
+
173
+ // The unbound steps of one feature, each named once (a Background step is unbound in every
174
+ // scenario), grouped by shape in order of first appearance. Every row of an outline, and any
175
+ // two steps that differ only in their values, share a shape. The consumer writes ONE definition
176
+ // for them: two definitions would be refused when pasted, as a duplicate when their matchers
177
+ // are equal, and as ambiguous when one is wider ("(\d+)" beside a decimal capture both match
178
+ // "1"). A step with a table and the same step without one share a shape too; parameters do not
179
+ // make two definitions distinct.
180
+ const stepsByShape = (unboundSteps) => {
181
+ const groups = new Map();
182
+ const named = new Set();
183
+ unboundSteps.forEach((step) => {
184
+ const identity = `${step.keyword}\u0000${step.stepText}`;
185
+ if (named.has(identity)) return;
186
+ named.add(identity);
187
+ const shape = shapeOf(step);
188
+ if (!groups.has(shape)) groups.set(shape, []);
189
+ groups.get(shape).push(step);
190
+ });
191
+ return [...groups.values()];
192
+ };
193
+
194
+ // The refusal for every unbound step of one feature, numbered, in feature order.
195
+ //
196
+ // Refusing on the first unbound step instead bills the consumer one full re-run per missing
197
+ // definition, and the message would be telling the truth about a fraction of the state.
198
+ //
199
+ // The phrase "No step definition matches:" followed by the step text in double quotes opens
200
+ // every entry and appears nowhere else in the message. That is a designed constraint, not a
201
+ // turn of phrase: it is what the existing undefined-step guard and two seam regressions match
202
+ // on, and what lets a reader count the entries. One entry is one definition to write; the
203
+ // other steps it binds follow on lines of their own, so every unbound step is still named, and
204
+ // the header counts steps, not entries.
205
+ const unmatchedStepRefusal = (featureTitle, unboundSteps) => {
206
+ const shapes = stepsByShape(unboundSteps);
207
+ const stepCount = shapes.reduce((count, steps) => count + steps.length, 0);
208
+ const entries = shapes.map(
209
+ (steps, index) =>
210
+ ` ${index + 1}. No step definition matches: "${steps[0].stepText}"\n` +
211
+ steps
212
+ .slice(1)
213
+ .map((step) => ` nor: "${step.stepText}"\n`)
214
+ .join("") +
215
+ ` ${starterCodeForShape(steps)}`,
216
+ );
217
+
218
+ return new Error(
219
+ `Fusion found ${stepCount} step${
220
+ stepCount === 1 ? "" : "s"
221
+ } in the feature "${featureTitle}" that no registered step definition matches.\n\n` +
222
+ `WHY: Fusion runs each step through the definition registered for that step's own\n` +
223
+ ` Gherkin keyword, so a step with no definition has nothing to run and the\n` +
224
+ ` scenario holding it cannot be reported honestly.\n` +
225
+ `HOW: register a definition for each entry below. The starter code under each one is\n` +
226
+ ` the verb, the matcher and the parameters its steps need, and one definition\n` +
227
+ ` binds every step its entry names. Or pass\n` +
228
+ ` errors: { stepsMustMatchFeatureFile: false } to have the scenarios holding them\n` +
229
+ ` reported as skipped tests instead.\n\n` +
230
+ `${entries.join("\n\n")}\n`,
231
+ );
232
+ };
233
+
234
+ // The starter code for ONE step, as the refusal would suggest it on its own. Kept as an export
235
+ // for probes that drive the generator directly (plan, 2026-10-08 refactor: "keep the
236
+ // starterCodeFor export").
237
+ const starterCodeFor = (step) => starterCodeForShape([step]);
238
+
239
+ export { starterCodeFor };
240
+ export { unmatchedStepRefusal };
@@ -0,0 +1,147 @@
1
+ // The option defaults, the global layer and the merge, owned here rather than by the removed
2
+ // intermediary, whose configuration module did exactly this (configuration.js:26-36: the
3
+ // defaults, the global object, then the per-call options, with `errors: true` expanded).
4
+ //
5
+ // Three layers, lowest first: the defaults, the global a consumer sets once through
6
+ // setFusionConfiguration, and the options passed to one Fusion() call. The per-call layer wins,
7
+ // which is the precedence the documentation has always stated.
8
+ //
9
+ // WHERE THE GLOBAL LAYER LIVES, AND WHY IT IS PER FILE. In src/shared-state.js, on globalThis,
10
+ // so that every copy of the dual package in a test file sees it: a setup script may require()
11
+ // the package while the steps import it. Jest gives each test file its own global and runs
12
+ // setupFiles inside it, so the global a setup script sets is visible to that file's Fusion
13
+ // calls and cannot reach another file. It is per-file configuration, which is the whole reason
14
+ // this option can be set in one place.
15
+
16
+ import { describeValue } from "./value-description.js";
17
+ import { shared, replaceShared } from "./shared-state.js";
18
+
19
+ // Fusion's three validation keys. stepsMustMatchFeatureFile decides between the
20
+ // unmatched-step refusal and a visible skipped test; scenariosMustMatchFeatureFile gates the
21
+ // duplicate declared-title check; allowScenariosNotInFeatureFile is accepted and vestigial,
22
+ // because Fusion generates the scenario definitions from the feature file itself and so has
23
+ // no scenario outside it to allow.
24
+ const everyValidation = (on) => ({
25
+ stepsMustMatchFeatureFile: on,
26
+ scenariosMustMatchFeatureFile: on,
27
+ allowScenariosNotInFeatureFile: on,
28
+ });
29
+
30
+ // Validation is ON by default: an unmatched step fails loudly, and a duplicated declared
31
+ // title is refused, unless the consumer asks otherwise.
32
+ const defaultOptions = () => ({
33
+ errors: everyValidation(true),
34
+ tagFilter: undefined,
35
+ scenarioNameTemplate: undefined,
36
+ });
37
+
38
+ // An option whose value is `undefined` is NOT SET, at every layer and inside `errors`, so it
39
+ // never overrides the layer below. Forwarding an unset environment variable,
40
+ // `{ tagFilter: process.env.TAGS }`, is the ordinary way to write one, and a key-wise
41
+ // Object.assign would otherwise copy that `undefined` over a global the consumer set on purpose
42
+ // (finding F4 of the PR #16 review). `errors: undefined` already meant "no change"; this makes
43
+ // every key agree with it.
44
+ const keysThatAreSet = (options) =>
45
+ Object.fromEntries(
46
+ Object.entries(options).filter(([, value]) => value !== undefined),
47
+ );
48
+
49
+ // What ONE layer's `errors` contributes, in the three forms a consumer may write it:
50
+ //
51
+ // true -> every key on. An explicit reset of all three.
52
+ // false -> every key off. The same shorthand in the other direction.
53
+ // { one key } -> ONLY that key. Naming one validation says nothing about the others, so the
54
+ // keys it did not mention keep whatever the layer below set.
55
+ //
56
+ // Returning only the named keys is what makes the layers compose. Expanding a partial object
57
+ // over all-true here instead would let a per-call object naming one key silently switch a
58
+ // DIFFERENT key back on, undoing a global the consumer set deliberately.
59
+ const errorsNamedBy = (errors) => {
60
+ if (errors === true) return everyValidation(true);
61
+ if (errors === false) return everyValidation(false);
62
+ // Anything else but an object, null or undefined is refused: `errors: 0` was read as "no
63
+ // change", leaving every validation on while the consumer meant off (fresh fuzz of PR #16).
64
+ if (errors !== undefined && errors !== null && !isAnOptionObject(errors))
65
+ throw new Error(
66
+ `The errors option must be true, false or an object, but was given ${describeValue(
67
+ errors,
68
+ )}.\n\n` +
69
+ `HOW: pass errors: false to switch every validation off, or an object naming the ones\n` +
70
+ ` to change, for example errors: { stepsMustMatchFeatureFile: false }.`,
71
+ );
72
+
73
+ return keysThatAreSet(errors || {});
74
+ };
75
+
76
+ // The middle layer. Replaced wholesale by each setFusionConfiguration call, never merged into:
77
+ // replace is what the previous setter did, and it is the only semantics under which a consumer
78
+ // can CLEAR a global they set earlier.
79
+ //
80
+ // Kept in the shared store (src/shared-state.js), not in a module variable: a global set
81
+ // through one copy of the dual package was otherwise lost to the other (measured: the mixed
82
+ // consumer in test/specs/baseline/assert-packaged-consumer.js).
83
+ const globalOptions = () => shared("globalOptions", () => ({}));
84
+
85
+ const isAnOptionObject = (candidate) =>
86
+ typeof candidate === "object" &&
87
+ candidate !== null &&
88
+ !Array.isArray(candidate);
89
+
90
+ const refuseNonOptionObject = (whatItWasGiven) =>
91
+ new Error(
92
+ `setFusionConfiguration needs an options object.\n\n` +
93
+ `WHAT: it was given ${describeValue(whatItWasGiven)}.\n` +
94
+ `WHY: the argument is merged under every Fusion() call of this test file, so anything\n` +
95
+ ` that is not an options object leaves the whole file silently unconfigured, and\n` +
96
+ ` the mistake then looks like a bug in the feature files.\n` +
97
+ `HOW: pass the same object a Fusion() call accepts, for example\n` +
98
+ ` setFusionConfiguration({ tagFilter: "@smoke and not @slow" }). The accepted keys\n` +
99
+ ` are errors, tagFilter, scenarioNameTemplate and loadRelativePath; an unknown key\n` +
100
+ ` is ignored, exactly as it is per call.`,
101
+ );
102
+
103
+ // Refused here, at the call, because that is the one place and time the consumer can act: a
104
+ // setup script runs before any step definition file loads.
105
+ const setFusionConfiguration = (optionsForEveryFusionCall) => {
106
+ if (!isAnOptionObject(optionsForEveryFusionCall))
107
+ throw refuseNonOptionObject(optionsForEveryFusionCall);
108
+
109
+ // Copied, so the stored global is ours: a consumer who later mutates the object they passed
110
+ // does not silently reconfigure the rest of their file.
111
+ replaceShared("globalOptions", Object.assign({}, optionsForEveryFusionCall));
112
+ };
113
+
114
+ // `errors` is merged key-wise ACROSS the layers, not layer-over-layer as a whole value, which
115
+ // is the same promise value 2 made within one layer: naming one validation says nothing about
116
+ // the others, whichever layer named it. A top-level Object.assign would instead replace a
117
+ // global errors object wholesale with a per-call one, losing a key the consumer only mentioned
118
+ // once.
119
+ //
120
+ // The result always carries all three keys, because the fold starts from them. So no reader
121
+ // downstream has to know that `errors` has three spellings or three layers.
122
+ const errorsAcross = (layers) =>
123
+ layers.reduce(
124
+ (merged, layer) =>
125
+ Object.prototype.hasOwnProperty.call(layer, "errors")
126
+ ? Object.assign(merged, errorsNamedBy(layer.errors))
127
+ : merged,
128
+ everyValidation(true),
129
+ );
130
+
131
+ const mergeFusionOptions = (perCallOptions) => {
132
+ const perCall = perCallOptions || {};
133
+ // Lowest to highest, and a fresh object every call: the per-call options are never written
134
+ // into the global, so one file's option cannot configure another file of the same run.
135
+ const merged = Object.assign(
136
+ defaultOptions(),
137
+ keysThatAreSet(globalOptions()),
138
+ keysThatAreSet(perCall),
139
+ );
140
+
141
+ return Object.assign(merged, {
142
+ errors: errorsAcross([globalOptions(), perCall]),
143
+ });
144
+ };
145
+
146
+ export { setFusionConfiguration };
147
+ export { mergeFusionOptions };