From 718c5b9395dd62a826608734bc97c58688a2b795 Mon Sep 17 00:00:00 2001 From: Trecek Date: Tue, 5 May 2026 21:39:53 -0700 Subject: [PATCH 1/3] add pre-commit hook to validate sub-CLAUDE.md file table completeness Introduces scripts/check_sub_claude_md.py as a validate-only pre-commit hook that checks every sub-CLAUDE.md file table mentions all .py files in its directory. This catches coverage gaps at commit time rather than CI time. - scripts/check_sub_claude_md.py: new hook script with SRC_EXPECTED (27 dirs) and TESTS_EXPECTED (19 dirs) lists mirroring the test suite's lists - .pre-commit-config.yaml: new check-sub-claude-md hook in local repo hooks - tests/docs/test_check_sub_claude_md_script.py: 9 unit/integration tests for check_coverage() and main(), plus list-sync guards against the test files - tests/infra/test_ci_dev_config.py: structural test verifying the hook is present in pre-commit config with pass_filenames: false - tests/docs/CLAUDE.md: added test_check_sub_claude_md_script.py entry Co-Authored-By: Claude Opus 4.7 --- .pre-commit-config.yaml | 7 + scripts/check_sub_claude_md.py | 109 +++++++++++++++ tests/docs/CLAUDE.md | 1 + tests/docs/test_check_sub_claude_md_script.py | 130 ++++++++++++++++++ tests/infra/test_ci_dev_config.py | 19 +++ 5 files changed, 266 insertions(+) create mode 100644 scripts/check_sub_claude_md.py create mode 100644 tests/docs/test_check_sub_claude_md_script.py 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..83dff7dc8 --- /dev/null +++ b/scripts/check_sub_claude_md.py @@ -0,0 +1,109 @@ +#!/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(): + 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 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..1bc741591 --- /dev/null +++ b/tests/docs/test_check_sub_claude_md_script.py @@ -0,0 +1,130 @@ +"""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.small + +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_skips_nonexistent_claude_md(self, script_mod, tmp_path): + """Returns empty list when expected CLAUDE.md path does not exist on disk.""" + result = script_mod.check_coverage(tmp_path, ["nonexistent/CLAUDE.md"]) + assert result == [] + + 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): + """Script's SRC_EXPECTED list 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): + """Script's TESTS_EXPECTED list matches EXPECTED_SUB_CLAUDE_MDS 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): From 92ed43ce5828a9fa504f45f0548a6505f3ce1502 Mon Sep 17 00:00:00 2001 From: Trecek Date: Tue, 5 May 2026 22:05:11 -0700 Subject: [PATCH 2/3] fix(review): flag missing CLAUDE.md and use backtick-form check for file coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Missing CLAUDE.md paths now append a failure message instead of silently continuing, preserving the coverage guarantee for directories that lost their CLAUDE.md file. - Regular .py file check changed from bare substring match to backtick-form check (f"\`{name}\`") — consistent with the existing __init__.py check and the actual table format used in all sub-CLAUDE.md files. Co-Authored-By: Claude Sonnet 4.6 --- scripts/check_sub_claude_md.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check_sub_claude_md.py b/scripts/check_sub_claude_md.py index 83dff7dc8..5630300cf 100644 --- a/scripts/check_sub_claude_md.py +++ b/scripts/check_sub_claude_md.py @@ -76,6 +76,7 @@ def check_coverage(root: Path, expected: list[str]) -> 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 @@ -84,7 +85,7 @@ def check_coverage(root: Path, expected: list[str]) -> list[str]: if "`__init__.py`" not in content: failures.append(f"{rel_path}: missing `__init__.py` in file table") else: - if py_file.name not in content: + if f"`{py_file.name}`" not in content: failures.append(f"{rel_path}: missing {py_file.name}") return failures From 6e56861be0c4d6187f8c88f862811158a80fbb9e Mon Sep 17 00:00:00 2001 From: Trecek Date: Tue, 5 May 2026 22:05:17 -0700 Subject: [PATCH 3/3] fix(review): update test suite for script changes and reclassify to medium - test_check_coverage_skips_nonexistent_claude_md renamed and updated to expect a failure message for missing CLAUDE.md (mirrors script fix). - Module pytestmark changed from small to medium: test_main_returns_zero_on_live_repo performs real filesystem I/O against the live repo tree, exceeding the small definition (no persistent I/O). - Fix pre-existing E501 line-length violations in docstrings and string literals. Co-Authored-By: Claude Sonnet 4.6 --- tests/docs/test_check_sub_claude_md_script.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/docs/test_check_sub_claude_md_script.py b/tests/docs/test_check_sub_claude_md_script.py index 1bc741591..cb76279aa 100644 --- a/tests/docs/test_check_sub_claude_md_script.py +++ b/tests/docs/test_check_sub_claude_md_script.py @@ -8,7 +8,7 @@ import pytest -pytestmark = pytest.mark.small +pytestmark = pytest.mark.medium REPO_ROOT = Path(__file__).parent.parent.parent _SCRIPT = REPO_ROOT / "scripts" / "check_sub_claude_md.py" @@ -31,7 +31,8 @@ def test_check_coverage_all_files_mentioned(self, script_mod, tmp_path): subdir = tmp_path / "mypackage" subdir.mkdir() (subdir / "CLAUDE.md").write_text( - "| File | Purpose |\n|------|----------|\n| `__init__.py` | init |\n| `foo.py` | foo |\n", + "| File | Purpose |\n|------|----------|\n" + "| `__init__.py` | init |\n| `foo.py` | foo |\n", encoding="utf-8", ) (subdir / "__init__.py").touch() @@ -77,10 +78,10 @@ def test_check_coverage_init_py_without_backticks_fails(self, script_mod, tmp_pa 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_skips_nonexistent_claude_md(self, script_mod, tmp_path): - """Returns empty list when expected CLAUDE.md path does not exist on disk.""" + 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 == [] + 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.""" @@ -103,7 +104,7 @@ def test_check_coverage_multiple_missing_files(self, script_mod, tmp_path): class TestExpectedListsSync: def test_src_expected_matches_test_file(self, script_mod): - """Script's SRC_EXPECTED list matches EXPECTED_SUB_CLAUDE_MDS in test_sub_claude_md_completeness.""" + """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) @@ -113,7 +114,7 @@ def test_src_expected_matches_test_file(self, script_mod): assert sorted(script_mod.SRC_EXPECTED) == sorted(test_mod.EXPECTED_SUB_CLAUDE_MDS) def test_tests_expected_matches_test_file(self, script_mod): - """Script's TESTS_EXPECTED list matches EXPECTED_SUB_CLAUDE_MDS in test_tests_sub_claude_md_completeness.""" + """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)