From a0fb279b1bcd6201292093850f6ac78ef14ea06b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 22 Oct 2026 21:18:49 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y --- agent_ready/DOCS-spec-notes.md | 97 ++++++++ agent_ready/README.md | 93 ++++++++ agent_ready/__init__.py | 1 + agent_ready/backends.py | 188 ++++++++++++++++ agent_ready/check.py | 137 +++++++++++ agent_ready/check.sh | 10 + agent_ready/checks_act.py | 156 +++++++++++++ agent_ready/checks_find.py | 186 +++++++++++++++ agent_ready/checks_read.py | 212 +++++++++++++++++ agent_ready/context.py | 85 +++++++ agent_ready/formats.py | 213 ++++++++++++++++++ agent_ready/htmlscan.py | 121 ++++++++++ agent_ready/report.py | 73 ++++++ agent_ready/tests/__init__.py | 0 agent_ready/tests/conftest.py | 136 +++++++++++ agent_ready/tests/fakes.py | 80 +++++++ agent_ready/tests/sitegen.py | 153 +++++++++++++ agent_ready/tests/test_backends.py | 75 ++++++ agent_ready/tests/test_checks_dist.py | 153 +++++++++++++ agent_ready/tests/test_checks_live.py | 83 +++++++ agent_ready/tests/test_cli.py | 202 +++++++++++++++++ agent_ready/tests/test_formats.py | 186 +++++++++++++++ agent_ready/tests/test_report_ctx.py | 54 +++++ repo_contract/README.md | 25 ++ repo_contract/agent_ready_gate.py | 127 +++++++++++ repo_contract/check.py | 13 +- repo_contract/install.py | 59 +---- repo_contract/install_web.py | 127 +++++++++++ repo_contract/isolate.py | 53 +++++ repo_contract/precommit.py | 90 ++++++++ repo_contract/templates/agent-ready-hook.yaml | 16 ++ repo_contract/templates/agent-ready.yml | 50 ++++ .../templates/check_agent_ready.shim.sh | 26 +++ repo_contract/tests/test_agent_ready_gate.py | 174 ++++++++++++++ repo_contract/tests/test_check.py | 14 ++ repo_contract/tests/test_install.py | 87 +++++++ repo_contract/tests/test_web.py | 208 +++++++++++++++++ repo_contract/web.py | 207 +++++++++++++++++ scripts/check_agent_ready.sh | 27 +++ scripts/install_repo_contract_gate.sh | 4 +- 40 files changed, 3952 insertions(+), 49 deletions(-) create mode 100644 agent_ready/DOCS-spec-notes.md create mode 100644 agent_ready/README.md create mode 100644 agent_ready/__init__.py create mode 100644 agent_ready/backends.py create mode 100755 agent_ready/check.py create mode 100755 agent_ready/check.sh create mode 100644 agent_ready/checks_act.py create mode 100644 agent_ready/checks_find.py create mode 100644 agent_ready/checks_read.py create mode 100644 agent_ready/context.py create mode 100644 agent_ready/formats.py create mode 100644 agent_ready/htmlscan.py create mode 100644 agent_ready/report.py create mode 100644 agent_ready/tests/__init__.py create mode 100644 agent_ready/tests/conftest.py create mode 100644 agent_ready/tests/fakes.py create mode 100644 agent_ready/tests/sitegen.py create mode 100644 agent_ready/tests/test_backends.py create mode 100644 agent_ready/tests/test_checks_dist.py create mode 100644 agent_ready/tests/test_checks_live.py create mode 100644 agent_ready/tests/test_cli.py create mode 100644 agent_ready/tests/test_formats.py create mode 100644 agent_ready/tests/test_report_ctx.py create mode 100644 repo_contract/agent_ready_gate.py create mode 100644 repo_contract/install_web.py create mode 100644 repo_contract/isolate.py create mode 100644 repo_contract/precommit.py create mode 100644 repo_contract/templates/agent-ready-hook.yaml create mode 100644 repo_contract/templates/agent-ready.yml create mode 100644 repo_contract/templates/check_agent_ready.shim.sh create mode 100644 repo_contract/tests/test_agent_ready_gate.py create mode 100644 repo_contract/tests/test_web.py create mode 100644 repo_contract/web.py create mode 100755 scripts/check_agent_ready.sh diff --git a/agent_ready/DOCS-spec-notes.md b/agent_ready/DOCS-spec-notes.md new file mode 100644 index 0000000..796ae33 --- /dev/null +++ b/agent_ready/DOCS-spec-notes.md @@ -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): + , + canonical site . +- AI Catalog (the format behind `AR-FIND-04`): + . + 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 . + +## 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 `` 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 `` and an HTTP `Link` header." | The catalog is valid (see the schema below) and lists `/llms.txt`. The homepage has ``. 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-` 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 `` 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 `
` 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 `` or ``. |
+| 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 `` 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
+  ``.
+
+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 ()
+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
+```
diff --git a/agent_ready/README.md b/agent_ready/README.md
new file mode 100644
index 0000000..4b05cb5
--- /dev/null
+++ b/agent_ready/README.md
@@ -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  [--act] [--min-text N] [--max-pages N]
+~/src/utils/agent_ready/check.sh 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-` 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
diff --git a/agent_ready/__init__.py b/agent_ready/__init__.py
new file mode 100644
index 0000000..ba35efb
--- /dev/null
+++ b/agent_ready/__init__.py
@@ -0,0 +1 @@
+"""AgentReady (agentready.org v2.0) compliance checker for kuhy's websites."""
diff --git a/agent_ready/backends.py b/agent_ready/backends.py
new file mode 100644
index 0000000..b66c9bc
--- /dev/null
+++ b/agent_ready/backends.py
@@ -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)
diff --git a/agent_ready/check.py b/agent_ready/check.py
new file mode 100755
index 0000000..c7a3dbf
--- /dev/null
+++ b/agent_ready/check.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""AgentReady compliance checker (agentready.org spec v2.0).
+
+    check.py dist  [--act]   # pre-commit / CI: build output
+    check.py url  [--act]     # post-deploy: the live site
+
+Prints ``PASS|FAIL|SKIP AR-XXX-NN `` 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())
diff --git a/agent_ready/check.sh b/agent_ready/check.sh
new file mode 100755
index 0000000..7a85881
--- /dev/null
+++ b/agent_ready/check.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+# Thin wrapper: run the AgentReady checker from any cwd.
+#   check.sh dist  [--act]
+#   check.sh 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 "$@"
diff --git a/agent_ready/checks_act.py b/agent_ready/checks_act.py
new file mode 100644
index 0000000..efa9179
--- /dev/null
+++ b/agent_ready/checks_act.py
@@ -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")
diff --git a/agent_ready/checks_find.py b/agent_ready/checks_find.py
new file mode 100644
index 0000000..54826cb
--- /dev/null
+++ b/agent_ready/checks_find.py
@@ -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 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)}  URLs are not on {ctx.site.origin}"
+        )
+    if not locs and not problems:
+        problems.append("sitemap has no  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 '
+        ],
+        "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",
+    )
diff --git a/agent_ready/checks_read.py b/agent_ready/checks_read.py
new file mode 100644
index 0000000..951c773
--- /dev/null
+++ b/agent_ready/checks_read.py
@@ -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  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 
)")
+    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  links to other pages"],
+        f"homepage links {len(links)} pages in raw HTML",
+    )
+    problems = []
+    if not page.jsonld_ok:
+        problems.append(
+            'homepage has no ',
+        ]
+    return (
+        '' + "".join(head) + ""
+        f"

{title}

{_TEXT}

" + '
print("d20")
' + '
' + "" + ) + + +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", + "

Not found

No page at this address.

", + ) + 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 = 'App
' + + +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", "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" {ORIGIN}/p{i}/\n") + write( + root, "sitemap.xml", SITEMAP.replace("", "".join(urls) + "") + ) diff --git a/agent_ready/tests/test_backends.py b/agent_ready/tests/test_backends.py new file mode 100644 index 0000000..9fc0b58 --- /dev/null +++ b/agent_ready/tests/test_backends.py @@ -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 diff --git a/agent_ready/tests/test_checks_dist.py b/agent_ready/tests/test_checks_dist.py new file mode 100644 index 0000000..f9f74a7 --- /dev/null +++ b/agent_ready/tests/test_checks_dist.py @@ -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 = ( + '' + f"{child}" + ) + 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 = '{}' + off = urlset.format("https://other.test/x/") + sitegen.write(good_dir, "sitemap.xml", off) + _, lines = run_main(capsys, "dist", str(good_dir)) + assert "1 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 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"{sitegen.ORIGIN}/{p}/" for p in ("dup", "gone") + ) + sitemap = (good_dir / "sitemap.xml").read_text() + sitegen.write( + good_dir, "sitemap.xml", sitemap.replace("", extra + "") + ) + 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 = '' + sitegen.write(good_dir, "index.html", home.replace("", scripts + "")) + 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") diff --git a/agent_ready/tests/test_checks_live.py b/agent_ready/tests/test_checks_live.py new file mode 100644 index 0000000..e1cd818 --- /dev/null +++ b/agent_ready/tests/test_checks_live.py @@ -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 = '; rel="alternate"; type="text/markdown"' + + +def _home(*, ctype: str = "text/html", **headers: str) -> Response: + return resp(f"

{LONG}

", ctype, **headers) + + +def test_challenge_page_for_one_agent_is_reported() -> None: + challenge = resp("Just a moment...") + 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") diff --git a/agent_ready/tests/test_cli.py b/agent_ready/tests/test_cli.py new file mode 100644 index 0000000..795f0e2 --- /dev/null +++ b/agent_ready/tests/test_cli.py @@ -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 diff --git a/agent_ready/tests/test_formats.py b/agent_ready/tests/test_formats.py new file mode 100644 index 0000000..fc49068 --- /dev/null +++ b/agent_ready/tests/test_formats.py @@ -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: "", "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"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() == "" # 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" diff --git a/agent_ready/tests/test_report_ctx.py b/agent_ready/tests/test_report_ctx.py new file mode 100644 index 0000000..5814b7b --- /dev/null +++ b/agent_ready/tests/test_report_ctx.py @@ -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") diff --git a/repo_contract/README.md b/repo_contract/README.md index cd1f963..936b16d 100644 --- a/repo_contract/README.md +++ b/repo_contract/README.md @@ -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, 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 ```bash diff --git a/repo_contract/agent_ready_gate.py b/repo_contract/agent_ready_gate.py new file mode 100644 index 0000000..3851b60 --- /dev/null +++ b/repo_contract/agent_ready_gate.py @@ -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()) diff --git a/repo_contract/check.py b/repo_contract/check.py index 80217fc..1d9ba1e 100644 --- a/repo_contract/check.py +++ b/repo_contract/check.py @@ -25,6 +25,7 @@ from repo_contract.contract import ( verbose_in_script, ) from repo_contract.stacks import detect_stacks, has_tests +from repo_contract.web import problems as web_problems EXIT_OK, EXIT_VIOLATIONS, EXIT_UNREADABLE = 0, 1, 2 DOC_NAMES = ("CLAUDE.md", "AGENTS.md") @@ -99,7 +100,17 @@ def _check_consistency( 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) text = read_doc(repo) if text is None: diff --git a/repo_contract/install.py b/repo_contract/install.py index dc230ae..1cad362 100644 --- a/repo_contract/install.py +++ b/repo_contract/install.py @@ -25,15 +25,14 @@ from dep_freshness.install import NO_WORKFLOW_MARKER from repo_contract import bootstrap from repo_contract.check import violations 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 TEMPLATES = bootstrap.TEMPLATES SHIM = Path("scripts/check_repo_contract.sh") WORKFLOW = Path(".github/workflows/repo-contract.yml") -PRECOMMIT = Path(".pre-commit-config.yaml") HOOK_ID = "repo-contract" -LOCAL_REPO = " - repo: local" -EMPTY_PRECOMMIT = f"repos:\n{LOCAL_REPO}\n hooks:\n" 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]: - """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 = [] if _needs_shim(repo): todo.append(str(SHIM)) if _needs_workflow(repo): todo.append(str(WORKFLOW)) - if _needs_hook(repo): + if needs_hook(repo, HOOK_ID): todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)") - return todo + return todo + plan_web(repo) 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.write_text(_template("repo-contract.yml"), encoding="utf-8") done.append(str(WORKFLOW)) - if _needs_hook(repo): - _write_hook(repo) + if needs_hook(repo, HOOK_ID): + write_hook(repo, _template("pre-commit-snippet.yaml")) done.append(f"{PRECOMMIT} ({HOOK_ID} hook)") - return done + return done + install_web(repo) def bootstrap_commands(repo: Path) -> list[str]: diff --git a/repo_contract/install_web.py b/repo_contract/install_web.py new file mode 100644 index 0000000..7719b6e --- /dev/null +++ b/repo_contract/install_web.py @@ -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 diff --git a/repo_contract/isolate.py b/repo_contract/isolate.py new file mode 100644 index 0000000..52404e0 --- /dev/null +++ b/repo_contract/isolate.py @@ -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") diff --git a/repo_contract/precommit.py b/repo_contract/precommit.py new file mode 100644 index 0000000..de62ef0 --- /dev/null +++ b/repo_contract/precommit.py @@ -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") diff --git a/repo_contract/templates/agent-ready-hook.yaml b/repo_contract/templates/agent-ready-hook.yaml new file mode 100644 index 0000000..5213db5 --- /dev/null +++ b/repo_contract/templates/agent-ready-hook.yaml @@ -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} diff --git a/repo_contract/templates/agent-ready.yml b/repo_contract/templates/agent-ready.yml new file mode 100644 index 0000000..8cc6639 --- /dev/null +++ b/repo_contract/templates/agent-ready.yml @@ -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 diff --git a/repo_contract/templates/check_agent_ready.shim.sh b/repo_contract/templates/check_agent_ready.shim.sh new file mode 100644 index 0000000..a82c282 --- /dev/null +++ b/repo_contract/templates/check_agent_ready.shim.sh @@ -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 "$@" diff --git a/repo_contract/tests/test_agent_ready_gate.py b/repo_contract/tests/test_agent_ready_gate.py new file mode 100644 index 0000000..a6068e3 --- /dev/null +++ b/repo_contract/tests/test_agent_ready_gate.py @@ -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

hi

") + assert gate.main(["--repo", str(root)]) == 1 diff --git a/repo_contract/tests/test_check.py b/repo_contract/tests/test_check.py index f13ae73..5d18c71 100644 --- a/repo_contract/tests/test_check.py +++ b/repo_contract/tests/test_check.py @@ -9,6 +9,7 @@ import pytest from repo_contract.check import Unreadable, main, read_doc, violations from .conftest import GOOD, good_repo, write +from .test_web import vite_repo def problems(repo: Path) -> list[str]: @@ -132,3 +133,16 @@ def test_verbose_and_placeholder_reported(repo: Path) -> None: out = problems(repo) assert any("quiet by default" 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) diff --git a/repo_contract/tests/test_install.py b/repo_contract/tests/test_install.py index 6514e14..f69f516 100644 --- a/repo_contract/tests/test_install.py +++ b/repo_contract/tests/test_install.py @@ -8,9 +8,11 @@ from pathlib import Path import pytest from repo_contract import install as inst +from repo_contract import web from repo_contract.check import violations from .conftest import good_repo, write +from .test_web import vite_repo def python_repo(root: Path) -> Path: @@ -112,3 +114,88 @@ def test_main_reports_bootstrap_actions( python_repo(repo) inst.main([str(repo)]) 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" diff --git a/repo_contract/tests/test_web.py b/repo_contract/tests/test_web.py new file mode 100644 index 0000000..a42e089 --- /dev/null +++ b/repo_contract/tests/test_web.py @@ -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", "\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", "\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) diff --git a/repo_contract/web.py b/repo_contract/web.py new file mode 100644 index 0000000..3f74b9d --- /dev/null +++ b/repo_contract/web.py @@ -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 = ""' + ) + ] + 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 diff --git a/scripts/check_agent_ready.sh b/scripts/check_agent_ready.sh new file mode 100755 index 0000000..c5d55a4 --- /dev/null +++ b/scripts/check_agent_ready.sh @@ -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 "$@" diff --git a/scripts/install_repo_contract_gate.sh b/scripts/install_repo_contract_gate.sh index 2c3a0e2..f173aee 100755 --- a/scripts/install_repo_contract_gate.sh +++ b/scripts/install_repo_contract_gate.sh @@ -6,7 +6,9 @@ # # Run this on EVERY new repo, right after `git init`. Idempotent. Pieces: # 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 [--check] [--no-bootstrap] # Exit: 0 contract satisfied | 1 violations left (e.g. ) | 2 not a git repo