docs: the CLI gate needs a heading, not a substring
The gate that checks every carlos command is documented used strings.Contains over the whole page body. "route" is a prefix of "routes", so the literal text "carlos routes" satisfied the check for "route" — the entire `### carlos route` section could be deleted with all 21 tests still green. It now asks for a heading whose anchor is #carlos-<cmd>, which is what a reader can actually land on, and the doc comment names the collision so nobody simplifies it back. Five more from the same review: - cliscan reads only the switch on `cmd`. A second string switch in main.go would otherwise feed non-commands into the gate, and the obvious repair is to document them. Still 36 commands. - ci.yml says AGENTS.md. The rename swept twelve Go files and missed the one file that quotes the rule it enforces. - anchors.json gains an em dash and an accented letter. Anchor() drops non-ASCII runes as separators, so "Montréal edge" is montr-al-edge — a word splitting in half — and the website's slugify must agree. Verified against the JS rule. - links_test records that the website's vendored copy carries a "//" notice key its checker skips, and that this file must never have one. - TestLinksInsideFencesAreNotLinks deleted. It asserted link targets were non-empty, which a link that leaked out of a fence would also satisfy, so it could not catch the regression its name claimed. The link-count assertion next to it already covers the premise. cli.md: five of ten `, which is …` appositives flattened (the shape had become the page's default sentence), the zero-argument `carlos deploy` paragraph corrected to include the claim-an-app offer an empty account actually gets, and two over-long lines rewrapped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+66
−45
.github/workflows/ci.yml+3 −3docs/site/anchors.json+3 −1docs/site/cli.md+22 −20internal/docsite/cli_test.go+15 −6internal/docsite/cliscan.go+9 −0internal/docsite/fences_test.go+0 −1internal/docsite/links_test.go+14 −0internal/docsite/parse_test.go+0 −14
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml| index b2d1ff026..b07a2f988 100644 |
| --- a/.github/workflows/ci.yml |
| +++ b/.github/workflows/ci.yml |
| @@ -1,4 +1,4 @@ |
| -# The build gate from CLAUDE.md, enforced by a machine rather than by |
| +# The build gate from AGENTS.md, enforced by a machine rather than by |
| # whoever happens to be at a keyboard. The gate itself is not new; what is |
| # new is that it now runs somewhere other than a maintainer's Linux laptop. |
| # |
| @@ -37,7 +37,7 @@ jobs: |
| cache: true |
| - run: go build ./... |
| - run: go vet ./... |
| - # -count=1 to match CLAUDE.md: never satisfy the gate from a cached |
| + # -count=1 to match AGENTS.md: never satisfy the gate from a cached |
| # pass. The explicit timeout is above the 10m default because the |
| # root package's e2e tests compile the carlos binary many times over. |
| - run: go test ./... -count=1 -timeout 30m |
| @@ -161,7 +161,7 @@ jobs: |
| - uses: actions/setup-go@v5 |
| with: |
| go-version-file: go.mod |
| - # CLAUDE.md's rule is "gofmt -l clean for files you touch", not for the |
| + # AGENTS.md's rule is "gofmt -l clean for files you touch", not for the |
| # tree: three pre-existing unformatted files are deliberately left |
| # alone, so a repo-wide check would fail on arrival and teach everyone |
| # to ignore this job. Scoped to the files the PR actually changes. |
diff --git a/docs/site/anchors.json b/docs/site/anchors.json| index 554e1c875..d2f9211bb 100644 |
| --- a/docs/site/anchors.json |
| +++ b/docs/site/anchors.json |
| @@ -9,5 +9,7 @@ |
| "canary/rehearsal": "canary-rehearsal", |
| "X-Carlos-Version": "x-carlos-version", |
| "-app": "app", |
| - "--app": "app" |
| + "--app": "app", |
| + "Ship — then promote": "ship-then-promote", |
| + "Montréal edge": "montr-al-edge" |
| } |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index c192e0149..155cb6d3c 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -20,7 +20,7 @@ logged in to more than one deployment. |
| Most app commands work two ways. Signed in with `carlos auth login`, they go |
| through a console's API, which is how someone with no box access and no cloud |
| credentials gets their work done. With `CARLOS_DEPLOYMENT_BUCKET` or |
| -`CARLOS_DEPLOYMENT_DIR` set, they write to the bucket directly, which is the |
| +`CARLOS_DEPLOYMENT_DIR` set, they write to the bucket directly. That is the |
| operator's path. Where a command only works one way, its section says so. |
| ## Your apps |
| @@ -33,10 +33,10 @@ the platform holds itself to, and a command that broke it would be a bug. |
| Logs this terminal in to a console. `carlos auth login` prints a short code |
| that you approve in a browser already signed in to that console, and the |
| -browser does not have to be on this machine, which is what makes this work |
| -over SSH. The token lands in `~/.carlos/credentials` at mode 0600, one entry |
| -per console, so a laptop can hold credentials for the flagship and your own |
| -deployment at once. |
| +browser does not have to be on this machine, so this works over SSH. The |
| +token lands in `~/.carlos/credentials` at mode 0600, one entry per console, |
| +so a laptop can hold credentials for the flagship and your own deployment at |
| +once. |
| ```sh |
| carlos auth login |
| @@ -120,14 +120,16 @@ carlos deploy -app hello ./hello |
| ``` |
| Signed-in only. With saved project defaults you can run `carlos deploy` with |
| -no arguments and no questions. The first run at a terminal asks which |
| -artifact to ship, works out from what you point it at whether that is a |
| -binary or a site, and offers to save the answer as the project's default. |
| -With neither arguments nor defaults and no terminal to ask at — CI, usually — |
| -it refuses instead of guessing. `-channel` overrides the channel it picks |
| -(normally the one the app's instances already follow), and `-host` scopes the |
| -watch to a single instance. A static site needs that second flag, having no |
| -instances to resolve a channel from. |
| +no arguments and no questions. A first run at a terminal fills the blanks by |
| +asking. If your account owns no apps yet, it offers to claim one named after |
| +the directory you are in, through the same door as `carlos apps create`. It |
| +then asks which artifact to ship, works out from what you point it at whether |
| +that is a binary or a site, and offers to save both answers as the project's |
| +defaults. With neither arguments nor defaults and no terminal to ask at — CI, |
| +usually — it refuses instead of guessing. `-channel` overrides the channel it |
| +picks (normally the one the app's instances already follow), and `-host` |
| +scopes the watch to a single instance. A static site needs that second flag, |
| +having no instances to resolve a channel from. |
| ```sh |
| carlos deploy -app website -kind static -host www.example.com ./dist |
| @@ -326,9 +328,9 @@ one's sha, so an entry can point at it. Uploads are create-only and capped at |
| Ledgers have no direct-bucket mode at all: every sub-verb except `verify` |
| goes through the console, because a contributor's machine is never given |
| bucket credentials. `carlos ledger verify` walks a published chain over plain |
| -HTTPS and re-hashes every entry. It needs no credentials at all, which is the whole point: |
| -anybody can check a ledger you publish, including you, from a machine that |
| -has never been logged in. |
| +HTTPS and re-hashes every entry. It needs no credentials at all: anybody can |
| +check a ledger you publish, including you, from a machine that has never been |
| +logged in. |
| ### carlos vet |
| @@ -376,9 +378,9 @@ carlos bootstrap |
| ``` |
| `-root <dir>` writes the files into a staging directory instead, creating no |
| -users and running no systemctl, which is how you read what it would do before |
| -it does it. `-offcloud` is for a box outside EC2, where credentials come from |
| -a staged 0600 file instead of an instance profile. |
| +users and running no systemctl, so you can read what it would do before it |
| +does it. `-offcloud` is for a box outside EC2, where credentials come from a |
| +staged 0600 file instead of an instance profile. |
| ### carlos accounts |
| @@ -392,7 +394,7 @@ carlos accounts create -name acme -owner someone@example.com |
| `-owner` is the path for a box or a CI job, where there is no browser to log |
| in with. It needs no identity of its own and takes precedence over any |
| -logged-in one, which is why it exists. |
| +logged-in one. |
| `carlos accounts migrate` copies an app's objects to an account-qualified |
| prefix and re-stamps its routes. Run it with `-dry-run` first; it prints the |
diff --git a/internal/docsite/cli_test.go b/internal/docsite/cli_test.go| index 18bbacaad..3a5b7a393 100644 |
| --- a/internal/docsite/cli_test.go |
| +++ b/internal/docsite/cli_test.go |
| @@ -1,9 +1,6 @@ |
| package docsite |
| -import ( |
| - "strings" |
| - "testing" |
| -) |
| +import "testing" |
| // mainFile is the CLI's dispatch, relative to this package. |
| const mainFile = "../../main.go" |
| @@ -47,6 +44,17 @@ func contains(xs []string, s string) bool { |
| // switch rather than the usage text on purpose: usage text is prose |
| // someone can forget to update, so gating against it would only prove |
| // the docs match a second copy of the docs. The switch is the CLI. |
| +// |
| +// It asks for a HEADING, not a substring of the body, and that is not |
| +// a stylistic preference. A substring check is defeated by prefixes: |
| +// "route" is a prefix of "routes", so the literal text "carlos routes" |
| +// anywhere on the page satisfies a Contains check for "route" — the |
| +// whole `### carlos route` section can be deleted with every test |
| +// still green (a reviewer did exactly that, 2026-08-25). The same |
| +// check also counts a mention inside another command's example fence, |
| +// or in a link, as documentation. A heading is the thing a reader can |
| +// actually land on, and `#carlos-route` and `#carlos-routes` are |
| +// distinct anchors. Do not "simplify" this back to Contains. |
| func TestCLICommandsAreDocumented(t *testing.T) { |
| site, err := Load(root) |
| if err != nil { |
| @@ -61,8 +69,9 @@ func TestCLICommandsAreDocumented(t *testing.T) { |
| t.Fatalf("scan: %v", err) |
| } |
| for _, cmd := range cmds { |
| - if !strings.Contains(page.Body, "carlos "+cmd) { |
| - t.Errorf("%s.md does not document %q (looked for %q)", cliPage, cmd, "carlos "+cmd) |
| + if !hasAnchor(page, Anchor("carlos "+cmd)) { |
| + t.Errorf("%s.md has no heading for %q (expected a heading anchoring #%s)", |
| + cliPage, cmd, Anchor("carlos "+cmd)) |
| } |
| } |
| } |
diff --git a/internal/docsite/cliscan.go b/internal/docsite/cliscan.go| index 9d6130a86..56fe105e5 100644 |
| --- a/internal/docsite/cliscan.go |
| +++ b/internal/docsite/cliscan.go |
| @@ -33,6 +33,15 @@ func TopLevelCommands(mainFile string) ([]string, error) { |
| if !ok { |
| return true |
| } |
| + // Only the dispatch switch — the one on `cmd`. main.go has a |
| + // single string switch today, but a second one added later |
| + // would feed non-commands into the gate, and the obvious way |
| + // to make that gate pass again is to document things that are |
| + // not commands. |
| + tag, ok := sw.Tag.(*ast.Ident) |
| + if !ok || tag.Name != "cmd" { |
| + return true |
| + } |
| for _, stmt := range sw.Body.List { |
| clause, ok := stmt.(*ast.CaseClause) |
| if !ok { |
diff --git a/internal/docsite/fences_test.go b/internal/docsite/fences_test.go| index fceaf8b95..f8a4f1be3 100644 |
| --- a/internal/docsite/fences_test.go |
| +++ b/internal/docsite/fences_test.go |
| @@ -113,7 +113,6 @@ func fencedCarlosVerbs(body string) []string { |
| continue |
| } |
| out = append(out, next) |
| - continue |
| } |
| } |
| return out |
diff --git a/internal/docsite/links_test.go b/internal/docsite/links_test.go| index 28020b514..0bb99e500 100644 |
| --- a/internal/docsite/links_test.go |
| +++ b/internal/docsite/links_test.go |
| @@ -91,6 +91,20 @@ func TestAnchorsAreUnique(t *testing.T) { |
| // TestAnchorMatchesFixture pins the anchor rule itself. The website's |
| // slugify reads the same file, so the two implementations assert |
| // against a shared fixture rather than against each other. |
| +// |
| +// One direction of that sharing is not symmetric: the website's |
| +// hack/sync-docs.mjs injects a "//" notice key into its vendored copy, |
| +// saying the file came from here and must not be edited there, and its |
| +// checker skips that key. This file must never carry one — Anchor("//") |
| +// is "", so a "//" key here fails the test below rather than being |
| +// ignored. |
| +// |
| +// The non-ASCII cases are the ones worth keeping: Go's strings.ToLower |
| +// and JavaScript's String.prototype.toLowerCase do not agree on every |
| +// code point, and both implementations DROP non-ASCII runes rather than |
| +// transliterating them — "Montréal" anchors as montr-al in both. Pin |
| +// anything with an em dash or an accented letter here before it reaches |
| +// a heading. |
| func TestAnchorMatchesFixture(t *testing.T) { |
| raw, err := os.ReadFile(filepath.Join(root, "anchors.json")) |
| if err != nil { |
diff --git a/internal/docsite/parse_test.go b/internal/docsite/parse_test.go| index b577afe1d..120b054bf 100644 |
| --- a/internal/docsite/parse_test.go |
| +++ b/internal/docsite/parse_test.go |
| @@ -46,20 +46,6 @@ func TestParsePageFindsHeadingsLinksAndFences(t *testing.T) { |
| } |
| } |
| -func TestLinksInsideFencesAreNotLinks(t *testing.T) { |
| - site, err := Load(testCorpus) |
| - if err != nil { |
| - t.Fatalf("load: %v", err) |
| - } |
| - for _, p := range site.Pages() { |
| - for _, l := range p.Links { |
| - if l.Target == "" { |
| - t.Errorf("%s: empty link target at line %d", p.Slug, l.Line) |
| - } |
| - } |
| - } |
| -} |
| - |
| func TestIndexLinkResolvesToIndexSlug(t *testing.T) { |
| l := parseDocsHref("/docs") |
| if l.Target != "index" { |