Skip to content

Commit b94ac21

Browse files
Jammy2211claude
authored andcommitted
Add a structure lint: two content homes, no loose bibs, no committed papers
scripts/validate_structure.py enforces the layout contract that would have prevented the pre-cleanup state: a top-level allowlist, .bib only under bibliography/, and no paper artifacts (PDF suffix or PDF magic bytes, so extensionless ADS downloads are caught too). Wired into make validate and a minimal GitHub Actions workflow alongside the existing citation validator; the write contract is documented in AGENTS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 653a76b commit b94ac21

4 files changed

Lines changed: 178 additions & 1 deletion

File tree

.github/workflows/validate.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: validate
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
validate:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-python@v5
14+
with:
15+
python-version: "3.11"
16+
- run: pip install pytest
17+
- run: make validate
18+
- run: make test

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
.PHONY: validate-literature-citations
1+
.PHONY: validate validate-literature-citations validate-structure test
2+
3+
validate: validate-literature-citations validate-structure
24

35
validate-literature-citations:
46
python scripts/validate_literature_citations.py
7+
8+
validate-structure:
9+
python scripts/validate_structure.py
10+
11+
test:
12+
python -m pytest tests/ -q

scripts/validate_structure.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Validate the PyAutoMemory repository layout.
2+
3+
The repo has exactly two content homes — `wiki/` (sub-wikis) and
4+
`bibliography/` (canonical BibTeX) — plus a small allowlisted set of root
5+
files and tooling folders. This lint fails on the historical failure modes:
6+
loose `.bib` files outside `bibliography/`, committed papers (PDF/HTML blobs,
7+
with or without a file extension), and unrecognised top-level entries.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import sys
14+
from pathlib import Path
15+
16+
ROOT = Path(__file__).resolve().parents[1]
17+
18+
ALLOWED_TOP_DIRS = {
19+
".git",
20+
".github",
21+
"bibliography",
22+
"scripts",
23+
"tests",
24+
"wiki",
25+
}
26+
ALLOWED_TOP_FILES = {
27+
".gitignore",
28+
"AGENTS.md",
29+
"CLAUDE.md",
30+
"CONTRIBUTING.md",
31+
"LICENSE",
32+
"Makefile",
33+
"README.md",
34+
"index.md",
35+
"logo.png",
36+
"reading-queue.md",
37+
}
38+
BANNED_SUFFIXES = {".pdf", ".htm", ".html"}
39+
BINARY_MAGIC = (b"%PDF-",)
40+
BINARY_EXEMPT = {"logo.png"}
41+
42+
43+
def iter_repo_files(root: Path):
44+
for path in sorted(root.rglob("*")):
45+
if ".git" in path.relative_to(root).parts:
46+
continue
47+
if path.is_file():
48+
yield path
49+
50+
51+
def validate_structure(root: Path) -> list[str]:
52+
errors: list[str] = []
53+
54+
for entry in sorted(root.iterdir()):
55+
name = entry.name
56+
if name == ".git": # a directory in a normal checkout, a file in a worktree
57+
continue
58+
if entry.is_dir():
59+
if name not in ALLOWED_TOP_DIRS:
60+
errors.append(
61+
f"unexpected top-level directory: {name}/ "
62+
"(content belongs under wiki/ or bibliography/)"
63+
)
64+
elif name not in ALLOWED_TOP_FILES:
65+
errors.append(
66+
f"unexpected top-level file: {name} "
67+
"(allowlist lives in scripts/validate_structure.py)"
68+
)
69+
70+
for path in iter_repo_files(root):
71+
rel = path.relative_to(root)
72+
if path.suffix == ".bib" and rel.parts[0] != "bibliography":
73+
errors.append(f"loose .bib file outside bibliography/: {rel}")
74+
if path.suffix.lower() in BANNED_SUFFIXES:
75+
errors.append(f"committed paper artifact (source PDFs live off-repo): {rel}")
76+
elif rel.name not in BINARY_EXEMPT:
77+
try:
78+
head = path.open("rb").read(8)
79+
except OSError:
80+
head = b""
81+
if head.startswith(BINARY_MAGIC):
82+
errors.append(f"committed PDF content (source PDFs live off-repo): {rel}")
83+
84+
return errors
85+
86+
87+
def main(argv: list[str] | None = None) -> int:
88+
parser = argparse.ArgumentParser(description=__doc__)
89+
parser.add_argument("--root", type=Path, default=ROOT)
90+
args = parser.parse_args(argv)
91+
92+
errors = validate_structure(args.root.resolve())
93+
for error in errors:
94+
print(f"ERROR: {error}")
95+
if errors:
96+
print(f"Repository structure is invalid ({len(errors)} problem(s)).")
97+
return 1
98+
print("Repository structure is valid.")
99+
return 0
100+
101+
102+
if __name__ == "__main__":
103+
sys.exit(main())

tests/test_validate_structure.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import sys
2+
from pathlib import Path
3+
4+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
5+
6+
from scripts.validate_structure import validate_structure
7+
8+
9+
def _minimal_repo(tmp_path: Path) -> Path:
10+
(tmp_path / "wiki" / "example" / "sources").mkdir(parents=True)
11+
(tmp_path / "bibliography").mkdir()
12+
(tmp_path / "bibliography" / "pyautomemory.bib").write_text("", encoding="utf-8")
13+
(tmp_path / "README.md").write_text("# readme\n", encoding="utf-8")
14+
return tmp_path
15+
16+
17+
def test_clean_layout_passes(tmp_path):
18+
root = _minimal_repo(tmp_path)
19+
assert validate_structure(root) == []
20+
21+
22+
def test_unexpected_top_level_entries_fail(tmp_path):
23+
root = _minimal_repo(tmp_path)
24+
(root / "DarkMatterModels").mkdir()
25+
(root / "euclid.sty").write_text("", encoding="utf-8")
26+
errors = validate_structure(root)
27+
assert any("DarkMatterModels" in e for e in errors)
28+
assert any("euclid.sty" in e for e in errors)
29+
30+
31+
def test_loose_bib_outside_bibliography_fails(tmp_path):
32+
root = _minimal_repo(tmp_path)
33+
(root / "wiki" / "example" / "library.bib").write_text("", encoding="utf-8")
34+
errors = validate_structure(root)
35+
assert any("loose .bib" in e for e in errors)
36+
37+
38+
def test_committed_pdf_content_fails_even_without_extension(tmp_path):
39+
root = _minimal_repo(tmp_path)
40+
(root / "wiki" / "example" / "Hubble1926.321H").write_bytes(b"%PDF-1.5 fake")
41+
(root / "wiki" / "example" / "paper.pdf").write_bytes(b"%PDF-1.4 fake")
42+
errors = validate_structure(root)
43+
assert sum("off-repo" in e for e in errors) == 2
44+
45+
46+
def test_actual_repository_layout_is_valid():
47+
repo_root = Path(__file__).resolve().parents[1]
48+
assert validate_structure(repo_root) == []

0 commit comments

Comments
 (0)