Skip to content

Commit 408b5d0

Browse files
jitokimoh-my-graph
andauthored
docs: five sentences v0.11.0 made false, and a test so the sixth cannot hide (#232)
* docs(examples): the MCP-dependent run has a door out (#219) The isolation bullet told a reader whose auto run depends on an MCP server that it will stop working, full stop. Since ADR 0032 shipped in v0.11.0 the run can type --accept-loaded-user-config, which sets SettingSources to nil and StrictMCPConfig to false (internal/coordinator/coordinator.go:763-766), so claude_protocol.go emits neither flag and the servers load again. SECURITY.md:369-374 already names that door in the same paragraph; this is the one place that left the reader with an actionable wrong answer. Signed-off-by: jitokim <pigberger70@gmail.com> Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> * docs(limitations): the flag is tagged, and the stamps say so (#219) Four sentences went stale when v0.11.0 was tagged on 2026-08-21. "Unreleased: no tagged build carries the flag" is false: git grep accept-loaded-user-config v0.11.0 -- cmd/oh-my-graph/flags.go finds it at :166 inside the tag, and cmd/oh-my-graph/version.go:9 reads "0.11.0". Deleted. The clause after it — a run that types nothing is byte-for-byte the v0.10.0 run — is still true and stays. Three "as of v0.10.0" stamps (:4, :54, :350) while the file already describes v0.11.0 behaviour in the ADR 0032 bullet at :222-265. Bumped. The same three stamps went stale at v0.8.0 one release earlier (CHANGELOG.md:383), so this is a recurrence, not a one-off. Signed-off-by: jitokim <pigberger70@gmail.com> Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> * test(docs): the document set is walked, so no file can be omitted by forgetting d537739 shipped --accept-loaded-user-config and conditioned the claims it falsified across six documents, docs/EXAMPLES.md among them — and still left docs/EXAMPLES.md:341 saying an MCP-dependent auto run "will stop working", full stop, until 77c9b6a. The miss was a sentence inside a file the sweep did edit, so what runs here is a predicate over a walked document set, not a wider grep. So the set is derived: every *.md at the repository root and every *.md under docs/ and plugin/, walked. expectedRoots is asserted PRESENT in that walk and never used to select what gets scanned, so a document that lands under docs/ or plugin/ tomorrow is scanned tomorrow with no edit here, and the list can only fail the day the walk stops reaching something it used to reach. Two absolutes are encoded, each carrying into the failure message the code address that falsifies it: internal/coordinator/coordinator.go:763-764 (toolPolicyFor sets SettingSources=nil under the opt-in) and :765 together with internal/runner/claude_protocol.go:55-56 (--strict-mcp-config is emitted only when StrictMCPConfig is true). A claim stated WITH the flag named close after it is true and passes; a document that states neither claim is never a finding, and nothing here demands that any document mention anything. Watched it fail rather than trusting it, which is what this repository's scar about scripts/changelog-entry-check.sh asks for. Restoring docs/EXAMPLES.md:460 to the wording it carried before d537739: claims_test.go:163: docs/EXAMPLES.md:460 states "no planned node gets that any more" unconditionally. with the quote and the coordinator.go address under it. git checkout -- restores green, and git status --short is clean afterwards. Test-only: doc.go carries the package clause and the reason, the guard is claims_test.go, and it imports nothing that spawns anything. CONTRIBUTING.md gains one sentence in the make-test paragraph naming this command, so the guard is discoverable where the suite is described. Signed-off-by: jitokim <pigberger70@gmail.com> Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> * docs(changelog): the five corrected sentences, and the guard that watches for the sixth Two entries under [Unreleased], both about the same recurrence: a claim the code made conditional and a document that kept stating it as an absolute. Fixed — the five sentences v0.11.0 falsified (one in docs/EXAMPLES.md, four in docs/LIMITATIONS.md), each with the address that settles it rather than the prose that surrounded it: coordinator.go:763-766 and claude_protocol.go:55-56 for the MCP door, the flag found at flags.go:166 inside the v0.11.0 tag, and version.go:9 for the three stale stamps. The true half of the deleted sentence is named as surviving, so nobody reads the deletion as wider than it was. Added — internal/docsclaims, what it walks, what it fails on, what it carries into the failure, what it does not demand, and that it was watched failing before it was trusted. No version bump, no new heading, no tag: [Unreleased] keeps its line and the diff is insertions only. Signed-off-by: jitokim <pigberger70@gmail.com> Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> --------- Signed-off-by: jitokim <pigberger70@gmail.com> Co-authored-by: oh-my-graph <graphs@oh-my-graph.dev>
1 parent 16f0ecc commit 408b5d0

6 files changed

Lines changed: 336 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,22 @@ oh-my-graph is **alpha software**. The graph YAML schema, the CLI, and the
6969
reason counts and this detail lives in `internal/runstatus`, so the four
7070
surfaces answer for the same directory with the same words instead of four
7171
different ones.
72+
73+
- **A guard over the user-facing documents, run by `make test`:** `go test
74+
./internal/docsclaims/`. It **walks** the document set rather than listing it
75+
— every `*.md` at the repository root and every `*.md` under `docs/` and
76+
`plugin/` — so a document that lands tomorrow is scanned tomorrow, and the
77+
roots it must reach are asserted present rather than used to pick what gets
78+
read. What it fails on is narrow: the two absolutes ADR 0032 falsified, stated
79+
anywhere with nothing conditioning them. Each failure carries the file, the
80+
line, the sentence and the code address that falsifies it
81+
(`internal/coordinator/coordinator.go:763-765`,
82+
`internal/runner/claude_protocol.go:55-56`), so it can be retraced instead of
83+
believed. It demands nothing of any document: a file that states neither claim
84+
is never a finding. It was watched failing before it was trusted — the
85+
pre-correction `docs/EXAMPLES.md` wording put back verbatim, the failure read,
86+
the file restored.
87+
7288
### Fixed
7389

7490
- **The planner now shares the assessor's bounded spawn retry.** #214 gave the
@@ -91,6 +107,19 @@ oh-my-graph is **alpha software**. The graph YAML schema, the CLI, and the
91107
cancelled context still stop on the first answer — retrying those is
92108
re-rolling a verdict until the loop likes one.
93109

110+
- **Five documentation sentences that v0.11.0 made false**, each corrected
111+
against the code rather than against the surrounding prose. `docs/EXAMPLES.md`
112+
told a reader whose `auto` run depended on an MCP server that it "will stop
113+
working", full stop, when `--accept-loaded-user-config` is exactly the door out
114+
of that (`internal/coordinator/coordinator.go:763-766` sets `StrictMCPConfig`
115+
false, and `internal/runner/claude_protocol.go:55-56` then emits no flag).
116+
`docs/LIMITATIONS.md` said no tagged build carried that flag — `git grep
117+
accept-loaded-user-config v0.11.0 -- cmd/oh-my-graph/flags.go` finds it at
118+
`:166` inside the tag — and stamped its gaps "as of v0.10.0" in three places
119+
while `cmd/oh-my-graph/version.go:9` reads `0.11.0`. The clause that survived
120+
the deletion is the true half: a run that types nothing is byte-for-byte the
121+
run that shipped in v0.10.0.
122+
94123
## [v0.11.0] - 2026-08-21
95124

96125
**Minor because two things you may now type were errors before**, and one thing

CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ handoff, retry, and halt-on-fail logic are all exercised through
3434
`map[nodeID]NodeOutcome` fixtures, so CI never needs a `claude` login and
3535
never costs money.
3636

37+
Inside that suite, `go test ./internal/docsclaims/` walks every Markdown file at
38+
the repository root and under `docs/` and `plugin/` for the absolutes ADR 0032
39+
falsified — run it instead of hand-writing a cross-check grep when a change
40+
makes a documented claim conditional.
41+
3742
One file outside the four exec-seam packages is a **sanctioned exception to
3843
"spawns nothing"**, and it is still an exception to nothing above:
3944
`cmd/oh-my-graph/skillargv_test.go` drives the real `CLIRunner` against a

docs/EXAMPLES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,8 @@ Two things that come with it, both real:
339339

340340
- **Planned nodes are now more isolated and less capable.** They no longer see
341341
your CLAUDE.md, your hooks, or your configured MCP servers. If an `auto` run
342-
of yours depended on an MCP server, it will stop working.
342+
of yours depended on an MCP server, it will stop working unless that run types
343+
`--accept-loaded-user-config`.
343344
- **It is still not a sandbox.** MCP closure is unverified (the flag is passed
344345
because it is free, not because it was measured); which skill a node actually
345346
activates is not knowable before the model chooses it, and slash-command

docs/LIMITATIONS.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Limitations & platform notes
22

33
Detail moved out of the README: the full platform-support notes, the honest
4-
gaps as of **v0.10.0**, and what is deliberately deferred. Where a gap has
4+
gaps as of **v0.11.0**, and what is deliberately deferred. Where a gap has
55
already been closed on `main` but not in a tagged release, this file says so
66
in the paragraph that describes it rather than in the stamp.
77

@@ -51,7 +51,7 @@ On Windows, prefer WSL.
5151

5252
## Known limitations
5353

54-
Honest gaps as of v0.10.0. **This file is where they are tracked** — the issue
54+
Honest gaps as of v0.11.0. **This file is where they are tracked** — the issue
5555
numbers below name the *closed* issue each gap was carved out of, which is
5656
provenance, not a tracker: those issues asked for the feature that shipped,
5757
and were closed when it did. What survived the feature is the paragraph, here.
@@ -260,9 +260,8 @@ has no open issue behind it.
260260
and `approval_policy="never"` are argv on every node, outside the branch the
261261
flag switches, so the sandbox floor and the no-network limit above are
262262
unchanged.
263-
<br>Unreleased: no tagged build carries the flag, and a run that types
264-
nothing is byte-for-byte the run that shipped in v0.10.0 — same argv, same
265-
screens, same `state.json`.
263+
<br>A run that types nothing is byte-for-byte the run that shipped in
264+
v0.10.0 — same argv, same screens, same `state.json`.
266265
([ADR 0032](adr/0032-a-planned-node-may-carry-the-operators-configuration.md))
267266
- **`agent:` tool reconciliation is undefined and unmeasured for hand-written
268267
graphs.** When a hand-written node names a subagent, oh-my-graph does not
@@ -348,7 +347,7 @@ See [Deferred](#deferred-not-implemented) below for the full out-of-scope list.
348347

349348
## Deferred (not implemented)
350349

351-
Called out honestly — these are **not** implemented as of v0.10.0:
350+
Called out honestly — these are **not** implemented as of v0.11.0:
352351

353352
- parallel-group sugar / any DSL beyond `depends_on`. (Retry is *not* on this
354353
list any more: a node's `retry` carries `max` **and** `on`, a per-cause

internal/docsclaims/claims_test.go

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
package docsclaims
2+
3+
import (
4+
"io/fs"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// A falsified is one absolute the code has already made false, plus the way a
13+
// document is allowed to keep saying it: conditioned.
14+
//
15+
// - absolute is the phrasing that is false on its own.
16+
// - qualifier is what must appear close after it for the sentence to be true.
17+
// A document that never says the absolute at all is never a finding — this
18+
// guard has no opinion about what a document must contain.
19+
// - address is the code that falsifies it, carried into the failure message
20+
// so whoever reads the failure can retrace it instead of trusting it.
21+
type falsified struct {
22+
name string
23+
absolute *regexp.Regexp
24+
qualifier *regexp.Regexp
25+
address string
26+
}
27+
28+
// How far after the absolute the qualifier may sit, in whitespace-normalised
29+
// bytes. Wide enough for a qualifier pushed onto the next wrapped line, narrow
30+
// enough that a mention elsewhere in the paragraph does not launder it.
31+
const qualifierWindow = 200
32+
33+
// optIn is the flag ADR 0032 shipped; naming it is what conditions both claims.
34+
var optIn = regexp.MustCompile(`--accept-loaded-user-config`)
35+
36+
// The two absolutes ADR 0032 falsified. d537739 conditioned both where they
37+
// stood in docs/EXAMPLES.md (:451, :460 today); they are encoded here so that
38+
// no document — that one or a new one — can state either as an absolute again.
39+
var falsifiedByADR0032 = []falsified{
40+
{
41+
name: `"no planned node gets that any more"`,
42+
absolute: regexp.MustCompile(`no planned node gets that any more`),
43+
qualifier: optIn,
44+
address: "internal/coordinator/coordinator.go:763-764 — toolPolicyFor sets " +
45+
"policy.SettingSources = nil when the run typed --accept-loaded-user-config, " +
46+
"so every planned node of such a run does get the operator's configuration",
47+
},
48+
{
49+
name: "\"as every planned node's always has\", of `--strict-mcp-config`",
50+
absolute: regexp.MustCompile(`every planned node('s)? always has`),
51+
qualifier: optIn,
52+
address: "internal/coordinator/coordinator.go:765 sets policy.StrictMCPConfig = false " +
53+
"under --accept-loaded-user-config, and internal/runner/claude_protocol.go:55-56 " +
54+
"emits --strict-mcp-config only when it is true, so such a node's argv carries none",
55+
},
56+
}
57+
58+
// expectedRoots is asserted PRESENT in the walk — it is never used to select
59+
// what gets scanned. Scanning follows the walk, so a document that lands under
60+
// docs/ or plugin/ tomorrow is scanned tomorrow with no edit here; this list
61+
// only fails the day the walk stops reaching a document it used to reach.
62+
var expectedRoots = []string{
63+
"README.md",
64+
"README.ko.md",
65+
"DESIGN.md",
66+
"SECURITY.md",
67+
"CONTRIBUTING.md",
68+
"CHANGELOG.md",
69+
"docs/EXAMPLES.md",
70+
"docs/LIMITATIONS.md",
71+
"docs/INSTALL.md",
72+
"docs/adr/0032-a-planned-node-may-carry-the-operators-configuration.md",
73+
"plugin/README.md",
74+
"plugin/commands/graph.md",
75+
"plugin/agents/oh-my-graph.md",
76+
"plugin/skills/run-graph/SKILL.md",
77+
}
78+
79+
// walkedSubtrees is the scope, not a file list: every Markdown file at the
80+
// repository root, and every Markdown file anywhere under these.
81+
var walkedSubtrees = []string{"docs", "plugin"}
82+
83+
type finding struct {
84+
line int
85+
claim falsified
86+
quote string
87+
}
88+
89+
func TestDocSetIsWalkedAndReachesEveryExpectedRoot(t *testing.T) {
90+
root := repoRoot(t)
91+
docs := docSet(t, root)
92+
93+
found := make(map[string]bool, len(docs))
94+
for _, rel := range docs {
95+
found[rel] = true
96+
}
97+
for _, want := range expectedRoots {
98+
if !found[want] {
99+
t.Errorf("the walk no longer reaches %s (%d documents found). "+
100+
"A guard that scans nothing passes; fix the walk, do not shrink this list.",
101+
want, len(docs))
102+
}
103+
}
104+
}
105+
106+
func TestScanFiresOnTheAbsoluteAndNotOnTheConditionedForm(t *testing.T) {
107+
cases := []struct {
108+
name string
109+
text string
110+
want int
111+
}{
112+
{
113+
// docs/EXAMPLES.md:457-458 as it stood before d537739, verbatim.
114+
name: "the absolute, as it shipped",
115+
text: "what declining buys: it does **not** hand the node your environment back,\n" +
116+
"because no planned node gets that any more. `--no-agent-mapping` remains the\n",
117+
want: 1,
118+
},
119+
{
120+
name: "the same claim, conditioned",
121+
text: "because no planned node gets that any more — unless the run typed\n" +
122+
"`--accept-loaded-user-config`, in which case every planned node has it\n",
123+
want: 0,
124+
},
125+
{
126+
name: "the strict-mcp-config absolute, wrapped across lines",
127+
text: "(Its argv also carries `--strict-mcp-config`, as every planned node's\n" +
128+
"always has; whether that closes MCP is unmeasured.)\n",
129+
want: 1,
130+
},
131+
{
132+
name: "the same claim, conditioned",
133+
text: "(Its argv also carries `--strict-mcp-config`, as every planned node's always\n" +
134+
"has unless the run typed `--accept-loaded-user-config`, which drops it.)\n",
135+
want: 0,
136+
},
137+
{
138+
// Requirement 3: absence is never a failure. This document states
139+
// neither claim, mentions neither flag, and must be silent.
140+
name: "a document that claims nothing at all",
141+
text: "# Install\n\n`go install github.com/jitokim/oh-my-graph/cmd/oh-my-graph@latest`\n",
142+
want: 0,
143+
},
144+
}
145+
146+
for _, tc := range cases {
147+
t.Run(tc.name, func(t *testing.T) {
148+
got := scan([]byte(tc.text))
149+
if len(got) != tc.want {
150+
t.Fatalf("scan found %d finding(s), want %d: %+v", len(got), tc.want, got)
151+
}
152+
})
153+
}
154+
}
155+
156+
func TestNoDocumentStatesAnAbsoluteADR0032Falsified(t *testing.T) {
157+
root := repoRoot(t)
158+
for _, rel := range docSet(t, root) {
159+
raw, err := os.ReadFile(filepath.Join(root, rel))
160+
if err != nil {
161+
t.Fatalf("read %s: %v", rel, err)
162+
}
163+
for _, f := range scan(raw) {
164+
t.Errorf("%s:%d states %s unconditionally.\n quoted: %q\n falsified by: %s\n"+
165+
" Condition it on --accept-loaded-user-config, or drop it.",
166+
rel, f.line, f.claim.name, f.quote, f.claim.address)
167+
}
168+
}
169+
}
170+
171+
// scan reports every absolute stated in raw with nothing conditioning it.
172+
func scan(raw []byte) []finding {
173+
text, offsets := normalize(raw)
174+
var findings []finding
175+
for _, claim := range falsifiedByADR0032 {
176+
for _, m := range claim.absolute.FindAllStringIndex(text, -1) {
177+
end := m[1] + qualifierWindow
178+
if end > len(text) {
179+
end = len(text)
180+
}
181+
if claim.qualifier.MatchString(text[m[1]:end]) {
182+
continue
183+
}
184+
quoteEnd := m[1] + 60
185+
if quoteEnd > len(text) {
186+
quoteEnd = len(text)
187+
}
188+
findings = append(findings, finding{
189+
line: lineOf(raw, offsets[m[0]]),
190+
claim: claim,
191+
quote: text[m[0]:quoteEnd],
192+
})
193+
}
194+
}
195+
return findings
196+
}
197+
198+
// normalize collapses every run of whitespace to one space, so a claim the
199+
// author wrapped across two lines still reads as one sentence. It returns the
200+
// offset in raw of each byte it kept, which is what turns a match back into a
201+
// line number.
202+
func normalize(raw []byte) (string, []int) {
203+
var b strings.Builder
204+
offsets := make([]int, 0, len(raw))
205+
prevSpace := false
206+
for i, c := range raw {
207+
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
208+
if prevSpace {
209+
continue
210+
}
211+
b.WriteByte(' ')
212+
offsets = append(offsets, i)
213+
prevSpace = true
214+
continue
215+
}
216+
b.WriteByte(c)
217+
offsets = append(offsets, i)
218+
prevSpace = false
219+
}
220+
return b.String(), offsets
221+
}
222+
223+
func lineOf(raw []byte, offset int) int {
224+
return 1 + strings.Count(string(raw[:offset]), "\n")
225+
}
226+
227+
// docSet derives the documents to scan: every *.md at the repository root, and
228+
// every *.md under each walked subtree. Nothing is excluded, because an
229+
// exclusion is the shape of the miss this guard exists to stop.
230+
func docSet(t *testing.T, root string) []string {
231+
t.Helper()
232+
233+
entries, err := os.ReadDir(root)
234+
if err != nil {
235+
t.Fatalf("read repository root %s: %v", root, err)
236+
}
237+
var docs []string
238+
for _, e := range entries {
239+
if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") {
240+
docs = append(docs, e.Name())
241+
}
242+
}
243+
244+
for _, sub := range walkedSubtrees {
245+
err := filepath.WalkDir(filepath.Join(root, sub), func(path string, d fs.DirEntry, err error) error {
246+
if err != nil {
247+
return err
248+
}
249+
if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
250+
return nil
251+
}
252+
rel, err := filepath.Rel(root, path)
253+
if err != nil {
254+
return err
255+
}
256+
docs = append(docs, filepath.ToSlash(rel))
257+
return nil
258+
})
259+
if err != nil {
260+
t.Fatalf("walk %s: %v", sub, err)
261+
}
262+
}
263+
return docs
264+
}
265+
266+
func repoRoot(t *testing.T) string {
267+
t.Helper()
268+
269+
root, err := filepath.Abs(filepath.Join("..", ".."))
270+
if err != nil {
271+
t.Fatalf("resolve repository root: %v", err)
272+
}
273+
if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
274+
t.Fatalf("no go.mod at %s — this guard is scanning the wrong tree: %v", root, err)
275+
}
276+
return root
277+
}

0 commit comments

Comments
 (0)