Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions linting/config/.gherkin-lintrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// DEPRECATED: legacy v0 validation only.
// This file is consumed by MegaLinter's gherkin-lint integration in
// .github/workflows/pr_validation.yml. Do not use it for CAMARA Validation v1.
// Remove it together with the v0 validation workflow.
{
"no-files-without-scenarios" : "on",
"no-unnamed-features": "on",
Expand Down
77 changes: 77 additions & 0 deletions linting/config/.gplintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// CAMARA Validation v1 GPLint configuration.
// GPLint's default config file name is .gplintrc. Keep this separate from the
// legacy .gherkin-lintrc used by v0/MegaLinter's gherkin-lint integration.
// Rule levels stay at warn; CAMARA severity is assigned by validation metadata.
{
"no-files-without-scenarios" : "warn",
"no-unnamed-features": "warn",
"no-unnamed-scenarios": "warn",
"no-dupe-scenario-names": ["warn", "in-feature"],
"no-dupe-feature-names": "warn",
"no-partially-commented-tag-lines": "warn",
"indentation" : [
"warn", {
"Feature": 0,
"Background": 2,
"Scenario": 2,
"Step": 2,
"Examples": 4,
"example": 6,
"given": 4,
"when": 4,
"then": 4,
"and": 4,
"but": 4,
"feature tag": 2,
"scenario tag": 2
}
],
"no-trailing-spaces": "warn",
"new-line-at-eof": ["off", "yes"],
"no-multiple-empty-lines": "warn",
"no-empty-file": "warn",
"no-scenario-outlines-without-examples": "warn",
"name-length": ["warn", {"Feature": 250, "Step": 250, "Scenario": 250}],
"no-restricted-tags": ["warn", {"tags": ["@watch", "@wip"]}],
"use-and": "warn",
"keywords-in-logical-order": "warn",
"no-duplicate-tags": "warn",
"no-superfluous-tags": "warn",
"no-homogenous-tags": "warn",
"one-space-between-tags": "warn",
"no-unused-variables": "warn",
"no-background-only-scenario": "warn",
"no-empty-background": "warn",
"scenario-size": ["off", { "steps-length": {"Background": 15, "Scenario": 15}}],
"allowed-tags": ["warn", {
"patterns": [
"^@watch$",
"^@wip$",
"^@.*$"
]
}],
"file-name": ["off", {"style": "kebab-case"}],
"max-scenarios-per-file": ["warn", {"maxScenarios": 50, "countOutlineExamples": false}],
"no-restricted-patterns": ["off", {
"Global": [
"^globally restricted pattern"
],
"Feature": [
"poor description",
"validate",
"verify"
],
"Background": [
"show last response",
"a debugging step"
],
"Scenario": [
"show last response",
"a debugging step"
]
}],
"required-tags": ["warn", {
"scenario": ["/^@.*$/"],
"ignoreUntagged": false
}]
}
5 changes: 3 additions & 2 deletions linting/docs/Reusable Workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@ Manual run of this workflow produces more detailed output compared to results pr
### Workflow configuration files in `linting` folder

Configuration files in `/linting/config/`:
- **.gherkin-lintrc** - ruleset for [gherkin-lint](https://github.com/gherkin-lint/gherkin-lint) tool
- **.gherkin-lintrc** - deprecated v0-only ruleset for the [gherkin-lint](https://github.com/vsiakka/gherkin-lint) tool used by MegaLinter in `pr_validation.yml`
- **.gplintrc** - ruleset for the [GPLint](https://github.com/gplint/gplint) tool used by CAMARA Validation v1
- **.spectral.yaml** - CAMARA rulest for [Spectral](https://meta.stoplight.io/docs/spectral) linter
- **.yamllint.yml** - ruleset for [yamllint](https://yamllint.readthedocs.io/en/stable/index.html) tool

The rulesets above are copied from [Commonalities/artifacts](https://github.com/camaraproject/Commonalities/tree/main/artifacts/linting_rules).
The legacy v0 linting rulesets originated from [Commonalities/artifacts](https://github.com/camaraproject/Commonalities/tree/main/artifacts/linting_rules). The GPLint ruleset is maintained separately in `tooling` because GPLint uses a different configuration schema from `gherkin-lint`.


### Caller Workflows in `linting` folder
Expand Down
1 change: 1 addition & 0 deletions validation/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engine-strict=true
1 change: 1 addition & 0 deletions validation/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
74 changes: 37 additions & 37 deletions validation/engines/gherkin_adapter.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""gherkin-lint engine adapter for the CAMARA validation framework.
"""GPLint engine adapter for the CAMARA validation framework.

Invokes gherkin-lint on BDD feature files, parses the JSON output,
and normalizes findings into the common findings model.
Invokes GPLint on BDD feature files, parses the JSON output, and normalizes
findings into the common findings model.

Design doc references:
- Section 8.1 step 7: full validation (gherkin-lint invocation)
- Section 2.2: check areas (gherkin-lint coverage)
- Section 8.1 step 7: full validation (Gherkin lint invocation)
- Section 2.2: check areas (Gherkin lint coverage)
"""

from __future__ import annotations
Expand All @@ -25,10 +25,11 @@
# ---------------------------------------------------------------------------

ENGINE_NAME = "gherkin"
LINTER_NAME = "gplint"

# gherkin-lint has no per-finding severity — all are reported identically.
# Default to "warn" so findings don't block in standard profile; post-filter
# rule metadata can elevate specific rules to "error".
# GPLint reports the configured rule level, but CAMARA severity is governed by
# validation rule metadata. Default to "warn" so the normalized model remains
# stable across the linter migration.
DEFAULT_LEVEL = "warn"

DEFAULT_TEST_GLOB = "code/Test_definitions/**/*.feature"
Expand Down Expand Up @@ -62,9 +63,9 @@ def derive_api_name(file_path: str) -> Optional[str]:


def normalize_file_errors(file_entry: dict, cwd: str) -> List[dict]:
"""Convert one gherkin-lint file entry into normalised findings.
"""Convert one GPLint file entry into normalised findings.

gherkin-lint JSON format per file::
GPLint JSON format per file::

{"filePath": "/absolute/path/to/file.feature",
"errors": [{"message": "...", "rule": "...", "line": N}, ...]}
Expand Down Expand Up @@ -99,13 +100,13 @@ def normalize_file_errors(file_entry: dict, cwd: str) -> List[dict]:


def parse_gherkin_output(raw_json: str, cwd: str) -> List[dict]:
"""Parse gherkin-lint ``--format json`` stdout into normalised findings.
"""Parse GPLint ``--format json`` output into normalised findings.

gherkin-lint outputs a JSON array of file entries. Files with no
errors (empty ``errors`` array) are skipped.
GPLint outputs a JSON array of file entries. Files with no errors
(empty ``errors`` array) are skipped.

Args:
raw_json: Raw JSON string from gherkin-lint stdout.
raw_json: Raw JSON string from GPLint.
cwd: Repo root path for relativizing absolute file paths.

Returns:
Expand All @@ -117,11 +118,11 @@ def parse_gherkin_output(raw_json: str, cwd: str) -> List[dict]:
try:
data = json.loads(raw_json)
except json.JSONDecodeError as exc:
logger.warning("Failed to parse gherkin-lint JSON output: %s", exc)
logger.warning("Failed to parse GPLint JSON output: %s", exc)
return []

if not isinstance(data, list):
logger.warning("gherkin-lint output is not a JSON array")
logger.warning("GPLint output is not a JSON array")
return []

findings = []
Expand All @@ -138,7 +139,7 @@ def parse_gherkin_output(raw_json: str, cwd: str) -> List[dict]:

@dataclass(frozen=True)
class GherkinResult:
"""Result of a gherkin-lint CLI invocation."""
"""Result of a Gherkin linter CLI invocation."""

findings: List[dict]
success: bool
Expand All @@ -149,9 +150,8 @@ def _expand_globs(patterns: Sequence[str], cwd: Path) -> List[str]:
"""Expand glob patterns relative to *cwd* into concrete file paths.

``subprocess.run()`` without ``shell=True`` does not expand globs,
and gherkin-lint's internal feature-finder mangles ``**`` patterns
(appends ``/**.feature`` to any pattern containing ``/**``).
Expanding in Python avoids both issues.
and historical Gherkin linter feature-finders can mangle ``**`` patterns.
Expanding in Python keeps the adapter behavior stable across linter swaps.

Returns repo-relative POSIX path strings.
"""
Expand All @@ -162,32 +162,31 @@ def _expand_globs(patterns: Sequence[str], cwd: Path) -> List[str]:
return expanded


def run_gherkin_lint(
def run_gplint(
config_path: Path,
file_patterns: List[str],
cwd: Path,
) -> GherkinResult:
"""Invoke gherkin-lint via npx and capture structured output.
"""Invoke GPLint and capture structured output.

Uses ``--format json`` for machine-readable output.

Args:
config_path: Path to the ``.gherkin-lintrc`` configuration file.
config_path: Path to the Gherkin linter configuration file.
file_patterns: Glob patterns for input feature files.
cwd: Working directory (repo root).

Returns:
:class:`GherkinResult` with parsed findings and status.
"""
# Expand globs in Python — gherkin-lint's feature-finder mangles
# ** patterns (turns "dir/**/*.feature" into "dir/**/*.feature/**.feature").
# Expand globs in Python to keep the CLI invocation deterministic.
files = _expand_globs(file_patterns, cwd)
if not files:
logger.info("No files matched patterns: %s", file_patterns)
return GherkinResult(findings=[], success=True)

cmd = [
"gherkin-lint",
LINTER_NAME,
"--format", "json",
"--config", str(config_path),
*files,
Expand All @@ -205,19 +204,20 @@ def run_gherkin_lint(
return GherkinResult(
findings=[],
success=False,
error_message="gherkin-lint not found — is it installed and on PATH?",
error_message="GPLint not found — is it installed and on PATH?",
)
except subprocess.TimeoutExpired:
return GherkinResult(
findings=[],
success=False,
error_message="gherkin-lint timed out after 120 seconds",
error_message="GPLint timed out after 120 seconds",
)

# Exit 0 = clean, exit 1 = findings found. Both produce valid JSON.
# gherkin-lint writes JSON to stderr (not stdout).
# Exit 0 = clean or warn-level findings, exit 1 = error-level findings.
# Both produce valid JSON; prefer stdout, but keep stderr fallback for
# CLI versions that emit machine output on stderr.
if result.returncode in (0, 1):
raw_json = result.stderr or result.stdout
raw_json = result.stdout or result.stderr
findings = parse_gherkin_output(raw_json, str(cwd))
return GherkinResult(findings=findings, success=True)

Expand All @@ -228,7 +228,7 @@ def run_gherkin_lint(
return GherkinResult(
findings=[],
success=False,
error_message=f"gherkin-lint exited with code {result.returncode}: {error_detail}",
error_message=f"GPLint exited with code {result.returncode}: {error_detail}",
)


Expand All @@ -254,7 +254,7 @@ def run_gherkin_engine(

Args:
repo_path: Root of the repository being validated.
config_path: Path to the gherkin-lint configuration file.
config_path: Path to the Gherkin linter configuration file.
file_patterns: Override glob patterns (default:
``["code/Test_definitions/**/*.feature"]``).

Expand All @@ -264,13 +264,13 @@ def run_gherkin_engine(
if file_patterns is None:
file_patterns = [DEFAULT_TEST_GLOB]

logger.info("Running gherkin-lint with config: %s", config_path)
logger.info("Running GPLint with config: %s", config_path)

result = run_gherkin_lint(config_path, file_patterns, cwd=repo_path)
result = run_gplint(config_path, file_patterns, cwd=repo_path)

if not result.success:
logger.error("gherkin-lint engine error: %s", result.error_message)
logger.error("GPLint engine error: %s", result.error_message)
return [_make_error_finding(result.error_message)]

logger.info("gherkin-lint produced %d finding(s)", len(result.findings))
logger.info("GPLint produced %d finding(s)", len(result.findings))
return result.findings
2 changes: 1 addition & 1 deletion validation/engines/python_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Runs native Python check functions against the repository, producing
findings conforming to the common findings model. Unlike the other
engine adapters (Spectral, yamllint, gherkin-lint), Python checks run
engine adapters (Spectral, yamllint, GPLint), Python checks run
in-process — no subprocess invocation.

Design doc references:
Expand Down
16 changes: 8 additions & 8 deletions validation/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ def run_engines(
# When release_plan_check_only is true, a Commonalities dependency
# declaration advanced in this PR. The code/common/ cache and API
# spec content on disk are still tied to the previous tag — running
# Spectral with the new ruleset or gherkin-lint against those files
# Spectral with the new ruleset or GPLint against those files
# produces misleading findings (DEC-029 exclusivity principle).
# Skip those engines entirely; Python engine still runs but its
# post-filter keeps only rules gated on release_plan_changed=true.
Expand Down Expand Up @@ -346,26 +346,26 @@ def run_engines(
engine_statuses["python"] = f"error: {exc}"
logger.error("Python checks failed: %s", exc)

# --- gherkin-lint ---
# --- GPLint ---
if skip_context_dependent:
engine_statuses["gherkin"] = "skipped (release-plan-check-only mode)"
logger.info("gherkin-lint: skipped (release-plan-check-only mode)")
logger.info("GPLint: skipped (release-plan-check-only mode)")
elif not test_files:
engine_statuses["gherkin"] = "skipped (no test files)"
logger.info("gherkin-lint: skipped (no test files)")
logger.info("GPLint: skipped (no test files)")
else:
try:
gherkin_config = paths.linting_config_dir / ".gherkin-lintrc"
gplint_config = paths.linting_config_dir / ".gplintrc"
findings = run_gherkin_engine(
repo_path=repo_path,
config_path=gherkin_config,
config_path=gplint_config,
)
all_findings.extend(findings)
engine_statuses["gherkin"] = f"{len(findings)} finding(s)"
logger.info("gherkin-lint: %d finding(s)", len(findings))
logger.info("GPLint: %d finding(s)", len(findings))
except Exception as exc:
engine_statuses["gherkin"] = f"error: {exc}"
logger.error("gherkin-lint failed: %s", exc)
logger.error("GPLint failed: %s", exc)

return all_findings, engine_statuses

Expand Down
Loading