A Gherkin linter for .feature files, implemented in TypeScript and built on @cucumber/gherkin. It aims to be a modern, maintainable take on the same idea as the original gherkin-lint CLI.
Requirements: Node.js >=20.
npm install gherkin-lint-plusFrom a git checkout (development):
cd gherkin-lint-plus
npm install
npm run buildnpm run build runs the unit tests first, then compiles to dist/.
You need a config file in the current working directory unless you pass -c (see below).
npx gherkin-lint-plus [options] [files...]- No arguments: lints all
**/*.featureunder., respecting ignore rules. - Paths: files, directories, or globs; directories are expanded to
**/*.featureunder them.
| Option | Description |
|---|---|
-c, --config [path] |
Config file (default: .gherkin-lintrc in cwd) |
-f, --format [format] |
Output format; only stylish is supported today |
-i, --ignore <items> |
Comma-separated ignore globs (overrides .gherkin-lintignore if present) |
-r, --rulesdir <dir> |
Extra directory of custom rules (repeatable); see Custom rules |
Exit code 1 if any file has lint errors.
The default name is .gherkin-lintrc in your working directory. Contents are JSON with optional comments (JSONC). Use -c / --config if the file lives elsewhere.
Each key is a rule name. Allowed values:
| Form | Meaning |
|---|---|
"on" |
Rule enabled with default options (or no extra options). |
"off" |
Rule disabled (not allowed for mandatory rules). |
["on", <options>] |
Rule enabled; <options> is an object for most rules, or a string where documented (e.g. new-line-at-eof, no-dupe-scenario-names). |
["off", <options>] |
Rule disabled (same restriction for mandatory rules). |
Optional rules are off if omitted from the config.
Mandatory rules always run. You may omit them or set "on" / ["on", …]; you must not set them to "off" or ["off", …] (the CLI reports a configuration error).
{
"indentation": "on",
"no-trailing-spaces": "on",
"file-name": ["on", { "style": "kebab-case" }],
"new-line-at-eof": ["on", "yes"]
}- Add
.gherkin-lintignorein the working directory: one glob pattern per line. - Or use
-i/--ignorewith comma-separated globs (this overrides the ignore file when provided).
If there is no ignore file and you did not pass -i, node_modules/** is ignored by default.
| Rule name | Category | In .gherkin-lintrc? |
Enable example |
|---|---|---|---|
no-tags-on-backgrounds |
Parse | No — fix the .feature file |
— |
one-feature-per-file |
Parse | No | — |
up-to-one-background-per-file |
Parse | No | — |
no-multiline-steps |
Parse | No | — |
unexpected-error |
Parse | No (other parse failures) | — |
no-empty-file |
Mandatory | Yes (cannot turn off) | Omit, or "no-empty-file": "on" |
no-files-without-scenarios |
Mandatory | Yes (cannot turn off) | Omit, or "on" |
no-unnamed-features |
Mandatory | Yes (cannot turn off) | Omit, or "on" |
no-unnamed-scenarios |
Mandatory | Yes (cannot turn off) | Omit, or "on" |
allowed-tags |
Optional | Yes | "allowed-tags": "on" + options (see below) |
file-name |
Optional | Yes | "file-name": ["on", { "style": "PascalCase" }] |
indentation |
Optional | Yes | "indentation": "on" or ["on", { … }] |
keywords-in-logical-order |
Optional | Yes | "keywords-in-logical-order": "on" |
max-scenarios-per-file |
Optional | Yes | "max-scenarios-per-file": ["on", { … }] |
name-length |
Optional | Yes | "name-length": ["on", { … }] |
new-line-at-eof |
Optional | Yes | "new-line-at-eof": ["on", "yes"] |
no-background-only-scenario |
Optional | Yes | "no-background-only-scenario": "on" |
no-dupe-feature-names |
Optional | Yes | "no-dupe-feature-names": "on" |
no-dupe-scenario-names |
Optional | Yes | "on" or ["on", "in-feature"] |
no-duplicate-tags |
Optional | Yes | "no-duplicate-tags": "on" |
no-empty-background |
Optional | Yes | "no-empty-background": "on" |
no-examples-in-scenarios |
Optional | Yes | "no-examples-in-scenarios": "on" |
no-homogenous-tags |
Optional | Yes | "no-homogenous-tags": "on" |
no-multiple-empty-lines |
Optional | Yes | "no-multiple-empty-lines": "on" |
no-partially-commented-tag-lines |
Optional | Yes | "no-partially-commented-tag-lines": "on" |
no-restricted-patterns |
Optional | Yes | ["on", { … }] |
no-restricted-tags |
Optional | Yes | ["on", { "tags": [], "patterns": [] }] |
no-scenario-outlines-without-examples |
Optional | Yes | "no-scenario-outlines-without-examples": "on" |
no-superfluous-tags |
Optional | Yes | "no-superfluous-tags": "on" |
no-trailing-spaces |
Optional | Yes | "no-trailing-spaces": "on" |
no-unused-variables |
Optional | Yes | "no-unused-variables": "on" |
one-space-between-tags |
Optional | Yes | "one-space-between-tags": "on" |
only-one-when |
Optional | Yes | "only-one-when": "on" |
required-tags |
Optional | Yes | ["on", { … }] |
scenario-size |
Optional | Yes | ["on", { "steps-length": { … } }] |
use-and |
Optional | Yes | "use-and": "on" |
Parse rules are emitted when the Gherkin parser rejects the file. They are not toggled in config; correct the source. Mandatory rules always run and cannot be disabled.
Backgrounds: you may have one optional Background under Feature, and one optional Background inside each Rule:. up-to-one-background-per-file fires when a second Background appears in the same scope (duplicate at feature level or duplicate inside one rule).
These appear as rule IDs on parse failures. Fix the .feature text; do not add them to .gherkin-lintrc.
| Rule | What it checks |
|---|---|
no-tags-on-backgrounds |
Tags are not allowed on Background. |
one-feature-per-file |
Only one Feature: per file. |
up-to-one-background-per-file |
At most one Background in each feature or rule block. |
no-multiline-steps |
Step text must not continue as a random continuation line (use And, Doc Strings, or tables). |
unexpected-error |
Other parser errors, surfaced with the raw message. |
Always evaluated. Use "on" or omit the key; never "off".
What: Whitespace-only or missing feature content is not allowed.
Example:
{
"no-empty-file": "on"
}What: The file must contain at least one scenario (including under Rule:).
Example:
{
"no-files-without-scenarios": "on"
}What: Feature: must have a name.
Example:
{
"no-unnamed-features": "on"
}What: Scenario: / Scenario Outline: must have a name.
Example:
{
"no-unnamed-scenarios": "on"
}Turn on with "rule-name": "on" or "off" to disable. Use ["on", …] when the rule needs options (below).
What: Only listed tags and patterns are allowed on tag-bearing nodes.
Enable / off:
{
"allowed-tags": [
"on",
{ "tags": ["@watch", "@wip"], "patterns": ["^@todo$"] }
]
}{ "allowed-tags": "off" }What: Basename of *.feature must match a naming style (default PascalCase).
Styles: PascalCase, Title Case, camelCase, kebab-case, snake_case
{
"file-name": ["on", { "style": "kebab-case" }]
}What: Checks column positions for keywords, tags, steps, and example tables. Defaults: feature-level keywords at 0; steps and table rows at 2 spaces. Override per keyword type; localized Gherkin keywords are supported.
{
"indentation": [
"on",
{
"Feature": 0,
"Background": 0,
"Rule": 0,
"Scenario": 0,
"Step": 2,
"Examples": 0,
"example": 2,
"given": 2,
"when": 2,
"then": 2,
"and": 2,
"but": 2,
"feature tag": 0,
"scenario tag": 0
}
]
}What: Given / When / Then order should follow a sensible flow.
{ "keywords-in-logical-order": "on" }What: Caps how many “scenarios” count per file (plain scenarios count as 1; outlines can count each example row).
Options: maxScenarios (default 10), countOutlineExamples (default true)
{
"max-scenarios-per-file": [
"on",
{ "maxScenarios": 10, "countOutlineExamples": true }
]
}What: Max length for Feature, Rule, Scenario, and step text (default 70 each).
{
"name-length": [
"on",
{ "Feature": 70, "Rule": 70, "Scenario": 70, "Step": 70 }
]
}What: Require or forbid a newline at end of file. Must use a string option: "yes" or "no".
{ "new-line-at-eof": ["on", "yes"] }{ "new-line-at-eof": ["on", "no"] }What: If there is only one scenario, a Background is not allowed (feature level or inside a Rule).
{ "no-background-only-scenario": "on" }What: The same feature name must not appear in more than one linted file (cross-file state).
{ "no-dupe-feature-names": "on" }What: Duplicate scenario names. String option: "anywhere" (default, cross-file) or "in-feature" (per feature only).
{ "no-dupe-scenario-names": "on" }{ "no-dupe-scenario-names": ["on", "in-feature"] }What: No duplicate tags on the same feature, rule, or scenario.
{ "no-duplicate-tags": "on" }What: Background must contain at least one step.
{ "no-empty-background": "on" }What: Examples belongs on scenario outlines, not plain scenarios.
{ "no-examples-in-scenarios": "on" }What: If every scenario shares the same tag(s), prefer moving those tags to the feature (or avoid redundant tagging).
{ "no-homogenous-tags": "on" }What: No runs of multiple blank lines.
{ "no-multiple-empty-lines": "on" }What: Tag lines must not be partially commented in a misleading way.
{ "no-partially-commented-tag-lines": "on" }What: Ban regex patterns (case-insensitive) in names, descriptions, and step text. Keys: Global, Feature, Rule, Background, Scenario, ScenarioOutline. Match step text only (not the Given/When keyword).
{
"no-restricted-patterns": [
"on",
{
"Global": ["^globally restricted"],
"Feature": ["poor description"],
"Background": [],
"Scenario": [],
"Rule": [],
"ScenarioOutline": []
}
]
}What: Disallow specific @tags and patterns.
{
"no-restricted-tags": [
"on",
{ "tags": ["@watch"], "patterns": ["^@todo$"] }
]
}What: Every scenario outline must have Examples.
{ "no-scenario-outlines-without-examples": "on" }What: Tags repeated on both feature and scenarios inside that feature are flagged.
{ "no-superfluous-tags": "on" }What: No trailing spaces on lines.
{ "no-trailing-spaces": "on" }What: Scenario outline placeholders must match Examples headers (no unused columns, no missing columns).
{ "no-unused-variables": "on" }What: Multiple tags on one line must be separated by a single space.
{ "one-space-between-tags": "on" }What: At most one When step per scenario.
{ "only-one-when": "on" }What: Scenarios must match tag regexes. Options: tags (regex strings), ignoreUntagged (default true — untagged scenarios skipped).
{
"required-tags": [
"on",
{ "tags": ["^@issue:[1-9]\\d*$"], "ignoreUntagged": false }
]
}What: Max steps per Background (feature level), Scenario (feature-level scenarios/outlines), and everything under a Rule (uses the Rule limit).
{
"scenario-size": [
"on",
{
"steps-length": {
"Rule": 15,
"Background": 15,
"Scenario": 15
}
}
]
}What: Repeated same step keyword (Given, When, …) should use And / But instead.
{ "use-and": "on" }Use -r / --rulesdir one or more times. Each path must be a directory (absolute or relative to cwd). The linter loads every *.ts file in that directory (not subfolders). Files are compiled at runtime with jiti; JavaScript rule files are not supported.
Each module must default-export a rule object:
import type { Feature } from '@cucumber/messages';
const name = 'my-rule';
export default {
name,
run(
feature: Feature | undefined,
file: { relativePath: string; lines: string[] },
config: unknown
) {
return [{ message: '…', rule: name, line: 1 }];
},
availableConfigs: [] as const,
};name— must match the key in.gherkin-lintrc.run— returns{ message, rule, line }[], orundefined/[]if OK.configis the second part of["on", …]when present, else{}.availableConfigs—[]if only"on"/"off"; otherwise a string array (allowed string options) or an object whose keys are allowed option names.
If two files define the same name, the one loaded last wins.
Add @cucumber/messages (and optionally typescript) to your project for typing. See built-in rules under src/rules/ and the rulesdir fixture.
| Script | Purpose |
|---|---|
npm test |
Run Vitest once |
npm run test:watch |
Vitest watch mode |
npm run test:coverage |
Tests + coverage (HTML & LCOV under coverage/) |
npm run build |
Tests, then tsc |
npm start |
Run node dist/main.js (after build) |
- Output: only the stylish formatter;
json/xunitare not implemented.
ISC