diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py index b967d7e2..2e75dca7 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py @@ -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})") diff --git a/src/aipass/seedgo/tests/test_checkers_batch9.py b/src/aipass/seedgo/tests/test_checkers_batch9.py index 03b96894..f1b2cb26 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch9.py +++ b/src/aipass/seedgo/tests/test_checkers_batch9.py @@ -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 # =========================================================================== diff --git a/src/aipass/seedgo/tests/test_readme_content_checks.py b/src/aipass/seedgo/tests/test_readme_content_checks.py index 99bca6dd..c794be7a 100644 --- a/src/aipass/seedgo/tests/test_readme_content_checks.py +++ b/src/aipass/seedgo/tests/test_readme_content_checks.py @@ -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 `` 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"]