From 1eff7498987f7ce47e0ab72dfc6f4e0bffa14dcd Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 4 Sep 2026 23:38:52 +0800 Subject: [PATCH] fix: validate the README REST endpoint table Fixes #65 --- README.md | 52 +++++++++++--------- scripts/check_docs.py | 111 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index bb68b3e..edbde04 100644 --- a/README.md +++ b/README.md @@ -565,47 +565,52 @@ KYROZEN_SERVER_TOKEN=change-me python server.py --host 0.0.0.0 --port 8000 | `GET` | `/` | Dark-themed chat web UI | | `POST` | `/api/chat` | Send a message with optional `profile`, `speaker`, `audience`, and `channel`; returns a memory receipt | | `POST` | `/api/chat/stream` | SSE streaming chat with the same optional profile and memory context | +| `GET` | `/api/cost` | Token usage and cost summary | +| `GET` | `/api/health` | Provider status + memory count | | `GET` | `/api/memory?q=keyword` | Search stored memories | -| `GET` | `/api/v2/memory?q=keyword&speaker=...&audience=...&channel=...` | Structured memory search with provenance and party scope | -| `GET/POST` | `/api/v2/tasks` | Durable task listing and creation | -| `POST` | `/api/v2/tasks/{task_id}/resume` | Explicitly resume a failed or blocked durable task | +| `GET` | `/api/v2/agents` | List specialised sub-agent profiles | +| `POST` | `/api/v2/agents/run` | Run a sub-agent with isolated memory and capabilities | +| `GET` | `/api/v2/events` | Auditable runtime, task, session, and learning events | | `GET` | `/api/v2/learning` | Learning proposal status | +| `POST` | `/api/v2/learning/capsules` | Import a capsule as an inactive candidate | +| `GET` | `/api/v2/learning/constitution` | Inspect the immutable user-owned learning policy | | `GET` | `/api/v2/learning/features` | Authoritative 20-feature registry and latest run status | | `GET` | `/api/v2/learning/metrics?profile=...` | Profile completion, correction, error, tool, token, and latency metrics | +| `GET` | `/api/v2/learning/{proposal_id}/capsule` | Export a redacted, harness-neutral experience capsule | | `GET` | `/api/v2/learning/{proposal_id}/evidence` | Proof card, applicability, replay, and outcome receipts | -| `POST` | `/api/v2/learning/{proposal_id}/replay` | Record paired sandboxed candidate/predecessor replay results | | `POST` | `/api/v2/learning/{proposal_id}/omission` | Record paired with/without-artifact results | -| `POST` | `/api/v2/learning/{proposal_id}/retire` | Retire an artifact with non-regressing omission evidence | +| `POST` | `/api/v2/learning/{proposal_id}/replay` | Record paired sandboxed candidate/predecessor replay results | | `POST` | `/api/v2/learning/{proposal_id}/restore` | Restore a retired artifact as a canary | -| `GET` | `/api/v2/learning/{proposal_id}/capsule` | Export a redacted, harness-neutral experience capsule | -| `POST` | `/api/v2/learning/capsules` | Import a capsule as an inactive candidate | -| `GET` | `/api/v2/learning/constitution` | Inspect the immutable user-owned learning policy | +| `POST` | `/api/v2/learning/{proposal_id}/retire` | Retire an artifact with non-regressing omission evidence | | `POST` | `/api/v2/learning/{proposal_id}/rollback` | Roll back an activated proposal | -| `GET/POST` | `/api/v2/memory/claims` | List or create typed, attributed memory claims; filter with `speaker`, `audience`, and `channel` | -| `GET/DELETE` | `/api/v2/memory/claims/{claim_id}` | Explain or dependency-completely forget a claim with party filters | -| `GET` | `/api/v2/events` | Auditable runtime, task, session, and learning events | -| `GET/POST` | `/api/v2/schedules` | Durable interval and one-shot Gateway jobs | +| `GET` | `/api/v2/memory?q=keyword&speaker=...&audience=...&channel=...` | Structured memory search with provenance and party scope | +| `GET` | `/api/v2/memory/claims` | List typed, attributed memory claims; filter with `speaker`, `audience`, and `channel` | +| `POST` | `/api/v2/memory/claims` | Create a typed, attributed memory claim | +| `DELETE` | `/api/v2/memory/claims/{claim_id}` | Dependency-completely forget a claim with party filters | +| `GET` | `/api/v2/memory/claims/{claim_id}` | Explain a claim with party filters | +| `GET` | `/api/v2/schedules` | List durable interval and one-shot Gateway jobs | +| `POST` | `/api/v2/schedules` | Create a durable interval or one-shot Gateway job | | `POST` | `/api/v2/schedules/{job_id}/disable` | Disable a scheduled job | +| `GET` | `/api/v2/sessions` | List durable sessions | +| `GET` | `/api/v2/sessions/{session_id}` | Resume/read a session context | | `GET` | `/api/v2/skills` | List installed candidate/active skills | | `POST` | `/api/v2/skills/install` | Install and validate a local `SKILL.md` package | | `POST` | `/api/v2/skills/{skill_id}/activate` | Activate a validated skill | | `POST` | `/api/v2/skills/{skill_id}/rollback` | Roll back a skill | -| `GET` | `/api/v2/sessions` | List durable sessions | -| `GET` | `/api/v2/sessions/{session_id}` | Resume/read a session context | -| `GET` | `/api/v2/agents` | List specialised sub-agent profiles | -| `POST` | `/api/v2/agents/run` | Run a sub-agent with isolated memory and capabilities | +| `GET` | `/api/v2/tasks` | List durable tasks | +| `POST` | `/api/v2/tasks` | Create a durable task | +| `POST` | `/api/v2/tasks/{task_id}/resume` | Explicitly resume a failed or blocked durable task | +| `GET` | `/api/voice/speak?text=...` | Text-to-speech via system TTS | +| `POST` | `/api/voice/transcribe` | Speech-to-text (passthrough) | +| `GET` | `/api/webhooks` | List registered webhooks | +| `POST` | `/api/webhooks/register` | Register a webhook URL | +| `POST` | `/api/webhooks/test` | Fire a test webhook | +| `POST` | `/mcp` | Model Context Protocol (JSON-RPC 2.0) | Browser tools (`browser_open`, `browser_snapshot`, `browser_click`, `browser_type`, and `browser_close`) use an isolated profile and are available after `pip install 'openkyrozen[browser]' && playwright install chromium`. Private and loopback destinations are blocked unless `KYROZEN_BROWSER_ALLOW_PRIVATE=1` is set. -| `GET` | `/api/cost` | Token usage and cost summary | -| `GET` | `/api/health` | Provider status + memory count | -| `GET` | `/api/voice/speak?text=...` | Text-to-speech via system TTS | -| `POST` | `/api/voice/transcribe` | Speech-to-text (passthrough) | -| `POST` | `/api/webhooks/register` | Register a webhook URL | -| `GET` | `/api/webhooks` | List registered webhooks | -| `POST` | `/api/webhooks/test` | Fire a test webhook | Successful `POST /api/chat` requests emit one `chat.completed` webhook after the reply is produced. `POST /api/chat/stream` emits the same event only after @@ -616,7 +621,6 @@ it. The POST body is `{"event":"chat.completed","data":{...}}`, where limited to 500 characters and redact common API-key/token patterns. Webhook delivery errors are audited as `WEBHOOK_FAILURE` and never change the chat response. -| `POST` | `/mcp` | Model Context Protocol (JSON-RPC 2.0) | API and MCP routes allow direct loopback access without a token. Any non-loopback deployment must set `KYROZEN_SERVER_TOKEN` and send it as diff --git a/scripts/check_docs.py b/scripts/check_docs.py index df2ad7b..f61ae9b 100755 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -111,6 +111,115 @@ def _discovered_test_count() -> int: return suite.countTestCases() +def _pipe_table_cells(line: str) -> list[str] | None: + """Parse one simple Markdown pipe row without adding a dependency.""" + stripped = line.strip() + if not stripped.startswith("|") or not stripped.endswith("|"): + return None + return [cell.strip() for cell in stripped[1:-1].split("|")] + + +def _endpoint_path(value: str) -> str: + """Remove Markdown code formatting and query details from an endpoint.""" + value = value.strip().strip("`") + return value.split("?", 1)[0].split("#", 1)[0] + + +def _endpoint_methods(value: str) -> list[str]: + """Expand a documented method cell such as ``GET/POST``.""" + value = value.replace("`", "").strip() + return [method.strip().upper() for method in value.split("/") if method.strip()] + + +def _check_readme_endpoint_table(path: Path, text: str, routes: set[tuple[str, str]]) -> list[str]: + """Validate README's endpoint table against the live FastAPI route set.""" + lines = text.splitlines() + heading_index = next( + (index for index, line in enumerate(lines) if line.strip() == "### REST API endpoints"), + None, + ) + if heading_index is None: + return [f"{path.name}: missing '### REST API endpoints' heading"] + + section_end = next( + ( + index + for index in range(heading_index + 1, len(lines)) + if re.match(r"^###\s+", lines[index]) + ), + len(lines), + ) + errors: list[str] = [] + header_index = next( + ( + index + for index in range(heading_index + 1, section_end) + if ( + (cells := _pipe_table_cells(lines[index])) + and [cell.lower() for cell in cells] + == ["method", "endpoint", "description"] + ) + ), + None, + ) + if header_index is None: + return [f"{path.name}: endpoint table header is missing or malformed"] + + separator_index = header_index + 1 + separator = _pipe_table_cells(lines[separator_index]) if separator_index < section_end else None + if not separator or len(separator) != 3 or not all(re.fullmatch(r":?-{3,}:?", cell) for cell in separator): + return [ + f"{path.name}:{separator_index + 1}: endpoint table separator is missing or malformed" + ] + + documented: set[tuple[str, str]] = set() + table_end = separator_index + 1 + while table_end < section_end: + cells = _pipe_table_cells(lines[table_end]) + if cells is None: + break + if len(cells) != 3: + errors.append( + f"{path.name}:{table_end + 1}: endpoint table row must have 3 columns" + ) + table_end += 1 + continue + methods = _endpoint_methods(cells[0]) + endpoint = _endpoint_path(cells[1]) + if not methods or not endpoint.startswith("/"): + errors.append(f"{path.name}:{table_end + 1}: malformed endpoint row") + for method in methods: + if not re.fullmatch(r"[A-Z]+", method): + errors.append(f"{path.name}:{table_end + 1}: invalid HTTP method '{method}'") + continue + pair = (method, endpoint) + if pair in documented: + errors.append( + f"{path.name}:{table_end + 1}: duplicate endpoint {method} {endpoint}" + ) + documented.add(pair) + table_end += 1 + + # A table row after prose is a broken table, even if Markdown renderers + # happen to display it as a second table. + for index in range(table_end, section_end): + cells = _pipe_table_cells(lines[index]) + if cells and len(cells) >= 2 and any( + method in _endpoint_methods(cells[0]) for method, _ in routes + ): + errors.append( + f"{path.name}:{index + 1}: endpoint row is outside the contiguous REST table" + ) + + missing = sorted(routes - documented) + extra = sorted(documented - routes) + for method, endpoint in missing: + errors.append(f"{path.name}: endpoint table is missing live route {method} {endpoint}") + for method, endpoint in extra: + errors.append(f"{path.name}: endpoint table documents unknown route {method} {endpoint}") + return errors + + def _check_verification_record() -> list[str]: if not VERIFICATION_DOC.exists(): return ["docs/self-evolution.md is missing"] @@ -159,6 +268,8 @@ def main() -> int: errors.extend(_check_verification_record()) for readme in README_FILES: text = readme.read_text(encoding="utf-8") + if readme.name == "README.md": + errors.extend(_check_readme_endpoint_table(readme, text, set(routes))) for pattern in STALE_TOOL_PATTERNS: match = pattern.search(text) if match: