Skip to content

ci: read the Go limits page by what it carries, and keep the catalog refresh going when it breaks - #167

Merged
viztor merged 10 commits into
mainfrom
claude/vigilant-mendel-bctz9n-go-limits
Nov 15, 2026
Merged

viztor merged 10 commits into
mainfrom
claude/vigilant-mendel-bctz9n-go-limits

Conversation

@viztor

@viztor viztor commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Why

Every nightly catalog freshness run since 2026-11-07 has failed in the Go limits generator:

Error: go-limits: no <TabItem label="Go"> section in the doc
    at parseGoLimitsDoc (src/go-limits.ts:143:13)
    at scripts/generate-go-limits.ts:133:30

Cause, and what the live page carries now

The reader handed any 2xx body to the markdown parser. go.md answered 404 on 2026-10-12, but since 11-07 it answers 200. Measured by this PR's CI on 2026-11-12:

  • Both https://opencode.ai/docs/go.md and the old https://opencode.ai/v2/docs/console/go/ now redirect to https://opencode.ai/docs/console/go/. It is an HTML page of about 98,000 characters.

  • The page carries three monthly-limit tables, one per Starlight tab:

    Tab Model rows First row
    Go 40 GLM-5.3-Flash, $60
    Go Plus 40 GLM-5.3-Flash, $180
    Go Teams 40 GLM-5.3-Flash, $65
  • The third tab means a reader that takes the first two tables by position can read the wrong pair. An earlier commit on this branch did exactly that, before the review caught it.

What changed

The reader is in scripts/go-limits-html.ts, which is pure and tested.

docFrom(answer)

  • Reads a 2xx answer by what its body carries. The status alone is never trusted, and an answer outside 2xx is not read.
  • It tries the tabbed markdown first, when it carries both tabs, then the HTML page.
  • Either is taken only when parseGoLimitsDoc reads it without an error. Anything else falls through to the next source.

Table choice by plan label (monthlyLimitTables, chooseTables)

  • Each table's label comes from its Starlight tab: the role="tabpanel" ancestor's aria-labelledby, matched to the role="tab" element's text. A panel's own label or the nearest heading is the fallback.
  • Go is the one table labelled Go, and Go Plus the one labelled Go Plus. Other tabs, such as Go Teams, are ignored.
  • A missing or duplicated plan label is refused. Only two tables with no plan or tab label keep the order the page had when it was first measured.

tableRows

  • Reads a cell as a browser shows it: <br> becomes a space, character references are decoded, and whitespace collapses.

firstDoc(GO_LIMITS_SOURCES, read)

  • The walk asks the canonical page, then go.md, and takes the first answer the parser reads.

noDocMessage

  • When no source works, it names every table: label, model rows, first row. It also gives the parser's or the chooser's reason.
  • On success, the generator's source: line names which tab it read as Go and as Go Plus, and which it ignored.

Elsewhere

Review

Four adversarial rounds. Each finding went to two skeptics, and only upheld findings were fixed.

Upheld and fixed:

  • acceptance weaker than the parser;
  • a third table read by position;
  • the source walk untested inside the script;
  • "never by its status" contradicting the code;
  • unasserted noDocMessage branches;
  • a stale generator header;
  • multi-line cells;
  • undecoded references;
  • firstDoc's JSDoc;
  • a heading inside a hidden tab panel labelling a later table;
  • "no Go tab" read as the markdown tab;
  • a test asserting a constant against its own literal.

Declined by both skeptics:

  • catching fetch rejections in the walk;
  • two tables under contradicting non-plan headings, which are already refused unless both are unlabelled.

Verification

  • Red first: each behaviour change opened with a test that failed before it. Seven mutants of the label logic were each caught.
  • Gate on Node 26 at 89e7049:
    • vp check: 0 errors, 18 warnings.
    • Unit suite: 102 files, 1764 passed, 1 skipped.
    • Mock suite: 52 passed.
    • pnpm run build and scripts/check.ts: passed.
  • Offline round trip: an HTML page rebuilt from the shipped table reads back through --from as up to date (28 models). A scratch three-tab page reads Go and Go Plus from their tabs and ignores the third.
  • Live, on this PR's CI:
    source: https://opencode.ai/docs/console/go/ (98163 characters), read by label: Go from table 1 (tab "Go", 40 model row(s), first "GLM-5.3-Flash" → "$60"); Go Plus from table 2 (tab "Go Plus", 40 model row(s), first "GLM-5.3-Flash" → "$180"); ignored table 3 (tab "Go Teams", 40 model row(s), first "GLM-5.3-Flash" → "$65")
    go-limits-data.ts is STALE — run: pnpm run limits:shim -- --write
    

Why catalog freshness is red here

The reader is right. The table is out of date: the vendor now lists 40 models per plan, where the shipped src/go-limits-data.ts has 28, and the amounts moved.

This PR changes the limits generator, so under D23 it gates on that table. The table is regenerated, never hand-edited:

pnpm run limits:shim -- --write && pnpm exec vp fmt --write src/go-limits-data.ts

That needs opencode.ai, which the session that wrote this PR cannot reach. Run those two commands on this branch and commit the result, and the gate turns green.

Once this is on main, the nightly refresh regenerates the table on its own and proposes it.

🤖 Generated with Claude Code

https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD

…refresh going when it breaks

Every nightly refresh since 2026-11-07 failed in the limits generator with
`no <TabItem label="Go"> section in the doc`, and because the two
generators ran in one fail-fast step, no catalog refresh was proposed
either, while the vendor added six models.

The cause is in the reader, not the parser. The HTML path always emits
the tab sections, so the error can only come from the first branch:
`go.md` answered 200 again, with a body that has no tab sections, and the
reader handed any 2xx body to the markdown parser.

- `scripts/go-limits-html.ts` gains `docFrom`, which reads one answer by
  its body: the tabbed markdown as it is, an HTML page carrying both
  monthly-limit tables through `htmlToDoc`, anything else (or a status
  outside 2xx) not at all. `noDocMessage` names what every source
  answered (status, redirect, content type, size, which shape it
  carried), so the next layout change names itself in the log.
- The generator asks the page the docs live on, then the old URL, and
  takes the first answer that carries the tables. `--from` accepts a
  saved page in either shape.
- The nightly step keeps `src/go-limits-data.ts` as it was when the
  limits generator fails, still proposes the catalog refresh, and fails
  at the end, naming the cause.

Red first: the five new cases in test/go-limits-html.test.ts failed on
main. Offline round trip: an HTML page rebuilt from the shipped table
reads back through `--from` as "up to date (28 models)". The live page
is measured by this PR's own `limits:shim` gate, since the vendor host is
not reachable from the session that wrote it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD

viztor commented Nov 12, 2026

Copy link
Copy Markdown
Owner Author

catalog freshness is red on this PR, and only on limits:shim. It is the live measurement the description asked for, and the reader passes it:

source: https://opencode.ai/v2/docs/console/go/ (98282 characters)
go-limits-data.ts is STALE — run: pnpm run limits:shim -- --write

The fixed reader took the moved page and parsed it with no unreadable rows. The vendor has changed the allowances since the last refresh on 11-06, so the shipped src/go-limits-data.ts no longer matches. Under D23 this PR gates on that, because it changes the generator.

Why the fix is not pushed from here: the table must be regenerated, never hand-edited, and opencode.ai is outside this session's network policy. It needs pnpm run limits:shim -- --write run on this branch by anyone who can reach the page, followed by vp fmt --write src/go-limits-data.ts and a commit. I will do it as soon as the host is reachable here.

The nightly step's change is also on its own PR, #168. It touches no generator input, so it can merge without the table, and from then on the catalog refresh is proposed even while the limits generator fails.


Generated by Claude Code

…test the source walk

reader-1, tests-2: docFrom took bodies the parser then threw on. The
markdown arm now needs both the Go and the Go Plus tab; the HTML arm
needs the allowance tables to each carry a model row of six cells
(modelRows, shared with htmlToDoc). Tests for a Go-only body, empty
tbodies, short rows, and one readable table beside an unreadable one.

reader-2: htmlToDoc took the first two monthly-limit tables by position.
It now throws unless there are exactly two, naming the count, and
docFrom requires exactly two, so a third table falls through to the
next source. The "fewer than two" test now reads "exactly 2"; a
three-table case ($5/$60/$120) is added for both functions.

reader-5, tests-1, rules-docs-2: the walk moved out of the import-time
generator into scripts/go-limits-html.ts as firstDoc (injected reader,
answering { doc, source, answer }; the script logs), with the ordered
GO_LIMITS_SOURCES beside it. Tested with a fake reader: first accepted
-> second never asked; first refused -> second read; none -> rejects
with the whole noDocMessage, both sources in order, the 2026-11-07
go.md shape as the second; no sources -> the bare message.
freshness-scope still answers "report" for catalog:shim.

tests-3, rules-docs-4: "by what its body carries, never its status"
now reads "a 2xx answer is read by what its body carries; the status
alone is never trusted" in docs/testing.md, AGENTS.md, the docFrom
JSDoc, the module doc and the generator header.

tests-5: the full 404 line is asserted, with no "(redirected"; an
empty content type reads "no content type"; HTML bodies report their
table count and, when fewer have a model row, how many; a Go tab
without Go Plus is named as such.

rules-docs-5: the generator header says what the code does now (the
moved page first, go.md second, read by content), with the history in
the past tense; the future-work paragraph is gone. GO_LIMITS_DOC_URL's
comment names it the old URL, asked second (src/go-limits.ts is an
input of limits:shim only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD
Make this branch carry exactly #168's versions of the files the two pull
requests share, so the nightly "propose the refresh" step and its tests
are one change wherever they land.

- .github/workflows/ci.yml: the nightly step now guards both generators
  the same way (each failure annotated where it happens, its file put
  back as committed, the other refresh still proposed, the step failing
  at the end), with the catalog restored before the limits generator
  starts. Byte-identical to #168.
- CONTRIBUTING.md: the "one broken source" bullet takes #168's text.
  This branch had no other CONTRIBUTING.md edit, so nothing was merged
  by hand. Byte-identical to #168.
- test/freshness-scope.test.ts: #168's tests that run the step itself in
  a scratch repository. Byte-identical to #168.

No findings carried in this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD
…multi-line cells

reader1-1: docFrom accepted a body on its shape (both tab strings, or two
allowance tables each with a six-cell row), which is weaker than what
parseGoLimitsDoc reads, so the walk could stop at a source the parser then
threw on and never ask the next one. Acceptance is now the parser's own
verdict: a body is tried as the tabbed markdown (both tabs) and then as the
converted HTML page (exactly two allowance tables), and a shape is taken
only when parseGoLimitsDoc reads it without throwing. noDocMessage appends
the parser's reason for each shape it was handed. The module, docFrom and
generator headers and docs/testing.md say so. Tests: both tabs with no rows
or a five-cell table; two tables whose rows end in Unlimited, &#36;15 or a
seventh empty cell; a page that quotes both tab strings and carries valid
tables; the walk asking the second source when the first only looks
readable; the parser's reasons in the error.

reader1-2: tableRows now reads a <br> as a space and collapses whitespace
inside a cell to one space, so a cell spanning lines no longer splits the
emitted markdown row and drops the model without an unmapped entry. Tests:
the cell reading, and a round trip through docFrom and the parser with a
newline in an input cell and <br> plus newline in a label.

ci1-1: carried byte-identical from the refresh-decouple branch: the
nightly step warns, after a successful push, which files a partial refresh
withdrew from an open refresh pull request; CONTRIBUTING.md says so; and
test/freshness-scope.test.ts pins it with a pre-existing refresh branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD
…ghtly step's fixes

reader2-2: tableRows stripped tags and never decoded character references,
so `&#36;15` was no dollar amount (a valid page refused), `$15&nbsp;` read
as NaN, `Grok&nbsp;4.7` keyed as `grok-nbsp-4.7` and `Q&amp;A 2` as
`q-amp-a-2`, and names shipped with a literal `&gt;`. tableRows now decodes
after stripping tags and before collapsing whitespace, in one pass
(`&amp;lt;` stays the text `&lt;`): `&amp; &lt; &gt; &quot; &apos; &nbsp;
&le; &ge;` and numeric `&#N;`/`&#xN;`, leaving an unknown name (looked up
in a Map, so `&constructor;` is unknown) or a number that names no character
as written. The `&#36;15` case no longer stands for "no dollar amount";
new cases read entity-encoded amounts, key a label with `&nbsp;`, `&gt;`,
`&amp;` and `&le;`, decode in one pass and leave unknown references alone.
docs/testing.md's row says so.

reader2-3: firstDoc's JSDoc said a later source is asked only when every
earlier one carried neither shape; it is asked whenever the earlier ones
answered nothing the parser reads (a status outside 2xx, neither shape, or a
shape the parser rejected). Reworded.

ci2-1, ci2-2, ci2-3: .github/workflows/ci.yml, CONTRIBUTING.md and
test/freshness-scope.test.ts copied byte-identical from
#168 (no inherited GIT_* reaches the scratch-repo
tests; the "both current" line only when both regenerated; an open refresh
pull request is asked for, not assumed, and a failed creation fails the step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD
…ble the page carries

The 2026-11-12 CI run measured the page carrying three monthly-limit
tables, behind both URLs, which now redirect to the canonical
https://opencode.ai/docs/console/go/. A choice by position or by count
cannot say which two are Go and Go Plus, so each table is now chosen by
the label the page attaches to it.

- scripts/go-limits-html.ts: monthlyLimitTables labels each allowance
  table from (a) its innermost role="tabpanel" ancestor's aria-labelledby,
  resolved to the element whose id attribute it names, (b) the panel's own
  data-label/aria-label (an unprocessed <starlight-tab-item data-label>
  counts as a panel), else (c) the nearest preceding h2-h4. Tags are
  scanned with attributes in any order and either quote, comments and
  script/style contents blanked first. chooseTables reads Go and Go Plus
  from the one table labelled each (case-insensitive), ignores other
  labelled tables, and refuses a missing or repeated plan label; with no
  plan or tab label, exactly two tables keep the measured order and any
  other count is refused. The Starlight markup is assumed, not measured,
  and the module header says so.
- Logs: noDocMessage lists every allowance table with its label source
  and text, its model-row count and its first row, and why the tables
  were not chosen; FoundDoc.read names which table was read as Go and as
  Go Plus and which were ignored, and the generator's source: line prints
  it, with the redirect.
- GO_LIMITS_DOC_MOVED_TO is the canonical https://opencode.ai/docs/console/go/;
  GO_LIMITS_SOURCES stays [that page, go.md].
- test/go-limits-html.test.ts: a three-tab Starlight page in a non-plan
  order, reordered single-quoted attributes with decoy ids, innermost and
  unprocessed panels, a tag inside a script or comment, heading labels,
  two unlabelled tables in order, three refused with each table named, a
  duplicated Go label, two tabs naming no plan, an empty labelled Go
  table refused by the parser, and the success description.
- AGENTS.md, docs/testing.md, the generator header and src/go-limits.ts's
  URL comment describe label-based selection and the unmeasured markup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD
… it, and name the markdown tabs as markdown

- scripts/go-limits-html.ts: labelAt's heading fallback skips an h2-h4 that
  lies inside a tab panel not holding the table, so a table after a tab group
  is no longer labelled by the last panel's heading (which refused an
  unambiguous page, or with a "Plus" tab read the wrong table as Go Plus).
- scripts/go-limits-html.ts: tabsCarried names the markdown shape ("no
  markdown Go <TabItem>" and its two siblings), so the error line no longer
  says "no Go tab" beside a table described as tab "Go".
- test/go-limits-html.test.ts: three cases for the panel-scoped heading
  (refused-before page now read by label, wrong-table page now refused, a
  heading inside the table's own panel still labels it), the reworded phrases
  in every expected error line, and the Go/Go duplicate case asserting the
  markdown phrase beside the tab labels.
- AGENTS.md and the module header: describe the choice as the code makes it
  (panel label step, panel-scoped headings, two tables with no plan label and
  no tab label kept in order, "rather than" in place of "never" by position).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD
… literal

The "asks the page the docs live on first" case compared a test-local
constant with its own literal, so it passed whatever the reader exported.

- test/go-limits-html.test.ts: import GO_LIMITS_DOC_MOVED_TO and assert it
  names the canonical page, in place of the self-comparing assertion. Under
  a mutation that points the constant back at the /v2/ URL, the old line
  passed and the new one fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD
…9n-go-limits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD
…bctz9n-go-limits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD

viztor commented Nov 15, 2026

Copy link
Copy Markdown
Owner Author

Merging with catalog freshness red, on purpose.

On c81a80c, with current main merged in, this PR's reader reads the vendor's live page: https://opencode.ai/docs/console/go/ (99,695 characters). It takes Go from tab "Go" (40 models, first GLM-5.3-Flash → $60) and Go Plus from tab "Go Plus" (40 models, $180), and ignores "Go Teams".

The only remaining error is that the committed src/go-limits-data.ts predates the vendor's change. Regenerating it needs network access to opencode.ai. The nightly refresh has that access, but it runs main's reader, which cannot read this page until this PR is on main.

So the order is: merge this, let tonight's nightly regenerate the table and propose it as a refresh PR, then port and merge that refresh. Every other check is green.


Generated by Claude Code

@viztor
viztor merged commit 4b854ed into main Nov 15, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants