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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ dist/
build/

# Node
node_modules/
node_modules

# Virtual environments
venv/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ tooling/

Maintained by **Commonalities Working Group**.

To add or modify a CAMARA Validation check, start with the [contributor guide](validation/docs/contributor-guide.md) and the [architecture overview](validation/docs/architecture-overview.md).
To add or modify a CAMARA Validation check, start with the [contributor guide](validation/docs/contributor-guide.md) and the [architecture overview](validation/docs/architecture-overview.md). To run CAMARA Validation against a local API repository clone, see [Running validation locally](validation/docs/contributor-guide.md#running-validation-locally).

* Meetings of the working group are held virtually
* Schedule: see [Commonalities Working Group wiki page](https://lf-camaraproject.atlassian.net/wiki/x/_QPe)
Expand Down
26 changes: 26 additions & 0 deletions validation/docs/contributor-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,32 @@ step (2–4 above), but the check logic itself lives in that engine's native con
(`linting/config/.spectral-r4.yaml`, `.gplintrc`, `.yamllint.yaml`) or adapter
(`validation/engines/*_adapter.py`), not in `python_checks/`.

## Running validation locally

To see the full verdict for an API repository before opening a PR, run the orchestrator
against a local clone:

```
pip install -r requirements.txt
(cd validation && npm ci)
python3 validation/scripts/validate_local.py <path-to-api-repo> [--out <dir>]
```

The script validates the clone's checked-out branch with this tooling checkout, as a
`workflow_dispatch` run, and enables validation regardless of
`config/validation-settings.yaml`. The ruleset follows the repository's
`release-plan.yaml`, as in CI. It prints the verdict and the findings per file, and leaves
`summary.json`, `findings.json` and `findings.tsv` in `<dir>/diagnostics/`. Exit code 0 is
pass or advisory, 1 is fail, 2 is an error.

Node tools (Spectral, gplint, Redocly) and the pinned `js-yaml` are read from
`validation/node_modules`. To use an install elsewhere, for example one shared by several
worktrees, set `CAMARA_NODE_MODULES` to that `node_modules` directory; the tests resolve
it the same way.

The PR check remains the authoritative result: a local run has no PR context, so rules that
depend on the base branch or a Release Review PR do not fire.

## Regression testing

Unit tests verify one check in isolation. Regression testing verifies the framework's
Expand Down
52 changes: 52 additions & 0 deletions validation/engines/node_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Locate the Node tool install (Spectral, Redocly) used by tests and local runs.

Resolution order for the ``node_modules`` directory:

1. ``CAMARA_NODE_MODULES`` environment variable
2. a ``spectral`` executable on ``PATH`` that sits in a ``node_modules/.bin`` directory
3. ``validation/node_modules`` in this repository
"""

from __future__ import annotations

import os
import shutil
from pathlib import Path

ENV_VAR = "CAMARA_NODE_MODULES"

_REPO_DEFAULT = Path(__file__).resolve().parent.parent / "node_modules"


def node_modules_dir() -> Path:
"""Return the ``node_modules`` directory to use."""
configured = os.environ.get(ENV_VAR, "").strip()
if configured:
return Path(configured)

on_path = shutil.which("spectral")
if on_path:
bin_dir = Path(on_path).parent
if bin_dir.name == ".bin":
return bin_dir.parent

return _REPO_DEFAULT


def bin_dir() -> Path:
"""Return the ``.bin`` directory of the resolved install."""
return node_modules_dir() / ".bin"


def spectral_bin() -> Path:
"""Return the path of the Spectral executable in the resolved install."""
return bin_dir() / "spectral"


def spectral_env() -> dict[str, str]:
"""Return the minimal environment for running Spectral under ``node``."""
return {
"PATH": os.environ.get("PATH", ""),
"NODE_PATH": str(node_modules_dir()),
"HOME": os.environ.get("HOME", ""),
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
from __future__ import annotations

import json
import os
import subprocess
from pathlib import Path
from typing import List

from validation.context import ValidationContext
from validation.engines import node_tools

from ._types import make_finding

Expand Down Expand Up @@ -68,6 +70,7 @@ def _run_helper(repo_path: Path, spec_file: str) -> list[dict] | dict:
result = subprocess.run(
["node", str(_HELPER), spec_file],
cwd=repo_path,
env={**os.environ, node_tools.ENV_VAR: str(node_tools.node_modules_dir())},
capture_output=True,
text=True,
timeout=_TIMEOUT_SECONDS,
Expand Down
10 changes: 10 additions & 0 deletions validation/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ modify its CLI or exit codes without updating that action.
python3 validate-release-plan.py <release-plan-file> [--check-files]
```

## `validate_local.py`

Runs the full orchestrator against a local clone of an API repository, as a
`workflow_dispatch` run of its checked-out branch. Usage, prerequisites and
exit codes: [contributor guide](../docs/contributor-guide.md#running-validation-locally).

```
python3 validation/scripts/validate_local.py <repo-path> [--out <dir>]
```

## `regression_runner.py`

Dispatches the validation framework against `regression/*` branches of a test
Expand Down
18 changes: 17 additions & 1 deletion validation/scripts/check-yaml-parser-conformance.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
#!/usr/bin/env node

import fs from "node:fs";
import { load } from "js-yaml";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";

// Load the pinned js-yaml from the validation install (CAMARA_NODE_MODULES or
// validation/node_modules) by its explicit path, never from a node_modules
// higher up the tree. The install may be a symlink.
const nodeModules = path.resolve(
process.env.CAMARA_NODE_MODULES?.trim() ||
path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "node_modules"),
);
const jsYamlDir = path.join(nodeModules, "js-yaml");
if (!fs.existsSync(path.join(jsYamlDir, "package.json"))) {
process.stderr.write(`js-yaml not installed in ${nodeModules}\n`);
process.exit(2);
}
const { load } = createRequire(import.meta.url)(jsYamlDir);

function toPositiveInt(value, fallback) {
return Number.isInteger(value) && value >= 0 ? value + 1 : fallback;
Expand Down
8 changes: 8 additions & 0 deletions validation/scripts/local-validation-settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Settings override for validate_local.py: enables validation for any
# repository, including forks and repos not yet onboarded in
# config/validation-settings.yaml.
version: 1
defaults:
stage: enabled
pr_profile: standard
release_profile: standard
182 changes: 182 additions & 0 deletions validation/scripts/validate_local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Run the CAMARA validation orchestrator against a local API repository.

Validates the repository as a ``workflow_dispatch`` run of its checked-out
branch, using this tooling checkout and a settings override that enables
validation for any repository. The ruleset follows ``release-plan.yaml``
as in CI.

Usage:
python3 validation/scripts/validate_local.py <repo-path> [--out <dir>]

Exit codes:
0 pass or advisory
1 fail (blocking findings)
2 error (usage, orchestrator crash, or an engine that did not run)
"""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Mapping

TOOLING_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(TOOLING_ROOT))

from validation.engines import node_tools # noqa: E402
from validation.output.formatting import format_rule_label # noqa: E402

CONFIG_OVERRIDE = Path(__file__).resolve().parent / "local-validation-settings.yaml"

NODE_TOOLS = ("spectral", "gplint", "redocly")

EXIT_PASS = 0
EXIT_FAIL = 1
EXIT_ERROR = 2

_ORIGIN_RE = re.compile(r"[:/]([^/:]+/[^/]+?)(?:\.git)?/?$")


def _git(repo_path: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(repo_path), *args],
capture_output=True,
text=True,
)
return result.stdout.strip() if result.returncode == 0 else ""


def repo_name(repo_path: Path) -> str:
"""Return ``owner/repo`` from the ``origin`` remote, else ``local/<dir>``."""
match = _ORIGIN_RE.search(_git(repo_path, "remote", "get-url", "origin"))
if match:
return match.group(1)
return f"local/{repo_path.resolve().name}"


def missing_node_tools() -> list[str]:
"""Return the Node tools absent from the resolved ``node_modules/.bin``."""
return [tool for tool in NODE_TOOLS if not (node_tools.bin_dir() / tool).exists()]


def build_env(
repo_path: Path,
output_dir: Path,
base_env: Mapping[str, str] | None = None,
) -> dict[str, str]:
"""Return the orchestrator environment for a dispatch-style local run."""
env = dict(os.environ if base_env is None else base_env)
name = repo_name(repo_path)
env.update(
{
"PATH": os.pathsep.join(filter(None, [str(node_tools.bin_dir()), env.get("PATH")])),
"NODE_PATH": str(node_tools.node_modules_dir()),
"PYTHONPATH": str(TOOLING_ROOT),
"VALIDATION_REPO_PATH": str(repo_path.resolve()),
"VALIDATION_REPO_NAME": name,
"VALIDATION_REPO_OWNER": name.split("/", 1)[0],
"VALIDATION_REF_NAME": _git(repo_path, "symbolic-ref", "--short", "HEAD"),
"VALIDATION_EVENT_NAME": "workflow_dispatch",
"VALIDATION_TOOLING_PATH": str(TOOLING_ROOT),
"VALIDATION_OUTPUT_DIR": str(output_dir),
"VALIDATION_CONFIG_PATH": str(CONFIG_OVERRIDE),
}
)
return env


def _read_summary(output_dir: Path) -> dict | None:
path = output_dir / "diagnostics" / "summary.json"
if not path.is_file():
return None
return json.loads(path.read_text(encoding="utf-8"))


def format_report(output_dir: Path) -> str:
"""Return the verdict, counts and per-file findings of a finished run."""
diag = output_dir / "diagnostics"
summary = _read_summary(output_dir)
if summary is None:
return f"no summary.json in {diag} (see the orchestrator log above)"

counts = summary.get("counts", {})
lines = [
f"result: {summary.get('result')} - {summary.get('summary', '')}",
"counts: " + " ".join(
f"{key}={counts.get(key, 0)}" for key in ("errors", "warnings", "hints", "blocking")
),
]

findings = json.loads((diag / "findings.json").read_text(encoding="utf-8"))
by_file: dict[str, list[dict]] = {}
for finding in findings:
by_file.setdefault(finding.get("path") or "(no file)", []).append(finding)
for path in sorted(by_file):
lines += ["", path]
for finding in sorted(by_file[path], key=lambda f: f.get("line") or 0):
lines.append(
f" {finding.get('line') or 0}\t{finding.get('level', '')}"
f"\t{format_rule_label(finding)}\t{finding.get('message', '')}"
)

lines += ["", f"diagnostics: {diag}"]
return "\n".join(lines)


def exit_code(output_dir: Path, orchestrator_rc: int) -> int:
"""Map the orchestrator return code and verdict to the script's exit code."""
summary = _read_summary(output_dir)
if orchestrator_rc != 0 or summary is None:
return EXIT_ERROR
return {"fail": EXIT_FAIL, "error": EXIT_ERROR}.get(summary.get("result"), EXIT_PASS)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Run CAMARA validation against a local API repository."
)
parser.add_argument("repo_path", type=Path, help="local clone of the API repository")
parser.add_argument(
"--out", type=Path, help="output directory (default: a new temporary directory)"
)
args = parser.parse_args(argv)

repo_path = args.repo_path.resolve()
if not (repo_path / "code" / "API_definitions").is_dir():
parser.error(f"not an API repository (no code/API_definitions): {repo_path}")
missing = missing_node_tools()
if missing:
print(
f"missing in {node_tools.bin_dir()}: {', '.join(missing)}\n"
f"run `npm ci` in validation/ or set {node_tools.ENV_VAR} "
"to an existing node_modules directory",
file=sys.stderr,
)
return EXIT_ERROR
output_dir = args.out or Path(tempfile.mkdtemp(prefix="camara-validation-"))

env = build_env(repo_path, output_dir)
print(
f"validating {env['VALIDATION_REPO_NAME']} "
f"(ref {env['VALIDATION_REF_NAME'] or '<detached>'}) with {TOOLING_ROOT}",
file=sys.stderr,
)
rc = subprocess.run(
[sys.executable, "-m", "validation.orchestrator"],
cwd=TOOLING_ROOT,
env=env,
).returncode

print(format_report(output_dir))
return exit_code(output_dir, rc)


if __name__ == "__main__":
sys.exit(main())
Loading
Loading