agent_ready: AgentReady v2.0 checker + repo_contract web-site gate

New agent_ready/ package (dist and live-url modes, 100% branch coverage)
checks robots, sitemap, llms.txt, ai-catalog, 404, no-JS text, markdown
mirrors and JSON-LD. repo_contract gains web-repo detection and installs the
agent-ready pre-commit hook, CI workflow and check_agent_ready.sh shim;
scripts/check_agent_ready.sh is the shared entry point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y
This commit is contained in:
Krzysztof Rudnicki 2026-10-22 21:18:49 +02:00
parent 08a4010a16
commit a0fb279b1b
40 changed files with 3952 additions and 49 deletions

View File

@ -0,0 +1,97 @@
# AgentReady spec notes
These notes record what the checker implements and where each rule comes from.
Fetched 2026-10-22.
## Sources
- AgentReady v2.0 (October 2026, MIT):
<https://github.com/agentready-org/standard/blob/main/spec/spec.md>,
canonical site <https://agentready.org>.
- AI Catalog (the format behind `AR-FIND-04`):
<https://github.com/Agent-Card/ai-catalog/blob/main/specification/ai-catalog.md>.
AgentReady defines no catalog schema of its own ("AgentReady does not
introduce its own bespoke manifest"), so the checker uses this one.
- llms.txt: the spec's informative "llms.txt shape" example, and <https://llmstxt.org/>.
## Requirements (quoted from spec.md)
| ID | Level | Requirement | Checker |
|---|---|---|---|
| AR-FIND-01 | MUST | "The site **MUST NOT** block agent and crawler access to public content, in `robots.txt` or at the network layer." | robots.txt is 200 `text/plain` and not HTML. It has a `User-agent: *` group, and no group has a blanket `Disallow: /`. In url mode the homepage returns 200 with no challenge page for ClaudeBot, GPTBot, Claude-SearchBot, OAI-SearchBot, Claude-User and ChatGPT-User. |
| AR-FIND-02 | SHOULD | "Answers agents are expected to cite **SHOULD** be published on one canonical, current, first-party docs page." | SKIP (manual) |
| AR-FIND-03 | SHOULD | "The site **SHOULD** list its pages in a `sitemap.xml` referenced from `robots.txt`." | robots.txt has a `Sitemap:` line. The sitemap is XML (by content type in url mode), and every `<loc>` is on the site's own host. |
| AR-FIND-04 | MAY | "...publish an AI Catalog at `/.well-known/ai-catalog.json` listing every agent-facing resource it ships ... linked from the homepage head with `<link rel="ai-catalog">` and an HTTP `Link` header." | The catalog is valid (see the schema below) and lists `/llms.txt`. The homepage has `<link rel="ai-catalog">`. In url mode the homepage also sends a `Link: <...>; rel="ai-catalog"` header. |
| AR-READ-01 | MUST | "The answer **MUST** be present in the initial HTML — server-rendered or prerendered, not injected by a single-page app after load." | The homepage and each sitemap page return 200 `text/html` with at least `--min-text` characters of visible body text. A page that is byte-identical to the homepage fails. An empty sitemap is a FAIL. |
| AR-READ-02 | MUST | "...accurate HTTP status codes: `404` (or a valid redirect) for missing paths ... never a `200` for a missing page" | url mode: two random `/agentready-probe-<hex>` paths must end, after redirects, on a 404 or 410. dist mode: a `404.html` with text must exist. |
| AR-READ-03 | SHOULD | "The homepage **SHOULD** be served as raw HTML with real links to docs, product, and pricing." | The homepage HTML has at least one `<a href>` to another page on the site. |
| AR-READ-04 | SHOULD | "Docs pages **SHOULD** answer the tasks users bring to agents." | SKIP (manual) |
| AR-READ-05 | SHOULD | "Code snippets **SHOULD** be fenced and language-tagged." | Every `<pre><code>` carries `language-*`, `lang-*` or `data-language`. Every fence in a markdown mirror has an info string. |
| AR-READ-06 | SHOULD | "Discovery files (`llms.txt`, `.well-known/*`, `openapi.json`, `AGENTS.md`) **SHOULD** be linked from content agents already visit" | `/llms.txt` and the catalog (plus `/openapi.json` with `--act`) exist and are linked from the homepage, by `<link>` or `<a>`. |
| AR-READ-07 | SHOULD | "The site **SHOULD** ship an `llms.txt`: a lean, described index of its key pages." | 200 `text/plain` or `text/markdown`, not HTML. It needs an H1 first, a `>` summary, and an `## H2` with a `- [name](url)` list; other prose is allowed. Every on-site link resolves. |
| AR-READ-08 | SHOULD | "Key pages **SHOULD** carry JSON-LD — the homepage above all" | The homepage has a parseable `application/ld+json` block with `@context`. |
| AR-READ-09 | MAY | "Pages **MAY** advertise a markdown mirror via `<link rel="alternate" type="text/markdown">` or an equivalent HTTP `Link:` header." | Every checked page advertises a mirror. The mirror is 200 `text/markdown` or `text/plain`, and is not HTML. |
| AR-READ-10 | n/a | "`AR-READ-10` is retired." | not emitted |
| AR-READ-11 | SHOULD | "Pricing **SHOULD** be published on a first-party page" | SKIP (n/a: no paid plans) |
| AR-ACT-01 | SHOULD | auth completable by an agent; "OAuth 2.0 with discoverable metadata under `/.well-known`" | `--act`: either `oauth-protected-resource` has `resource`, or `oauth-authorization-server` has `issuer`. |
| AR-ACT-02 | SHOULD | "A public API **SHOULD** be described by a machine-readable OpenAPI contract." | `--act`: `/openapi.json` has `openapi` and `paths`, and every operation has an `operationId`. |
| AR-ACT-03 | SHOULD | "...expose an MCP server over Streamable HTTP at `/mcp`." | `--act`, url mode: a POST `initialize` gets a JSON-RPC result, or a 401 with `WWW-Authenticate`. |
| AR-ACT-04 | MAY | "An MCP server **MAY** ship a server card" | `--act`: the catalog has an `application/mcp-server-card+json` entry. |
| AR-ACT-05/06/08/09 | MAY/SHOULD | MCP Apps, SDKs+CLI, payments, NLWeb `/ask` | `--act`: SKIP (manual) |
| AR-ACT-07 | SHOULD | "...register WebMCP tools on `document.modelContext`." | `--act`: `modelContext` appears in the homepage HTML or in its same-site scripts. |
| AR-ACT-10 | MUST | "...**MUST** expose at least one interface an agent can act through: an MCP server (`AR-ACT-03`) or a CLI (`AR-ACT-06`)." | `--act`: same verdict as the `/mcp` probe. |
## kuhy's choices that are stricter than the spec
- Every in-scope item is required, whatever its strength. That includes the
MAY items (FIND-04, READ-09).
- The spec says a site "**MAY** block a training crawler". kuhy chose to allow
training crawlers too, so a blanket `Disallow` for GPTBot, ClaudeBot or any
other agent is a FAIL.
- The act stage is off by default. Turn it on with `--act` only for sites with
user actions. The spec allows this: "a purely informational site conforms on
`AR-FIND-*` and `AR-READ-*` alone".
## AI Catalog schema (ai-catalog.md, specVersion 1.0)
- The media type is `application/ai-catalog+json`. When fetched by link
discovery, plain `application/json` is also acceptable.
- At the top level, the object MUST have:
- `specVersion`: a string in "Major.Minor" form, for example `"1.0"`.
- `entries`: an array, which may be empty.
- The top level MAY have `host`, `extensions` and `signature`.
- If `host` is present, it MUST have `displayName`. It MAY have
`identifier`, `documentationUrl` and `logoUrl`.
- Each entry MUST have:
- `identifier`: a URI or URN. The recommended form is
`urn:air:{publisher}:{namespace}:{name}`.
- `type`: a media type, for example `application/mcp-server-card+json`.
- **exactly one** of `url` or `data`.
- `displayName` is optional on entries (ai-catalog ADR-0016).
- Link discovery uses `Link: <...>; rel="ai-catalog"` or
`<link rel="ai-catalog" href="..." type="application/ai-catalog+json">`.
Minimal valid example:
```json
{"specVersion": "1.0",
"host": {"displayName": "kuhy"},
"entries": [{"identifier": "urn:air:kuhy.duckdns.org:docs:llms",
"type": "text/plain", "url": "https://kuhy.duckdns.org/llms.txt"}]}
```
Agentic Resource Discovery v0.91 (<https://agenticresourcediscovery.org/spec>)
renamed the path to `/.well-known/ard.json` and the relation to `rel="ard"`.
It keeps `ai-catalog.json` only as an optional predecessor. AgentReady v2.0
still names `ai-catalog.json`, so that is the path the checker tests. It does
not test `ard.json`.
## llms.txt shape (spec.md, informative)
```md
# Acme
> Acme ships a payments API for developers.
## Docs
- [Quickstart](https://example.com/docs/quickstart): first charge in one request
```

93
agent_ready/README.md Normal file
View File

@ -0,0 +1,93 @@
# agent_ready
This tool checks kuhy's websites against [AgentReady](https://agentready.org)
v2.0. It runs every find and read item, and the act items when you pass `--act`.
It is Python 3 standard library only, with no dependencies.
`DOCS-spec-notes.md` maps each `AR-*` ID to the check that tests it.
## Usage
```sh
~/src/utils/agent_ready/check.sh dist <build-dir> [--act] [--min-text N] [--max-pages N]
~/src/utils/agent_ready/check.sh url <base-url> [--act] [--min-text N] [--max-pages N] [--timeout S] [--budget S]
```
The output is one line per check:
```text
PASS AR-FIND-03 sitemap lists 12 pages
FAIL AR-READ-02 soft 404, unknown paths must return 404: /agentready-probe-1a2b -> HTTP 200
SKIP AR-READ-11 pricing page: n/a for sites without paid plans
```
| exit | meaning |
|---|---|
| 0 | every check passed (SKIPs do not fail) |
| 1 | at least one FAIL, at any strength: MUST, SHOULD or MAY |
| 2 | usage error (bad arguments, missing directory, not an http(s) URL) |
| 3 | network undeterminable: the homepage was unreachable, or a fetch timed out or hit the 45 s budget, and nothing failed |
## Pre-commit and CI (dist mode)
Point the checker at the build output after the build step. Dist mode checks
that each file exists and is well-formed:
- robots.txt
- sitemap.xml
- llms.txt
- the AI Catalog
- 404.html
- the text in every sitemap page's HTML
- each page's markdown mirror
```yaml
# .github/workflows/*.yml, after the build
- run: ~/src/utils/agent_ready/check.sh dist dist/
```
```yaml
# .pre-commit-config.yaml (local hook; build first, e.g. in a wrapper script)
- id: agent-ready
name: AgentReady (build output)
entry: bash -c 'pnpm build >/dev/null && ~/src/utils/agent_ready/check.sh dist dist'
language: system
pass_filenames: false
```
Dist mode cannot check HTTP behaviour: content types, the `Link` header, real
404 status, or responses to agent user-agents. Those checks print as SKIP, or
the check falls back to "404.html exists".
## Post-deploy (url mode)
Run this once the deploy is live:
```sh
~/src/utils/agent_ready/check.sh url https://kuhy.duckdns.org
```
Url mode checks status codes **and** content types. A server that answers
every path with `200 text/html` (an SPA fallback) fails on robots.txt, the
sitemap, llms.txt, the catalog and the 404 probe. A status-only check would
pass it. Url mode also checks that:
- `/agentready-probe-<random>` ends on a 404, after following redirects.
- No sitemap page is byte-identical to the homepage shell.
- The homepage answers 200 to ClaudeBot, GPTBot and the other agent
user-agents.
## Tests
```sh
~/.claude/scripts/capped.sh python -m pytest ~/src/utils/agent_ready/tests -q
```
The test fixtures are generated in `tmp_path` by `tests/sitegen.py`, so there
are no static mirror `.md` files to trip the markdown-naming gate. The good
and bad build trees are served by loopback `http.server` instances in four
shapes:
- compliant
- SPA fallback
- JSON 404 on every path
- 403 to GPTBot

1
agent_ready/__init__.py Normal file
View File

@ -0,0 +1 @@
"""AgentReady (agentready.org v2.0) compliance checker for kuhy's websites."""

188
agent_ready/backends.py Normal file
View File

@ -0,0 +1,188 @@
"""Two backends behind one ``get(path)``: a build directory and a live site.
DirSite answers like a plain static host (index.html for ``/x/``, a 404 for
anything missing), so the find/read checks are written once for both modes.
"""
from __future__ import annotations
import http.client
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import unquote, urljoin, urlsplit
from agent_ready.report import Undeterminable
DEFAULT_UA = "Claude-User/1.0 (+agentready-check)"
MAX_BODY = 5 * 1024 * 1024
_EXT_MIME = {
".html": "text/html",
".md": "text/markdown",
".txt": "text/plain",
".xml": "application/xml",
".json": "application/json",
}
@dataclass(frozen=True)
class Response:
"""A fetched resource: final status, bare media type, headers, body."""
status: int
ctype: str
body: bytes
headers: dict[str, str] = field(default_factory=dict)
@property
def text(self) -> str:
"""Body decoded as UTF-8 (lenient)."""
return self.body.decode("utf-8", "replace")
class Site:
"""Common URL handling; subclasses implement ``_fetch``."""
live = False
def __init__(self, origin: str) -> None:
self.origin = origin.rstrip("/")
self._cache: dict[tuple[str, str], Response | Undeterminable] = {}
self._lock = threading.Lock()
def to_path(self, url: str, base: str = "/") -> str | None:
"""Site path for a URL or relative href; None when off-site."""
absolute = urljoin(self.origin + base, url.strip())
parts = urlsplit(absolute)
if (
parts.scheme not in ("http", "https")
or parts.netloc != urlsplit(self.origin).netloc
):
return None
return parts.path or "/"
def get(self, path: str, ua: str = DEFAULT_UA) -> Response:
"""Fetch ``path`` (memoized, including network failures)."""
key = (path, ua)
with self._lock:
hit = self._cache.get(key)
if hit is None:
try:
hit = self._fetch(path, ua)
except Undeterminable as exc:
hit = exc
with self._lock:
self._cache[key] = hit
if isinstance(hit, Undeterminable):
raise hit
return hit
def prefetch(self, paths: list[str], uas: tuple[str, ...] = (DEFAULT_UA,)) -> None:
"""Warm the cache for every (path, ua) pair; parallel for live sites."""
if not self.live:
return
pairs = [(p, ua) for p in dict.fromkeys(paths) for ua in uas]
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda pair: self._safe_get(*pair), pairs))
def _safe_get(self, path: str, ua: str) -> None:
try:
self.get(path, ua)
except Undeterminable:
pass
def _fetch(self, path: str, ua: str) -> Response:
raise NotImplementedError
class DirSite(Site):
"""A build output directory served the way a static host serves it."""
def __init__(self, root: Path) -> None:
super().__init__("https://dist.invalid")
self.root = root.resolve()
def file_for(self, path: str) -> Path | None:
"""The file a static host would serve for ``path``."""
rel = unquote(path.split("?", 1)[0]).lstrip("/")
base = (self.root / rel).resolve()
if not base.is_relative_to(self.root):
return None
cands = (
[base / "index.html"]
if path.endswith("/") or not rel
else [base, base.with_name(base.name + ".html"), base / "index.html"]
)
return next((c for c in cands if c.is_file()), None)
def _fetch(self, path: str, ua: str) -> Response:
target = self.file_for(path)
if target is None:
fallback = self.root / "404.html"
body = fallback.read_bytes() if fallback.is_file() else b""
return Response(404, "text/html", body)
return Response(
200,
_EXT_MIME.get(target.suffix, "application/octet-stream"),
target.read_bytes(),
)
class HttpSite(Site):
"""A live site. Follows redirects; HTTP errors are responses, not raises."""
live = True
def __init__(self, base: str, timeout: float = 8.0, budget: float = 45.0) -> None:
parts = urlsplit(base)
super().__init__(f"{parts.scheme}://{parts.netloc}")
self.timeout = timeout
self.deadline = time.monotonic() + budget
def _remaining(self) -> float:
left = self.deadline - time.monotonic()
if left < 1:
raise Undeterminable("overall time budget used up")
return min(self.timeout, left)
def _fetch(self, path: str, ua: str) -> Response:
return self.request(path, ua)
def request(
self,
path: str,
ua: str = DEFAULT_UA,
data: bytes | None = None,
headers: dict[str, str] | None = None,
) -> Response:
"""Uncached GET (or POST when ``data`` is given)."""
req = urllib.request.Request(
self.origin + path,
data=data,
headers={"User-Agent": ua, "Accept": "*/*", **(headers or {})},
)
try:
with urllib.request.urlopen(req, timeout=self._remaining()) as resp:
return _response(resp.status, resp.headers, resp.read(MAX_BODY))
except urllib.error.HTTPError as exc:
return _response(exc.code, exc.headers, exc.read(MAX_BODY))
except (
urllib.error.URLError,
TimeoutError,
OSError,
http.client.HTTPException,
) as exc:
raise Undeterminable(f"{path}: {getattr(exc, 'reason', exc)}") from exc
def _response(status: int, headers: http.client.HTTPMessage, body: bytes) -> Response:
"""Normalise headers: lowercase keys, repeated headers joined by ', '."""
flat: dict[str, str] = {}
for key in set(headers.keys()):
flat[key.lower()] = ", ".join(headers.get_all(key) or [])
ctype = flat.get("content-type", "").split(";", 1)[0].strip().lower()
return Response(status, ctype, body, flat)

137
agent_ready/check.py Executable file
View File

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""AgentReady compliance checker (agentready.org spec v2.0).
check.py dist <build-dir> [--act] # pre-commit / CI: build output
check.py url <base-url> [--act] # post-deploy: the live site
Prints ``PASS|FAIL|SKIP AR-XXX-NN <msg>`` per check. Exit 0 all pass, 1 any
FAIL (every in-scope item is required, whatever its MUST/SHOULD/MAY
strength), 2 usage error, 3 network undeterminable.
"""
from __future__ import annotations
import argparse
import sys
from collections.abc import Callable
from pathlib import Path
from urllib.parse import urlsplit
if __package__ in (None, ""): # run as a plain script: make the package importable
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from agent_ready import checks_find, checks_read
from agent_ready.backends import DirSite, HttpSite, Site
from agent_ready.checks_act import run_act
from agent_ready.context import HOME, Ctx
from agent_ready.report import (
EXIT_NETWORK,
EXIT_USAGE,
Report,
Undeterminable,
)
# (AR id used when a network failure aborts the check, check function)
CHECKS: list[tuple[str, Callable[[Ctx], None]]] = [
("AR-FIND-01", checks_find.check_robots),
("AR-FIND-01", checks_find.check_agent_access),
("AR-FIND-03", checks_find.check_sitemap),
("AR-FIND-04", checks_find.check_catalog),
("AR-READ-01", checks_read.check_pages),
("AR-READ-02", checks_read.check_status_codes),
("AR-READ-03", checks_read.check_homepage),
("AR-READ-06", checks_read.check_discovery_links),
("AR-READ-07", checks_read.check_llms),
("AR-FIND-02", checks_read.skip_manual),
("AR-ACT-10", run_act),
]
def parse_args(argv: list[str] | None) -> argparse.Namespace:
"""Parse the command line (argparse exits 2 on usage errors)."""
parser = argparse.ArgumentParser(
prog="agent_ready", description=__doc__.split("\n\n")[0]
)
sub = parser.add_subparsers(dest="mode", required=True)
for mode, target in (
("dist", "build output directory"),
("url", "base URL, e.g. https://example.org"),
):
cmd = sub.add_parser(mode, help=f"check a {target}")
cmd.add_argument("target", help=target)
cmd.add_argument(
"--act",
action="store_true",
help="also check the act stage (OpenAPI, MCP, auth)",
)
cmd.add_argument(
"--min-text",
type=int,
default=100,
help="min visible chars per page (default 100)",
)
cmd.add_argument(
"--max-pages",
type=int,
default=40,
help="url mode: max pages to fetch (default 40; dist checks all)",
)
url = sub.choices["url"]
url.add_argument(
"--timeout",
type=float,
default=8.0,
help="per-request timeout in s (default 8)",
)
url.add_argument(
"--budget",
type=float,
default=45.0,
help="overall network budget in s (default 45)",
)
return parser.parse_args(argv)
def make_site(args: argparse.Namespace) -> Site | None:
"""Build the backend, or report a usage error and return None."""
if args.mode == "dist":
root = Path(args.target)
if not root.is_dir():
print(f"agent_ready: not a directory: {root}", file=sys.stderr)
return None
return DirSite(root)
parts = urlsplit(args.target)
if parts.scheme not in ("http", "https") or not parts.netloc:
print(f"agent_ready: not an http(s) URL: {args.target}", file=sys.stderr)
return None
return HttpSite(args.target, timeout=args.timeout, budget=args.budget)
def main(argv: list[str] | None = None) -> int:
"""Run every check and return the exit code."""
args = parse_args(argv)
site = make_site(args)
if site is None:
return EXIT_USAGE
report = Report(out=sys.stdout)
ctx = Ctx(
site=site,
report=report,
min_text=args.min_text,
max_pages=args.max_pages,
act=args.act,
)
try:
site.get(HOME)
except Undeterminable as exc:
report.skip(
"AR-FIND-01", f"undeterminable (network): homepage unreachable: {exc}"
)
return EXIT_NETWORK
for ar_id, check in CHECKS:
report.guard(ar_id, lambda check=check: check(ctx))
return report.exit_code()
if __name__ == "__main__":
sys.exit(main())

10
agent_ready/check.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/bash
# Thin wrapper: run the AgentReady checker from any cwd.
# check.sh dist <build-dir> [--act]
# check.sh url <base-url> [--act]
# Exit codes: 0 all pass, 1 any FAIL, 2 usage error, 3 network undeterminable.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly HERE
PYTHONPATH="$(dirname "$HERE")${PYTHONPATH:+:$PYTHONPATH}" exec python3 -m agent_ready.check "$@"

156
agent_ready/checks_act.py Normal file
View File

@ -0,0 +1,156 @@
"""Act stage: AR-ACT-01..10. Opt-in (``--act``): only for sites with user actions."""
from __future__ import annotations
import json
from agent_ready import formats
from agent_ready.backends import HttpSite
from agent_ready.context import CATALOG, Ctx
_INIT = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "agentready-check", "version": "1"},
},
}
def _json(ctx: Ctx, path: str) -> object | None:
"""Parsed JSON at ``path`` when it is 200 and parses, else None."""
resp = ctx.site.get(path)
if resp.status != 200:
return None
try:
return json.loads(resp.body)
except ValueError:
return None
def check_auth(ctx: Ctx) -> None:
"""AR-ACT-01: OAuth metadata (RFC 9728 ``resource`` or RFC 8414 ``issuer``)."""
prm = _json(ctx, "/.well-known/oauth-protected-resource")
asm = _json(ctx, "/.well-known/oauth-authorization-server")
ok = (isinstance(prm, dict) and "resource" in prm) or (
isinstance(asm, dict) and "issuer" in asm
)
ctx.report.verdict(
"AR-ACT-01",
[]
if ok
else [
"no OAuth metadata under /.well-known (oauth-protected-resource / oauth-authorization-server)"
],
"OAuth metadata discoverable under /.well-known",
)
def check_openapi(ctx: Ctx) -> None:
"""AR-ACT-02: /openapi.json with paths and an operationId on every operation."""
doc = _json(ctx, "/openapi.json")
if not isinstance(doc, dict) or not isinstance(doc.get("openapi"), str):
ctx.report.fail("AR-ACT-02", "/openapi.json missing or not an OpenAPI document")
return
paths = doc.get("paths") if isinstance(doc.get("paths"), dict) else {}
ops = [
op
for item in paths.values()
if isinstance(item, dict)
for m, op in item.items()
if m in ("get", "post", "put", "patch", "delete")
]
missing = sum(
1 for op in ops if not (isinstance(op, dict) and op.get("operationId"))
)
problems = ["no operations in paths"] if not ops else []
if missing:
problems.append(f"{missing} operations lack operationId")
ctx.report.verdict(
"AR-ACT-02", problems, f"/openapi.json describes {len(ops)} operations"
)
def _mcp_ok(ctx: Ctx) -> bool | None:
"""True if /mcp answers initialize (or is OAuth-gated); None in dist mode."""
if not isinstance(ctx.site, HttpSite):
return None
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
resp = ctx.site.request("/mcp", data=json.dumps(_INIT).encode(), headers=headers)
if resp.status == 401:
return "www-authenticate" in resp.headers
return resp.status == 200 and '"jsonrpc"' in resp.text and '"result"' in resp.text
def check_mcp(ctx: Ctx) -> None:
"""AR-ACT-03 MCP at /mcp, AR-ACT-04 server card, AR-ACT-10 an interface exists."""
ok = _mcp_ok(ctx)
if ok is None:
ctx.report.skip("AR-ACT-03", "/mcp is a server endpoint: check in url mode")
ctx.report.skip("AR-ACT-10", "MCP/CLI interface: check in url mode")
else:
ctx.report.verdict(
"AR-ACT-03",
[]
if ok
else [
"/mcp does not answer an MCP initialize (nor 401 + WWW-Authenticate)"
],
"/mcp answers MCP initialize",
)
ctx.report.verdict(
"AR-ACT-10",
[]
if ok
else ["no MCP server at /mcp; a CLI (AR-ACT-06) cannot be auto-detected"],
"agents can act through /mcp",
)
cards = [
t
for t in formats.catalog_types(_json(ctx, CATALOG))
if t == "application/mcp-server-card+json"
]
ctx.report.verdict(
"AR-ACT-04",
[] if cards else ["AI Catalog lists no application/mcp-server-card+json entry"],
"AI Catalog lists an MCP server card",
)
def check_webmcp(ctx: Ctx) -> None:
"""AR-ACT-07: WebMCP tools registered on document.modelContext."""
resp, page = ctx.home()
sources = [resp.text]
for src in page.scripts[:10]:
path = ctx.site.to_path(src)
if path:
sources.append(ctx.site.get(path).text)
found = any("modelContext" in s for s in sources)
ctx.report.verdict(
"AR-ACT-07",
[] if found else ["no document.modelContext usage in homepage HTML/scripts"],
"homepage registers WebMCP tools",
)
def run_act(ctx: Ctx) -> None:
"""All act checks, or one SKIP line when the stage is off."""
if not ctx.act:
ctx.report.skip(
"AR-ACT-10", "act stage off (pass --act for sites with user actions)"
)
return
ctx.report.guard("AR-ACT-01", lambda: check_auth(ctx))
ctx.report.guard("AR-ACT-02", lambda: check_openapi(ctx))
ctx.report.guard("AR-ACT-03", lambda: check_mcp(ctx))
ctx.report.guard("AR-ACT-07", lambda: check_webmcp(ctx))
ctx.report.skip("AR-ACT-05", "MCP Apps ui:// view: manual review")
ctx.report.skip("AR-ACT-06", "SDKs on npm/PyPI and a CLI: manual review")
ctx.report.skip("AR-ACT-08", "agent payments: n/a unless the product charges")
ctx.report.skip("AR-ACT-09", "NLWeb /ask endpoint: manual review")

186
agent_ready/checks_find.py Normal file
View File

@ -0,0 +1,186 @@
"""Find stage: AR-FIND-01..04 (robots.txt, agent access, sitemap, AI Catalog)."""
from __future__ import annotations
import json
from urllib.parse import urlsplit
from agent_ready import formats
from agent_ready.context import CATALOG, HOME, LLMS, Ctx, link_header_href
from agent_ready.report import summarize
# Training, search-index and user-initiated fetchers named in the spec's
# crawl-policy notes. Real UA strings for the two training crawlers.
AGENT_UAS = {
"ClaudeBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
"GPTBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
"Claude-SearchBot": "Claude-SearchBot/1.0",
"OAI-SearchBot": "OAI-SearchBot/1.0; +https://openai.com/searchbot",
"Claude-User": "Claude-User/1.0",
"ChatGPT-User": "ChatGPT-User/1.0; +https://openai.com/bot",
}
_CHALLENGE = (
"just a moment",
"cf-chl",
"challenge-platform",
"attention required",
"captcha",
)
XML_TYPES = ("application/xml", "text/xml", "application/rss+xml")
def check_robots(ctx: Ctx) -> None:
"""AR-FIND-01: robots.txt exists, is plain text and blocks no agent."""
resp = ctx.site.get("/robots.txt")
if resp.status != 200:
ctx.report.fail("AR-FIND-01", f"/robots.txt: HTTP {resp.status}")
return
problems = ctx.type_problem(resp, ("text/plain",), "/robots.txt")
if formats.looks_like_html(resp.text):
ctx.report.fail(
"AR-FIND-01",
"; ".join([*problems, "/robots.txt body is an HTML page (SPA fallback?)"]),
)
return
ctx.robots_text = resp.text
groups = formats.parse_robots(resp.text)
if not formats.has_wildcard_group(groups):
problems.append("no 'User-agent: *' group (spec: one rule for all agents)")
blocked = formats.blocked_agents(groups)
if blocked:
problems.append(f"blanket Disallow for: {', '.join(blocked)}")
ctx.report.verdict(
"AR-FIND-01",
problems,
"robots.txt is plain text, has a * group and blocks no agent",
)
def check_agent_access(ctx: Ctx) -> None:
"""AR-FIND-01 (bot protection): homepage is 200 for each agent UA (url mode)."""
if not ctx.live:
ctx.report.skip("AR-FIND-01", "agent user-agent fetches need url mode")
return
base, _ = ctx.home()
ctx.site.prefetch([HOME], tuple(AGENT_UAS.values()))
bad = []
for name, ua in AGENT_UAS.items():
resp = ctx.site.get(HOME, ua)
lowered = resp.text[:20000].lower()
if resp.status != 200:
bad.append(f"{name} HTTP {resp.status}")
elif resp.body != base.body and any(m in lowered for m in _CHALLENGE):
bad.append(f"{name} got a challenge page")
ctx.report.verdict("AR-FIND-01", bad, f"homepage 200 for {', '.join(AGENT_UAS)}")
def _sitemap_paths(ctx: Ctx) -> list[str]:
"""Sitemap locations from robots.txt (also fixes the dist-mode origin)."""
urls = formats.robots_sitemaps(ctx.robots_text or "")
if urls and not ctx.live:
parts = urlsplit(urls[0])
ctx.site.origin = f"{parts.scheme}://{parts.netloc}"
paths = [p for u in urls if (p := ctx.site.to_path(u))]
if ctx.robots_text is None:
ctx.report.fail("AR-FIND-03", "no valid robots.txt to reference the sitemap")
elif not urls:
ctx.report.fail("AR-FIND-03", "robots.txt has no 'Sitemap:' line")
elif not paths:
ctx.report.fail(
"AR-FIND-03", f"robots.txt Sitemap is on another host: {urls[0]}"
)
else:
ctx.report.ok("AR-FIND-03", f"robots.txt references {summarize(urls)}")
return paths or ["/sitemap.xml"]
def _read_sitemap(ctx: Ctx, path: str, problems: list[str]) -> tuple[str, list[str]]:
"""Fetch and parse one sitemap; record problems; return (kind, locs)."""
resp = ctx.site.get(path)
if resp.status != 200:
problems.append(f"{path}: HTTP {resp.status}")
return "", []
problems.extend(ctx.type_problem(resp, XML_TYPES, path))
try:
return formats.parse_sitemap(resp.body)
except ValueError as exc:
problems.append(f"{path}: {exc}")
return "", []
def check_sitemap(ctx: Ctx) -> None:
"""AR-FIND-03: sitemap referenced from robots.txt, valid XML, on-site <loc>s."""
problems: list[str] = []
locs: list[str] = []
for path in _sitemap_paths(ctx)[:5]:
kind, found = _read_sitemap(ctx, path, problems)
if kind == "sitemapindex":
for child in [p for u in found[:10] if (p := ctx.site.to_path(u))]:
locs += _read_sitemap(ctx, child, problems)[1]
else:
locs += found
if locs and not ctx.live and ctx.site.origin.endswith("dist.invalid"):
parts = urlsplit(locs[0])
ctx.site.origin = f"{parts.scheme}://{parts.netloc}"
pages = [p for loc in locs if (p := ctx.site.to_path(loc))]
if len(pages) < len(locs):
problems.append(
f"{len(locs) - len(pages)} <loc> URLs are not on {ctx.site.origin}"
)
if not locs and not problems:
problems.append("sitemap has no <loc> entries")
ctx.sitemap_pages = list(dict.fromkeys(pages))
ctx.report.verdict(
"AR-FIND-03", problems, f"sitemap lists {len(ctx.sitemap_pages)} pages"
)
def check_catalog(ctx: Ctx) -> None:
"""AR-FIND-04: valid AI Catalog, lists llms.txt, linked from homepage head."""
resp = ctx.site.get(CATALOG)
problems: list[str] = []
if resp.status != 200:
problems.append(f"{CATALOG}: HTTP {resp.status}")
else:
problems += ctx.type_problem(
resp, ("application/ai-catalog+json", "application/json"), CATALOG
)
try:
doc = json.loads(resp.body)
except ValueError:
doc = None
problems.append(f"{CATALOG} is not JSON")
if doc is not None:
problems += formats.catalog_problems(doc)
listed = {ctx.site.to_path(u) for u in formats.catalog_urls(doc)}
if ctx.exists(LLMS) and LLMS not in listed:
problems.append("catalog has no entry for /llms.txt")
ctx.report.verdict("AR-FIND-04", problems, f"{CATALOG} is a valid AI Catalog")
home, scan = ctx.home()
href = scan.link_href("ai-catalog")
ok = href is not None and ctx.site.to_path(href) == CATALOG
ctx.report.verdict(
"AR-FIND-04",
[]
if ok
else [
'homepage head lacks <link rel="ai-catalog" href="/.well-known/ai-catalog.json">'
],
"homepage head links the catalog",
)
if not ctx.live:
ctx.report.skip(
"AR-FIND-04", "HTTP Link header can only be checked in url mode"
)
return
header = link_header_href(home, "ai-catalog")
ok = header is not None and ctx.site.to_path(header) == CATALOG
ctx.report.verdict(
"AR-FIND-04",
[]
if ok
else [
'homepage response lacks HTTP header Link: <...ai-catalog.json>; rel="ai-catalog"'
],
"homepage sends the ai-catalog Link header",
)

212
agent_ready/checks_read.py Normal file
View File

@ -0,0 +1,212 @@
"""Read stage: AR-READ-01..09 and 11 (AR-READ-10 is retired in spec v2.0)."""
from __future__ import annotations
import secrets
from agent_ready import formats
from agent_ready.context import CATALOG, HOME, LLMS, TEXT_TYPES, Ctx, link_header_href
from agent_ready.htmlscan import scan
from agent_ready.report import summarize
def _pages(ctx: Ctx) -> list[str]:
"""Homepage plus sitemap pages, capped. An empty sitemap is a READ-01 FAIL."""
if not ctx.sitemap_pages:
ctx.report.fail(
"AR-READ-01", "sitemap yielded no pages; only the homepage was checked"
)
every = list(dict.fromkeys([HOME, *ctx.sitemap_pages]))
pages = ctx.cap(every)
if len(pages) < len(every):
ctx.report.skip(
"AR-READ-01",
f"checked {len(pages)} of {len(every)} pages (--max-pages); rest unchecked",
)
ctx.site.prefetch(pages)
return pages
def _mirror_href(ctx: Ctx, path: str) -> str | None:
"""Markdown mirror advertised by <link rel=alternate type=text/markdown> or Link header."""
resp, page = ctx.page(path)
href = page.link_href("alternate", "text/markdown")
if href is None and ctx.live:
href = link_header_href(resp, "alternate", "text/markdown")
return href
def check_pages(ctx: Ctx) -> None:
"""AR-READ-01 text in initial HTML, AR-READ-05 tagged code, AR-READ-09 mirrors."""
pages = _pages(ctx)
home, _ = ctx.home()
no_text: list[str] = []
untagged: list[str] = []
blocks = 0
for path in pages:
resp, page = ctx.page(path)
if resp.status != 200:
no_text.append(f"{path} (HTTP {resp.status})")
continue
if ctx.type_problem(resp, ("text/html",), path):
no_text.append(f"{path} ({resp.ctype or 'no content-type'})")
elif path != HOME and resp.body == home.body:
no_text.append(f"{path} (byte-identical to the homepage shell)")
elif len(page.text) < ctx.min_text:
no_text.append(f"{path} ({len(page.text)} chars of text < {ctx.min_text})")
blocks += page.code_blocks
if page.untagged_code:
untagged.append(f"{path} ({page.untagged_code} untagged <pre><code>)")
ctx.report.verdict(
"AR-READ-01",
["answer not in initial HTML: " + summarize(no_text)] if no_text else [],
f"{len(pages)} pages carry >= {ctx.min_text} chars of text without JS",
)
md_untagged = _check_mirrors(ctx, pages)
untagged += md_untagged
ctx.report.verdict(
"AR-READ-05",
["code without a language tag: " + summarize(untagged)] if untagged else [],
f"{blocks} HTML code blocks (plus mirror fences) all language-tagged",
)
def _check_mirrors(ctx: Ctx, pages: list[str]) -> list[str]:
"""AR-READ-09 for every page; returns mirrors with untagged fences (READ-05)."""
bad: list[str] = []
untagged: list[str] = []
targets: dict[str, str] = {}
for path in pages:
if ctx.site.get(path).status != 200:
continue
href = _mirror_href(ctx, path)
target = ctx.site.to_path(href, path) if href else None
if target is None:
bad.append(f"{path} (no text/markdown alternate link)")
else:
targets[path] = target
ctx.site.prefetch(list(targets.values()))
for path, target in targets.items():
resp = ctx.site.get(target)
if resp.status != 200:
bad.append(f"{target} (HTTP {resp.status})")
elif (
ctx.type_problem(resp, TEXT_TYPES, target)
or formats.looks_like_html(resp.text)
or not resp.text.strip()
):
bad.append(f"{target} (not markdown: {resp.ctype or 'empty'})")
elif n := formats.untagged_fences(resp.text):
untagged.append(f"{target} ({n} untagged fences)")
ctx.report.verdict(
"AR-READ-09",
["markdown mirror missing/broken: " + summarize(bad)] if bad else [],
f"{len(targets)} pages advertise a working markdown mirror",
)
return untagged
def check_status_codes(ctx: Ctx) -> None:
"""AR-READ-02: unknown paths are a real 404 (url) / a 404.html exists (dist)."""
if not ctx.live:
resp = ctx.site.get(f"/agentready-probe-{secrets.token_hex(6)}")
text = scan(resp.text).text if resp.body else ""
ok = resp.status == 404 and len(text) >= 10
ctx.report.verdict(
"AR-READ-02",
[] if ok else ["no 404.html with real text in the build output"],
"404.html exists for unknown paths",
)
return
token = secrets.token_hex(6)
probes = [f"/agentready-probe-{token}", f"/agentready-probe-{token}/page.html"]
ctx.site.prefetch(probes)
bad = [
f"{p} -> HTTP {s}"
for p in probes
if (s := ctx.site.get(p).status) not in (404, 410)
]
ctx.report.verdict(
"AR-READ-02",
["soft 404, unknown paths must return 404: " + ", ".join(bad)] if bad else [],
"unknown paths return 404",
)
def check_homepage(ctx: Ctx) -> None:
"""AR-READ-03 real links on the homepage, AR-READ-08 JSON-LD on it."""
resp, page = ctx.home()
links = {
p
for a in page.anchors
if not a.startswith(("#", "javascript:", "mailto:"))
and (p := ctx.site.to_path(a))
and p != HOME
}
ok = resp.status == 200 and bool(links)
ctx.report.verdict(
"AR-READ-03",
[] if ok else ["homepage HTML has no <a href> links to other pages"],
f"homepage links {len(links)} pages in raw HTML",
)
problems = []
if not page.jsonld_ok:
problems.append(
'homepage has no <script type="application/ld+json"> with @context'
)
if page.jsonld_bad:
problems.append(
f"{page.jsonld_bad} JSON-LD blocks fail to parse or lack @context"
)
ctx.report.verdict(
"AR-READ-08", problems, f"homepage carries {page.jsonld_ok} JSON-LD blocks"
)
def check_discovery_links(ctx: Ctx) -> None:
"""AR-READ-06: discovery files are linked from the homepage."""
_, page = ctx.home()
linked = {
ctx.site.to_path(h)
for h in [*page.anchors, *(href for _, _, href in page.links)]
}
files = [LLMS, CATALOG] + (["/openapi.json"] if ctx.act else [])
for path in files:
problems = []
if not ctx.exists(path):
problems.append(f"{path} does not exist")
if path not in linked:
problems.append(f"{path} is not linked from the homepage (<link> or <a>)")
ctx.report.verdict(
"AR-READ-06", problems, f"{path} exists and is linked from the homepage"
)
def check_llms(ctx: Ctx) -> None:
"""AR-READ-07: llms.txt in the spec shape, every on-site link resolving."""
resp = ctx.site.get(LLMS)
if resp.status != 200:
ctx.report.fail("AR-READ-07", f"{LLMS}: HTTP {resp.status}")
return
problems = ctx.type_problem(resp, TEXT_TYPES, LLMS) + formats.llms_problems(
resp.text
)
if not formats.looks_like_html(resp.text):
paths = [
p for u in formats.llms_links(resp.text) if (p := ctx.site.to_path(u, LLMS))
]
paths = ctx.cap(paths)
ctx.site.prefetch(paths)
broken = [p for p in paths if ctx.site.get(p).status != 200]
if broken:
problems.append("broken links: " + summarize(broken))
ctx.report.verdict("AR-READ-07", problems, f"{LLMS} has H1, summary and link lists")
def skip_manual(ctx: Ctx) -> None:
"""Items with no mechanical check (reported, never silently dropped)."""
ctx.report.skip(
"AR-FIND-02", "first-party docs page answering user questions: manual review"
)
ctx.report.skip("AR-READ-04", "docs pages answer users' tasks: manual review")
ctx.report.skip("AR-READ-11", "pricing page: n/a for sites without paid plans")

85
agent_ready/context.py Normal file
View File

@ -0,0 +1,85 @@
"""Shared state for one checker run (site, report, options, discoveries)."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from agent_ready.backends import Response, Site
from agent_ready.formats import looks_like_html
from agent_ready.htmlscan import PageScan, scan
from agent_ready.report import Report
HOME = "/"
LLMS = "/llms.txt"
CATALOG = "/.well-known/ai-catalog.json"
TEXT_TYPES = ("text/plain", "text/markdown")
@dataclass
class Ctx:
"""Everything a check needs; filled in as checks discover the site."""
site: Site
report: Report
min_text: int = 100
max_pages: int = 40
act: bool = False
robots_text: str | None = None
sitemap_pages: list[str] = field(default_factory=list)
_scans: dict[str, PageScan] = field(default_factory=dict)
@property
def live(self) -> bool:
"""True in url mode (headers and content types are real)."""
return self.site.live
def cap(self, items: list[str]) -> list[str]:
"""Apply --max-pages in url mode only; dist mode is free, so check everything."""
return items[: self.max_pages] if self.live else items
def page(self, path: str) -> tuple[Response, PageScan]:
"""Fetch and scan an HTML page (scan memoized)."""
resp = self.site.get(path)
if path not in self._scans:
self._scans[path] = scan(resp.text) if resp.status == 200 else PageScan()
return resp, self._scans[path]
def home(self) -> tuple[Response, PageScan]:
"""The homepage and its scan."""
return self.page(HOME)
def type_problem(
self, resp: Response, allowed: tuple[str, ...], what: str
) -> list[str]:
"""Content-type problem for live responses (dist mode has none)."""
if (
not self.live
or resp.ctype in allowed
or any(a.endswith("+xml") and resp.ctype.endswith("+xml") for a in allowed)
):
return []
return [
f"{what} served as {resp.ctype or 'no content-type'}, want {' or '.join(allowed)}"
]
def exists(self, path: str) -> bool:
"""True when ``path`` is 200 and, for a non-page file, not an HTML fallback."""
resp = self.site.get(path)
is_page = path.endswith(("/", ".html"))
return resp.status == 200 and (is_page or not looks_like_html(resp.text))
def link_header_href(resp: Response, rel: str, mime: str | None = None) -> str | None:
"""href of the first RFC 8288 ``Link`` header value with ``rel`` (and type)."""
for part in re.split(r",\s*(?=<)", resp.headers.get("link", "")):
match = re.match(r"\s*<([^>]*)>(.*)", part)
if not match:
continue
params = match.group(2).lower()
rels = re.search(r'rel="?([^";]+)"?', params)
if not rels or rel not in rels.group(1).split():
continue
if mime is None or re.search(rf'type="?{re.escape(mime)}"?', params):
return match.group(1)
return None

213
agent_ready/formats.py Normal file
View File

@ -0,0 +1,213 @@
"""Validators for the file formats AgentReady points at.
robots.txt (RFC 9309), sitemap.xml (sitemaps.org), llms.txt (agentready
spec "llms.txt shape" / llmstxt.org) and the AI Catalog
(github.com/Agent-Card/ai-catalog, specification/ai-catalog.md).
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
_LINK_ITEM = re.compile(r"^\s*[-*]\s+\[[^\]]+\]\(([^)\s]+)\)")
_FENCE = re.compile(r"^\s{0,3}(```+|~~~+)\s*(\S*)")
_SPEC_VERSION = re.compile(r"^\d+\.\d+$")
def looks_like_html(text: str) -> bool:
"""True when a supposedly plain-text/XML/JSON body is an HTML page."""
head = text.lstrip()[:200].lower()
return head.startswith(("<!doctype html", "<html")) or "<head" in head
# --------------------------------------------------------------- robots.txt
@dataclass
class RobotsGroup:
"""One ``User-agent`` group and its rules, in file order."""
agents: list[str] = field(default_factory=list)
rules: list[tuple[str, str]] = field(default_factory=list)
def parse_robots(text: str) -> list[RobotsGroup]:
"""Parse robots.txt into groups (consecutive User-agent lines share one)."""
groups: list[RobotsGroup] = []
current: RobotsGroup | None = None
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if ":" not in line:
continue
key, value = (part.strip() for part in line.split(":", 1))
key = key.lower()
if key == "user-agent":
if current is None or current.rules:
current = RobotsGroup()
groups.append(current)
current.agents.append(value.lower())
elif key in ("allow", "disallow") and current is not None:
current.rules.append((key, value))
return groups
def has_wildcard_group(groups: list[RobotsGroup]) -> bool:
"""True when a ``User-agent: *`` group exists (one rule for all agents)."""
return any("*" in g.agents for g in groups)
def blocked_agents(groups: list[RobotsGroup]) -> list[str]:
"""Agents whose group disallows the whole site (``/`` or ``/*``).
An empty ``Disallow:`` allows everything; an ``Allow: /`` in the same
group wins over the blanket disallow (longest-match tie goes to allow).
"""
blocked: list[str] = []
for group in groups:
disallow_all = any(k == "disallow" and v in ("/", "/*") for k, v in group.rules)
allow_all = any(k == "allow" and v in ("/", "/*") for k, v in group.rules)
if disallow_all and not allow_all:
blocked.extend(group.agents)
return blocked
def robots_sitemaps(text: str) -> list[str]:
"""Return every ``Sitemap:`` URL."""
out = []
for raw in text.splitlines():
key, _, value = raw.partition(":")
if key.strip().lower() == "sitemap" and value.strip():
out.append(value.strip())
return out
# -------------------------------------------------------------- sitemap.xml
def parse_sitemap(body: bytes) -> tuple[str, list[str]]:
"""Return (root kind ``urlset``/``sitemapindex``, list of <loc> values).
Raises ValueError for anything that is not a sitemap document.
"""
try:
root = ET.fromstring(body)
except ET.ParseError as exc:
raise ValueError(f"not XML: {exc}") from exc
kind = root.tag.rsplit("}", 1)[-1]
if kind not in ("urlset", "sitemapindex"):
raise ValueError(f"root element <{kind}> is not <urlset>/<sitemapindex>")
locs = [
(el.text or "").strip()
for el in root.iter()
if el.tag.rsplit("}", 1)[-1] == "loc"
]
return kind, [loc for loc in locs if loc]
# ----------------------------------------------------------------- llms.txt
def llms_problems(text: str) -> list[str]:
"""Check llms.txt shape: H1 first, blockquote summary, H2 link lists."""
if looks_like_html(text):
return ["body is an HTML page, not llms.txt markdown"]
lines = [ln for ln in text.splitlines() if ln.strip()]
problems = []
if not lines or not re.match(r"^# \S", lines[0]):
problems.append("first line must be an H1 title ('# Name')")
if not any(ln.startswith(">") for ln in lines[:4]):
problems.append("missing blockquote summary ('> ...') after the H1")
in_h2 = False
links = 0
for ln in lines:
if ln.startswith("## "):
in_h2 = True
elif in_h2 and _LINK_ITEM.match(ln):
links += 1
if links == 0:
problems.append("no '## Section' with a '- [name](url): notes' link list")
return problems
def llms_links(text: str) -> list[str]:
"""Return every link-list URL in llms.txt, in order."""
return [m.group(1) for ln in text.splitlines() if (m := _LINK_ITEM.match(ln))]
# --------------------------------------------------------------- ai-catalog
def catalog_problems(doc: object) -> list[str]:
"""Validate an AI Catalog (Level 1 + Host Info if present)."""
if not isinstance(doc, dict):
return ["top level must be a JSON object"]
problems = []
version = doc.get("specVersion")
if not isinstance(version, str):
problems.append("missing string specVersion")
elif not _SPEC_VERSION.match(version):
problems.append(f"specVersion {version!r} is not 'Major.Minor'")
host = doc.get("host")
if host is not None and not (
isinstance(host, dict) and isinstance(host.get("displayName"), str)
):
problems.append("host.displayName (string) is required when host is present")
entries = doc.get("entries")
if not isinstance(entries, list):
return [*problems, "missing entries array"]
for i, entry in enumerate(entries):
problems.extend(f"entries[{i}]: {p}" for p in _entry_problems(entry))
return problems
def _entry_problems(entry: object) -> list[str]:
"""Problems with one Catalog Entry."""
if not isinstance(entry, dict):
return ["must be an object"]
problems = [
f"missing string {k}"
for k in ("identifier", "type")
if not isinstance(entry.get(k), str)
]
if ("url" in entry) == ("data" in entry):
problems.append("must carry exactly one of url/data")
return problems
def catalog_urls(doc: object) -> list[str]:
"""Every entry ``url`` in a catalog (empty for invalid docs)."""
if not isinstance(doc, dict) or not isinstance(doc.get("entries"), list):
return []
return [
e["url"]
for e in doc["entries"]
if isinstance(e, dict) and isinstance(e.get("url"), str)
]
def catalog_types(doc: object) -> list[str]:
"""Every entry ``type`` in a catalog."""
if not isinstance(doc, dict) or not isinstance(doc.get("entries"), list):
return []
return [
e["type"]
for e in doc["entries"]
if isinstance(e, dict) and isinstance(e.get("type"), str)
]
# ----------------------------------------------------------------- markdown
def untagged_fences(text: str) -> int:
"""Count opening code fences with no language info string."""
count = 0
open_fence = ""
for line in text.splitlines():
match = _FENCE.match(line)
if not match:
continue
marker, info = match.groups()
if open_fence:
if (
marker[0] == open_fence[0]
and len(marker) >= len(open_fence)
and not info
):
open_fence = ""
continue
open_fence = marker
count += not info
return count

121
agent_ready/htmlscan.py Normal file
View File

@ -0,0 +1,121 @@
"""One-pass HTML scan: visible body text, links, JSON-LD and code blocks."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from html.parser import HTMLParser
_HIDDEN = {"script", "style", "noscript", "template", "svg", "head", "title"}
_LANG = re.compile(r"\b(language|lang)-\S+")
@dataclass
class PageScan:
"""What an agent that does not run JavaScript sees in a page."""
text: str = ""
links: list[tuple[set[str], str, str]] = field(default_factory=list)
anchors: list[str] = field(default_factory=list)
scripts: list[str] = field(default_factory=list)
jsonld_ok: int = 0
jsonld_bad: int = 0
code_blocks: int = 0
untagged_code: int = 0
def link_href(self, rel: str, mime: str | None = None) -> str | None:
"""First ``<link>`` href with ``rel`` (and ``type`` if given)."""
for rels, typ, href in self.links:
if rel in rels and (mime is None or typ == mime):
return href
return None
def _tagged(attrs: dict[str, str]) -> bool:
"""True when an element carries a code-language marker."""
return bool(_LANG.search(attrs.get("class", ""))) or bool(
attrs.get("data-language") or attrs.get("data-lang")
)
class _Scanner(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.out = PageScan()
self._hidden: list[str] = []
self._text: list[str] = []
self._pre: list[list[bool]] = [] # [pre_tagged, code_seen]
self._ld: list[str] | None = None
def handle_starttag(
self, tag: str, attr_list: list[tuple[str, str | None]]
) -> None:
attrs = {k: v or "" for k, v in attr_list}
if tag == "body": # </head> is optional in HTML; <body> always ends it
self._hidden = [h for h in self._hidden if h not in ("head", "title")]
if tag == "link" and attrs.get("href"):
rels = set(attrs.get("rel", "").lower().split())
self.out.links.append((rels, attrs.get("type", "").lower(), attrs["href"]))
elif tag == "a" and attrs.get("href"):
self.out.anchors.append(attrs["href"])
elif tag == "script":
if attrs.get("src"):
self.out.scripts.append(attrs["src"])
if attrs.get("type", "").lower() == "application/ld+json":
self._ld = []
elif tag == "pre":
self._pre.append([_tagged(attrs), False])
elif tag == "code" and self._pre and not self._pre[-1][1]:
self._pre[-1][1] = True
self.out.code_blocks += 1
self.out.untagged_code += not (self._pre[-1][0] or _tagged(attrs))
if tag in _HIDDEN:
self._hidden.append(tag)
def handle_endtag(self, tag: str) -> None:
if tag == "script" and self._ld is not None:
self._finish_ld("".join(self._ld))
self._ld = None
if tag == "pre" and self._pre:
self._pre.pop()
if tag in _HIDDEN and tag in self._hidden:
while self._hidden and self._hidden.pop() != tag:
pass
def handle_data(self, data: str) -> None:
if self._ld is not None:
self._ld.append(data)
elif not self._hidden:
self._text.append(data)
def _finish_ld(self, raw: str) -> None:
try:
doc = json.loads(raw)
except ValueError:
self.out.jsonld_bad += 1
return
nodes = doc if isinstance(doc, list) else [doc]
ok = (
all(
isinstance(n, dict) and ("@context" in n or "@graph" in n)
for n in nodes
)
and nodes
)
if ok:
self.out.jsonld_ok += 1
else:
self.out.jsonld_bad += 1
def result(self) -> PageScan:
self.out.text = re.sub(r"\s+", " ", " ".join(self._text)).strip()
return self.out
def scan(html: str) -> PageScan:
"""Scan an HTML document."""
parser = _Scanner()
parser.feed(html)
parser.close()
return parser.result()

73
agent_ready/report.py Normal file
View File

@ -0,0 +1,73 @@
"""Result lines (``PASS|FAIL|SKIP AR-XXX-NN msg``) and the exit-code policy."""
from __future__ import annotations
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TextIO
EXIT_OK, EXIT_FAIL, EXIT_USAGE, EXIT_NETWORK = 0, 1, 2, 3
class Undeterminable(Exception):
"""A network-level failure (refused, timeout, deadline): no verdict."""
@dataclass
class Report:
"""Collects check lines and decides the exit code.
Every in-scope item is required for kuhy's sites, so a FAIL of any
strength (MUST/SHOULD/MAY) fails the run. SKIP never fails it.
"""
out: TextIO = field(default_factory=lambda: sys.stdout)
fails: int = 0
undetermined: int = 0
def emit(self, status: str, ar_id: str, msg: str) -> None:
"""Print one result line."""
if status == "FAIL":
self.fails += 1
print(f"{status} {ar_id} {msg}", file=self.out, flush=True)
def ok(self, ar_id: str, msg: str) -> None:
"""Emit PASS."""
self.emit("PASS", ar_id, msg)
def fail(self, ar_id: str, msg: str) -> None:
"""Emit FAIL."""
self.emit("FAIL", ar_id, msg)
def skip(self, ar_id: str, msg: str) -> None:
"""Emit SKIP."""
self.emit("SKIP", ar_id, msg)
def verdict(self, ar_id: str, problems: list[str], pass_msg: str) -> bool:
"""FAIL with the problems joined, or PASS with ``pass_msg``."""
if problems:
self.fail(ar_id, "; ".join(problems))
return False
self.ok(ar_id, pass_msg)
return True
def guard(self, ar_id: str, fn: Callable[[], object]) -> None:
"""Run a check; a network failure becomes an undeterminable SKIP."""
try:
fn()
except Undeterminable as exc:
self.undetermined += 1
self.skip(ar_id, f"undeterminable (network): {exc}")
def exit_code(self) -> int:
"""0 all pass, 1 any fail, 3 nothing failed but something undeterminable."""
if self.fails:
return EXIT_FAIL
return EXIT_NETWORK if self.undetermined else EXIT_OK
def summarize(items: list[str], limit: int = 4) -> str:
"""Join up to ``limit`` items and note how many more there were."""
head = ", ".join(items[:limit])
return head + (f" (+{len(items) - limit} more)" if len(items) > limit else "")

View File

View File

@ -0,0 +1,136 @@
"""Fixtures: generated site trees and loopback HTTP servers that serve them."""
from __future__ import annotations
import socket
import threading
from collections.abc import Callable, Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from agent_ready.tests import sitegen
_MIME = {
".html": "text/html; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".xml": "application/xml",
".json": "application/json",
}
def _resolve(root: Path, path: str) -> Path | None:
"""Map a URL path to a file the way a static host does."""
rel = path.split("?", 1)[0].lstrip("/")
cand = root / rel
if path.endswith("/"):
cand = cand / "index.html"
return cand if cand.is_file() else None
def make_handler(root: Path, mode: str, block_ua: str) -> type[BaseHTTPRequestHandler]:
"""Build a handler class.
``mode`` is ``good`` (real 404s), ``spa`` (200 index.html for every path,
the kuhy.duckdns.org trap) or ``json404`` (every non-root path is a JSON
404, the popes.kuhy.duckdns.org shape).
"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args: object) -> None: # silence test output
return
def _send(
self,
status: int,
ctype: str,
body: bytes,
extra: dict[str, str] | None = None,
) -> None:
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
for key, value in (extra or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
if block_ua and block_ua in self.headers.get("User-Agent", ""):
self._send(403, "text/html", b"<html>Just a moment...</html>")
return
origin = f"http://{self.headers['Host']}"
if mode == "spa" or (mode == "json404" and self.path == "/"):
body = (root / "index.html").read_bytes()
self._send(200, "text/html; charset=utf-8", body)
return
target = None if mode == "json404" else _resolve(root, self.path)
if target is None:
if mode == "json404":
self._send(404, "application/json", b'{"detail":"Not Found"}')
else:
self._send(404, "text/html", (root / "404.html").read_bytes())
return
if target.name == "ai-catalog.json":
ctype = "application/ai-catalog+json"
else:
ctype = _MIME.get(target.suffix, "application/octet-stream")
body = target.read_bytes().replace(sitegen.ORIGIN.encode(), origin.encode())
extra = {}
if self.path == "/":
extra["Link"] = '</.well-known/ai-catalog.json>; rel="ai-catalog"'
self._send(200, ctype, body, extra)
def do_POST(self) -> None:
"""Minimal MCP: answer initialize at /mcp in good mode only."""
self.rfile.read(int(self.headers.get("Content-Length", 0)))
if mode == "good" and self.path == "/mcp":
self._send(
200, "application/json", b'{"jsonrpc":"2.0","id":1,"result":{}}'
)
else:
self._send(200, "text/html", (root / "index.html").read_bytes())
return Handler
@pytest.fixture
def good_dir(tmp_path: Path) -> Path:
"""A compliant build output."""
return sitegen.build_good(tmp_path / "good")
@pytest.fixture
def bad_dir(tmp_path: Path) -> Path:
"""A build output failing every find/read item."""
return sitegen.build_bad(tmp_path / "bad")
@pytest.fixture
def serve() -> Iterator[Callable[..., str]]:
"""Start loopback servers on demand; return their base URL."""
servers: list[ThreadingHTTPServer] = []
def start(root: Path, mode: str = "good", block_ua: str = "") -> str:
server = ThreadingHTTPServer(
("127.0.0.1", 0), make_handler(root, mode, block_ua)
)
threading.Thread(target=server.serve_forever, daemon=True).start()
servers.append(server)
return f"http://127.0.0.1:{server.server_address[1]}"
yield start
for server in servers:
server.shutdown()
server.server_close()
@pytest.fixture
def dead_url() -> str:
"""A loopback URL with nothing listening (connection refused)."""
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
return f"http://127.0.0.1:{port}"

View File

@ -0,0 +1,80 @@
"""In-memory sites and CLI helpers for tests that need exact responses."""
from __future__ import annotations
import io
import pytest
from agent_ready.backends import DEFAULT_UA, HttpSite, Response, Site
from agent_ready.check import main
from agent_ready.context import Ctx
from agent_ready.report import Report
HTML = "text/html"
_MISSING = Response(404, HTML, b"")
def resp(body: str, ctype: str = HTML, status: int = 200, **headers: str) -> Response:
"""Build a Response; ``headers`` keys are already lowercase names."""
return Response(status, ctype, body.encode(), dict(headers))
class FakeSite(Site):
"""A dist-like (``live=False``) site answering from a dict."""
def __init__(self, pages: dict[str, Response]) -> None:
super().__init__("https://fake.test")
self.pages = pages
def _fetch(self, path: str, ua: str) -> Response:
return self.pages.get(path, _MISSING)
class FakeHttp(HttpSite):
"""A live site with no sockets: per-path (and per-UA) canned responses."""
def __init__(
self,
pages: dict[str, Response],
ua_pages: dict[tuple[str, str], Response] | None = None,
mcp: Response | None = None,
) -> None:
super().__init__("https://fake.test")
self.pages = pages
self.ua_pages = ua_pages or {}
self.mcp = mcp or _MISSING
def _fetch(self, path: str, ua: str) -> Response:
return self.ua_pages.get((path, ua)) or self.pages.get(path, _MISSING)
def request(
self,
path: str,
ua: str = DEFAULT_UA,
data: bytes | None = None,
headers: dict[str, str] | None = None,
) -> Response:
"""The canned MCP answer, whatever the request."""
return self.mcp
def make_ctx(site: Site, *, act: bool = False) -> tuple[Ctx, io.StringIO]:
"""A Ctx writing to a StringIO; returns it with that buffer."""
out = io.StringIO()
return Ctx(site=site, report=Report(out=out), act=act), out
def run_main(capsys: pytest.CaptureFixture[str], *argv: str) -> tuple[int, list[str]]:
"""Run the CLI in-process; return exit code and output lines."""
code = main(list(argv))
return code, capsys.readouterr().out.splitlines()
def lines_for(lines: list[str], ar_id: str, status: str = "") -> list[str]:
"""Output lines for ``ar_id`` (optionally only one status)."""
return [
ln
for ln in lines
if ln.split()[1] == ar_id and (not status or ln.startswith(status))
]

View File

@ -0,0 +1,153 @@
"""Build good and bad site trees in a temp dir (no static fixture files).
Generated at test time on purpose: mirror files such as ``index.md`` would
otherwise trip the repo's markdown-naming gate.
"""
from __future__ import annotations
import json
from pathlib import Path
ORIGIN = "https://example.test"
_TEXT = (
"Dice roller for tabletop games. Roll any combination of polyhedral dice, "
"keep the highest, and read the full breakdown of every throw. This text "
"is in the initial HTML so an agent that never runs JavaScript can read it."
)
_LD = json.dumps(
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Dice",
"url": ORIGIN,
}
)
CATALOG = {
"specVersion": "1.0",
"host": {"displayName": "kuhy", "identifier": "did:web:example.test"},
"entries": [
{
"identifier": "urn:air:example.test:docs:llms",
"displayName": "llms.txt",
"type": "text/plain",
"url": f"{ORIGIN}/llms.txt",
}
],
}
LLMS = f"""# Dice
> A dice roller for tabletop games.
Plain prose is allowed between sections.
## Pages
- [Home]({ORIGIN}/): what the roller does
- [About]({ORIGIN}/about/): who made it
"""
ROBOTS = f"""# robots.txt
User-agent: *
Disallow: /search/
Sitemap: {ORIGIN}/sitemap.xml
"""
SITEMAP = f"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>{ORIGIN}/</loc></url>
<url><loc>{ORIGIN}/about/</loc></url>
</urlset>
"""
def page(title: str, mirror: str, *, home: bool = False) -> str:
"""Return a server-rendered HTML page with every read-stage feature."""
head = [
f"<title>{title}</title>",
f'<link rel="alternate" type="text/markdown" href="{mirror}">',
]
if home:
head += [
'<link rel="ai-catalog" href="/.well-known/ai-catalog.json" type="application/ai-catalog+json">',
'<link rel="alternate" type="text/plain" href="/llms.txt">',
f'<script type="application/ld+json">{_LD}</script>',
]
return (
'<!doctype html><html lang="en"><head>' + "".join(head) + "</head><body>"
f"<h1>{title}</h1><p>{_TEXT}</p>"
'<pre><code class="language-python">print("d20")</code></pre>'
'<nav><a href="/">Home</a> <a href="/about/">About</a> <a href="/llms.txt">llms.txt</a></nav>'
"</body></html>"
)
def mirror(title: str) -> str:
"""Return a markdown mirror with a language-tagged fence."""
return f"# {title}\n\n{_TEXT}\n\n```python\nprint('d20')\n```\n"
def write(root: Path, rel: str, text: str) -> None:
"""Write ``text`` to ``root/rel``, creating parent dirs."""
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def build_good(root: Path) -> Path:
"""Create a fully compliant build output under ``root``."""
write(root, "index.html", page("Dice", "/index.md", home=True))
write(root, "index.md", mirror("Dice"))
write(root, "about/index.html", page("About", "/about/index.md"))
write(root, "about/index.md", mirror("About"))
write(
root,
"404.html",
"<!doctype html><html><body><h1>Not found</h1><p>No page at this address.</p></body></html>",
)
write(root, "robots.txt", ROBOTS)
write(root, "sitemap.xml", SITEMAP)
write(root, "llms.txt", LLMS)
write(root, ".well-known/ai-catalog.json", json.dumps(CATALOG))
return root
SHELL = '<!doctype html><html><head><title>App</title></head><body><div id="root"></div><script src="/app.js"></script></body></html>'
def build_bad(root: Path) -> Path:
"""Create a build output that violates every find/read item."""
write(root, "index.html", SHELL)
write(root, "about/index.html", SHELL)
write(
root,
"robots.txt",
"User-agent: *\nDisallow:\n\nUser-agent: GPTBot\nDisallow: /\n",
)
write(root, "sitemap.xml", "<html>not xml")
write(root, "llms.txt", "# Dice\n\n## Pages\n- [About](/about/)\n")
write(
root,
".well-known/ai-catalog.json",
json.dumps({"entries": [{"identifier": "x"}]}),
)
return root
def add_pages(root: Path, count: int, *, broken_last: bool = False) -> None:
"""Add ``count`` more sitemap pages; the last one an empty shell if asked."""
urls = []
for i in range(count):
title = f"Post {i}"
html = (
SHELL if broken_last and i == count - 1 else page(title, f"/p{i}/index.md")
)
write(root, f"p{i}/index.html", html)
write(root, f"p{i}/index.md", mirror(title))
urls.append(f" <url><loc>{ORIGIN}/p{i}/</loc></url>\n")
write(
root, "sitemap.xml", SITEMAP.replace("</urlset>", "".join(urls) + "</urlset>")
)

View File

@ -0,0 +1,75 @@
"""Backend edge cases: URL mapping, memoized failures, traversal, deadline."""
from __future__ import annotations
from pathlib import Path
import pytest
from agent_ready.backends import DirSite, HttpSite, Response, Site
from agent_ready.report import Undeterminable
from agent_ready.tests.fakes import FakeHttp, resp
class FlakySite(Site):
"""Raises Undeterminable on every fetch and counts the attempts."""
live = True
def __init__(self) -> None:
super().__init__("https://flaky.test")
self.attempts = 0
def _fetch(self, path: str, ua: str) -> Response:
self.attempts += 1
raise Undeterminable(f"{path}: boom")
def test_to_path_rejects_offsite_and_non_http(tmp_path: Path) -> None:
site = DirSite(tmp_path)
assert site.to_path("https://other.test/x") is None
assert site.to_path("mailto:a@b.test") is None
assert site.to_path("/rel/") == "/rel/"
assert site.to_path("https://dist.invalid") == "/"
def test_failures_are_memoized() -> None:
site = FlakySite()
for _ in range(2):
with pytest.raises(Undeterminable, match="boom"):
site.get("/x")
assert site.attempts == 1
def test_prefetch_swallows_failures_then_get_raises() -> None:
site = FlakySite()
site.prefetch(["/a", "/b", "/a"])
assert site.attempts == 2
with pytest.raises(Undeterminable):
site.get("/a")
def test_base_fetch_is_abstract() -> None:
with pytest.raises(NotImplementedError):
Site("https://x.test")._fetch("/", "ua")
def test_dist_never_serves_outside_root(tmp_path: Path) -> None:
root = tmp_path / "dist"
root.mkdir()
(tmp_path / "secret.txt").write_text("nope")
site = DirSite(root)
assert site.file_for("/../secret.txt") is None
assert site.get("/../secret.txt").status == 404
def test_exhausted_budget_is_undeterminable() -> None:
site = HttpSite("https://budget.test/anything", budget=0)
with pytest.raises(Undeterminable, match="time budget"):
site.get("/")
def test_fake_http_serves_canned_pages() -> None:
site = FakeHttp({"/": resp("hi")})
assert site.get("/").text == "hi"
assert site.get("/nope").status == 404

View File

@ -0,0 +1,153 @@
"""Dist-mode scenarios: one mutation of the good tree per failing branch."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from agent_ready.tests import sitegen
from agent_ready.tests.fakes import lines_for, run_main
OPENAPI_OP = {"get": {"operationId": "ping"}}
def test_robots_without_wildcard_group(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
robots = f"User-agent: Foo\nDisallow: /x\n\nSitemap: {sitegen.ORIGIN}/sitemap.xml\n"
sitegen.write(good_dir, "robots.txt", robots)
_, lines = run_main(capsys, "dist", str(good_dir))
assert "no 'User-agent: *' group" in lines_for(lines, "AR-FIND-01", "FAIL")[0]
def test_sitemap_found_without_robots_reference_sets_origin(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
sitegen.write(good_dir, "robots.txt", "User-agent: *\nDisallow:\n")
code, lines = run_main(capsys, "dist", str(good_dir))
assert code == 1
assert "no 'Sitemap:' line" in lines_for(lines, "AR-FIND-03", "FAIL")[0]
assert lines_for(lines, "AR-FIND-03", "FAIL")[1:] == []
assert lines_for(lines, "AR-READ-01", "PASS")
def test_sitemap_index_follows_children(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
child = f"{sitegen.ORIGIN}/sitemap-pages.xml"
index = (
'<?xml version="1.0"?><sitemapindex '
'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
f"<sitemap><loc>{child}</loc></sitemap></sitemapindex>"
)
sitegen.write(good_dir, "sitemap-pages.xml", sitegen.SITEMAP)
sitegen.write(good_dir, "sitemap.xml", index)
code, lines = run_main(capsys, "dist", str(good_dir))
assert code == 0, lines
assert "lists 2 pages" in lines_for(lines, "AR-FIND-03", "PASS")[-1]
def test_sitemap_with_offsite_locs_and_none_at_all(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
urlset = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">{}</urlset>'
off = urlset.format("<url><loc>https://other.test/x/</loc></url>")
sitegen.write(good_dir, "sitemap.xml", off)
_, lines = run_main(capsys, "dist", str(good_dir))
assert "1 <loc> URLs are not on" in lines_for(lines, "AR-FIND-03", "FAIL")[0]
sitegen.write(good_dir, "sitemap.xml", urlset.format(""))
_, lines = run_main(capsys, "dist", str(good_dir))
assert "no <loc> entries" in lines_for(lines, "AR-FIND-03", "FAIL")[0]
assert "sitemap yielded no pages" in lines_for(lines, "AR-READ-01", "FAIL")[0]
def test_sitemap_pages_missing_or_copies_of_the_shell(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
home = (good_dir / "index.html").read_text()
sitegen.write(good_dir, "dup/index.html", home)
extra = "".join(
f"<url><loc>{sitegen.ORIGIN}/{p}/</loc></url>" for p in ("dup", "gone")
)
sitemap = (good_dir / "sitemap.xml").read_text()
sitegen.write(
good_dir, "sitemap.xml", sitemap.replace("</urlset>", extra + "</urlset>")
)
code, lines = run_main(capsys, "dist", str(good_dir))
assert code == 1
problem = lines_for(lines, "AR-READ-01", "FAIL")[0]
assert "/gone/ (HTTP 404)" in problem
assert "/dup/ (byte-identical to the homepage shell)" in problem
def test_markdown_mirror_with_untagged_fence(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
sitegen.write(good_dir, "about/index.md", "# About\n\n```\nplain\n```\n")
_, lines = run_main(capsys, "dist", str(good_dir))
assert "about/index.md (1 untagged fences)" in lines_for(lines, "AR-READ-05")[0]
def test_llms_with_broken_link(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
llms = (good_dir / "llms.txt").read_text() + "- [Gone](/gone/): missing\n"
sitegen.write(good_dir, "llms.txt", llms)
_, lines = run_main(capsys, "dist", str(good_dir))
assert "broken links: /gone/" in lines_for(lines, "AR-READ-07", "FAIL")[0]
def test_homepage_with_unparseable_json_ld(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
home = (good_dir / "index.html").read_text()
sitegen.write(good_dir, "index.html", home.replace('"@context"', "@context"))
_, lines = run_main(capsys, "dist", str(good_dir))
assert "1 JSON-LD blocks fail" in lines_for(lines, "AR-READ-08", "FAIL")[-1]
def test_webmcp_found_in_linked_script(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
home = (good_dir / "index.html").read_text()
scripts = '<script src="https://cdn.other.test/x.js"></script><script src="/app.js"></script>'
sitegen.write(good_dir, "index.html", home.replace("</body>", scripts + "</body>"))
sitegen.write(good_dir, "app.js", "document.modelContext.registerTool({});")
_, lines = run_main(capsys, "dist", str(good_dir), "--act")
assert lines_for(lines, "AR-ACT-07", "PASS")
@pytest.mark.parametrize(
("doc", "status", "needle"),
[
({"openapi": "3.1.0", "paths": {"/p": OPENAPI_OP}}, "PASS", "1 operations"),
(
{"openapi": "3.1.0", "paths": {"/p": {"get": {}}}},
"FAIL",
"lack operationId",
),
({"openapi": "3.1.0", "paths": {}}, "FAIL", "no operations in paths"),
({"openapi": "3.1.0", "paths": 5}, "FAIL", "no operations in paths"),
(
{
"openapi": "3.1.0",
"paths": {"/a": "x", "/b": {"parameters": [], "get": 1}},
},
"FAIL",
"1 operations lack",
),
],
)
def test_openapi_document_shapes(
capsys: pytest.CaptureFixture[str],
good_dir: Path,
doc: dict[str, object],
status: str,
needle: str,
) -> None:
sitegen.write(good_dir, "openapi.json", json.dumps(doc))
_, lines = run_main(capsys, "dist", str(good_dir), "--act")
found = lines_for(lines, "AR-ACT-02", status)
assert found and needle in found[0], lines_for(lines, "AR-ACT-02")

View File

@ -0,0 +1,83 @@
"""Live-mode checks driven by canned responses (no sockets, no network)."""
from __future__ import annotations
import pytest
from agent_ready import checks_act, checks_find, checks_read
from agent_ready.backends import Response
from agent_ready.tests import sitegen
from agent_ready.tests.fakes import FakeHttp, make_ctx, resp
LONG = "Readable text for agents that never run JavaScript. " * 4
MD_LINK = '</index.md>; rel="alternate"; type="text/markdown"'
def _home(*, ctype: str = "text/html", **headers: str) -> Response:
return resp(f"<html><body><p>{LONG}</p></body></html>", ctype, **headers)
def test_challenge_page_for_one_agent_is_reported() -> None:
challenge = resp("<html>Just a moment...</html>")
ua = checks_find.AGENT_UAS["GPTBot"]
site = FakeHttp({"/": _home()}, ua_pages={("/", ua): challenge})
ctx, out = make_ctx(site)
checks_find.check_agent_access(ctx)
assert out.getvalue().startswith("FAIL AR-FIND-01 GPTBot got a challenge page")
def test_sitemap_on_another_host_is_a_failure() -> None:
robots = "User-agent: *\nDisallow:\nSitemap: https://other.test/sitemap.xml\n"
site = FakeHttp({"/robots.txt": resp(robots, "text/plain")})
ctx, out = make_ctx(site)
checks_find.check_robots(ctx)
ctx.sitemap_pages = []
assert checks_find._sitemap_paths(ctx) == ["/sitemap.xml"]
assert (
"Sitemap is on another host: https://other.test/sitemap.xml" in out.getvalue()
)
def test_markdown_mirror_advertised_only_by_link_header() -> None:
site = FakeHttp(
{
"/": _home(link=MD_LINK),
"/index.md": resp(sitegen.mirror("Home"), "text/markdown"),
}
)
ctx, out = make_ctx(site)
checks_read.check_pages(ctx)
assert (
"PASS AR-READ-09 1 pages advertise a working markdown mirror" in out.getvalue()
)
def test_page_served_with_wrong_content_type() -> None:
ctx, out = make_ctx(FakeHttp({"/": _home(ctype="text/plain")}))
checks_read.check_pages(ctx)
assert (
"FAIL AR-READ-01 answer not in initial HTML: / (text/plain)" in out.getvalue()
)
def _mcp(status: int, **headers: str) -> Response:
return Response(status, "application/json", b"{}", dict(headers))
@pytest.mark.parametrize(
("answer", "status"),
[
(_mcp(401, **{"www-authenticate": "Bearer"}), "PASS"),
(_mcp(401), "FAIL"),
],
)
def test_mcp_behind_oauth_needs_www_authenticate(answer: Response, status: str) -> None:
ctx, out = make_ctx(FakeHttp({}, mcp=answer), act=True)
checks_act.check_mcp(ctx)
assert f"{status} AR-ACT-03" in out.getvalue()
def test_act_off_reports_a_single_skip() -> None:
ctx, out = make_ctx(FakeHttp({}))
checks_act.run_act(ctx)
assert out.getvalue().startswith("SKIP AR-ACT-10 act stage off")

View File

@ -0,0 +1,202 @@
"""End-to-end tests of ``check.py`` in dist and url mode."""
from __future__ import annotations
import re
import runpy
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
import pytest
from agent_ready.check import main
from agent_ready.tests import sitegen
LINE = re.compile(r"^(PASS|FAIL|SKIP) AR-(FIND|READ|ACT)-\d\d \S")
PKG = Path(__file__).resolve().parents[1]
def run(capsys: pytest.CaptureFixture[str], *argv: str) -> tuple[int, list[str]]:
"""Run the CLI in-process; return exit code and output lines."""
code = main(list(argv))
lines = capsys.readouterr().out.splitlines()
bad = [ln for ln in lines if not LINE.match(ln)]
assert not bad, bad
return code, lines
def statuses(lines: list[str], ar_id: str) -> set[str]:
"""Return the set of statuses reported for ``ar_id``."""
return {ln.split()[0] for ln in lines if ln.split()[1] == ar_id}
def test_dist_good_passes(capsys: pytest.CaptureFixture[str], good_dir: Path) -> None:
code, lines = run(capsys, "dist", str(good_dir))
assert code == 0, "\n".join(lines)
assert not [ln for ln in lines if ln.startswith("FAIL")]
for ar_id in (
"AR-FIND-01",
"AR-FIND-03",
"AR-FIND-04",
"AR-READ-01",
"AR-READ-02",
"AR-READ-07",
"AR-READ-09",
):
found = statuses(lines, ar_id)
assert "PASS" in found and "FAIL" not in found, ar_id
assert statuses(lines, "AR-READ-11") == {"SKIP"}
assert statuses(lines, "AR-ACT-10") == {"SKIP"}
def test_dist_bad_fails_every_item(
capsys: pytest.CaptureFixture[str], bad_dir: Path
) -> None:
code, lines = run(capsys, "dist", str(bad_dir))
assert code == 1
for ar_id in (
"AR-FIND-01",
"AR-FIND-03",
"AR-FIND-04",
"AR-READ-01",
"AR-READ-02",
"AR-READ-03",
"AR-READ-06",
"AR-READ-07",
"AR-READ-08",
"AR-READ-09",
):
assert "FAIL" in statuses(lines, ar_id), ar_id
assert any("gptbot" in ln for ln in lines if "AR-FIND-01" in ln)
def test_dist_untagged_code_fails(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
about = good_dir / "about/index.html"
about.write_text(about.read_text().replace(' class="language-python"', ""))
code, lines = run(capsys, "dist", str(good_dir))
assert code == 1
assert "FAIL" in statuses(lines, "AR-READ-05")
def test_dist_act_opt_in(capsys: pytest.CaptureFixture[str], good_dir: Path) -> None:
code, lines = run(capsys, "dist", str(good_dir), "--act")
assert code == 1
assert "FAIL" in statuses(lines, "AR-ACT-02")
def test_usage_errors(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
assert main(["dist", str(tmp_path / "missing")]) == 2
assert main(["url", "not-a-url"]) == 2
with pytest.raises(SystemExit) as exc:
main([])
assert exc.value.code == 2
def test_url_good_passes(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
code, lines = run(capsys, "url", serve(good_dir))
assert code == 0, "\n".join(lines)
assert statuses(lines, "AR-READ-02") == {"PASS"}
def test_url_spa_fallback_fails(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
"""Every path 200 text/html: a status-only check would wrongly pass."""
code, lines = run(capsys, "url", serve(good_dir, "spa"))
assert code == 1
for ar_id in (
"AR-FIND-01",
"AR-FIND-03",
"AR-FIND-04",
"AR-READ-01",
"AR-READ-02",
"AR-READ-07",
"AR-READ-09",
):
assert "FAIL" in statuses(lines, ar_id), ar_id
assert any("text/html" in ln for ln in lines if "AR-FIND-01" in ln)
assert any(
"does not exist" in ln for ln in lines if "AR-READ-06" in ln
) # HTML fallback != file
def test_url_json_404(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
code, lines = run(capsys, "url", serve(good_dir, "json404"))
assert code == 1
assert statuses(lines, "AR-READ-02") == {"PASS"}
assert "FAIL" in statuses(lines, "AR-FIND-01")
def test_url_blocked_agent(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
code, lines = run(capsys, "url", serve(good_dir, block_ua="GPTBot"))
assert code == 1
assert any(ln.startswith("FAIL AR-FIND-01") and "GPTBot" in ln for ln in lines)
def test_url_unreachable_is_3(
capsys: pytest.CaptureFixture[str], dead_url: str
) -> None:
assert main(["url", dead_url]) == 3
def test_entry_points_from_other_cwd(good_dir: Path, tmp_path: Path) -> None:
for cmd in (
["bash", str(PKG / "check.sh")],
[sys.executable, str(PKG / "check.py")],
):
proc = subprocess.run(
[*cmd, "dist", str(good_dir)],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
def test_dist_checks_every_page_url_caps_visibly(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
"""Dist mode ignores --max-pages; url mode names the cap instead of hiding it."""
sitegen.add_pages(good_dir, 6, broken_last=True)
code, lines = run(capsys, "dist", str(good_dir), "--max-pages", "3")
assert code == 1
assert any(ln.startswith("FAIL AR-READ-01") and "/p5/" in ln for ln in lines)
code, lines = run(capsys, "url", serve(good_dir), "--max-pages", "3")
assert any(ln.startswith("SKIP AR-READ-01 checked 3 of 8 pages") for ln in lines), (
lines
)
def test_url_act_mcp(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
_, lines = run(capsys, "url", serve(good_dir), "--act")
assert statuses(lines, "AR-ACT-03") == {"PASS"}
assert statuses(lines, "AR-ACT-10") == {"PASS"}
assert "FAIL" in statuses(lines, "AR-ACT-07")
code, lines = run(capsys, "url", serve(good_dir, "spa"), "--act")
assert code == 1
assert statuses(lines, "AR-ACT-03") == {"FAIL"}
assert statuses(lines, "AR-ACT-10") == {"FAIL"}
def test_script_entry_point_in_process(good_dir: Path) -> None:
"""Run check.py as ``__main__`` here so the guard lines count for coverage."""
argv = ["check.py", "dist", str(good_dir)]
with pytest.MonkeyPatch.context() as patch:
patch.setattr(sys, "argv", argv)
patch.setattr(sys, "path", list(sys.path)) # check.py prepends to it
with pytest.raises(SystemExit) as exc:
runpy.run_path(str(PKG / "check.py"), run_name="__main__")
assert exc.value.code == 0

View File

@ -0,0 +1,186 @@
"""Unit tests for the format validators and the HTML scanner."""
from __future__ import annotations
import pytest
from agent_ready import formats, htmlscan
from agent_ready.tests import sitegen
# Verbatim from spec/spec.md "llms.txt shape (informative)".
SPEC_LLMS = """# Acme
> Acme ships a payments API for developers.
## Docs
- [Quickstart](https://example.com/docs/quickstart): first charge in one request
- [Authentication](https://example.com/docs/auth): API keys, scopes, rotation
## API
- [OpenAPI](https://example.com/openapi.json): full request and response contract
"""
# Shape of https://vercel.com/.well-known/ai-catalog.json (2026-10-22), trimmed.
VERCEL_CATALOG = {
"specVersion": "1.0",
"host": {"displayName": "Vercel", "identifier": "https://vercel.com"},
"entries": [
{
"identifier": "urn:air:vercel.com:api:rest",
"displayName": "Vercel REST API",
"type": "application/json",
"url": "https://vercel.com/openapi.json",
"tags": ["api"],
}
],
}
def test_llms_spec_example_is_valid() -> None:
assert formats.llms_problems(SPEC_LLMS) == []
def test_llms_allows_prose_and_plain_bullets() -> None:
text = SPEC_LLMS.replace(
"## API", "Version: v2.0\n\n## Notes\n- plain bullet\n\n## API"
)
assert formats.llms_problems(text) == []
@pytest.mark.parametrize(
("mutate", "needle"),
[
(lambda t: t.replace("# Acme\n", ""), "H1"),
(
lambda t: t.replace("> Acme ships a payments API for developers.\n", ""),
"blockquote",
),
(lambda t: "# Acme\n> summary\n\nno sections\n", "link list"),
(lambda t: "<!doctype html><html></html>", "HTML"),
],
)
def test_llms_rejects(mutate: object, needle: str) -> None:
problems = formats.llms_problems(mutate(SPEC_LLMS)) # type: ignore[operator]
assert any(needle in p for p in problems), problems
def test_llms_links() -> None:
assert formats.llms_links(SPEC_LLMS)[0] == "https://example.com/docs/quickstart"
def test_catalog_valid_examples() -> None:
assert formats.catalog_problems(VERCEL_CATALOG) == []
assert formats.catalog_problems(sitegen.CATALOG) == []
minimal = {
"specVersion": "1.0",
"entries": [{"identifier": "a", "type": "t", "data": {}}],
}
assert formats.catalog_problems(minimal) == []
@pytest.mark.parametrize(
("doc", "needle"),
[
([], "object"),
({"entries": []}, "specVersion"),
({"specVersion": "1", "entries": []}, "Major.Minor"),
({"specVersion": "1.0"}, "entries"),
({"specVersion": "1.0", "entries": [{"type": "t", "url": "u"}]}, "identifier"),
({"specVersion": "1.0", "entries": [{"identifier": "i", "url": "u"}]}, "type"),
(
{
"specVersion": "1.0",
"entries": [{"identifier": "i", "type": "t", "url": "u", "data": 1}],
},
"exactly one",
),
({"specVersion": "1.0", "entries": [], "host": {}}, "host.displayName"),
],
)
def test_catalog_rejects(doc: object, needle: str) -> None:
assert any(needle in p for p in formats.catalog_problems(doc)), (
formats.catalog_problems(doc)
)
def test_robots_blanket_blocks() -> None:
text = "User-agent: *\nDisallow:\n\nUser-Agent: GPTBot\nUser-agent: CCBot\nDisallow: /*\n"
groups = formats.parse_robots(text)
assert formats.has_wildcard_group(groups)
assert formats.blocked_agents(groups) == ["gptbot", "ccbot"]
def test_robots_partial_disallow_and_allow_override() -> None:
text = "User-agent: *\nDisallow: /search/\n\nUser-agent: ClaudeBot\nDisallow: /\nAllow: /\n"
assert formats.blocked_agents(formats.parse_robots(text)) == []
assert formats.robots_sitemaps("Sitemap: https://x.test/s.xml\n") == [
"https://x.test/s.xml"
]
def test_sitemap_parse() -> None:
kind, locs = formats.parse_sitemap(sitegen.SITEMAP.encode())
assert kind == "urlset"
assert locs == ["https://example.test/", "https://example.test/about/"]
with pytest.raises(ValueError):
formats.parse_sitemap(b"<html>not xml")
def test_untagged_fences() -> None:
assert formats.untagged_fences("```python\nx\n```\n") == 0
assert formats.untagged_fences("```\nx\n```\n~~~\ny\n~~~\n") == 2
def test_htmlscan_good_home() -> None:
scan = htmlscan.scan(sitegen.page("Dice", "/index.md", home=True))
assert len(scan.text) > 150
assert scan.code_blocks == 1 and scan.untagged_code == 0
assert scan.link_href("alternate", "text/markdown") == "/index.md"
assert scan.link_href("ai-catalog") == "/.well-known/ai-catalog.json"
assert "/about/" in scan.anchors
assert scan.jsonld_ok == 1
def test_htmlscan_shell_has_no_text() -> None:
scan = htmlscan.scan(sitegen.SHELL)
assert scan.text.strip() == "" # <title> is head, not body text
assert scan.anchors == []
bare = htmlscan.scan(
'<pre><code>x</code></pre><script type="application/ld+json">{bad</script>'
)
assert bare.untagged_code == 1 and bare.jsonld_bad == 1
def test_sitemap_wrong_root_is_rejected() -> None:
with pytest.raises(ValueError, match="not <urlset>"):
formats.parse_sitemap(b"<feed><loc>https://x.test/</loc></feed>")
def test_catalog_entry_must_be_an_object() -> None:
doc = {"specVersion": "1.0", "entries": ["nope"]}
assert formats.catalog_problems(doc) == ["entries[0]: must be an object"]
def test_catalog_accessors_tolerate_invalid_docs() -> None:
for doc in ([], {"entries": "x"}):
assert formats.catalog_urls(doc) == []
assert formats.catalog_types(doc) == []
assert formats.catalog_urls(sitegen.CATALOG) == [f"{sitegen.ORIGIN}/llms.txt"]
def test_fence_inside_fence_is_not_a_new_block() -> None:
nested = "````md\n```\ninner\n```\n````\n"
assert formats.untagged_fences(nested) == 0
assert formats.untagged_fences("```python\n~~~\n```\n```\nz\n```\n") == 1
def test_htmlscan_jsonld_without_context_is_bad() -> None:
scan = htmlscan.scan(
'<script type="application/ld+json">{"name": "x"}</script>'
'<script type="application/ld+json">[]</script>'
)
assert scan.jsonld_ok == 0 and scan.jsonld_bad == 2
def test_htmlscan_unclosed_inner_hidden_tag_closes_with_outer() -> None:
scan = htmlscan.scan("<body><template><noscript>x</template><p>visible</p></body>")
assert scan.text == "visible"

View File

@ -0,0 +1,54 @@
"""Report policy, Link-header parsing and context helpers."""
from __future__ import annotations
import io
from agent_ready.backends import Response
from agent_ready.context import link_header_href
from agent_ready.report import Report, Undeterminable, summarize
from agent_ready.tests.fakes import FakeSite, make_ctx, resp
def _boom() -> None:
raise Undeterminable("refused")
def test_guard_turns_network_failure_into_skip_and_exit_3() -> None:
out = io.StringIO()
report = Report(out=out)
report.guard("AR-FIND-01", _boom)
assert out.getvalue() == "SKIP AR-FIND-01 undeterminable (network): refused\n"
assert report.exit_code() == 3
report.fail("AR-FIND-01", "x")
assert report.exit_code() == 1
def test_summarize_notes_the_overflow() -> None:
assert summarize(["a", "b"]) == "a, b"
assert summarize(list("abcdef")) == "a, b, c, d (+2 more)"
def _linked(link: str) -> Response:
return Response(200, "text/html", b"", {"link": link})
def test_link_header_skips_unusable_values() -> None:
header = (
"garbage, </no-rel>; type=x, </other>; rel=preload, "
'</a.md>; rel="alternate"; type="text/plain", '
'</b.md>; rel="alternate"; type="text/markdown"'
)
assert link_header_href(_linked(header), "alternate", "text/markdown") == "/b.md"
assert link_header_href(_linked(header), "alternate") == "/a.md"
assert link_header_href(_linked(header), "ai-catalog") is None
assert link_header_href(_linked(""), "alternate") is None
def test_exists_rejects_html_fallback_for_files() -> None:
ctx, _ = make_ctx(
FakeSite({"/f.txt": resp("<html><head></head>"), "/p/": resp("<html>")})
)
assert not ctx.exists("/f.txt")
assert ctx.exists("/p/")
assert not ctx.exists("/missing.txt")

View File

@ -68,6 +68,31 @@ GitHub Actions), then bootstraps the `## Commands` section and
The hook only fires on commits touching CLAUDE.md/AGENTS.md/test_changed.sh, The hook only fires on commits touching CLAUDE.md/AGENTS.md/test_changed.sh,
so a repo that never got the installer is caught only by `sweep.py`. so a repo that never got the installer is caught only by `sweep.py`.
## Websites: the AgentReady gate
Every website kuhy owns must pass `~/src/utils/agent_ready` (decided
2026-10-22). A repo is a website when `repo-contract.toml` has an
`[agent_ready]` table, or when detection finds a node web build: a `build`
script in package.json plus a Vite or Astro config or a root `index.html`.
`check.py` fails a detected repo that has no table. Keys:
`dist = "<build dir>"` with optional `build` and `install`, or
`url = "https://..."` for server-rendered sites, or `skip = "<reason>"` for a
repo that is not a website. `act = true` turns on the act checks.
For a website, the installer also writes the proposed table (it never
overwrites one), the `scripts/check_agent_ready.sh` shim, the `agent-ready`
pre-commit hook, and `.github/workflows/agent-ready.yml`. That workflow checks
out kuhyx/utils into `.utils`, sets up Node, and runs
`check_agent_ready.sh --strict --install`. The shared gate
`scripts/check_agent_ready.sh` builds the site and runs
`agent_ready/check.sh dist|url`. The build runs in a throwaway copy of the
working tree (`isolate.py`), because a live `dist/` may be bind-mounted into
Caddy and must never be emptied by a commit. The hook only fires when a
web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
`files = "<regex>"`); CI rebuilds on every push. Without `--strict`, an unreachable url
degrades to a pass, and the gate defers to that workflow when run inside
another GitHub Actions job.
## Tests ## Tests
```bash ```bash

View File

@ -0,0 +1,127 @@
"""Gate: the repo's website passes the shared AgentReady checker.
Usage: agent_ready_gate.py [--repo PATH] [--strict] [--install]
Reads `[agent_ready]` from repo-contract.toml (see web.py). dist mode runs the
`build` command in a throwaway copy of the tree (never the live dist), then `agent_ready/check.sh dist <dir>`; url mode runs
`check.sh url <url>`. `--install` runs `install` first (CI: fresh checkout).
Exit: 0 pass / no site / skip, 1 FAIL (checker, build or missing config),
2 malformed config, 3 undeterminable (url mode, only with --strict).
Without --strict an unreachable site degrades to a pass with a banner, like
dependency-freshness: `--no-verify` is banned, so an offline commit must still
go through, and CI (always online, always --strict) is the authority.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import tempfile
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.isolate import populate
from repo_contract.web import CONFIG, TABLE, AgentReady, ConfigError, detect, load
CHECKER = Path(__file__).resolve().parents[1] / "agent_ready" / "check.sh"
EXIT_OK, EXIT_FAIL, EXIT_CONFIG, EXIT_UNDETERMINABLE = 0, 1, 2, 3
TAIL = 40
def _run_step(repo: Path, label: str, command: str) -> bool:
"""Run a build/install command quietly; show its tail only on failure."""
done = subprocess.run(
["bash", "-c", command],
cwd=repo,
capture_output=True,
text=True,
check=False,
)
if done.returncode == 0:
return True
tail = (done.stdout + done.stderr).splitlines()[-TAIL:]
print("\n".join(tail), file=sys.stderr)
print(
f"agent-ready: {label} failed (exit {done.returncode}): {command}",
file=sys.stderr,
)
return False
def _run_checker(cfg: AgentReady, args: list[str], *, strict: bool) -> int:
"""Run the shared checker on `args` (mode + target); degrade exit 3 locally."""
argv = [str(CHECKER), *args, *(["--act"] if cfg.act else [])]
code = subprocess.run(["bash", *argv], check=False).returncode
if code == EXIT_UNDETERMINABLE and not strict:
print(
f"agent-ready: DEGRADED: {cfg.target} unreachable, passing locally; "
"CI (--strict) is the authority",
file=sys.stderr,
)
return EXIT_OK
return code
def _check(repo: Path, cfg: AgentReady, *, strict: bool, install: bool) -> int:
"""Build if configured, then run the shared checker; returns the exit code.
The build runs in a throwaway copy (isolate.py): the live `dist` may be
bind-mounted into a serving container, and a gate must never empty it.
"""
if cfg.mode == "url":
return _run_checker(cfg, ["url", cfg.target], strict=strict)
if install and cfg.install and not _run_step(repo, "install", cfg.install):
return EXIT_FAIL
if not cfg.build:
return _run_checker(cfg, ["dist", str(repo / cfg.target)], strict=strict)
with tempfile.TemporaryDirectory(prefix="agent-ready-") as tmp:
copy = Path(tmp) / repo.name
copy.mkdir()
populate(repo, copy)
if not _run_step(copy, "build", cfg.build):
return EXIT_FAIL
return _run_checker(cfg, ["dist", str(copy / cfg.target)], strict=strict)
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())
parser.add_argument("--strict", action="store_true", help="CI: offline fails")
parser.add_argument("--install", action="store_true", help="run `install` first")
args = parser.parse_args(argv)
repo = args.repo.expanduser().resolve()
try:
cfg = load(repo)
except ConfigError as exc:
print(f"agent-ready: {CONFIG}: {exc}", file=sys.stderr)
return EXIT_CONFIG
if cfg is None:
if detect(repo) is None:
print("agent-ready: no website in this repo")
return EXIT_OK
print(
f"agent-ready: web build detected but {CONFIG} has no [{TABLE}]; run "
"~/src/utils/scripts/install_repo_contract_gate.sh",
file=sys.stderr,
)
return EXIT_FAIL
if cfg.mode == "skip":
print(f"agent-ready: skipped: {cfg.target}")
return EXIT_OK
if os.environ.get("GITHUB_ACTIONS") and not args.strict:
# A repo's generic pre-commit workflow has no node toolchain or deps;
# the dedicated agent-ready workflow builds and checks with --strict.
print("agent-ready: CI pre-commit run; the agent-ready workflow enforces it")
return EXIT_OK
return _check(repo, cfg, strict=args.strict, install=args.install)
if __name__ == "__main__": # pragma: no cover
sys.exit(main())

View File

@ -25,6 +25,7 @@ from repo_contract.contract import (
verbose_in_script, verbose_in_script,
) )
from repo_contract.stacks import detect_stacks, has_tests 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 EXIT_OK, EXIT_VIOLATIONS, EXIT_UNREADABLE = 0, 1, 2
DOC_NAMES = ("CLAUDE.md", "AGENTS.md") DOC_NAMES = ("CLAUDE.md", "AGENTS.md")
@ -99,7 +100,17 @@ def _check_consistency(
def violations(repo: Path) -> tuple[list[str], list[str]]: def violations(repo: Path) -> tuple[list[str], list[str]]:
"""(violations, detected stacks) for `repo`. Raises Unreadable.""" """(violations, detected stacks) for `repo`. Raises Unreadable.
Website repos also owe the AgentReady gate (web.problems), reported
whatever state the Commands section is in.
"""
found, stacks = _doc_violations(repo)
return found + web_problems(repo), stacks
def _doc_violations(repo: Path) -> tuple[list[str], list[str]]:
"""Violations of the `## Commands` contract proper."""
stacks = detect_stacks(repo) stacks = detect_stacks(repo)
text = read_doc(repo) text = read_doc(repo)
if text is None: if text is None:

View File

@ -25,15 +25,14 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
from repo_contract import bootstrap from repo_contract import bootstrap
from repo_contract.check import violations from repo_contract.check import violations
from repo_contract.infer import propose from repo_contract.infer import propose
from repo_contract.install_web import install_web, plan_web
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
from repo_contract.stacks import detect_stacks from repo_contract.stacks import detect_stacks
TEMPLATES = bootstrap.TEMPLATES TEMPLATES = bootstrap.TEMPLATES
SHIM = Path("scripts/check_repo_contract.sh") SHIM = Path("scripts/check_repo_contract.sh")
WORKFLOW = Path(".github/workflows/repo-contract.yml") WORKFLOW = Path(".github/workflows/repo-contract.yml")
PRECOMMIT = Path(".pre-commit-config.yaml")
HOOK_ID = "repo-contract" HOOK_ID = "repo-contract"
LOCAL_REPO = " - repo: local"
EMPTY_PRECOMMIT = f"repos:\n{LOCAL_REPO}\n hooks:\n"
def _template(name: str) -> str: def _template(name: str) -> str:
@ -60,54 +59,20 @@ def _needs_workflow(repo: Path) -> bool:
) )
def _needs_hook(repo: Path) -> bool:
target = repo / PRECOMMIT
return not target.is_file() or f"id: {HOOK_ID}" not in target.read_text(
encoding="utf-8"
)
def _local_block_end(lines: list[str]) -> int:
"""Index just past the LAST `- repo: local` block, or -1 if there is none."""
starts = [n for n, line in enumerate(lines) if line.rstrip() == LOCAL_REPO]
if not starts:
return -1
after = [
n for n in range(starts[-1] + 1, len(lines)) if lines[n].startswith(" - repo:")
]
return after[0] if after else len(lines)
def _write_hook(repo: Path) -> None:
"""Insert the hook into the LAST local hooks list, keeping comments intact."""
target = repo / PRECOMMIT
body = target.read_text(encoding="utf-8") if target.is_file() else EMPTY_PRECOMMIT
lines = (body if body.endswith("\n") else body + "\n").split("\n")[:-1]
cut = _local_block_end(lines)
if cut < 0:
raise ValueError(
f"{target} has no `{LOCAL_REPO}` block to add the hook to; "
"add one by hand, then re-run"
)
block = [
ln
for ln in _template("pre-commit-snippet.yaml").rstrip("\n").split("\n")
if not ln.startswith("#")
]
lines[cut:cut] = ["", *block]
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
def plan(repo: Path) -> list[str]: def plan(repo: Path) -> list[str]:
"""Which gate pieces are missing or drifted (the Commands section excluded).""" """Which gate pieces are missing or drifted (the Commands section excluded).
A website (detected or declared in repo-contract.toml) also gets the
AgentReady gate: config, shim, workflow and hook (see install_web.py).
"""
todo = [] todo = []
if _needs_shim(repo): if _needs_shim(repo):
todo.append(str(SHIM)) todo.append(str(SHIM))
if _needs_workflow(repo): if _needs_workflow(repo):
todo.append(str(WORKFLOW)) todo.append(str(WORKFLOW))
if _needs_hook(repo): if needs_hook(repo, HOOK_ID):
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)") todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
return todo return todo + plan_web(repo)
def install(repo: Path) -> list[str]: def install(repo: Path) -> list[str]:
@ -124,10 +89,10 @@ def install(repo: Path) -> list[str]:
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(_template("repo-contract.yml"), encoding="utf-8") target.write_text(_template("repo-contract.yml"), encoding="utf-8")
done.append(str(WORKFLOW)) done.append(str(WORKFLOW))
if _needs_hook(repo): if needs_hook(repo, HOOK_ID):
_write_hook(repo) write_hook(repo, _template("pre-commit-snippet.yaml"))
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)") done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
return done return done + install_web(repo)
def bootstrap_commands(repo: Path) -> list[str]: def bootstrap_commands(repo: Path) -> list[str]:

View File

@ -0,0 +1,127 @@
"""The AgentReady half of the installer: a website gets the agent-ready gate.
Why it rides on install_repo_contract_gate.sh rather than a separate script:
that installer is already the one step every new repo gets after `git init`,
so a new Vite site cannot come out of scaffolding without the gate. Pieces,
mirroring the repo-contract and dependency-freshness gates: the
`[agent_ready]` table in repo-contract.toml (proposed from detection, never
overwritten), the scripts/check_agent_ready.sh delegate, the pre-commit hook
and the agent-ready CI workflow (skipped when the repo has no Actions).
"""
from __future__ import annotations
from pathlib import Path
from dep_freshness.install import NO_WORKFLOW_MARKER
from repo_contract.precommit import (
PRECOMMIT,
hook_line,
needs_hook,
set_hook_line,
write_hook,
)
from repo_contract.web import (
CONFIG,
HOOK_ID,
SHIM,
TABLE,
WORKFLOW,
AgentReady,
ConfigError,
detect,
hook_files,
load,
render,
)
TEMPLATES = Path(__file__).resolve().parent / "templates"
HEADER = (
"# Repo-contract settings, read by ~/src/utils/repo_contract.\n"
"# [agent_ready]: this repo is a website and must pass the AgentReady\n"
"# checker (~/src/utils/agent_ready). See repo_contract/web.py for keys.\n\n"
)
CONFIG_PIECE = f"{CONFIG} ([{TABLE}])"
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
def _template(name: str) -> str:
return (TEMPLATES / name).read_text(encoding="utf-8")
def _drifted(repo: Path, rel: Path, template: str) -> bool:
target = repo / rel
return not target.is_file() or target.read_text(encoding="utf-8") != _template(
template
)
def _files_line(cfg: AgentReady) -> str:
"""The hook's `files:` line: the regex as a single-quoted YAML scalar."""
return " files: '" + hook_files(cfg).replace("'", "''") + "'"
def _settings(repo: Path) -> tuple[AgentReady | None, bool]:
"""(settings to gate with, whether the config must be written)."""
try:
declared = load(repo)
except ConfigError:
return None, False # check.py reports it; never guess over a typo
if declared is not None:
return (None if declared.mode == "skip" else declared), False
proposed = detect(repo)
return proposed, proposed is not None
def _pieces(repo: Path) -> tuple[AgentReady | None, list[str]]:
cfg, write_config = _settings(repo)
if cfg is None:
return None, []
todo = [CONFIG_PIECE] if write_config else []
if _drifted(repo, SHIM, "check_agent_ready.shim.sh"):
todo.append(str(SHIM))
if not (repo / NO_WORKFLOW_MARKER).exists() and _drifted(
repo, WORKFLOW, "agent-ready.yml"
):
todo.append(str(WORKFLOW))
if needs_hook(repo, HOOK_ID) or hook_line(repo, HOOK_ID, "files") != _files_line(
cfg
):
todo.append(HOOK_PIECE)
return cfg, todo
def plan_web(repo: Path) -> list[str]:
"""Which AgentReady gate pieces are missing or drifted."""
return _pieces(repo)[1]
def _write_config(repo: Path, cfg: AgentReady) -> None:
target = repo / CONFIG
if target.is_file():
body = target.read_text(encoding="utf-8")
body = (body if body.endswith("\n") else body + "\n") + "\n"
else:
body = HEADER
target.write_text(body + render(cfg), encoding="utf-8")
def install_web(repo: Path) -> list[str]:
"""Write every missing or drifted AgentReady gate piece. Returns them."""
cfg, todo = _pieces(repo)
if cfg is not None and CONFIG_PIECE in todo:
_write_config(repo, cfg)
if str(SHIM) in todo:
target = repo / SHIM
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(_template("check_agent_ready.shim.sh"), encoding="utf-8")
target.chmod(0o755)
if str(WORKFLOW) in todo:
target = repo / WORKFLOW
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(_template("agent-ready.yml"), encoding="utf-8")
if cfg is not None and HOOK_PIECE in todo:
if needs_hook(repo, HOOK_ID):
write_hook(repo, _template("agent-ready-hook.yaml"))
set_hook_line(repo, HOOK_ID, "files", _files_line(cfg))
return todo

53
repo_contract/isolate.py Normal file
View File

@ -0,0 +1,53 @@
"""A throwaway copy of a repo's working tree to run the site build in.
Why: kcd2-dice-solver (and personal-website) bind-mount `dist/` into the live
Caddy container, so a gate that ran `pnpm run build` in the repo emptied the
live site on every commit (Vite's emptyOutDir) and shipped the uncommitted
tree. Building in a copy means no build command, prerender step or outDir
setting can ever write the configured live `dist`.
The copy is the git working tree (tracked + untracked, minus ignored files, so
a stale `dist/` never leaks in) with `node_modules` symlinked, not copied.
Outside git (tests, a bare dir) everything but node_modules/.git is copied.
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
SKIP = ("node_modules", ".git")
def _git_files(repo: Path) -> list[str]:
"""Working-tree files git would see: tracked + untracked, not ignored."""
out = subprocess.run(
["git", "-C", str(repo), "ls-files", "-z", "--cached", "--others"]
+ ["--exclude-standard"],
capture_output=True,
check=True,
).stdout
return sorted({f.decode() for f in out.split(b"\0") if f})
def populate(repo: Path, dest: Path) -> None:
"""Fill the empty dir `dest` with a build-ready copy of `repo`."""
if (repo / ".git").exists():
for rel in _git_files(repo):
src = repo / rel
if not src.is_file() or rel.split("/")[0] in SKIP:
continue # deleted in the worktree, or a submodule/dep dir
target = dest / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target, follow_symlinks=False)
else:
shutil.copytree(
repo,
dest,
symlinks=True,
ignore=shutil.ignore_patterns(*SKIP),
dirs_exist_ok=True,
)
if (repo / "node_modules").is_dir():
(dest / "node_modules").symlink_to(repo / "node_modules")

View File

@ -0,0 +1,90 @@
"""Add a local hook to a repo's .pre-commit-config.yaml without a YAML round-trip.
A round-trip would drop the comments explaining why each hook is there, so the
snippet is spliced in as text at the end of the LAST `- repo: local` block.
"""
from __future__ import annotations
from pathlib import Path
PRECOMMIT = Path(".pre-commit-config.yaml")
LOCAL_REPO = " - repo: local"
EMPTY_PRECOMMIT = f"repos:\n{LOCAL_REPO}\n hooks:\n"
def needs_hook(repo: Path, hook_id: str) -> bool:
"""True when the pre-commit config is missing or lacks `id: <hook_id>`."""
target = repo / PRECOMMIT
return not target.is_file() or f"id: {hook_id}" not in target.read_text(
encoding="utf-8"
)
def local_block_end(lines: list[str]) -> int:
"""Index just past the LAST `- repo: local` block, or -1 if there is none."""
starts = [n for n, line in enumerate(lines) if line.rstrip() == LOCAL_REPO]
if not starts:
return -1
after = [
n for n in range(starts[-1] + 1, len(lines)) if lines[n].startswith(" - repo:")
]
return after[0] if after else len(lines)
def write_hook(repo: Path, snippet: str) -> None:
"""Insert `snippet` (comment lines dropped) into the LAST local hooks list."""
target = repo / PRECOMMIT
body = target.read_text(encoding="utf-8") if target.is_file() else EMPTY_PRECOMMIT
lines = (body if body.endswith("\n") else body + "\n").split("\n")[:-1]
cut = local_block_end(lines)
if cut < 0:
raise ValueError(
f"{target} has no `{LOCAL_REPO}` block to add the hook to; "
"add one by hand, then re-run"
)
block = [ln for ln in snippet.rstrip("\n").split("\n") if not ln.startswith("#")]
lines[cut:cut] = ["", *block]
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _hook_block(lines: list[str], hook_id: str) -> tuple[int, int]:
"""[start, end) of the hook's lines, trailing blanks excluded.
Precondition for this and the two callers below: the hook exists
(`needs_hook` is False), so the id line is there to anchor on.
"""
start = lines.index(f" - id: {hook_id}")
end = next(
(
n
for n in range(start + 1, len(lines))
if lines[n].startswith((" - ", " - "))
),
len(lines),
)
while not lines[end - 1].strip():
end -= 1
return start, end
def hook_line(repo: Path, hook_id: str, key: str) -> str | None:
"""The hook's `<key>:` line (8-space indent) as written, or None."""
lines = (repo / PRECOMMIT).read_text(encoding="utf-8").split("\n")
start, end = _hook_block(lines, hook_id)
prefix = f" {key}:"
return next((ln for ln in lines[start:end] if ln.startswith(prefix)), None)
def set_hook_line(repo: Path, hook_id: str, key: str, line: str) -> None:
"""Replace the hook's `<key>:` line with `line`, or append it to the block."""
target = repo / PRECOMMIT
lines = target.read_text(encoding="utf-8").split("\n")
start, end = _hook_block(lines, hook_id)
prefix = f" {key}:"
found = [n for n in range(start, end) if lines[n].startswith(prefix)]
if found:
lines[found[0]] = line
else:
lines.insert(end, line)
target.write_text("\n".join(lines), encoding="utf-8")

View File

@ -0,0 +1,16 @@
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
# for any repo with [agent_ready] in repo-contract.toml (a website).
# Every website passes the AgentReady checker (~/src/utils/agent_ready):
# builds, then checks robots/sitemap/llms.txt/ai-catalog/404/no-JS text/
# markdown mirrors/JSON-LD in the build output. `files:` limits it to
# commits touching web inputs (override: [agent_ready] files in
# repo-contract.toml; the installer rewrites this line to match). CI
# rebuilds on every push and is the strict authority. pre-commit stage,
# not pre-push: a cold tsc + vite build + check measured 1.5-2.7 s
# (kcd2-dice-solver, personal-website copies, 2026-10-22).
- id: agent-ready
name: AgentReady (website build output)
entry: scripts/check_agent_ready.sh
language: system
pass_filenames: false
files: {files}

View File

@ -0,0 +1,50 @@
name: agent-ready
# Every website kuhy owns passes the AgentReady checker. The local pre-commit
# hook can be skipped (a fresh clone has no hooks), so this rebuilds the site
# from a clean checkout and checks the output on every push. Settings live in
# repo-contract.toml [agent_ready]; see ~/src/utils/repo_contract/web.py.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
agent-ready:
name: AgentReady
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The gate (repo_contract) and the checker (agent_ready) both live in
# the shared utils repo, so every site is judged by the same checker.
- name: Check out the shared gate
uses: actions/checkout@v4
with:
repository: kuhyx/utils
path: .utils
# The gate and checker are stdlib-only (tomllib needs 3.11+).
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
# Node comes from .nvmrc (the dependency-freshness pin). Without one the
# runner's preinstalled Node builds the site; a channel name like lts/*
# here would turn the dependency-freshness gate red in every site repo.
- name: Set up Node (pinned by .nvmrc)
if: hashFiles('.nvmrc') != ''
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
# pnpm switches itself to the `packageManager` version in package.json.
- name: Install pnpm
if: hashFiles('pnpm-lock.yaml') != ''
run: npm install --global pnpm
- name: Build and check the site
run: bash .utils/scripts/check_agent_ready.sh --strict --install

View File

@ -0,0 +1,26 @@
#!/bin/bash
# ============================================================================
# Fail if this repo's website fails the AgentReady checker.
#
# Thin delegate to the shared gate in ~/src/utils, which owns the config
# parsing, the build step and the checker call. Copy this file to
# scripts/check_agent_ready.sh in a repo; never copy the shared gate itself.
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_agent_ready.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared agent-ready gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

View File

@ -0,0 +1,174 @@
"""The agent-ready gate: build, then run the shared AgentReady checker."""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
from repo_contract import agent_ready_gate as gate
from .conftest import write
from .test_web import config, vite_repo
@pytest.fixture
def fake_checker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A stand-in check.sh that logs its argv and exits with $FAKE_RC."""
log = tmp_path / "checker.log"
script = write(
tmp_path,
"fake/check.sh",
f'#!/bin/bash\necho "$@" >> {log}\nexit "${{FAKE_RC:-0}}"\n',
0o755,
)
monkeypatch.setattr(gate, "CHECKER", script)
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
return log
def site(tmp_path: Path, body: str) -> Path:
root = tmp_path / "site"
root.mkdir()
config(root, body)
return root
def test_dist_builds_in_a_copy_then_checks(tmp_path: Path, fake_checker: Path) -> None:
root = site(
tmp_path,
'[agent_ready]\ndist = "out"\nbuild = "mkdir -p out && touch out/built"\n'
'install = "touch installed"\nact = true\n',
)
assert gate.main(["--repo", str(root)]) == 0
assert not (root / "out").exists()
assert not (root / "installed").exists()
mode, checked, act = fake_checker.read_text().split("\n")[0].split(" ")
assert (mode, Path(checked).name, act) == ("dist", "out", "--act")
assert root not in Path(checked).parents
assert gate.main(["--repo", str(root), "--install"]) == 0
assert (root / "installed").exists()
LIVE_BUILD = (
'[agent_ready]\ndist = "dist"\n'
'build = "rm -rf dist && mkdir dist && echo built > dist/index.html"\n'
)
def _live_site(root: Path) -> Path:
config(root, LIVE_BUILD)
write(root, "dist/live.html", "served by caddy\n")
write(root, "src/app.ts", "x\n")
write(root, "node_modules/dep/index.js", "module.exports = 1\n")
return root
def _assert_live_untouched(root: Path, log: Path) -> None:
assert (root / "dist" / "live.html").read_text() == "served by caddy\n"
assert not (root / "dist" / "index.html").exists()
assert "index.html" in log.read_text() # the copy WAS built and checked
@pytest.fixture
def listing_checker(fake_checker: Path) -> Path:
"""The fake checker also logs what the checked build dir contains."""
script = gate.CHECKER
script.write_text(script.read_text().replace('echo "$@"', 'echo "$@"; ls "$2"'))
return fake_checker
def test_live_dist_untouched_without_git(tmp_path: Path, listing_checker: Path) -> None:
root = _live_site(tmp_path / "site")
assert gate.main(["--repo", str(root)]) == 0
_assert_live_untouched(root, listing_checker)
def test_live_dist_untouched_in_git_repo(tmp_path: Path, listing_checker: Path) -> None:
root = _live_site(tmp_path / "site")
write(root, ".gitignore", "dist/\nnode_modules/\n")
subprocess.run(["git", "init", "-q", str(root)], check=True)
(root / "src" / "gone.ts").write_text("x\n")
subprocess.run(["git", "-C", str(root), "add", "-A"], check=True)
(root / "src" / "gone.ts").unlink() # tracked but deleted in the worktree
config(root, LIVE_BUILD.replace("echo built", "ls node_modules/dep src"))
assert gate.main(["--repo", str(root)]) == 0
_assert_live_untouched(root, listing_checker)
def test_failed_build_fails_without_checking(
tmp_path: Path, fake_checker: Path, capsys: pytest.CaptureFixture[str]
) -> None:
root = site(tmp_path, '[agent_ready]\ndist = "d"\nbuild = "echo boom; exit 7"\n')
assert gate.main(["--repo", str(root)]) == 1
assert "boom" in capsys.readouterr().err
assert not fake_checker.exists()
def test_failed_install_fails(tmp_path: Path, fake_checker: Path) -> None:
root = site(tmp_path, '[agent_ready]\ndist = "d"\ninstall = "exit 3"\n')
assert gate.main(["--repo", str(root), "--install"]) == 1
assert not fake_checker.exists()
def test_checker_failure_propagates(
tmp_path: Path, fake_checker: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = site(tmp_path, '[agent_ready]\ndist = "d"\n')
monkeypatch.setenv("FAKE_RC", "1")
assert gate.main(["--repo", str(root)]) == 1
def test_url_mode_degrades_offline_unless_strict(
tmp_path: Path,
fake_checker: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
root = site(tmp_path, '[agent_ready]\nurl = "https://x.example"\n')
monkeypatch.setenv("FAKE_RC", "3")
assert gate.main(["--repo", str(root)]) == 0
assert "DEGRADED" in capsys.readouterr().err
assert gate.main(["--repo", str(root), "--strict"]) == 3
assert fake_checker.read_text().splitlines()[0] == "url https://x.example"
def test_skip_and_no_site(
tmp_path: Path, fake_checker: Path, capsys: pytest.CaptureFixture[str]
) -> None:
root = site(tmp_path, '[agent_ready]\nskip = "auth-gated"\n')
assert gate.main(["--repo", str(root)]) == 0
assert "auth-gated" in capsys.readouterr().out
assert gate.main(["--repo", str(tmp_path / "fake")]) == 0
assert not fake_checker.exists()
def test_unconfigured_web_repo_fails(tmp_path: Path, fake_checker: Path) -> None:
root = vite_repo(tmp_path / "v")
assert gate.main(["--repo", str(root)]) == 1
def test_bad_config_is_usage_error(tmp_path: Path, fake_checker: Path) -> None:
root = site(tmp_path, "[agent_ready]\n")
assert gate.main(["--repo", str(root)]) == 2
def test_pre_commit_in_ci_defers_to_the_workflow(
tmp_path: Path,
fake_checker: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
root = site(tmp_path, '[agent_ready]\ndist = "d"\nbuild = "exit 1"\n')
monkeypatch.setenv("GITHUB_ACTIONS", "true")
assert gate.main(["--repo", str(root)]) == 0
assert "agent-ready workflow" in capsys.readouterr().out
assert gate.main(["--repo", str(root), "--strict"]) == 1
@pytest.mark.skipif(not gate.CHECKER.exists(), reason="agent_ready not checked out")
def test_real_checker_fails_a_bare_build(tmp_path: Path) -> None:
root = site(tmp_path, '[agent_ready]\ndist = "dist"\n')
write(root, "dist/index.html", "<!doctype html><title>x</title><p>hi</p>")
assert gate.main(["--repo", str(root)]) == 1

View File

@ -9,6 +9,7 @@ import pytest
from repo_contract.check import Unreadable, main, read_doc, violations from repo_contract.check import Unreadable, main, read_doc, violations
from .conftest import GOOD, good_repo, write from .conftest import GOOD, good_repo, write
from .test_web import vite_repo
def problems(repo: Path) -> list[str]: def problems(repo: Path) -> list[str]:
@ -132,3 +133,16 @@ def test_verbose_and_placeholder_reported(repo: Path) -> None:
out = problems(repo) out = problems(repo)
assert any("quiet by default" in p for p in out) assert any("quiet by default" in p for p in out)
assert any("placeholder" in p for p in out) assert any("placeholder" in p for p in out)
def test_unconfigured_web_repo_is_a_violation(repo: Path) -> None:
good_repo(repo)
vite_repo(repo)
found, _ = violations(repo)
assert any("web build detected" in p for p in found)
def test_web_problems_reported_even_without_claude_md(repo: Path) -> None:
write(repo, "repo-contract.toml", "[agent_ready]\n")
found, _ = violations(repo)
assert any(p.startswith("repo-contract.toml:") for p in found)

View File

@ -8,9 +8,11 @@ from pathlib import Path
import pytest import pytest
from repo_contract import install as inst from repo_contract import install as inst
from repo_contract import web
from repo_contract.check import violations from repo_contract.check import violations
from .conftest import good_repo, write from .conftest import good_repo, write
from .test_web import vite_repo
def python_repo(root: Path) -> Path: def python_repo(root: Path) -> Path:
@ -112,3 +114,88 @@ def test_main_reports_bootstrap_actions(
python_repo(repo) python_repo(repo)
inst.main([str(repo)]) inst.main([str(repo)])
assert "bootstrap: created scripts/test_changed.sh" in capsys.readouterr().out assert "bootstrap: created scripts/test_changed.sh" in capsys.readouterr().out
WEB_PIECES = [
"repo-contract.toml ([agent_ready])",
"scripts/check_agent_ready.sh",
".github/workflows/agent-ready.yml",
".pre-commit-config.yaml (agent-ready hook)",
]
def test_vite_repo_gets_the_agent_ready_gate(repo: Path) -> None:
vite_repo(repo)
assert inst.plan(repo)[3:] == WEB_PIECES
assert inst.install(repo)[3:] == WEB_PIECES
toml = (repo / "repo-contract.toml").read_text(encoding="utf-8")
assert 'dist = "dist"' in toml and 'build = "pnpm run build"' in toml
hooks = (repo / inst.PRECOMMIT).read_text(encoding="utf-8")
assert "id: repo-contract" in hooks and "id: agent-ready" in hooks
assert (repo / "scripts/check_agent_ready.sh").stat().st_mode & stat.S_IXUSR
assert "check_agent_ready.sh --strict --install" in (
repo / ".github/workflows/agent-ready.yml"
).read_text(encoding="utf-8")
assert inst.install(repo) == []
assert inst.plan(repo) == []
def test_declared_url_site_gets_gate_but_config_is_kept(repo: Path) -> None:
body = '# mine\n[agent_ready]\nurl = "https://x.example"\n'
write(repo, "repo-contract.toml", body)
assert inst.install(repo)[3:] == WEB_PIECES[1:]
assert (repo / "repo-contract.toml").read_text(encoding="utf-8") == body
def test_existing_toml_without_table_gets_it_appended(repo: Path) -> None:
vite_repo(repo)
write(repo, "repo-contract.toml", "[other]\nx = 1")
inst.install(repo)
text = (repo / "repo-contract.toml").read_text(encoding="utf-8")
assert text.startswith("[other]\nx = 1\n") and "[agent_ready]" in text
def test_skipped_or_broken_config_installs_no_web_pieces(repo: Path) -> None:
write(repo, "repo-contract.toml", '[agent_ready]\nskip = "auth-gated"\n')
assert len(inst.plan(repo)) == 3
write(repo, "repo-contract.toml", "[agent_ready]\n")
assert len(inst.install(repo)) == 3
def test_marker_skips_agent_ready_workflow(repo: Path) -> None:
write(repo, ".dep-freshness-no-workflow")
write(repo, "repo-contract.toml", '[agent_ready]\nurl = "https://x.example"\n')
inst.install(repo)
assert not (repo / ".github/workflows/agent-ready.yml").exists()
def _agent_ready_files(repo: Path) -> list[str]:
lines = (repo / inst.PRECOMMIT).read_text(encoding="utf-8").splitlines()
start = lines.index(" - id: agent-ready")
return [ln for ln in lines[start:] if ln.startswith(" files: ")]
def test_hook_carries_the_files_filter_and_follows_config(repo: Path) -> None:
vite_repo(repo)
inst.install(repo)
assert _agent_ready_files(repo) == [" files: '" + web.DEFAULT_FILES + "'"]
with (repo / "repo-contract.toml").open("a", encoding="utf-8") as fh:
fh.write('files = "^(app/|it\'s\\\\.html$)"\n')
assert inst.plan(repo) == [".pre-commit-config.yaml (agent-ready hook)"]
assert inst.install(repo) == [".pre-commit-config.yaml (agent-ready hook)"]
assert _agent_ready_files(repo) == [" files: '^(app/|it''s\\.html$)'"]
assert inst.install(repo) == []
def test_hook_without_files_line_gets_one(repo: Path) -> None:
write(repo, "repo-contract.toml", '[agent_ready]\nurl = "https://x.example"\n')
write(
repo,
inst.PRECOMMIT.as_posix(),
"repos:\n - repo: local\n hooks:\n - id: agent-ready\n"
" entry: scripts/check_agent_ready.sh\n\n"
" - id: other\n entry: x\n",
)
inst.install(repo)
lines = (repo / inst.PRECOMMIT).read_text(encoding="utf-8").splitlines()
assert lines[5].startswith(" files: '") and lines[7] == " - id: other"

View File

@ -0,0 +1,208 @@
"""Web-build detection and the `[agent_ready]` table of repo-contract.toml."""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from repo_contract import web
from .conftest import write
PKG = '{"scripts": {"build": "tsc -b && vite build"}}'
def vite_repo(root: Path, lock: str = "pnpm-lock.yaml") -> Path:
"""A minimal Vite site: package.json with a build script + vite config."""
write(root, "package.json", PKG)
write(root, "vite.config.ts", "export default {}\n")
write(root, "index.html", "<!doctype html>\n")
if lock:
write(root, lock, "")
return root
def config(root: Path, body: str) -> None:
write(root, web.CONFIG.as_posix(), body)
def test_detects_vite_with_pnpm(tmp_path: Path) -> None:
got = web.detect(vite_repo(tmp_path))
assert got == web.AgentReady(
mode="dist",
target="dist",
build="pnpm run build",
install="pnpm install --frozen-lockfile",
)
@pytest.mark.parametrize(
("lock", "install"),
[
("yarn.lock", "yarn install --frozen-lockfile"),
("package-lock.json", "npm ci"),
("", "npm install"),
],
)
def test_install_command_follows_lockfile(
tmp_path: Path, lock: str, install: str
) -> None:
got = web.detect(vite_repo(tmp_path, lock))
assert got is not None and got.install == install
def test_out_dir_read_from_vite_config(tmp_path: Path) -> None:
vite_repo(tmp_path)
write(tmp_path, "vite.config.ts", "export default { build: { outDir: './site' } }")
got = web.detect(tmp_path)
assert got is not None and got.target == "site"
def test_index_html_alone_is_enough(tmp_path: Path) -> None:
write(tmp_path, "package.json", PKG)
write(tmp_path, "index.html", "<!doctype html>\n")
assert web.detect(tmp_path) is not None
def test_astro_config_is_enough(tmp_path: Path) -> None:
write(tmp_path, "package.json", PKG)
write(tmp_path, "astro.config.mjs", "export default {}\n")
assert web.detect(tmp_path) is not None
@pytest.mark.parametrize(
"files",
[
{"package.json": PKG}, # node, no web entry point: a library/CLI
{"package.json": '{"scripts": {"dev": "vite"}}', "index.html": "x"},
{"package.json": "not json", "index.html": "x"},
{"index.html": "x"}, # no package.json at all
{"pyproject.toml": "[project]\nname='x'\n"},
],
)
def test_conservative_detection(tmp_path: Path, files: dict[str, str]) -> None:
for rel, body in files.items():
write(tmp_path, rel, body)
assert web.detect(tmp_path) is None
def test_load_absent_file_or_table(tmp_path: Path) -> None:
assert web.load(tmp_path) is None
config(tmp_path, "[other]\nx = 1\n")
assert web.load(tmp_path) is None
def test_load_dist_url_skip(tmp_path: Path) -> None:
config(tmp_path, '[agent_ready]\ndist = "./out/"\nbuild = "make"\nact = true\n')
assert web.load(tmp_path) == web.AgentReady("dist", "out", "make", None, True)
config(tmp_path, '[agent_ready]\nurl = "https://x.example"\n')
assert web.load(tmp_path) == web.AgentReady("url", "https://x.example")
config(tmp_path, '[agent_ready]\nskip = "auth-gated service"\n')
assert web.load(tmp_path) == web.AgentReady("skip", "auth-gated service")
@pytest.mark.parametrize(
("body", "match"),
[
("[agent_ready\n", "not valid TOML"),
("agent_ready = 1\n", "must be a table"),
("[agent_ready]\n", "exactly one of"),
('[agent_ready]\ndist = "d"\nurl = "https://x"\n', "exactly one of"),
('[agent_ready]\ndist = ""\n', "non-empty string"),
("[agent_ready]\nskip = 3\n", "non-empty string"),
('[agent_ready]\nurl = "ftp://x"\n', "http"),
('[agent_ready]\ndist = "/abs"\n', "relative"),
('[agent_ready]\ndist = "../up"\n', "relative"),
('[agent_ready]\nurl = "https://x"\nbuild = "b"\n', "only apply to dist"),
('[agent_ready]\ndist = "d"\nbuild = 1\n', "'build' must be a string"),
('[agent_ready]\ndist = "d"\nact = "yes"\n', "'act' must be true or false"),
('[agent_ready]\ndist = "d"\nbogus = 1\n', "unknown key"),
],
)
def test_load_rejects_bad_config(tmp_path: Path, body: str, match: str) -> None:
config(tmp_path, body)
with pytest.raises(web.ConfigError, match=match):
web.load(tmp_path)
def test_render_round_trips(tmp_path: Path) -> None:
cfg = web.AgentReady("dist", "dist", 'pnpm run "build"', "pnpm i", True)
config(tmp_path, web.render(cfg))
assert web.load(tmp_path) == cfg
config(tmp_path, web.render(web.AgentReady("url", "https://x.example")))
assert web.load(tmp_path) == web.AgentReady("url", "https://x.example")
def test_problems_flag_unconfigured_web_repo(tmp_path: Path) -> None:
vite_repo(tmp_path)
assert any("web build detected" in p for p in web.problems(tmp_path))
def test_problems_skip_and_non_web_pass(tmp_path: Path) -> None:
assert web.problems(tmp_path) == []
vite_repo(tmp_path)
config(tmp_path, '[agent_ready]\nskip = "self-hosted app"\n')
assert web.problems(tmp_path) == []
def test_problems_bad_config(tmp_path: Path) -> None:
config(tmp_path, "[agent_ready]\n")
assert web.problems(tmp_path)[0].startswith("repo-contract.toml:")
def test_problems_configured_without_gate_pieces(tmp_path: Path) -> None:
config(tmp_path, '[agent_ready]\ndist = "dist"\n')
found = web.problems(tmp_path)
assert any("agent-ready hook" in p for p in found)
assert any("scripts/check_agent_ready.sh" in p for p in found)
write(tmp_path, "scripts/check_agent_ready.sh")
write(tmp_path, ".pre-commit-config.yaml", " - id: agent-ready\n")
assert web.problems(tmp_path) == []
def test_files_filter_default_and_override(tmp_path: Path) -> None:
assert web.hook_files(web.AgentReady("dist", "dist")) == web.DEFAULT_FILES
config(tmp_path, '[agent_ready]\ndist = "d"\nfiles = "^(app/|site\\\\.toml$)"\n')
cfg = web.load(tmp_path)
assert cfg is not None and web.hook_files(cfg) == r"^(app/|site\.toml$)"
config(tmp_path, web.render(cfg))
assert web.load(tmp_path) == cfg
@pytest.mark.parametrize(
"path",
[
"src/main.tsx",
"public/robots.txt",
"index.html",
"vite.config.ts",
"astro.config.mjs",
"package.json",
"pnpm-lock.yaml",
"package-lock.json",
"yarn.lock",
"repo-contract.toml",
],
)
def test_default_filter_matches_web_files(path: str) -> None:
assert re.search(web.DEFAULT_FILES, path)
@pytest.mark.parametrize("path", ["README.md", "tests/a.test.ts", "docs/src/x"])
def test_default_filter_skips_the_rest(path: str) -> None:
assert not re.search(web.DEFAULT_FILES, path)
@pytest.mark.parametrize(
("body", "match"),
[
('[agent_ready]\ndist = "d"\nfiles = "("\n', "not a valid regex"),
('[agent_ready]\ndist = "d"\nfiles = 1\n', "'files' must be a string"),
],
)
def test_bad_files_filter(tmp_path: Path, body: str, match: str) -> None:
config(tmp_path, body)
with pytest.raises(web.ConfigError, match=match):
web.load(tmp_path)

207
repo_contract/web.py Normal file
View File

@ -0,0 +1,207 @@
"""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
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
import tomllib
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

27
scripts/check_agent_ready.sh Executable file
View File

@ -0,0 +1,27 @@
#!/bin/bash
# ============================================================================
# Fail if the current repo's website fails the AgentReady checker.
#
# Reads [agent_ready] from repo-contract.toml (dist = build dir, url = live
# site, skip = not a website), runs the build, then agent_ready/check.sh.
# See repo_contract/web.py and agent_ready/README.md.
#
# This is the REAL gate. Per-repo scripts/check_agent_ready.sh files are thin
# shims that exec this one -- do not overwrite this file with a shim.
#
# Usage: scripts/check_agent_ready.sh [--repo PATH] [--strict] [--install]
# Exit: 0 pass | 1 FAIL | 2 bad config | 3 undeterminable (--strict only)
# ============================================================================
set -euo pipefail
UTILS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly UTILS_ROOT
main() {
PYTHONPATH="$UTILS_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
python3 "$UTILS_ROOT/repo_contract/agent_ready_gate.py" "$@"
}
main "$@"

View File

@ -6,7 +6,9 @@
# #
# Run this on EVERY new repo, right after `git init`. Idempotent. Pieces: # Run this on EVERY new repo, right after `git init`. Idempotent. Pieces:
# scripts/check_repo_contract.sh shim, pre-commit hook, CI workflow (skipped # scripts/check_repo_contract.sh shim, pre-commit hook, CI workflow (skipped
# when the repo has .dep-freshness-no-workflow), Commands section. # when the repo has .dep-freshness-no-workflow), Commands section. A website
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
# #
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap] # Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo # Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo