From ae721baedad9a6fed34bc255b3b822119c47d22c Mon Sep 17 00:00:00 2001 From: Justin McLean Date: Sat, 4 Jul 2026 01:51:00 +1000 Subject: [PATCH] feat(validator): add HARD check for required files in organizations// Add validate_organization_structure (check #16) that enforces every organizations// adapter directory contains README.md and organization.md. Clears the Known Gap in the organization-adapters spec: No structural validator check yet enforces required files in organizations// (currently README + organization.md by convention). The check is HARD (ORGANIZATION_CATEGORY) so an incomplete adapter directory fails the validator rather than emitting only an advisory. The live tree (ASF/ and independent/) already carries both files, so no existing adapter is broken. The _template/ directory is excluded from the check, matching the existing known_organizations() convention. 8 new test cases in TestOrganizationStructure cover: well-formed org, missing README.md, missing organization.md, both files absent, template exclusion, multi-org sweep, empty organizations/ directory, and the HARD-category assertion. All 433 tests pass (uv run direct). Generated-by: Claude (Opus 4.7) --- .../src/skill_and_tool_validator/__init__.py | 44 ++++++++++++++ .../tests/test_validator.py | 59 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py b/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py index bca27944..93ab8e44 100644 --- a/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py +++ b/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py @@ -794,6 +794,46 @@ def known_organizations(root: Path | None = None) -> set[str]: return {d.name for d in base.iterdir() if d.is_dir() and d.name != "_template"} +# Required files that every organizations// adapter directory must contain. +# Authoring convention (README + organization.md) elevated to a HARD check so +# a malformed adapter is caught before any skill references it. +_ORG_REQUIRED_FILES: tuple[str, ...] = ("README.md", "organization.md") + + +def validate_organization_structure(root: Path | None = None) -> Iterable[Violation]: + """Enforce required files inside each ``organizations//`` adapter directory. + + Every adapter directory (excluding ``_template``) must contain: + + - ``README.md`` — human-readable description of the organization adapter. + - ``organization.md`` — the machine-readable shared defaults that skills + inherit when ``organization: `` is declared in a project config. + + Both are HARD violations: a directory that exists but lacks either file is + an incomplete adapter and cannot be reliably resolved by skills at runtime. + """ + repo_root = root or find_repo_root() + base = repo_root / ORGANIZATIONS_DIR + if not base.exists(): + return + + for org_dir in sorted(base.iterdir()): + if not org_dir.is_dir(): + continue + if org_dir.name == "_template": + continue + for required_file in _ORG_REQUIRED_FILES: + if not (org_dir / required_file).exists(): + yield Violation( + org_dir / required_file, + None, + f"organizations/{org_dir.name}/ is missing required file '{required_file}' " + f"— every organization adapter must declare its identity (README.md) " + f"and its shared defaults (organization.md)", + category=ORGANIZATION_CATEGORY, + ) + + def validate_frontmatter(path: Path, text: str, root: Path | None = None) -> Iterable[Violation]: """Validate the YAML frontmatter of a SKILL.md file.""" fm = parse_frontmatter(text) @@ -3522,6 +3562,10 @@ def run_validation(root: Path | None = None) -> list[Violation]: # Tool-level checks: every tools// has a README that declares its capability. violations.extend(validate_tools(repo_root)) + # Organization-adapter structure: each organizations// must have README.md + # and organization.md (HARD — an incomplete adapter cannot be reliably resolved). + violations.extend(validate_organization_structure(repo_root)) + # Adapter authoring smoke: contract:* tool READMEs declare credentials, # operations, and config keys (SOFT advisory). violations.extend(validate_adapter_authoring(repo_root)) diff --git a/tools/skill-and-tool-validator/tests/test_validator.py b/tools/skill-and-tool-validator/tests/test_validator.py index 3f6baafc..1a980ad9 100644 --- a/tools/skill-and-tool-validator/tests/test_validator.py +++ b/tools/skill-and-tool-validator/tests/test_validator.py @@ -101,6 +101,7 @@ validate_mail_privacy_boundary, validate_modes_doc_consistency, validate_name_convention, + validate_organization_structure, validate_override_contract, validate_override_file, validate_placeholders, @@ -3067,6 +3068,64 @@ def test_frontmatter_organization_unknown(self, tmp_path: Path) -> None: assert len(violations) == 1 +class TestOrganizationStructure: + """Tests for validate_organization_structure — required files inside organizations//.""" + + def _make_org(self, tmp_path: Path, org: str, files: list[str]) -> None: + org_dir = tmp_path / "organizations" / org + org_dir.mkdir(parents=True) + for fname in files: + (org_dir / fname).write_text("# placeholder\n") + + def test_well_formed_org_no_violations(self, tmp_path: Path) -> None: + self._make_org(tmp_path, "ASF", ["README.md", "organization.md"]) + vs = list(validate_organization_structure(tmp_path)) + assert vs == [] + + def test_missing_readme_yields_violation(self, tmp_path: Path) -> None: + self._make_org(tmp_path, "MyOrg", ["organization.md"]) + vs = list(validate_organization_structure(tmp_path)) + assert len(vs) == 1 + assert "README.md" in vs[0].message + assert vs[0].category == ORGANIZATION_CATEGORY + + def test_missing_organization_md_yields_violation(self, tmp_path: Path) -> None: + self._make_org(tmp_path, "MyOrg", ["README.md"]) + vs = list(validate_organization_structure(tmp_path)) + assert len(vs) == 1 + assert "organization.md" in vs[0].message + assert vs[0].category == ORGANIZATION_CATEGORY + + def test_both_files_missing_yields_two_violations(self, tmp_path: Path) -> None: + self._make_org(tmp_path, "MyOrg", []) + vs = list(validate_organization_structure(tmp_path)) + assert len(vs) == 2 + missing = {v.path.name for v in vs} + assert missing == {"README.md", "organization.md"} + + def test_template_dir_is_excluded(self, tmp_path: Path) -> None: + # _template without required files must not trigger violations. + (tmp_path / "organizations" / "_template").mkdir(parents=True) + vs = list(validate_organization_structure(tmp_path)) + assert vs == [] + + def test_multiple_orgs_each_checked(self, tmp_path: Path) -> None: + self._make_org(tmp_path, "ASF", ["README.md", "organization.md"]) + self._make_org(tmp_path, "independent", ["README.md"]) # missing organization.md + vs = list(validate_organization_structure(tmp_path)) + assert len(vs) == 1 + assert "independent" in str(vs[0].path) + assert "organization.md" in vs[0].message + + def test_no_organizations_dir_is_silent(self, tmp_path: Path) -> None: + # Repos that have not yet created organizations/ do not error. + vs = list(validate_organization_structure(tmp_path)) + assert vs == [] + + def test_organization_category_is_hard(self) -> None: + assert ORGANIZATION_CATEGORY in HARD_CATEGORIES + + # --------------------------------------------------------------------------- # Capability sync check: docs/labels-and-capabilities.md ↔ live source # ---------------------------------------------------------------------------