From ddbf610bd5cab83bce74754c527de8c0e5a76540 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 06:52:11 +0000 Subject: [PATCH] The README's first code block was LangGraph's API, not GraphARC's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every call in the Quick Start was wrong, and it was the first code a visitor copies. Verified against this tree rather than read: - `from grapharc.runtime import StateGraph` -> ImportError. This package has never exported `StateGraph`; the module exports `GraphARC`. The very first line failed, so nobody who pasted the block got as far as the rest. - `add_node("process", lambda ...)` with no `writes=` -> TypeError, the argument is required. Per-node write permissions are the project's headline claim, and the snippet showed them being skipped. - `add_edge("START", "process")` -> ValueError: unknown node 'START'. START is a sentinel, not the string. - Plain `pydantic.BaseModel` for state. Accepted at runtime, but it sidesteps `GraphARCState` and the typed-contract story the page is selling. The block came in with 241b272 ("README refactoring: improve discoverability and structure"), which also added a contents list whose `#usage` and `#documentation` entries point at sections that do not exist — a dead anchor on GitHub silently does nothing when clicked, so reading the page never surfaced it either. Replaced with a snippet that runs, and that shows the three things that are actually the point: typed state, declared writes, a Budget on the run. The contents list now names the real sections. Nothing caught any of this because nothing ran it. This module's own docstring describes exactly that drift for the admission-gate section and fixes it by executing the block; the Quick Start had the same standing on the page and none of the discipline. So it gets the same treatment: - `test_the_quick_start_block_actually_runs_against_this_tree` executes the block and compares stdout with the result the page states. - `test_the_quick_start_reaches_no_live_backend` keeps the first snippet a visitor copies from being able to spend money. - `test_every_table_of_contents_link_resolves_to_a_real_heading` derives anchors the way GitHub does and fails on a dead one. Both new tests were confirmed to fail against the old README and pass against the new one, so they are not vacuous. Full suite green on 3.12; ruff clean. Co-Authored-By: Claude Opus 5 --- README.md | 48 ++++++++++++++++++----------- tests/test_readme.py | 72 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 648ae8f..8f6360c 100644 --- a/README.md +++ b/README.md @@ -41,34 +41,48 @@ Build production-grade multi-agent systems with built-in safety, auditability, a - [Core Components](#core-components) - [What it adds on top of LangGraph](#what-it-adds-on-top-of-langgraph) - [Install](#install) -- [Usage](#usage) -- [Documentation](#documentation) +- [Quickstart](#quickstart) — the CLI tour +- [The admission gate](#the-admission-gate) +- [Configuration, and the zero-config path](#configuration-and-the-zero-config-path) +- [The model gateway](#the-model-gateway) +- [Independent verification](#independent-verification) +- [Tools and the harness](#tools-and-the-harness) +- [Memory](#memory) +- [Sessions, the HTTP API, and policy](#sessions-the-http-api-and-policy) +- [Reading a run afterwards](#reading-a-run-afterwards) +- [Tests are gates](#tests-are-gates) +- [Status and limits](#status-and-limits) ## Quick Start ```python -from grapharc.runtime import StateGraph -from pydantic import BaseModel +from grapharc import Budget, GraphARC, GraphARCState +from grapharc.runtime.graph import END, START -# Define your state -class MyState(BaseModel): - messages: list[str] +# State is a typed contract, not a free-form dict: `extra="forbid"`. +class MyState(GraphARCState): + question: str result: str = "" -# Create a graph -graph = StateGraph(MyState) +def process(state: MyState) -> dict: + return {"result": f"handled: {state.question}"} + +graph = GraphARC(MyState, name="quickstart", budget=Budget(max_iterations=10)) -# Add nodes and edges -graph.add_node("process", lambda state: {"result": "done"}) -graph.add_edge("START", "process") -graph.add_edge("process", "END") +# `writes` is required. A node that returns a field it did not declare +# raises WritePermissionError instead of quietly writing it. +graph.add_node("process", process, writes={"result"}) +graph.add_edge(START, "process") +graph.add_edge("process", END) -# Compile and run -compiled = graph.compile() -result = compiled.invoke({"messages": ["hello"]}) +print(graph.compile().invoke({"question": "hello"})) +# {'question': 'hello', 'result': 'handled: hello'} ``` -See [Usage](#usage) for more detailed examples. +Three things in that snippet are the whole point, and none of them are optional: +the state is a typed schema, the node declares what it may write, and the run +carries a `Budget`. See [Quickstart](#quickstart) for the CLI tour and +[The admission gate](#the-admission-gate) for the part with no prior art. ## Architecture diff --git a/tests/test_readme.py b/tests/test_readme.py index f2a6f68..0e41fd3 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -86,6 +86,78 @@ def test_the_python_block_reaches_no_live_backend(): assert forbidden not in code, forbidden +def test_the_quick_start_block_actually_runs_against_this_tree(): + """The page's *first* code block, executed rather than admired. + + It was previously LangGraph's API rather than GraphARC's — it opened with + `from grapharc.runtime import StateGraph`, a name this package has never + exported, so the very first line raised `ImportError`. The two calls after + it were wrong in their own right: `add_node` without `writes=` raises + `TypeError` (the argument is required, and per-node write permissions are + the project's headline claim), and `add_edge("START", ...)` raises + `ValueError` because `START` is a sentinel, not the string `"START"`. + + Nothing caught it because nothing ran it. The admission-gate section has + been executed by this file since it was written; the Quick Start had the + same standing on the page and none of the same discipline, which is exactly + the drift this module's docstring describes. So: same treatment. The + trailing comment on the page states the printed result, and it is compared + against what the block really prints. + """ + blocks = _blocks("Quick Start") + assert [lang for lang, _ in blocks] == ["python"], blocks + code = blocks[0][1] + + # The expectation is written on the page as a trailing `# {...}` comment, + # so the snippet stays copy-pasteable instead of carrying a second block. + expected = [ + line.lstrip("# ").strip() for line in code.splitlines() if line.startswith("# {") + ] + assert len(expected) == 1, "the Quick Start must state its printed result" + + buffer = io.StringIO() + namespace: dict = {"__name__": "__readme__"} + with redirect_stdout(buffer): + exec(compile(code, f"{README}:quick-start", "exec"), namespace) + + assert _normalise(buffer.getvalue()) == _normalise(expected[0]) + + +def test_the_quick_start_reaches_no_live_backend(): + """The first snippet a visitor copies must not be able to spend money.""" + code = _blocks("Quick Start")[0][1] + + for forbidden in ("get_model(", "openrouter", "ClaudeCodeCLIChatModel", "claude-cli"): + assert forbidden not in code, forbidden + + +def test_every_table_of_contents_link_resolves_to_a_real_heading(): + """Two of the seven entries pointed at sections that do not exist. + + `#usage` and `#documentation` were both dead — the refactor that added the + contents list invented them — and a dead anchor on GitHub silently does + nothing when clicked, so neither reading the page nor running the suite + surfaced it. Anchors are derived here the way GitHub derives them, and + every in-page link in the list has to land somewhere. + """ + text = README.read_text(encoding="utf-8") + contents = _section("Table of Contents") + + anchors = set() + for line in text.splitlines(): + if not line.startswith("#"): + continue + title = line.lstrip("#").strip() + slug = re.sub(r"[^a-z0-9\s-]", "", title.lower()) + anchors.add(re.sub(r"\s+", "-", slug.strip())) + + linked = re.findall(r"\]\(#([a-z0-9-]+)\)", contents) + assert linked, "the contents list has no in-page links at all" + + dead = sorted(set(linked) - anchors) + assert not dead, f"table of contents links to non-existent sections: {dead}" + + def _embedded_images() -> list[str]: """Every local image the README embeds, read off the README itself.