Skip to content

Commit a68d957

Browse files
The README's first code block was LangGraph's API, not GraphARC's (#55)
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 <noreply@anthropic.com>
1 parent 65f7fa0 commit a68d957

2 files changed

Lines changed: 103 additions & 17 deletions

File tree

README.md

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,34 +41,48 @@ Build production-grade multi-agent systems with built-in safety, auditability, a
4141
- [Core Components](#core-components)
4242
- [What it adds on top of LangGraph](#what-it-adds-on-top-of-langgraph)
4343
- [Install](#install)
44-
- [Usage](#usage)
45-
- [Documentation](#documentation)
44+
- [Quickstart](#quickstart) — the CLI tour
45+
- [The admission gate](#the-admission-gate)
46+
- [Configuration, and the zero-config path](#configuration-and-the-zero-config-path)
47+
- [The model gateway](#the-model-gateway)
48+
- [Independent verification](#independent-verification)
49+
- [Tools and the harness](#tools-and-the-harness)
50+
- [Memory](#memory)
51+
- [Sessions, the HTTP API, and policy](#sessions-the-http-api-and-policy)
52+
- [Reading a run afterwards](#reading-a-run-afterwards)
53+
- [Tests are gates](#tests-are-gates)
54+
- [Status and limits](#status-and-limits)
4655

4756
## Quick Start
4857

4958
```python
50-
from grapharc.runtime import StateGraph
51-
from pydantic import BaseModel
59+
from grapharc import Budget, GraphARC, GraphARCState
60+
from grapharc.runtime.graph import END, START
5261

53-
# Define your state
54-
class MyState(BaseModel):
55-
messages: list[str]
62+
# State is a typed contract, not a free-form dict: `extra="forbid"`.
63+
class MyState(GraphARCState):
64+
question: str
5665
result: str = ""
5766

58-
# Create a graph
59-
graph = StateGraph(MyState)
67+
def process(state: MyState) -> dict:
68+
return {"result": f"handled: {state.question}"}
69+
70+
graph = GraphARC(MyState, name="quickstart", budget=Budget(max_iterations=10))
6071

61-
# Add nodes and edges
62-
graph.add_node("process", lambda state: {"result": "done"})
63-
graph.add_edge("START", "process")
64-
graph.add_edge("process", "END")
72+
# `writes` is required. A node that returns a field it did not declare
73+
# raises WritePermissionError instead of quietly writing it.
74+
graph.add_node("process", process, writes={"result"})
75+
graph.add_edge(START, "process")
76+
graph.add_edge("process", END)
6577

66-
# Compile and run
67-
compiled = graph.compile()
68-
result = compiled.invoke({"messages": ["hello"]})
78+
print(graph.compile().invoke({"question": "hello"}))
79+
# {'question': 'hello', 'result': 'handled: hello'}
6980
```
7081

71-
See [Usage](#usage) for more detailed examples.
82+
Three things in that snippet are the whole point, and none of them are optional:
83+
the state is a typed schema, the node declares what it may write, and the run
84+
carries a `Budget`. See [Quickstart](#quickstart) for the CLI tour and
85+
[The admission gate](#the-admission-gate) for the part with no prior art.
7286

7387
## Architecture
7488

tests/test_readme.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,78 @@ def test_the_python_block_reaches_no_live_backend():
8686
assert forbidden not in code, forbidden
8787

8888

89+
def test_the_quick_start_block_actually_runs_against_this_tree():
90+
"""The page's *first* code block, executed rather than admired.
91+
92+
It was previously LangGraph's API rather than GraphARC's — it opened with
93+
`from grapharc.runtime import StateGraph`, a name this package has never
94+
exported, so the very first line raised `ImportError`. The two calls after
95+
it were wrong in their own right: `add_node` without `writes=` raises
96+
`TypeError` (the argument is required, and per-node write permissions are
97+
the project's headline claim), and `add_edge("START", ...)` raises
98+
`ValueError` because `START` is a sentinel, not the string `"START"`.
99+
100+
Nothing caught it because nothing ran it. The admission-gate section has
101+
been executed by this file since it was written; the Quick Start had the
102+
same standing on the page and none of the same discipline, which is exactly
103+
the drift this module's docstring describes. So: same treatment. The
104+
trailing comment on the page states the printed result, and it is compared
105+
against what the block really prints.
106+
"""
107+
blocks = _blocks("Quick Start")
108+
assert [lang for lang, _ in blocks] == ["python"], blocks
109+
code = blocks[0][1]
110+
111+
# The expectation is written on the page as a trailing `# {...}` comment,
112+
# so the snippet stays copy-pasteable instead of carrying a second block.
113+
expected = [
114+
line.lstrip("# ").strip() for line in code.splitlines() if line.startswith("# {")
115+
]
116+
assert len(expected) == 1, "the Quick Start must state its printed result"
117+
118+
buffer = io.StringIO()
119+
namespace: dict = {"__name__": "__readme__"}
120+
with redirect_stdout(buffer):
121+
exec(compile(code, f"{README}:quick-start", "exec"), namespace)
122+
123+
assert _normalise(buffer.getvalue()) == _normalise(expected[0])
124+
125+
126+
def test_the_quick_start_reaches_no_live_backend():
127+
"""The first snippet a visitor copies must not be able to spend money."""
128+
code = _blocks("Quick Start")[0][1]
129+
130+
for forbidden in ("get_model(", "openrouter", "ClaudeCodeCLIChatModel", "claude-cli"):
131+
assert forbidden not in code, forbidden
132+
133+
134+
def test_every_table_of_contents_link_resolves_to_a_real_heading():
135+
"""Two of the seven entries pointed at sections that do not exist.
136+
137+
`#usage` and `#documentation` were both dead — the refactor that added the
138+
contents list invented them — and a dead anchor on GitHub silently does
139+
nothing when clicked, so neither reading the page nor running the suite
140+
surfaced it. Anchors are derived here the way GitHub derives them, and
141+
every in-page link in the list has to land somewhere.
142+
"""
143+
text = README.read_text(encoding="utf-8")
144+
contents = _section("Table of Contents")
145+
146+
anchors = set()
147+
for line in text.splitlines():
148+
if not line.startswith("#"):
149+
continue
150+
title = line.lstrip("#").strip()
151+
slug = re.sub(r"[^a-z0-9\s-]", "", title.lower())
152+
anchors.add(re.sub(r"\s+", "-", slug.strip()))
153+
154+
linked = re.findall(r"\]\(#([a-z0-9-]+)\)", contents)
155+
assert linked, "the contents list has no in-page links at all"
156+
157+
dead = sorted(set(linked) - anchors)
158+
assert not dead, f"table of contents links to non-existent sections: {dead}"
159+
160+
89161
def _embedded_images() -> list[str]:
90162
"""Every local image the README embeds, read off the README itself.
91163

0 commit comments

Comments
 (0)