mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:07:24 +01:00
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:
parent
08a4010a16
commit
a0fb279b1b
97
agent_ready/DOCS-spec-notes.md
Normal file
97
agent_ready/DOCS-spec-notes.md
Normal 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
93
agent_ready/README.md
Normal 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
1
agent_ready/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""AgentReady (agentready.org v2.0) compliance checker for kuhy's websites."""
|
||||||
188
agent_ready/backends.py
Normal file
188
agent_ready/backends.py
Normal 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
137
agent_ready/check.py
Executable 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
10
agent_ready/check.sh
Executable 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
156
agent_ready/checks_act.py
Normal 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
186
agent_ready/checks_find.py
Normal 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
212
agent_ready/checks_read.py
Normal 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
85
agent_ready/context.py
Normal 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
213
agent_ready/formats.py
Normal 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
121
agent_ready/htmlscan.py
Normal 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
73
agent_ready/report.py
Normal 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 "")
|
||||||
0
agent_ready/tests/__init__.py
Normal file
0
agent_ready/tests/__init__.py
Normal file
136
agent_ready/tests/conftest.py
Normal file
136
agent_ready/tests/conftest.py
Normal 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}"
|
||||||
80
agent_ready/tests/fakes.py
Normal file
80
agent_ready/tests/fakes.py
Normal 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))
|
||||||
|
]
|
||||||
153
agent_ready/tests/sitegen.py
Normal file
153
agent_ready/tests/sitegen.py
Normal 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>")
|
||||||
|
)
|
||||||
75
agent_ready/tests/test_backends.py
Normal file
75
agent_ready/tests/test_backends.py
Normal 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
|
||||||
153
agent_ready/tests/test_checks_dist.py
Normal file
153
agent_ready/tests/test_checks_dist.py
Normal 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")
|
||||||
83
agent_ready/tests/test_checks_live.py
Normal file
83
agent_ready/tests/test_checks_live.py
Normal 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")
|
||||||
202
agent_ready/tests/test_cli.py
Normal file
202
agent_ready/tests/test_cli.py
Normal 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
|
||||||
186
agent_ready/tests/test_formats.py
Normal file
186
agent_ready/tests/test_formats.py
Normal 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"
|
||||||
54
agent_ready/tests/test_report_ctx.py
Normal file
54
agent_ready/tests/test_report_ctx.py
Normal 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")
|
||||||
@ -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
|
||||||
|
|||||||
127
repo_contract/agent_ready_gate.py
Normal file
127
repo_contract/agent_ready_gate.py
Normal 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())
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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]:
|
||||||
|
|||||||
127
repo_contract/install_web.py
Normal file
127
repo_contract/install_web.py
Normal 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
53
repo_contract/isolate.py
Normal 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")
|
||||||
90
repo_contract/precommit.py
Normal file
90
repo_contract/precommit.py
Normal 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")
|
||||||
16
repo_contract/templates/agent-ready-hook.yaml
Normal file
16
repo_contract/templates/agent-ready-hook.yaml
Normal 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}
|
||||||
50
repo_contract/templates/agent-ready.yml
Normal file
50
repo_contract/templates/agent-ready.yml
Normal 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
|
||||||
26
repo_contract/templates/check_agent_ready.shim.sh
Normal file
26
repo_contract/templates/check_agent_ready.shim.sh
Normal 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 "$@"
|
||||||
174
repo_contract/tests/test_agent_ready_gate.py
Normal file
174
repo_contract/tests/test_agent_ready_gate.py
Normal 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
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
208
repo_contract/tests/test_web.py
Normal file
208
repo_contract/tests/test_web.py
Normal 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
207
repo_contract/web.py
Normal 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
27
scripts/check_agent_ready.sh
Executable 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 "$@"
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user