mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 17:00:23 +00:00
- Remove def load_vault_config delegation wrappers from all 7 worker modules - Add from paperforge.config import load_vault_config, paperforge_paths at module level - Remove unused system_dir/resources_dir/control_dir extraction in pipeline_paths - Fix sync.py intra-function imports (run_selection_sync, run_index_refresh) - Fix repair.py import path for _resolve_formal_note_path (direct from _utils) - Add ruff and pre-commit as test dependencies in pyproject.toml - Configure per-file ruff ignores for pre-existing issues - Run ruff check --fix + ruff format (353 + 58 issues auto-fixed) - All 203 tests pass, ruff check zero warnings
346 lines
12 KiB
Python
346 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""PaperForge consistency audit script.
|
|
|
|
Checks hard constraints across the codebase:
|
|
1. No old command names in active code/docs
|
|
2. No paperforge_lite references in Python code
|
|
3. No dead internal links in markdown
|
|
4. All command/*.md files have valid structure
|
|
|
|
Exit code: 0 if all pass, 1 if any fail.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from collections.abc import Iterable
|
|
from pathlib import Path
|
|
|
|
# Root of the repository (parent of this script)
|
|
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
|
|
|
# Patterns for old command names that should NOT appear outside migration/historical docs
|
|
OLD_COMMAND_PATTERNS = {
|
|
"paperforge selection-sync": r"paperforge\s+selection-sync",
|
|
"paperforge index-refresh": r"paperforge\s+index-refresh",
|
|
"paperforge ocr run": r"paperforge\s+ocr\s+run",
|
|
"paperforge ocr doctor": r"paperforge\s+ocr\s+doctor",
|
|
"/LD-deep": r"/LD-deep",
|
|
"/LD-paper": r"/LD-paper",
|
|
"/lp-ocr": r"/lp-ocr(?!\w)", # word boundary
|
|
"/lp-index-refresh": r"/lp-index-refresh",
|
|
"/lp-selection-sync": r"/lp-selection-sync",
|
|
"/lp-status": r"/lp-status(?!\w)", # word boundary
|
|
}
|
|
|
|
# Files/directories to exclude from Check 1
|
|
CHECK1_EXCLUDES = {
|
|
"docs/MIGRATION-v1.2.md",
|
|
"docs/ARCHITECTURE.md", # ADR-010 documents historical command names
|
|
".planning",
|
|
".pytest_cache",
|
|
"__pycache__",
|
|
".git",
|
|
"scripts/consistency_audit.py", # Don't flag the audit script itself
|
|
}
|
|
|
|
# In Python code, these contexts are acceptable (backward compat aliases, tests, etc.)
|
|
CHECK1_PY_EXCLUDE_PATTERNS = [
|
|
# Docstrings mentioning backward compatibility
|
|
r"backward compat",
|
|
r"deprecated",
|
|
r"alias",
|
|
r"命令迁移", # migration note in Chinese
|
|
r"migration",
|
|
# Test files checking for old commands
|
|
r"test_command_docs",
|
|
r"test_.*dispatch",
|
|
# Comments describing what a function handles
|
|
r"Handle `paperforge ocr doctor`",
|
|
]
|
|
|
|
# Required sections in command/*.md files
|
|
COMMAND_DOC_REQUIRED_SECTIONS = [
|
|
"Purpose",
|
|
"CLI Equivalent",
|
|
"Prerequisites",
|
|
"Arguments",
|
|
"Example",
|
|
"Output",
|
|
"Error Handling",
|
|
"Platform Notes",
|
|
]
|
|
|
|
|
|
def should_exclude_check1(path: Path) -> bool:
|
|
"""Check if a path should be excluded from Check 1."""
|
|
rel = path.relative_to(REPO_ROOT).as_posix()
|
|
return any(rel.startswith(exclude) or exclude in rel for exclude in CHECK1_EXCLUDES)
|
|
|
|
|
|
def find_files(pattern: str, root: Path = REPO_ROOT) -> Iterable[Path]:
|
|
"""Find files matching a glob pattern, excluding common ignored dirs."""
|
|
ignore_dirs = {".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv"}
|
|
for path in root.rglob(pattern):
|
|
if any(part in ignore_dirs for part in path.parts):
|
|
continue
|
|
yield path
|
|
|
|
|
|
def check_old_commands() -> tuple[int, list[str]]:
|
|
"""Check 1: No old command names in active code/docs."""
|
|
violations: list[str] = []
|
|
|
|
# Check markdown files
|
|
for md_path in find_files("*.md"):
|
|
if should_exclude_check1(md_path):
|
|
continue
|
|
content = md_path.read_text(encoding="utf-8")
|
|
rel = md_path.relative_to(REPO_ROOT).as_posix()
|
|
for name, pattern in OLD_COMMAND_PATTERNS.items():
|
|
for match in re.finditer(pattern, content):
|
|
# Skip matches inside inline code that explicitly mention migration
|
|
line_start = content.rfind("\n", 0, match.start()) + 1
|
|
line_end = content.find("\n", match.end())
|
|
if line_end == -1:
|
|
line_end = len(content)
|
|
line = content[line_start:line_end]
|
|
# Allow old commands in explicit migration sections of AGENTS.md
|
|
if rel == "AGENTS.md" and "命令迁移说明" in content[: match.start()]:
|
|
# Check if this is in section 11 (migration section)
|
|
section_start = content.find("## 11. 命令迁移说明")
|
|
if section_start != -1 and match.start() > section_start:
|
|
continue
|
|
violations.append(f" [{rel}] {name}: {line.strip()}")
|
|
|
|
# Check Python files (user-facing strings only)
|
|
for py_path in find_files("*.py"):
|
|
if should_exclude_check1(py_path):
|
|
continue
|
|
content = py_path.read_text(encoding="utf-8")
|
|
rel = py_path.relative_to(REPO_ROOT).as_posix()
|
|
for name, pattern in OLD_COMMAND_PATTERNS.items():
|
|
for match in re.finditer(pattern, content):
|
|
# Skip if in a test file or backward-compat code
|
|
if "test_" in rel or "tests/" in rel:
|
|
continue
|
|
line_start = content.rfind("\n", 0, match.start()) + 1
|
|
line_end = content.find("\n", match.end())
|
|
if line_end == -1:
|
|
line_end = len(content)
|
|
line = content[line_start:line_end]
|
|
# Skip docstrings/comments about backward compatibility
|
|
stripped = line.strip()
|
|
if any(re.search(p, stripped, re.IGNORECASE) for p in CHECK1_PY_EXCLUDE_PATTERNS):
|
|
continue
|
|
# Skip CLI parser setup for backward compat aliases
|
|
if "add_parser" in stripped or "help=" in stripped:
|
|
continue
|
|
violations.append(f" [{rel}] {name}: {stripped}")
|
|
|
|
passed = len(violations) == 0
|
|
return (0 if passed else 1), violations
|
|
|
|
|
|
def check_paperforge_lite() -> tuple[int, list[str]]:
|
|
"""Check 2: No paperforge_lite references in Python code."""
|
|
violations: list[str] = []
|
|
pattern = re.compile(r"paperforge_lite")
|
|
|
|
for py_path in find_files("*.py"):
|
|
rel = py_path.relative_to(REPO_ROOT).as_posix()
|
|
# Skip the audit script itself
|
|
if rel == "scripts/consistency_audit.py":
|
|
continue
|
|
content = py_path.read_text(encoding="utf-8")
|
|
for match in pattern.finditer(content):
|
|
line_start = content.rfind("\n", 0, match.start()) + 1
|
|
line_end = content.find("\n", match.end())
|
|
if line_end == -1:
|
|
line_end = len(content)
|
|
line = content[line_start:line_end].strip()
|
|
violations.append(f" [{rel}] {line}")
|
|
|
|
passed = len(violations) == 0
|
|
return (0 if passed else 1), violations
|
|
|
|
|
|
def check_dead_links() -> tuple[int, list[str]]:
|
|
"""Check 3: No dead internal links in markdown."""
|
|
violations: list[str] = []
|
|
# Match markdown links: [text](path) or [text](./path)
|
|
link_pattern = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
|
|
for md_path in find_files("*.md"):
|
|
content = md_path.read_text(encoding="utf-8")
|
|
rel = md_path.relative_to(REPO_ROOT).as_posix()
|
|
for match in link_pattern.finditer(content):
|
|
link_text = match.group(1)
|
|
link_target = match.group(2)
|
|
|
|
# Skip external URLs
|
|
if link_target.startswith("http://") or link_target.startswith("https://"):
|
|
continue
|
|
# Skip anchor-only links
|
|
if link_target.startswith("#"):
|
|
continue
|
|
# Skip mailto links
|
|
if link_target.startswith("mailto:"):
|
|
continue
|
|
|
|
# Resolve the target relative to the current markdown file
|
|
if link_target.startswith("/"):
|
|
target_path = REPO_ROOT / link_target.lstrip("/")
|
|
else:
|
|
target_path = md_path.parent / link_target
|
|
|
|
# Check if the target exists (file or directory)
|
|
if not target_path.exists():
|
|
# Try appending .md for implicit markdown links
|
|
if not (target_path.with_suffix(".md")).exists():
|
|
violations.append(f" [{rel}] Dead link to '{link_target}' (text: '{link_text}')")
|
|
|
|
passed = len(violations) == 0
|
|
return (0 if passed else 1), violations
|
|
|
|
|
|
def check_command_docs() -> tuple[int, list[str]]:
|
|
"""Check 4: All command/*.md files have valid structure."""
|
|
violations: list[str] = []
|
|
command_dir = REPO_ROOT / "command"
|
|
|
|
if not command_dir.exists():
|
|
violations.append(" command/ directory not found")
|
|
return 1, violations
|
|
|
|
command_files = sorted(command_dir.glob("pf-*.md"))
|
|
if not command_files:
|
|
violations.append(" No command/pf-*.md files found")
|
|
return 1, violations
|
|
|
|
for cmd_path in command_files:
|
|
content = cmd_path.read_text(encoding="utf-8")
|
|
rel = cmd_path.relative_to(REPO_ROOT).as_posix()
|
|
missing = []
|
|
for section in COMMAND_DOC_REQUIRED_SECTIONS:
|
|
# Look for ## Section or ## Section (subheading)
|
|
if not re.search(rf"^##\s+{re.escape(section)}(\s|$)", content, re.MULTILINE):
|
|
missing.append(section)
|
|
if missing:
|
|
violations.append(f" [{rel}] Missing sections: {', '.join(missing)}")
|
|
|
|
passed = len(violations) == 0
|
|
return (0 if passed else 1), violations
|
|
|
|
|
|
def check_duplicate_utils() -> tuple[int, list[str]]:
|
|
"""Check 5: Detect worker modules with duplicated utility functions outside the re-export from _utils.py."""
|
|
import ast
|
|
|
|
violations: list[str] = []
|
|
|
|
worker_dir = REPO_ROOT / "paperforge" / "worker"
|
|
if not worker_dir.exists():
|
|
return 0, violations
|
|
|
|
# Known shared function names from _utils.py
|
|
shared_funcs = frozenset(
|
|
{
|
|
"read_json",
|
|
"write_json",
|
|
"read_jsonl",
|
|
"write_jsonl",
|
|
"yaml_quote",
|
|
"yaml_block",
|
|
"yaml_list",
|
|
"slugify_filename",
|
|
"_extract_year",
|
|
"load_journal_db",
|
|
"lookup_impact_factor",
|
|
"scan_library_records",
|
|
"_resolve_formal_note_path",
|
|
}
|
|
)
|
|
|
|
skip_files = frozenset({"_utils.py", "_retry.py", "_progress.py", "__init__.py"})
|
|
|
|
for py_path in sorted(worker_dir.glob("*.py")):
|
|
if py_path.name in skip_files:
|
|
continue
|
|
rel = py_path.relative_to(REPO_ROOT).as_posix()
|
|
try:
|
|
tree = ast.parse(py_path.read_text(encoding="utf-8"))
|
|
except SyntaxError:
|
|
violations.append(f" [{rel}] Syntax error -- cannot parse")
|
|
continue
|
|
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.FunctionDef):
|
|
continue
|
|
if node.name not in shared_funcs:
|
|
continue
|
|
|
|
# Check if this function is a legitimate re-export from _utils
|
|
# (import from _utils + simple return wrapper)
|
|
is_reexport = False
|
|
for child in ast.walk(node):
|
|
if isinstance(child, ast.ImportFrom):
|
|
if child.module and "_utils" in child.module:
|
|
is_reexport = True
|
|
break
|
|
elif isinstance(child, ast.Import):
|
|
for alias in child.names:
|
|
if "_utils" in (alias.name or ""):
|
|
is_reexport = True
|
|
break
|
|
|
|
if is_reexport:
|
|
continue
|
|
|
|
violations.append(f" [{rel}] Function '{node.name}' is defined locally but exists in _utils.py")
|
|
|
|
passed = len(violations) == 0
|
|
return (0 if passed else 1), violations
|
|
|
|
|
|
def main() -> int:
|
|
print("=== Consistency Audit Results ===\n")
|
|
|
|
checks = [
|
|
("Check 1: No old command names", check_old_commands),
|
|
("Check 2: No paperforge_lite in Python", check_paperforge_lite),
|
|
("Check 3: No dead links", check_dead_links),
|
|
("Check 4: Command docs structure", check_command_docs),
|
|
("Check 5: No duplicate utility functions", check_duplicate_utils),
|
|
]
|
|
|
|
total_passed = 0
|
|
total_failed = 0
|
|
|
|
for name, check_fn in checks:
|
|
exit_code, details = check_fn()
|
|
status = "PASS" if exit_code == 0 else "FAIL"
|
|
print(f"[{status}] {name}")
|
|
if details:
|
|
print(f" Found: {len(details)} occurrence(s)")
|
|
for detail in details:
|
|
print(detail)
|
|
else:
|
|
print(" Found: 0 occurrences")
|
|
print()
|
|
|
|
if exit_code == 0:
|
|
total_passed += 1
|
|
else:
|
|
total_failed += 1
|
|
|
|
print("=== Summary ===")
|
|
print(f"Passed: {total_passed}/{len(checks)}")
|
|
print(f"Failed: {total_failed}/{len(checks)}")
|
|
|
|
return 1 if total_failed > 0 else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|