Write down that a source can be empty without being broken

The two preceding changes make sentences in docs/ false. INGESTION.md
described `canParse` as a structural check without saying that "structural"
excludes the presence of a row — which is exactly the drift that let this
bug in — and described the Stage 1 gate as rejecting every empty parse.
SOURCES.md § 11 left a reader to conclude that an empty Infinity Nikki lane
means the source has died.

AGENTS.md gets the rule in both places somebody would look: § Working on
parsers, next to the bullet about a source resting on one row, and § Fandom
where the fourth template is described.

The general lesson is worth more than the incident. Any page that can list
nothing can produce this, so the guidance is about what identifies a page
versus what happens to be on it — not about this wiki.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-03 18:46:15 +02:00
co-authored by Claude Opus 5
parent 4b2b3d0958
commit 4d45635b0e
3 changed files with 73 additions and 1 deletions
+28 -1
View File
@@ -202,6 +202,7 @@ export interface SourceParser {
id: string; // "game8"
label: string; // "Game8"
canParse(html: string): boolean; // structural sanity check
statesNoEvents?(html: string): boolean; // the page says it lists none
parse(html: string, ctx: ParseContext): GachaEvent[];
}
```
@@ -216,6 +217,24 @@ in attribute quote style (`class="a-table"` on Genshin, `class='a-table'` on NTE
the kind of variation a naive check gets wrong. Every regex in `html.ts` is attribute-agnostic for
the same reason.
**"Structural" means identity, never the presence of a row** — and that half was learned the
expensive way. `isInfinityNikkiEventPage` recognised its page by finding a *populated*
`Current`/`Upcoming` table, so when the wiki emptied both between versions (2026-09-03, § below) the
tripwire fired on a page whose markup had not changed by a byte, reported
`the source has likely been redesigned`, and reached the `broken` tier in a day and a half. A check
that reads data cannot tell a rewrite from a quiet week; a check that reads headings, ids or column
names can, because those survive an empty table. Ask what identifies the page, not what is on it.
`statesNoEvents` is the other half of that lesson, and it is optional because most pages say nothing
either way. It answers a question `canParse` and a row count together cannot: **is this source
broken, or is this game simply between patches?** Both look identical from a count of zero, so the
signal has to be the page's own words — Infinity Nikki's wiki prints "There are no Events in this
category" under both headings when it is listing nothing. A parser that implements it lets the
refresh runner store an empty snapshot as a real answer (§ Stage 1); one that does not keeps the
strict gate, which is the right default. Never infer it from an empty table: a redesign that broke
every selector yields an empty table too, and storing *that* is the silently emptied calendar the
gate exists to prevent.
### The adapter registry
```ts
@@ -376,7 +395,15 @@ conduct for why that ordering is load-bearing.
injection, so the whole runner is tested offline against a fake fetch. A fetched body is *rejected*
— the previous snapshot survives — when it fails `canParse`, throws, or yields zero events; storing
an empty parse would make the feed build prefer it over the fixture and silently empty a game's
calendar. One source down is a warning and exit 0; every source failing is exit 1, so CI never
calendar.
The one exception is a page that states its own emptiness: when a parser's `statesNoEvents` says the
document itself reports listing nothing, the empty parse is stored as that source's real answer and
the cycle counts as confirmed rather than failed. Without it a game between versions fails every
cycle until its next patch ships — three of them reach the `broken` tier and fail the workflow, over
a lane that is correctly empty, while the snapshot it is holding goes on ageing. The distinction is
the page's statement and never a row count, so a redesign still rejects, and the run says which of
the two it saw: `0 events — the page states it currently lists none`. One source down is a warning and exit 0; every source failing is exit 1, so CI never
commits a cycle that learned nothing.
## Stage 2 — parse
+16
View File
@@ -430,6 +430,22 @@ Two shapes the parser handles, both of which would otherwise lose or corrupt a r
Asking the wiki's editors to state the zone on the Duration column would upgrade this source to
exact instants, and remains the cheapest improvement available to it.
**Update, 2026-09-03: this page goes empty between versions, and the source was reading that as a
redesign.** With 2.7's events ended and 2.8 not yet listed, the wiki replaced both the `Current
Events` and `Upcoming Events` tables with `There are no Events in this category`, leaving everything
else — including twenty `Past Events` tables of identical shape — untouched. `canParse` identified
the page by finding a *populated* table, so it threw `the source has likely been redesigned` at a
page nobody had touched; four cycles of that reached the `broken` tier and the calendar went on
holding a snapshot whose every row had expired on 27 August.
The fix was in the pipeline, not in this assessment: `canParse` now identifies the page by its
section headings, and `statesNoEvents` lets a parser report the page's own declaration of emptiness
so the runner stores it as an answer instead of a failure (`docs/INGESTION.md` § The parser interface
and § Stage 1). **So an empty Infinity Nikki lane is now the expected state between patches, not a
symptom** — the thing to check before suspecting this source is whether the wiki is listing anything
at all. It refills on its own when the next version is posted. The verdict on the source is
unchanged and it stays built.
## 12. Thirteen Game8 hubs, swept on 2026-08-19 — two are worth building, eleven are not
Not a P1 game between them: this section answers a direct request to check a list of Game8 game