mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:47:15 +01:00
- sickdays: normalise_keep raises TypeError (not ValueError) for a bare string -- ruff 0.17.2 TRY004; it is a caller type error, the CLI always passes a list. - sickdays: remove_sick_day and set_kept_lockers share require_sick(), which clears pylint R0801 (duplicate-code, 9.99/10 under --fail-under=10). - repo_contract/web.py: tomllib sorts as stdlib under ruff 0.17.x (I001). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y
207 lines
7.3 KiB
Python
207 lines
7.3 KiB
Python
"""Which repos are websites, and their `[agent_ready]` table in repo-contract.toml.
|
|
|
|
Every website kuhy owns must pass the AgentReady checker
|
|
(`~/src/utils/agent_ready`). A repo opts in, or is detected and proposed, with:
|
|
|
|
[agent_ready]
|
|
dist = "dist" # build output dir, or
|
|
url = "https://site.example" # live site (server-rendered), or
|
|
skip = "self-hosted app behind auth" # not a website: a reason, always
|
|
build = "pnpm run build" # dist only, optional
|
|
install = "pnpm install --frozen-lockfile" # dist only, CI runs it first
|
|
act = true # optional: also run act items
|
|
files = "^(src/|public/)" # optional: pre-commit files regex
|
|
|
|
The pre-commit hook only builds when a web-relevant file is staged (DEFAULT_FILES
|
|
unless `files` overrides it); CI rebuilds and checks on every push regardless.
|
|
|
|
TOML because it parses with the stdlib: the CI runner of the repo-contract
|
|
workflow installs nothing, and agent_ready itself is stdlib-only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import tomllib
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
from repo_contract.infer import _pm
|
|
from repo_contract.sources import package_scripts, read_text
|
|
|
|
CONFIG = Path("repo-contract.toml")
|
|
TABLE = "agent_ready"
|
|
SHIM = Path("scripts/check_agent_ready.sh")
|
|
WORKFLOW = Path(".github/workflows/agent-ready.yml")
|
|
HOOK_ID = "agent-ready"
|
|
MODES = ("dist", "url", "skip")
|
|
OPTIONAL = ("build", "install", "act", "files")
|
|
DEFAULT_FILES = (
|
|
r"^(src/|public/|index\.html$|(vite|astro)\.config\.[cm]?[jt]s$"
|
|
r"|package\.json$|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$"
|
|
r"|repo-contract\.toml$)"
|
|
)
|
|
_EXTS = ("js", "ts", "mjs", "mts", "cjs", "cts")
|
|
VITE = tuple(f"vite.config.{e}" for e in _EXTS)
|
|
ASTRO = tuple(f"astro.config.{e}" for e in _EXTS)
|
|
_OUT_DIR = re.compile(r"""outDir\s*:\s*['"]([^'"]+)['"]""")
|
|
INSTALL = {
|
|
"pnpm": "pnpm install --frozen-lockfile",
|
|
"yarn": "yarn install --frozen-lockfile",
|
|
}
|
|
|
|
|
|
class ConfigError(ValueError):
|
|
"""The `[agent_ready]` table exists but is malformed."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AgentReady:
|
|
"""One repo's AgentReady gate settings."""
|
|
|
|
mode: str
|
|
target: str
|
|
build: str | None = None
|
|
install: str | None = None
|
|
act: bool = False
|
|
files: str | None = None
|
|
|
|
|
|
def _clean_dist(value: str) -> str:
|
|
"""`./out/` -> `out`; refuse paths that leave the repo."""
|
|
path = PurePosixPath(value)
|
|
if path.is_absolute() or ".." in path.parts:
|
|
raise ConfigError(f"dist must be a relative path inside the repo: {value!r}")
|
|
return path.as_posix()
|
|
|
|
|
|
def _string(table: dict[str, object], key: str) -> str | None:
|
|
value = table.get(key)
|
|
if value is not None and not isinstance(value, str):
|
|
raise ConfigError(f"'{key}' must be a string")
|
|
return value
|
|
|
|
|
|
def _parse(table: dict[str, object]) -> AgentReady:
|
|
unknown = sorted(set(table) - set(MODES) - set(OPTIONAL))
|
|
if unknown:
|
|
raise ConfigError(f"unknown key(s) in [{TABLE}]: {', '.join(unknown)}")
|
|
modes = [m for m in MODES if m in table]
|
|
if len(modes) != 1:
|
|
raise ConfigError(f"[{TABLE}] needs exactly one of: {', '.join(MODES)}")
|
|
mode, target = modes[0], table[modes[0]]
|
|
if not isinstance(target, str) or not target.strip():
|
|
raise ConfigError(f"'{mode}' must be a non-empty string")
|
|
build, install = _string(table, "build"), _string(table, "install")
|
|
if mode != "dist" and (build or install):
|
|
raise ConfigError("'build'/'install' only apply to dist mode")
|
|
if mode == "url" and not target.startswith(("http://", "https://")):
|
|
raise ConfigError(f"url must be http(s)://: {target!r}")
|
|
act = table.get("act", False)
|
|
if not isinstance(act, bool):
|
|
raise ConfigError("'act' must be true or false")
|
|
if mode == "dist":
|
|
target = _clean_dist(target)
|
|
files = _string(table, "files")
|
|
if files is not None:
|
|
try:
|
|
re.compile(files)
|
|
except re.error as exc:
|
|
raise ConfigError(f"'files' is not a valid regex: {exc}") from exc
|
|
return AgentReady(mode, target, build, install, act, files)
|
|
|
|
|
|
def load(repo: Path) -> AgentReady | None:
|
|
"""The repo's `[agent_ready]` settings; None when not declared."""
|
|
path = repo / CONFIG
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
data = tomllib.loads(read_text(path))
|
|
except tomllib.TOMLDecodeError as exc:
|
|
raise ConfigError(f"not valid TOML: {exc}") from exc
|
|
if TABLE not in data:
|
|
return None
|
|
if not isinstance(data[TABLE], dict):
|
|
raise ConfigError(f"'{TABLE}' must be a table: [{TABLE}]")
|
|
return _parse(data[TABLE])
|
|
|
|
|
|
def _install_command(repo: Path) -> str:
|
|
pm = _pm(repo)
|
|
if pm in INSTALL:
|
|
return INSTALL[pm]
|
|
return "npm ci" if (repo / "package-lock.json").is_file() else "npm install"
|
|
|
|
|
|
def detect(repo: Path) -> AgentReady | None:
|
|
"""Proposed settings for a node web build; None when not clearly a site.
|
|
|
|
Conservative on purpose: package.json must carry a `build` script AND the
|
|
repo must have a Vite/Astro config or a root index.html. Python and other
|
|
server-rendered sites are opt-in (`url = ...`), never guessed.
|
|
"""
|
|
if "build" not in package_scripts(repo):
|
|
return None
|
|
configs = [repo / n for n in (*VITE, *ASTRO) if (repo / n).is_file()]
|
|
if not configs and not (repo / "index.html").is_file():
|
|
return None
|
|
out = next(
|
|
(m.group(1) for c in configs if (m := _OUT_DIR.search(read_text(c)))), "dist"
|
|
)
|
|
return AgentReady(
|
|
"dist", _clean_dist(out), f"{_pm(repo)} run build", _install_command(repo)
|
|
)
|
|
|
|
|
|
def render(cfg: AgentReady) -> str:
|
|
"""The `[agent_ready]` TOML table for `cfg` (json strings are TOML strings)."""
|
|
lines = [f"[{TABLE}]", f"{cfg.mode} = {json.dumps(cfg.target)}"]
|
|
lines += [
|
|
f"{key} = {json.dumps(value)}"
|
|
for key, value in (("build", cfg.build), ("install", cfg.install))
|
|
if value
|
|
]
|
|
if cfg.act:
|
|
lines.append("act = true")
|
|
if cfg.files is not None:
|
|
lines.append(f"files = {json.dumps(cfg.files)}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def hook_files(cfg: AgentReady) -> str:
|
|
"""The pre-commit `files:` regex for the agent-ready hook."""
|
|
return DEFAULT_FILES if cfg.files is None else cfg.files
|
|
|
|
|
|
def has_hook(repo: Path) -> bool:
|
|
"""Whether the pre-commit config already runs the agent-ready hook."""
|
|
return f"id: {HOOK_ID}" in read_text(repo / ".pre-commit-config.yaml")
|
|
|
|
|
|
def problems(repo: Path) -> list[str]:
|
|
"""Repo-contract violations about the AgentReady gate."""
|
|
try:
|
|
cfg = load(repo)
|
|
except ConfigError as exc:
|
|
return [f"{CONFIG}: {exc}"]
|
|
fix = "run ~/src/utils/scripts/install_repo_contract_gate.sh"
|
|
if cfg is None:
|
|
if detect(repo) is None:
|
|
return []
|
|
return [
|
|
(
|
|
f"web build detected but {CONFIG} has no [{TABLE}] table: {fix}, "
|
|
'or declare skip = "<why this is not a website>"'
|
|
)
|
|
]
|
|
if cfg.mode == "skip":
|
|
return []
|
|
out = []
|
|
if not has_hook(repo):
|
|
out.append(f"[{TABLE}] is set but no {HOOK_ID} hook in pre-commit: {fix}")
|
|
if not (repo / SHIM).is_file():
|
|
out.append(f"[{TABLE}] is set but {SHIM} is missing: {fix}")
|
|
return out
|