Skip to content

Commit 9544674

Browse files
authored
Merge branch 'main' into fix/placeholder-quoting-diagnostic
2 parents 30e57b4 + 408b5d0 commit 9544674

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
@@ -104,6 +104,22 @@ oh-my-graph is **alpha software**. The graph YAML schema, the CLI, and the
104104
reason counts and this detail lives in `internal/runstatus`, so the four
105105
surfaces answer for the same directory with the same words instead of four
106106
different ones.
107+
108+
- **A guard over the user-facing documents, run by `make test`:** `go test
109+
./internal/docsclaims/`. It **walks** the document set rather than listing it
110+
— every `*.md` at the repository root and every `*.md` under `docs/` and
111+
`plugin/` — so a document that lands tomorrow is scanned tomorrow, and the
112+
roots it must reach are asserted present rather than used to pick what gets
113+
read. What it fails on is narrow: the two absolutes ADR 0032 falsified, stated
114+
anywhere with nothing conditioning them. Each failure carries the file, the
115+
line, the sentence and the code address that falsifies it
116+
(`internal/coordinator/coordinator.go:763-765`,
117+
`internal/runner/claude_protocol.go:55-56`), so it can be retraced instead of
118+
believed. It demands nothing of any document: a file that states neither claim
119+
is never a finding. It was watched failing before it was trusted — the
120+
pre-correction `docs/EXAMPLES.md` wording put back verbatim, the failure read,
121+
the file restored.
122+
107123
### Fixed
108124

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

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

131160
**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)