From bcf0d0c570ed0a3277fb979d224383380367dbfd Mon Sep 17 00:00:00 2001 From: Herbert Damker <52109189+hdamker@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:52:36 +0200 Subject: [PATCH] feat(validation): add externalDocs repository/description checks (P-038, P-039) --- validation/engines/python_checks/__init__.py | 2 + .../python_checks/externaldocs_checks.py | 110 ++++++++ validation/rules/python-rules.yaml | 32 +++ .../tests/test_python_checks_externaldocs.py | 246 ++++++++++++++++++ .../tests/test_rule_metadata_integrity.py | 6 +- 5 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 validation/engines/python_checks/externaldocs_checks.py create mode 100644 validation/tests/test_python_checks_externaldocs.py diff --git a/validation/engines/python_checks/__init__.py b/validation/engines/python_checks/__init__.py index 369110b8..faf968fe 100644 --- a/validation/engines/python_checks/__init__.py +++ b/validation/engines/python_checks/__init__.py @@ -7,6 +7,7 @@ from ._types import CheckDescriptor, CheckScope from .error_code_checks import check_conflict_deprecated, check_contextcode_format +from .externaldocs_checks import check_externaldocs from .filename_checks import check_filename_kebab_case, check_filename_matches_api_name from .info_description_checks import check_info_description_templates from .metadata_checks import check_commonalities_version @@ -70,6 +71,7 @@ CheckScope.API, check_info_description_templates, ), + CheckDescriptor("check-externaldocs-repository", CheckScope.API, check_externaldocs), # --- Repo-level checks (run once) --- CheckDescriptor("check-test-directory-exists", CheckScope.REPO, check_test_directory_exists), CheckDescriptor("check-release-plan-semantics", CheckScope.REPO, check_release_plan_semantics), diff --git a/validation/engines/python_checks/externaldocs_checks.py b/validation/engines/python_checks/externaldocs_checks.py new file mode 100644 index 00000000..d75b1b44 --- /dev/null +++ b/validation/engines/python_checks/externaldocs_checks.py @@ -0,0 +1,110 @@ +"""externalDocs repository and description checks (Design Guide §5.4). + +Design Guide §5.4 (ExternalDocs Object) is a hard SHALL: ``externalDocs.url`` +must be ``https://github.com/camaraproject/{apiRepository}`` for the +repository hosting the API, and ``externalDocs.description`` must read +"Product documentation at CAMARA". No carve-out for intentional +cross-repository references — a full sweep of upstream/apis/* found zero +legitimate cases; every mismatch was a stale copy-paste or repo rename. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import List + +from validation.context import ValidationContext + +from ._types import load_yaml_safe, make_finding + +_URL_ENGINE_RULE = "check-externaldocs-repository" +_DESCRIPTION_ENGINE_RULE = "check-externaldocs-description" + +_EXPECTED_DESCRIPTION = "Product documentation at CAMARA" + + +def check_externaldocs( + repo_path: Path, context: ValidationContext +) -> List[dict]: + """Validate externalDocs.url and externalDocs.description. + + Per-API check. Emits two distinct engine_rule values from the same + externalDocs node so the postfilter can give them different + severities (P-038 warn, P-039 hint): + + - P-038 (check-externaldocs-repository): externalDocs missing + entirely, or url is not exactly + https://github.com/camaraproject/{repo-name}. + - P-039 (check-externaldocs-description): description is not + exactly "Product documentation at CAMARA". Only checked when + externalDocs is present — a missing object is a single P-038 + finding, not P-038 + P-039. + + Match is strict exact-string (no trailing-slash tolerance, no + case-insensitive description match) — matches the Design Guide + template literally. + """ + api = context.apis[0] + spec_path = repo_path / api.spec_file + spec = load_yaml_safe(spec_path) + + if spec is None: + return [] + + repo_name = context.repository.rsplit("/", 1)[-1] + expected_url = f"https://github.com/camaraproject/{repo_name}" + + external_docs = spec.get("externalDocs") + + if not isinstance(external_docs, dict): + return [ + make_finding( + engine_rule=_URL_ENGINE_RULE, + level="warn", + message=( + f"externalDocs is missing in {api.spec_file} — " + f"expected url '{expected_url}'" + ), + path=api.spec_file, + line=1, + api_name=api.api_name, + ) + ] + + findings: List[dict] = [] + + url = external_docs.get("url") + if url != expected_url: + actual = "is missing" if url is None else f"is '{url}'" + findings.append( + make_finding( + engine_rule=_URL_ENGINE_RULE, + level="warn", + message=( + f"externalDocs.url in {api.spec_file} {actual} — " + f"expected '{expected_url}'" + ), + path=api.spec_file, + line=1, + api_name=api.api_name, + ) + ) + + description = external_docs.get("description") + if description != _EXPECTED_DESCRIPTION: + actual = "is missing" if description is None else f"is '{description}'" + findings.append( + make_finding( + engine_rule=_DESCRIPTION_ENGINE_RULE, + level="hint", + message=( + f"externalDocs.description in {api.spec_file} {actual} " + f"— expected '{_EXPECTED_DESCRIPTION}'" + ), + path=api.spec_file, + line=1, + api_name=api.api_name, + ) + ) + + return findings diff --git a/validation/rules/python-rules.yaml b/validation/rules/python-rules.yaml index 394272df..a3ba9b1b 100644 --- a/validation/rules/python-rules.yaml +++ b/validation/rules/python-rules.yaml @@ -596,3 +596,35 @@ Prefer block-style YAML for examples. Rewrite invalid JSON-like flow-style YAML mappings or sequences so the file parses with YAML 1.2-conformant parsers. + +# P-038: check-externaldocs-repository (DG §5.4) +# externalDocs must reference the canonical CAMARA GitHub repository for +# this API. Fires when externalDocs is missing entirely, or url is not +# exactly https://github.com/camaraproject/{repo-name}. No carve-out for +# intentional cross-repository references — Design Guide §5.4 is a hard +# SHALL and a full upstream/apis/* sweep found zero legitimate cross-repo +# cases; every mismatch was a stale copy-paste or repo rename. +- id: P-038 + engine: python + engine_rule: check-externaldocs-repository + short_title: "externalDocs.url must reference this repository" + conditional_level: + default: warn + suggestion: >- + Set externalDocs.url to https://github.com/camaraproject/{repo-name}, + replacing {repo-name} with this repository's actual name. + +# P-039: check-externaldocs-description (DG §5.4) +# externalDocs.description must exactly match the Design Guide template +# text. Satellite rule emitted by check_externaldocs() alongside P-038 — +# only fires when externalDocs is present at all (a missing object is a +# single P-038 finding, not P-038 + P-039). +- id: P-039 + engine: python + engine_rule: check-externaldocs-description + short_title: "externalDocs.description must match the DG template" + conditional_level: + default: hint + suggestion: >- + Set externalDocs.description to exactly "Product documentation at + CAMARA". diff --git a/validation/tests/test_python_checks_externaldocs.py b/validation/tests/test_python_checks_externaldocs.py new file mode 100644 index 00000000..4e5f6a80 --- /dev/null +++ b/validation/tests/test_python_checks_externaldocs.py @@ -0,0 +1,246 @@ +"""Unit tests for validation.engines.python_checks.externaldocs_checks (P-038/P-039).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import yaml + +from validation.context import ApiContext, ValidationContext +from validation.engines.python_checks.externaldocs_checks import ( + check_externaldocs, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_context( + repository: str = "camaraproject/ConsentManagement", + api_name: str = "consent-management", +) -> ValidationContext: + api = ApiContext( + api_name=api_name, + target_api_version="1.0.0", + target_api_status="public", + target_api_maturity="stable", + api_pattern="request-response", + spec_file=f"code/API_definitions/{api_name}.yaml", + ) + return ValidationContext( + repository=repository, + branch_type="release", + trigger_type="dispatch", + profile="advisory", + stage="enabled", + target_release_type=None, + commonalities_release=None, + commonalities_version=None, + icm_release=None, + base_ref=None, + is_release_review_pr=False, + release_plan_changed=None, + pr_number=None, + apis=(api,), + workflow_run_url="", + tooling_ref="", + ) + + +def _write_spec( + tmp_path: Path, + api_name: str = "consent-management", + external_docs: Optional[object] = "__default__", +) -> None: + spec: dict = { + "openapi": "3.0.3", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": {}, + } + if external_docs != "__default__": + if external_docs is not None: + spec["externalDocs"] = external_docs + else: + spec["externalDocs"] = { + "description": "Product documentation at CAMARA", + "url": "https://github.com/camaraproject/ConsentManagement", + } + + spec_dir = tmp_path / "code" / "API_definitions" + spec_dir.mkdir(parents=True, exist_ok=True) + (spec_dir / f"{api_name}.yaml").write_text( + yaml.dump(spec, default_flow_style=False), encoding="utf-8" + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestCheckExternaldocs: + + # --- Happy path --- + + def test_matching_url_and_description_ok(self, tmp_path: Path): + _write_spec(tmp_path) + ctx = _make_context() + assert check_externaldocs(tmp_path, ctx) == [] + + # --- externalDocs missing entirely --- + + def test_missing_externaldocs_single_p038_finding(self, tmp_path: Path): + _write_spec(tmp_path, external_docs=None) + ctx = _make_context() + findings = check_externaldocs(tmp_path, ctx) + assert len(findings) == 1 + assert findings[0]["engine_rule"] == "check-externaldocs-repository" + assert findings[0]["level"] == "warn" + assert "missing" in findings[0]["message"] + + def test_externaldocs_not_a_mapping_treated_as_missing(self, tmp_path: Path): + _write_spec(tmp_path, external_docs="not-a-mapping") + ctx = _make_context() + findings = check_externaldocs(tmp_path, ctx) + assert len(findings) == 1 + assert findings[0]["engine_rule"] == "check-externaldocs-repository" + + # --- url mismatches (P-038) --- + + def test_source_case_stale_repo_reference(self, tmp_path: Path): + """ConsentManagement r1.1 rc: url left pointing at sibling ConsentInfo.""" + _write_spec( + tmp_path, + external_docs={ + "description": "Product documentation at CAMARA", + "url": "https://github.com/camaraproject/ConsentInfo", + }, + ) + ctx = _make_context(repository="camaraproject/ConsentManagement") + findings = check_externaldocs(tmp_path, ctx) + assert len(findings) == 1 + assert findings[0]["engine_rule"] == "check-externaldocs-repository" + assert findings[0]["level"] == "warn" + assert "ConsentInfo" in findings[0]["message"] + + def test_url_key_missing_within_present_object(self, tmp_path: Path): + _write_spec( + tmp_path, + external_docs={"description": "Product documentation at CAMARA"}, + ) + ctx = _make_context() + findings = check_externaldocs(tmp_path, ctx) + assert len(findings) == 1 + assert findings[0]["engine_rule"] == "check-externaldocs-repository" + assert "is missing" in findings[0]["message"] + + def test_trailing_slash_is_not_tolerated(self, tmp_path: Path): + _write_spec( + tmp_path, + external_docs={ + "description": "Product documentation at CAMARA", + "url": "https://github.com/camaraproject/ConsentManagement/", + }, + ) + ctx = _make_context() + findings = check_externaldocs(tmp_path, ctx) + assert len(findings) == 1 + assert findings[0]["engine_rule"] == "check-externaldocs-repository" + + def test_fork_repository_compares_against_camaraproject_org(self, tmp_path: Path): + """context.repository is / during fork validation; the + expected url always targets the canonical camaraproject org.""" + _write_spec(tmp_path) + ctx = _make_context(repository="hdamker/ConsentManagement") + assert check_externaldocs(tmp_path, ctx) == [] + + def test_fork_repository_wrong_url_still_flagged(self, tmp_path: Path): + _write_spec( + tmp_path, + external_docs={ + "description": "Product documentation at CAMARA", + "url": "https://github.com/hdamker/ConsentManagement", + }, + ) + ctx = _make_context(repository="hdamker/ConsentManagement") + findings = check_externaldocs(tmp_path, ctx) + assert len(findings) == 1 + assert findings[0]["engine_rule"] == "check-externaldocs-repository" + + # --- description mismatches (P-039) --- + + def test_description_wrong_wording(self, tmp_path: Path): + _write_spec( + tmp_path, + external_docs={ + "description": "Project documentation at Camara", + "url": "https://github.com/camaraproject/ConsentManagement", + }, + ) + ctx = _make_context() + findings = check_externaldocs(tmp_path, ctx) + assert len(findings) == 1 + assert findings[0]["engine_rule"] == "check-externaldocs-description" + assert findings[0]["level"] == "hint" + + def test_description_key_missing_within_present_object(self, tmp_path: Path): + _write_spec( + tmp_path, + external_docs={ + "url": "https://github.com/camaraproject/ConsentManagement" + }, + ) + ctx = _make_context() + findings = check_externaldocs(tmp_path, ctx) + assert len(findings) == 1 + assert findings[0]["engine_rule"] == "check-externaldocs-description" + assert "is missing" in findings[0]["message"] + + # --- both wrong: two distinct findings --- + + def test_both_url_and_description_wrong(self, tmp_path: Path): + _write_spec( + tmp_path, + external_docs={ + "description": "Project documentation at Camara", + "url": "https://github.com/camaraproject/WrongRepo", + }, + ) + ctx = _make_context() + findings = check_externaldocs(tmp_path, ctx) + engine_rules = {f["engine_rule"] for f in findings} + assert engine_rules == { + "check-externaldocs-repository", + "check-externaldocs-description", + } + levels = {f["engine_rule"]: f["level"] for f in findings} + assert levels["check-externaldocs-repository"] == "warn" + assert levels["check-externaldocs-description"] == "hint" + + # --- DeviceStatus-style self-reference (not a false positive) --- + + def test_repo_hosting_multiple_apis_self_references_repo_not_filename( + self, tmp_path: Path + ): + _write_spec( + tmp_path, + api_name="device-reachability-status", + external_docs={ + "description": "Product documentation at CAMARA", + "url": "https://github.com/camaraproject/DeviceStatus", + }, + ) + ctx = _make_context( + repository="camaraproject/DeviceStatus", + api_name="device-reachability-status", + ) + assert check_externaldocs(tmp_path, ctx) == [] + + # --- edge cases --- + + def test_missing_spec_file(self, tmp_path: Path): + ctx = _make_context() + assert check_externaldocs(tmp_path, ctx) == [] diff --git a/validation/tests/test_rule_metadata_integrity.py b/validation/tests/test_rule_metadata_integrity.py index 060de333..bdae5d5b 100644 --- a/validation/tests/test_rule_metadata_integrity.py +++ b/validation/tests/test_rule_metadata_integrity.py @@ -88,7 +88,7 @@ def test_expected_rule_counts(self, all_rules): counts = {} for r in all_rules: counts[r.engine] = counts.get(r.engine, 0) + 1 - assert counts["python"] == 36 + assert counts["python"] == 38 assert counts["spectral"] == 88 assert counts["gherkin"] == 25 assert counts["yamllint"] == 13 @@ -353,8 +353,8 @@ def test_suggestions_are_exception_not_norm(self, all_rules): """ with_suggestions = [r.id for r in all_rules if r.suggestion is not None] with_overrides = [r.id for r in all_rules if r.message_override is not None] - assert len(with_suggestions) == 26, ( - f"Expected 26 explicit suggestions (update test if adding " + assert len(with_suggestions) == 28, ( + f"Expected 28 explicit suggestions (update test if adding " f"suggestions): {with_suggestions}" ) assert len(with_overrides) == 0, (