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
Original file line number Diff line number Diff line change
Expand Up @@ -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/<org>/ 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/<org>/`` 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: <org>`` 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)
Expand Down Expand Up @@ -3522,6 +3562,10 @@ def run_validation(root: Path | None = None) -> list[Violation]:
# Tool-level checks: every tools/<name>/ has a README that declares its capability.
violations.extend(validate_tools(repo_root))

# Organization-adapter structure: each organizations/<org>/ 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))
Expand Down
59 changes: 59 additions & 0 deletions tools/skill-and-tool-validator/tests/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<org>/."""

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
# ---------------------------------------------------------------------------
Expand Down