diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 179d8a5d4..e452e0da4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,6 +53,13 @@ repos: files: ^src/autoskillit/server/tools_ pass_filenames: false + - id: check-sub-claude-md + name: Check sub-CLAUDE.md file table completeness + language: system + entry: python scripts/check_sub_claude_md.py + files: ^(tests/|src/autoskillit/).*(\.py|CLAUDE\.md)$ + pass_filenames: false + - repo: https://github.com/gitleaks/gitleaks rev: v8.30.0 hooks: diff --git a/scripts/check_sub_claude_md.py b/scripts/check_sub_claude_md.py new file mode 100644 index 000000000..5630300cf --- /dev/null +++ b/scripts/check_sub_claude_md.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Validate sub-CLAUDE.md file tables cover all .py files in their directories. + +Pre-commit hook (validate-only). Exits 1 with structured messages when a .py +file exists in a directory whose CLAUDE.md does not mention it. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SRC_ROOT = PROJECT_ROOT / "src" / "autoskillit" +TESTS_ROOT = PROJECT_ROOT / "tests" + +SRC_EXPECTED = [ + "core/types/CLAUDE.md", + "core/runtime/CLAUDE.md", + "execution/headless/CLAUDE.md", + "execution/process/CLAUDE.md", + "execution/session/CLAUDE.md", + "execution/merge_queue/CLAUDE.md", + "recipe/rules/CLAUDE.md", + "server/tools/CLAUDE.md", + "cli/doctor/CLAUDE.md", + "cli/fleet/CLAUDE.md", + "cli/session/CLAUDE.md", + "cli/ui/CLAUDE.md", + "cli/update/CLAUDE.md", + "hooks/guards/CLAUDE.md", + "hooks/formatters/CLAUDE.md", + "CLAUDE.md", + "core/CLAUDE.md", + "config/CLAUDE.md", + "pipeline/CLAUDE.md", + "execution/CLAUDE.md", + "workspace/CLAUDE.md", + "planner/CLAUDE.md", + "recipe/CLAUDE.md", + "migration/CLAUDE.md", + "fleet/CLAUDE.md", + "cli/CLAUDE.md", + "hooks/CLAUDE.md", +] + +TESTS_EXPECTED = [ + "arch/CLAUDE.md", + "assets/CLAUDE.md", + "cli/CLAUDE.md", + "config/CLAUDE.md", + "contracts/CLAUDE.md", + "core/CLAUDE.md", + "docs/CLAUDE.md", + "execution/CLAUDE.md", + "fleet/CLAUDE.md", + "hooks/CLAUDE.md", + "infra/CLAUDE.md", + "migration/CLAUDE.md", + "pipeline/CLAUDE.md", + "planner/CLAUDE.md", + "recipe/CLAUDE.md", + "server/CLAUDE.md", + "skills/CLAUDE.md", + "skills_extended/CLAUDE.md", + "workspace/CLAUDE.md", +] + + +def check_coverage(root: Path, expected: list[str]) -> list[str]: + """Check that each CLAUDE.md in expected mentions all .py files in its directory. + + Returns a list of failure messages (empty if all coverage is complete). + """ + failures: list[str] = [] + for rel_path in expected: + claude_md = root / rel_path + if not claude_md.exists(): + failures.append(f"{rel_path}: CLAUDE.md not found") + continue + content = claude_md.read_text(encoding="utf-8") + directory = claude_md.parent + for py_file in directory.glob("*.py"): + if py_file.name == "__init__.py": + if "`__init__.py`" not in content: + failures.append(f"{rel_path}: missing `__init__.py` in file table") + else: + if f"`{py_file.name}`" not in content: + failures.append(f"{rel_path}: missing {py_file.name}") + return failures + + +def main() -> int: + src_failures = check_coverage(SRC_ROOT, SRC_EXPECTED) + tests_failures = check_coverage(TESTS_ROOT, TESTS_EXPECTED) + all_failures = src_failures + tests_failures + if all_failures: + print("sub-CLAUDE.md file table gaps found:\n") + for f in all_failures: + print(f" {f}") + print(f"\nTotal: {len(all_failures)} gap(s)") + print("\nTo fix: add the missing file(s) to the CLAUDE.md file table in the") + print("directory where the .py file(s) were added.") + return 1 + print("All sub-CLAUDE.md file tables are complete.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/docs/CLAUDE.md b/tests/docs/CLAUDE.md index 51d00791d..95ad15fda 100644 --- a/tests/docs/CLAUDE.md +++ b/tests/docs/CLAUDE.md @@ -20,3 +20,4 @@ Documentation integrity, link validity, and naming convention tests. | `test_rationale_document_completeness.py` | Validate experiment-type rationale document completeness | | `test_sub_claude_md_completeness.py` | Structural tests for per-subfolder CLAUDE.md files under src/autoskillit/ | | `test_tests_sub_claude_md_completeness.py` | Structural tests for per-subfolder CLAUDE.md files under tests/ | +| `test_check_sub_claude_md_script.py` | Unit and integration tests for the check_sub_claude_md.py pre-commit hook script | diff --git a/tests/docs/test_check_sub_claude_md_script.py b/tests/docs/test_check_sub_claude_md_script.py new file mode 100644 index 000000000..cb76279aa --- /dev/null +++ b/tests/docs/test_check_sub_claude_md_script.py @@ -0,0 +1,131 @@ +"""Unit and integration tests for scripts/check_sub_claude_md.py.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.medium + +REPO_ROOT = Path(__file__).parent.parent.parent +_SCRIPT = REPO_ROOT / "scripts" / "check_sub_claude_md.py" + + +@pytest.fixture(scope="module") +def script_mod(): + spec = importlib.util.spec_from_file_location("check_sub_claude_md", _SCRIPT) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + yield mod + sys.modules.pop(spec.name, None) + + +class TestCheckCoverage: + def test_check_coverage_all_files_mentioned(self, script_mod, tmp_path): + """Returns empty list when CLAUDE.md mentions every .py file in the directory.""" + subdir = tmp_path / "mypackage" + subdir.mkdir() + (subdir / "CLAUDE.md").write_text( + "| File | Purpose |\n|------|----------|\n" + "| `__init__.py` | init |\n| `foo.py` | foo |\n", + encoding="utf-8", + ) + (subdir / "__init__.py").touch() + (subdir / "foo.py").touch() + result = script_mod.check_coverage(tmp_path, ["mypackage/CLAUDE.md"]) + assert result == [] + + def test_check_coverage_missing_regular_py_file(self, script_mod, tmp_path): + """Returns failure string containing the missing filename.""" + subdir = tmp_path / "mypackage" + subdir.mkdir() + (subdir / "CLAUDE.md").write_text( + "| File | Purpose |\n|------|----------|\n| `__init__.py` | init |\n", + encoding="utf-8", + ) + (subdir / "__init__.py").touch() + (subdir / "bar.py").touch() + result = script_mod.check_coverage(tmp_path, ["mypackage/CLAUDE.md"]) + assert result == ["mypackage/CLAUDE.md: missing bar.py"] + + def test_check_coverage_missing_init_py_backtick(self, script_mod, tmp_path): + """Returns failure when __init__.py exists but CLAUDE.md lacks backtick-wrapped mention.""" + subdir = tmp_path / "mypackage" + subdir.mkdir() + (subdir / "CLAUDE.md").write_text( + "| File | Purpose |\n|------|----------|\n| `foo.py` | foo |\n", + encoding="utf-8", + ) + (subdir / "__init__.py").touch() + (subdir / "foo.py").touch() + result = script_mod.check_coverage(tmp_path, ["mypackage/CLAUDE.md"]) + assert result == ["mypackage/CLAUDE.md: missing `__init__.py` in file table"] + + def test_check_coverage_init_py_without_backticks_fails(self, script_mod, tmp_path): + """Returns failure when CLAUDE.md mentions __init__.py without backtick wrapping.""" + subdir = tmp_path / "mypackage" + subdir.mkdir() + (subdir / "CLAUDE.md").write_text( + "| File | Purpose |\n|------|----------|\n| __init__.py | init |\n", + encoding="utf-8", + ) + (subdir / "__init__.py").touch() + result = script_mod.check_coverage(tmp_path, ["mypackage/CLAUDE.md"]) + assert result == ["mypackage/CLAUDE.md: missing `__init__.py` in file table"] + + def test_check_coverage_flags_missing_claude_md(self, script_mod, tmp_path): + """Returns failure when expected CLAUDE.md path does not exist on disk.""" + result = script_mod.check_coverage(tmp_path, ["nonexistent/CLAUDE.md"]) + assert result == ["nonexistent/CLAUDE.md: CLAUDE.md not found"] + + def test_check_coverage_multiple_missing_files(self, script_mod, tmp_path): + """Returns one failure entry per missing file.""" + subdir = tmp_path / "mypackage" + subdir.mkdir() + (subdir / "CLAUDE.md").write_text( + "| File | Purpose |\n|------|----------|\n", + encoding="utf-8", + ) + (subdir / "__init__.py").touch() + (subdir / "a.py").touch() + (subdir / "b.py").touch() + result = sorted(script_mod.check_coverage(tmp_path, ["mypackage/CLAUDE.md"])) + assert result == [ + "mypackage/CLAUDE.md: missing `__init__.py` in file table", + "mypackage/CLAUDE.md: missing a.py", + "mypackage/CLAUDE.md: missing b.py", + ] + + +class TestExpectedListsSync: + def test_src_expected_matches_test_file(self, script_mod): + """SRC_EXPECTED matches EXPECTED_SUB_CLAUDE_MDS in test_sub_claude_md_completeness.""" + test_module_name = "test_sub_claude_md_completeness" + test_file = REPO_ROOT / "tests" / "docs" / f"{test_module_name}.py" + spec = importlib.util.spec_from_file_location(test_module_name, test_file) + assert spec is not None and spec.loader is not None + test_mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(test_mod) + assert sorted(script_mod.SRC_EXPECTED) == sorted(test_mod.EXPECTED_SUB_CLAUDE_MDS) + + def test_tests_expected_matches_test_file(self, script_mod): + """TESTS_EXPECTED list matches that in test_tests_sub_claude_md_completeness.""" + test_module_name = "test_tests_sub_claude_md_completeness" + test_file = REPO_ROOT / "tests" / "docs" / f"{test_module_name}.py" + spec = importlib.util.spec_from_file_location(test_module_name, test_file) + assert spec is not None and spec.loader is not None + test_mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(test_mod) + assert sorted(script_mod.TESTS_EXPECTED) == sorted(test_mod.EXPECTED_SUB_CLAUDE_MDS) + + +class TestMain: + def test_main_returns_zero_on_live_repo(self, script_mod): + """main() returns 0 against the actual project (integration guard).""" + result = script_mod.main() + assert result == 0, "main() should return 0 on a clean repo" diff --git a/tests/infra/test_ci_dev_config.py b/tests/infra/test_ci_dev_config.py index f08a7979d..cc8b4b605 100644 --- a/tests/infra/test_ci_dev_config.py +++ b/tests/infra/test_ci_dev_config.py @@ -48,6 +48,25 @@ def test_ruff_tid251_configured(self): "test_architecture.py relies on this rule being enforced by ruff at pre-commit time" ) + def test_sub_claude_md_hook_present(self): + """pre-commit config must include a sub-CLAUDE.md completeness check hook. + + Without this, agents can commit new .py files without updating the + directory's CLAUDE.md file table, causing CI test failures. + """ + config = yaml.safe_load(PRECOMMIT_CONFIG.read_text()) + hooks = [hook for repo in config.get("repos", []) for hook in repo.get("hooks", [])] + sub_claude_hooks = [h for h in hooks if "check_sub_claude_md" in h.get("entry", "")] + assert sub_claude_hooks, ( + "Missing 'check-sub-claude-md' hook in .pre-commit-config.yaml — " + "add it to catch sub-CLAUDE.md coverage gaps before CI" + ) + hook = sub_claude_hooks[0] + assert hook.get("pass_filenames") is False, ( + "check-sub-claude-md hook must use pass_filenames: false — " + "the script does its own filesystem scan" + ) + class TestCIWorkflow: def test_lockfile_check_present_in_workflow(self):