carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 7ae89d3c9cc661431b5a64c534bee86141134319 parent 6652c4d
8 files changed, +66 −45
  • .github/workflows/ci.yml +3 −3
  • docs/site/anchors.json +3 −1
  • docs/site/cli.md +22 −20
  • internal/docsite/cli_test.go +15 −6
  • internal/docsite/cliscan.go +9 −0
  • internal/docsite/fences_test.go +0 −1
  • internal/docsite/links_test.go +14 −0
  • internal/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" {