mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:07:15 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Flutter repos now fail the repo contract when a launcher icon's ink is not on the shared 340px circle (png_ink), the launcher label breaks the naming rule (capital, ASCII letters, <= 10 chars, <= 522 wide in GoogleSansFlex), or an applicationIdSuffix variant still carries the LAUNCHER category. hook_sync rewrites a repo's stale repo-contract `files:` line to the widened template. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017k9j5dbjenpfZj7dh2faPN
156 lines
5.7 KiB
Python
156 lines
5.7 KiB
Python
"""Gate: the repo documents exact run/test/lint/coverage commands.
|
|
|
|
Usage: check.py [--repo PATH]. Exit 0 pass, 1 violations (printed), 2 not a
|
|
repo or unreadable CLAUDE.md. Contract text lives in README.md / DOCS-*.md.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
if __package__ in (None, ""): # pragma: no cover - plain-script import path
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from repo_contract.contract import (
|
|
KEYS,
|
|
LISTER,
|
|
Entry,
|
|
entry_problems,
|
|
has_section,
|
|
parse_entries,
|
|
script_token,
|
|
verbose_in_script,
|
|
)
|
|
from repo_contract.launcher import problems as launcher_problems
|
|
from repo_contract.stacks import detect_stacks, has_tests
|
|
from repo_contract.web import problems as web_problems
|
|
|
|
EXIT_OK, EXIT_VIOLATIONS, EXIT_UNREADABLE = 0, 1, 2
|
|
DOC_NAMES = ("CLAUDE.md", "AGENTS.md")
|
|
|
|
|
|
class Unreadable(Exception):
|
|
"""Raised for a path that is not a repo or a doc that cannot be read."""
|
|
|
|
|
|
def read_doc(repo: Path) -> str | None:
|
|
"""CLAUDE.md text (symlinks followed); None if absent; Unreadable on I/O error."""
|
|
doc = repo / "CLAUDE.md"
|
|
if not doc.exists():
|
|
return None
|
|
try:
|
|
return doc.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
raise Unreadable(f"{doc}: {exc}") from exc
|
|
|
|
|
|
def _missing_doc(repo: Path) -> list[str]:
|
|
"""Violation for a missing CLAUDE.md, hinting at the AGENTS.md symlink."""
|
|
if (repo / "CLAUDE.md").is_symlink():
|
|
return ["CLAUDE.md is a dangling symlink"]
|
|
if (repo / "AGENTS.md").is_file():
|
|
return [
|
|
"CLAUDE.md missing: symlink it to AGENTS.md (ln -s AGENTS.md CLAUDE.md)"
|
|
]
|
|
return ["CLAUDE.md missing (or AGENTS.md that CLAUDE.md symlinks to)"]
|
|
|
|
|
|
def _check_changed(repo: Path, command: str) -> list[str]:
|
|
"""test-changed must run an existing, quiet entry-point script."""
|
|
script = script_token(command, repo)
|
|
if script is None:
|
|
msg = "'- test-changed:' must invoke an existing changed-files script"
|
|
return [f"{msg} (e.g. scripts/test_changed.sh)"]
|
|
out: list[str] = []
|
|
if not os.access(script, os.X_OK):
|
|
out.append(f"{script.relative_to(repo)} is not executable")
|
|
try:
|
|
if verbose_in_script(script.read_text(encoding="utf-8")):
|
|
out.append(f"{script.relative_to(repo)} runs a test tool verbosely")
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
raise Unreadable(f"{script}: {exc}") from exc
|
|
return out
|
|
|
|
|
|
def _check_consistency(
|
|
repo: Path, entries: dict[str, Entry], stacks: list[str]
|
|
) -> list[str]:
|
|
"""Cross-entry rules: n/a vs real tests, coverage-gaps lister."""
|
|
out: list[str] = []
|
|
test = entries["test"]
|
|
if test.command is None:
|
|
if has_tests(repo, stacks):
|
|
out.append("'- test:' is n/a but the repo contains tests")
|
|
for key in ("test-changed", "coverage", "coverage-gaps"):
|
|
if entries[key].command is not None:
|
|
out.append(f"'- {key}:' must be n/a when test is n/a")
|
|
return out
|
|
if entries["test-changed"].command is None:
|
|
out.append("'- test-changed:' cannot be n/a while test is a real command")
|
|
cov, gaps = entries["coverage"], entries["coverage-gaps"]
|
|
if cov.command is not None and gaps.command is None:
|
|
out.append(
|
|
"'- coverage-gaps:' must run the shared lister: `coverage-gaps <report>`"
|
|
)
|
|
if gaps.command is not None and gaps.command.split()[0] != LISTER:
|
|
out.append(f"'- coverage-gaps:' must start with `{LISTER} <report>`")
|
|
return out
|
|
|
|
|
|
def violations(repo: Path) -> tuple[list[str], list[str]]:
|
|
"""(violations, detected stacks) for `repo`. Raises Unreadable.
|
|
|
|
Website repos also owe the AgentReady gate (web.problems), and Flutter
|
|
apps the launcher rules (launcher.problems), reported whatever state the
|
|
Commands section is in.
|
|
"""
|
|
found, stacks = _doc_violations(repo)
|
|
return found + web_problems(repo) + launcher_problems(repo), stacks
|
|
|
|
|
|
def _doc_violations(repo: Path) -> tuple[list[str], list[str]]:
|
|
"""Violations of the `## Commands` contract proper."""
|
|
stacks = detect_stacks(repo)
|
|
text = read_doc(repo)
|
|
if text is None:
|
|
return _missing_doc(repo), stacks
|
|
if not has_section(text):
|
|
return ["CLAUDE.md has no '## Commands' section"], stacks
|
|
entries, out = parse_entries(text)
|
|
absent = [k for k in KEYS if k not in entries]
|
|
if absent:
|
|
out.append("'## Commands' is missing: " + ", ".join(f"- {k}:" for k in absent))
|
|
return out, stacks
|
|
for entry in entries.values():
|
|
out.extend(entry_problems(entry))
|
|
changed = entries["test-changed"]
|
|
if changed.command is not None:
|
|
out.extend(_check_changed(repo, changed.command))
|
|
out.extend(_check_consistency(repo, entries, stacks))
|
|
return out, stacks
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""CLI entry point; returns the process exit code."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--repo", type=Path, default=Path.cwd())
|
|
repo = parser.parse_args(argv).repo.expanduser().resolve()
|
|
if not repo.is_dir() or not (repo / ".git").exists():
|
|
print(f"repo-contract: {repo} is not a git repo", file=sys.stderr)
|
|
return EXIT_UNREADABLE
|
|
try:
|
|
found, _ = violations(repo)
|
|
except Unreadable as exc:
|
|
print(f"repo-contract: unreadable: {exc}", file=sys.stderr)
|
|
return EXIT_UNREADABLE
|
|
for problem in found:
|
|
print(f"repo-contract: {repo.name}: {problem}", file=sys.stderr)
|
|
return EXIT_VIOLATIONS if found else EXIT_OK
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
sys.exit(main())
|