fix(seedgo): de-git readme_check — audit reads local files only (DPLAN-0198)
Drops git check-ignore + git log from readme_check. Runtime dirs tolerated via static list (_is_runtime_artifact); freshness checks date-presence only, no history comparison. Local-CI parity proven 13/13 both ways (working tree + git archive clean checkout). Invariant: a checker never consults git or .gitignore; only bypass.json excludes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0661949c15
commit
8efb204486
@@ -24,7 +24,6 @@ Checks:
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
@@ -36,57 +35,31 @@ from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
||||
AUDIT_SCOPE = "entry_point"
|
||||
|
||||
|
||||
def _is_gitignored(path: Path) -> bool:
|
||||
"""Check if a path is covered by .gitignore rules.
|
||||
# Runtime / generated paths that legitimately may be absent in a clean
|
||||
# checkout (e.g. CI) while present in a working tree. A README documenting
|
||||
# one of these is not a violation when it's missing from disk.
|
||||
# Pure local-file check — never consults git or .gitignore. (A standards
|
||||
# audit reads the files that are there; git is a separate concern.)
|
||||
_RUNTIME_ARTIFACTS = {
|
||||
"logs",
|
||||
"artifacts",
|
||||
"dropbox",
|
||||
"system_logs",
|
||||
"docs.local",
|
||||
".trinity",
|
||||
"STATUS.local.md",
|
||||
"DASHBOARD.local.json",
|
||||
}
|
||||
|
||||
Uses ``git check-ignore`` from the repo root (discovered via
|
||||
``git rev-parse --show-toplevel``). Falls back to a built-in list
|
||||
of known AIPass gitignored patterns when git is unavailable.
|
||||
"""
|
||||
try:
|
||||
top = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if top.returncode == 0:
|
||||
repo_root = top.stdout.strip()
|
||||
# Test both the bare path and its directory form. .gitignore dir-only
|
||||
# patterns (trailing slash: logs/, artifacts/, **/*_json/, .trinity/)
|
||||
# only match when git knows the path is a directory. In a clean
|
||||
# checkout the gitignored dir does not exist on disk, so git cannot
|
||||
# infer "directory" from the bare path and reports it un-ignored —
|
||||
# appending a trailing slash signals directory intent and restores
|
||||
# the match. (Without this, README dir-tree/dead-link checks fail in
|
||||
# CI's clean checkout while passing in a working tree.)
|
||||
for candidate in (str(path), str(path).rstrip("/") + "/"):
|
||||
result = subprocess.run(
|
||||
["git", "-C", repo_root, "check-ignore", "-q", candidate],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
logger.info("git check-ignore unavailable for %s", path)
|
||||
|
||||
def _is_runtime_artifact(path: Path) -> bool:
|
||||
"""True if path is a runtime/generated artifact that may be absent in a
|
||||
clean checkout. Local-file only — never consults git or .gitignore."""
|
||||
name = path.name
|
||||
known_ignored = {
|
||||
"logs",
|
||||
"artifacts",
|
||||
"dropbox",
|
||||
"system_logs",
|
||||
"docs.local",
|
||||
".trinity",
|
||||
}
|
||||
if name in known_ignored:
|
||||
if name in _RUNTIME_ARTIFACTS:
|
||||
return True
|
||||
if name.endswith("_json"):
|
||||
return True
|
||||
if name in ("STATUS.local.md", "DASHBOARD.local.json"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -243,35 +216,17 @@ def check_required_sections(lines: List[str], file_path: str, bypass_rules: list
|
||||
return {"name": "Required sections", "passed": False, "message": f"Missing sections: {', '.join(missing)}"}
|
||||
|
||||
|
||||
def _get_latest_py_commit_date(branch_root: Path) -> Optional[datetime]:
|
||||
"""Get the date of the last git commit that touched a .py file in the branch."""
|
||||
apps_dir = branch_root / "apps"
|
||||
if not apps_dir.exists():
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%cd", "--date=short", "--", "*.py"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(apps_dir),
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
return datetime.strptime(result.stdout.strip(), "%Y-%m-%d")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError) as exc:
|
||||
logger.info("git log for freshness check failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def check_last_updated_freshness(
|
||||
lines: List[str], branch_root: Path, file_path: str, bypass_rules: list | None = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Check that Last Updated date is within 7 days of last git commit touching .py files.
|
||||
Check that the README declares a well-formed "Last Updated" date.
|
||||
|
||||
Uses git history (not filesystem mtime) to avoid false positives from
|
||||
checkout/merge/pull operations that reset mtimes without semantic changes.
|
||||
Local-file only: verifies the field is present and parseable. Does NOT
|
||||
compare against code history — recency is not a property of the files on
|
||||
disk, so it has no place in a local standards audit (and would diverge
|
||||
between a working tree and a clean CI checkout). A "code changed, re-check
|
||||
your README" nudge, if wanted, belongs outside the audit as its own flag.
|
||||
|
||||
Looks for patterns:
|
||||
- *Last Updated: YYYY-MM-DD*
|
||||
@@ -282,46 +237,27 @@ def check_last_updated_freshness(
|
||||
if is_bypassed(file_path, "readme", None, bypass_rules):
|
||||
return {"name": "Last Updated freshness", "passed": True, "message": "Bypassed by bypass rules"}
|
||||
|
||||
readme_date = None
|
||||
date_pattern = re.compile(r"\*{0,2}Last Updated\*{0,2}:\*{0,2}\s*(\d{4}-\d{2}-\d{2})")
|
||||
|
||||
for line in lines:
|
||||
match = date_pattern.search(line)
|
||||
if match:
|
||||
try:
|
||||
readme_date = datetime.strptime(match.group(1), "%Y-%m-%d")
|
||||
datetime.strptime(match.group(1), "%Y-%m-%d")
|
||||
except ValueError:
|
||||
logger.info("Malformed date in README: %s", match.group(1))
|
||||
readme_date = None
|
||||
break
|
||||
logger.info("Malformed Last Updated date in README: %s", match.group(1))
|
||||
return {
|
||||
"name": "Last Updated freshness",
|
||||
"passed": False,
|
||||
"message": f"Malformed Last Updated date: {match.group(1)}",
|
||||
}
|
||||
return {
|
||||
"name": "Last Updated freshness",
|
||||
"passed": True,
|
||||
"message": f"Last Updated date present ({match.group(1)})",
|
||||
}
|
||||
|
||||
if readme_date is None:
|
||||
return {"name": "Last Updated freshness", "passed": False, "message": 'No "Last Updated" date found in README'}
|
||||
|
||||
latest_commit = _get_latest_py_commit_date(branch_root)
|
||||
|
||||
if latest_commit is None:
|
||||
return {"name": "Last Updated freshness", "passed": True, "message": "No git history for .py files (skip)"}
|
||||
|
||||
days_behind = (latest_commit - readme_date).days
|
||||
|
||||
if days_behind <= 7:
|
||||
return {
|
||||
"name": "Last Updated freshness",
|
||||
"passed": True,
|
||||
"message": f"README date {readme_date.strftime('%Y-%m-%d')} is within 7 days of latest code commit",
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "Last Updated freshness",
|
||||
"passed": False,
|
||||
"message": (
|
||||
f"README is {days_behind} days behind last code change"
|
||||
f" ({latest_commit.strftime('%Y-%m-%d')}). Review and update"
|
||||
" README CONTENT to reflect recent changes, then set Last"
|
||||
" Updated. Do not just bump the date."
|
||||
),
|
||||
}
|
||||
return {"name": "Last Updated freshness", "passed": False, "message": 'No "Last Updated" date found in README'}
|
||||
|
||||
|
||||
def check_directory_tree(lines: List[str], branch_root: Path, file_path: str, bypass_rules: list | None = None) -> Dict:
|
||||
@@ -395,7 +331,7 @@ def check_directory_tree(lines: List[str], branch_root: Path, file_path: str, by
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
if _is_gitignored(branch_root / dir_name):
|
||||
if _is_runtime_artifact(branch_root / dir_name):
|
||||
continue
|
||||
missing_dirs.append(dir_name)
|
||||
|
||||
@@ -598,7 +534,7 @@ def check_markdown_links(lines: List[str], branch_root: Path, file_path: str, by
|
||||
for link_text, link_path in links:
|
||||
resolved = (branch_root / link_path).resolve()
|
||||
if not resolved.exists():
|
||||
if _is_gitignored(branch_root / link_path):
|
||||
if _is_runtime_artifact(branch_root / link_path):
|
||||
continue
|
||||
dead_links.append(f"{link_path} ({link_text})")
|
||||
|
||||
|
||||
@@ -183,16 +183,11 @@ def test_required_sections_alternate_names():
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_last_updated_freshness_within_7_days(tmp_path):
|
||||
"""README date within 7 days of last git commit passes."""
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
commit_date = datetime.now() - timedelta(days=3)
|
||||
readme_date = datetime.now() - timedelta(days=5)
|
||||
def test_last_updated_freshness_date_present(tmp_path):
|
||||
"""Well-formed Last Updated date passes."""
|
||||
lines: List[str] = [
|
||||
"# Branch",
|
||||
f"*Last Updated: {readme_date.strftime('%Y-%m-%d')}*",
|
||||
"*Last Updated: 2026-06-01*",
|
||||
"",
|
||||
]
|
||||
branch_root = tmp_path / "mybranch"
|
||||
@@ -203,24 +198,16 @@ def test_last_updated_freshness_within_7_days(tmp_path):
|
||||
check_last_updated_freshness,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"aipass.seedgo.apps.handlers.aipass_standards.readme_check._get_latest_py_commit_date",
|
||||
return_value=commit_date,
|
||||
):
|
||||
result = check_last_updated_freshness(lines, branch_root, "/fake/apps/entry.py")
|
||||
result = check_last_updated_freshness(lines, branch_root, "/fake/apps/entry.py")
|
||||
assert result["passed"] is True
|
||||
assert "present" in result["message"]
|
||||
|
||||
|
||||
def test_last_updated_freshness_stale(tmp_path):
|
||||
"""README date >7 days behind last git commit fails."""
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
commit_date = datetime.now() - timedelta(days=2)
|
||||
readme_date = datetime.now() - timedelta(days=20)
|
||||
def test_last_updated_freshness_bold_format(tmp_path):
|
||||
"""Bold markdown format for Last Updated date passes."""
|
||||
lines: List[str] = [
|
||||
"# Branch",
|
||||
f"**Last Updated:** {readme_date.strftime('%Y-%m-%d')}",
|
||||
"**Last Updated:** 2026-05-18",
|
||||
"",
|
||||
]
|
||||
branch_root = tmp_path / "mybranch"
|
||||
@@ -231,40 +218,29 @@ def test_last_updated_freshness_stale(tmp_path):
|
||||
check_last_updated_freshness,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"aipass.seedgo.apps.handlers.aipass_standards.readme_check._get_latest_py_commit_date",
|
||||
return_value=commit_date,
|
||||
):
|
||||
result = check_last_updated_freshness(lines, branch_root, "/fake/apps/entry.py")
|
||||
result = check_last_updated_freshness(lines, branch_root, "/fake/apps/entry.py")
|
||||
assert result["passed"] is True
|
||||
assert "2026-05-18" in result["message"]
|
||||
|
||||
|
||||
def test_last_updated_freshness_malformed_date(tmp_path):
|
||||
"""Malformed date (regex matches but strptime fails) fails."""
|
||||
lines: List[str] = [
|
||||
"# Branch",
|
||||
"*Last Updated: 2026-13-45*",
|
||||
"",
|
||||
]
|
||||
branch_root = tmp_path / "mybranch"
|
||||
branch_root.mkdir()
|
||||
(branch_root / "apps").mkdir()
|
||||
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_check import (
|
||||
check_last_updated_freshness,
|
||||
)
|
||||
|
||||
result = check_last_updated_freshness(lines, branch_root, "/fake/apps/entry.py")
|
||||
assert result["passed"] is False
|
||||
assert "days behind" in result["message"]
|
||||
assert "Do not just bump" in result["message"]
|
||||
|
||||
|
||||
def test_last_updated_freshness_no_git_history(tmp_path):
|
||||
"""No git history for .py files passes gracefully."""
|
||||
from unittest.mock import patch
|
||||
|
||||
lines: List[str] = [
|
||||
"# Branch",
|
||||
"*Last Updated: 2026-01-01*",
|
||||
"",
|
||||
]
|
||||
branch_root = tmp_path / "mybranch"
|
||||
branch_root.mkdir()
|
||||
(branch_root / "apps").mkdir()
|
||||
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_check import (
|
||||
check_last_updated_freshness,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"aipass.seedgo.apps.handlers.aipass_standards.readme_check._get_latest_py_commit_date",
|
||||
return_value=None,
|
||||
):
|
||||
result = check_last_updated_freshness(lines, branch_root, "/fake/apps/entry.py")
|
||||
assert result["passed"] is True
|
||||
assert "skip" in result["message"]
|
||||
assert "Malformed" in result["message"]
|
||||
|
||||
|
||||
def test_last_updated_freshness_missing():
|
||||
@@ -286,62 +262,21 @@ def test_last_updated_freshness_missing():
|
||||
assert "Last Updated" in result["message"]
|
||||
|
||||
|
||||
def test_last_updated_freshness_boundary_7_days(tmp_path):
|
||||
"""README exactly 7 days behind last commit passes (<=7)."""
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
commit_date = datetime.now()
|
||||
readme_date = commit_date - timedelta(days=7)
|
||||
lines: List[str] = [
|
||||
"# Branch",
|
||||
f"*Last Updated: {readme_date.strftime('%Y-%m-%d')}*",
|
||||
"",
|
||||
]
|
||||
def test_last_updated_freshness_bypassed(tmp_path):
|
||||
"""Bypassed freshness check passes immediately."""
|
||||
lines: List[str] = ["# Branch", "No date.", ""]
|
||||
branch_root = tmp_path / "mybranch"
|
||||
branch_root.mkdir()
|
||||
(branch_root / "apps").mkdir()
|
||||
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_check import (
|
||||
check_last_updated_freshness,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"aipass.seedgo.apps.handlers.aipass_standards.readme_check._get_latest_py_commit_date",
|
||||
return_value=commit_date,
|
||||
):
|
||||
result = check_last_updated_freshness(lines, branch_root, "/fake/apps/entry.py")
|
||||
bypass = [{"file": "/fake/apps/entry.py", "standard": "readme"}]
|
||||
result = check_last_updated_freshness(lines, branch_root, "/fake/apps/entry.py", bypass)
|
||||
assert result["passed"] is True
|
||||
|
||||
|
||||
def test_last_updated_freshness_boundary_8_days(tmp_path):
|
||||
"""README 8 days behind last commit fails (>7)."""
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
commit_date = datetime.now()
|
||||
readme_date = commit_date - timedelta(days=8)
|
||||
lines: List[str] = [
|
||||
"# Branch",
|
||||
f"*Last Updated: {readme_date.strftime('%Y-%m-%d')}*",
|
||||
"",
|
||||
]
|
||||
branch_root = tmp_path / "mybranch"
|
||||
branch_root.mkdir()
|
||||
(branch_root / "apps").mkdir()
|
||||
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_check import (
|
||||
check_last_updated_freshness,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"aipass.seedgo.apps.handlers.aipass_standards.readme_check._get_latest_py_commit_date",
|
||||
return_value=commit_date,
|
||||
):
|
||||
result = check_last_updated_freshness(lines, branch_root, "/fake/apps/entry.py")
|
||||
assert result["passed"] is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. readme_check -- check_directory_tree
|
||||
# ===========================================================================
|
||||
|
||||
@@ -473,29 +473,54 @@ def test_check_module_missing_readme_has_8_failures(tmp_path):
|
||||
assert result["score"] == 0
|
||||
|
||||
|
||||
def test_is_gitignored_directory_only_pattern_nonexistent(tmp_path, monkeypatch):
|
||||
"""Dir-only .gitignore patterns match non-existent paths via the slash form.
|
||||
|
||||
Regression (DPLAN-0195): in a clean checkout (CI) the gitignored dir does
|
||||
not exist on disk, so ``git check-ignore <bare-path>`` reports it un-ignored
|
||||
because git cannot confirm "directory" to match a dir-only pattern (trailing
|
||||
slash). ``_is_gitignored`` must also test the trailing-slash form. Without
|
||||
this the README dir-tree/dead-link checks passed in a working tree but failed
|
||||
in CI's clean checkout.
|
||||
"""
|
||||
import subprocess
|
||||
def test_is_runtime_artifact_known_dirs():
|
||||
"""_is_runtime_artifact recognizes known runtime dirs and suffixes."""
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_check import (
|
||||
_is_gitignored,
|
||||
_is_runtime_artifact,
|
||||
)
|
||||
|
||||
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
|
||||
(tmp_path / ".gitignore").write_text("logs/\n**/*_json/\n.trinity/\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert _is_runtime_artifact(Path("/any/path/logs")) is True
|
||||
assert _is_runtime_artifact(Path("/any/path/artifacts")) is True
|
||||
assert _is_runtime_artifact(Path("/any/path/.trinity")) is True
|
||||
assert _is_runtime_artifact(Path("/any/path/cli_json")) is True
|
||||
assert _is_runtime_artifact(Path("/any/path/seedgo_json")) is True
|
||||
assert _is_runtime_artifact(Path("/any/path/STATUS.local.md")) is True
|
||||
assert _is_runtime_artifact(Path("/any/path/DASHBOARD.local.json")) is True
|
||||
assert _is_runtime_artifact(Path("/any/path/docs.local")) is True
|
||||
assert _is_runtime_artifact(Path("/any/path/dropbox")) is True
|
||||
assert _is_runtime_artifact(Path("/any/path/system_logs")) is True
|
||||
assert _is_runtime_artifact(Path("/any/path/src")) is False
|
||||
assert _is_runtime_artifact(Path("/any/path/apps")) is False
|
||||
assert _is_runtime_artifact(Path("/any/path/tests")) is False
|
||||
|
||||
# Non-existent dirs — only match when the trailing-slash form is tested.
|
||||
assert _is_gitignored(tmp_path / "logs") is True
|
||||
assert _is_gitignored(tmp_path / "cli_json") is True
|
||||
assert _is_gitignored(tmp_path / ".trinity") is True
|
||||
# A path not covered by any pattern stays un-ignored.
|
||||
assert _is_gitignored(tmp_path / "src") is False
|
||||
|
||||
def test_directory_tree_passes_absent_runtime_dir(tmp_path):
|
||||
"""Parity regression: README tree lists runtime dir (logs), dir absent on
|
||||
disk, no git available — tree check passes via _is_runtime_artifact."""
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_check import (
|
||||
check_directory_tree,
|
||||
)
|
||||
|
||||
branch_root = tmp_path / "mybranch"
|
||||
branch_root.mkdir()
|
||||
apps_dir = branch_root / "apps"
|
||||
apps_dir.mkdir()
|
||||
(apps_dir / "modules").mkdir()
|
||||
|
||||
lines = [
|
||||
"## Architecture",
|
||||
"```",
|
||||
"mybranch/",
|
||||
"├── apps/",
|
||||
"│ └── modules/",
|
||||
"├── logs/",
|
||||
"├── cli_json/",
|
||||
"└── artifacts/",
|
||||
"```",
|
||||
]
|
||||
|
||||
result = check_directory_tree(lines, branch_root, str(apps_dir / "entry.py"))
|
||||
assert result["passed"] is True
|
||||
assert "verified" in result["message"]
|
||||
|
||||
Reference in New Issue
Block a user