fix: stop failing the build on a page that says it has no events

Infinity Nikki's wiki prints "There are no Events in this category"
between versions, and the refresh runner has honoured that since
2026-09-03 — it stores the empty parse as the source's real answer
rather than letting a quiet lane reach the broken tier. The feed build
never asked, so the same page arrived at CI as parsedCount 0,
indistinguishable from a parser that has stopped reading a redesigned
page, and brokenSources failed every build while every refresh stayed
green.

The runner's verdict cannot travel on its own: only a parser has seen
the page, and by the time brokenSources runs there is nothing left but
the feed. So the fact rides on SourceHealth, defaulted so an older feed
the service worker cached keeps validating and reads as the strict
answer.

Both ends now ask it the same way — of an empty parse only, from the
page's own words only — because a redesign yields zero rows too, and
excusing that is the silently emptied calendar the gate exists for.

The rule sits in a module rather than in build-feed.ts, which writes
public/ and so runs a build if a test imports it. That is how the two
ends drifted apart with nothing to catch it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-06 18:02:16 +02:00
co-authored by Claude Opus 5
parent e51585f2b2
commit 1156c358a7
9 changed files with 304 additions and 39 deletions
+89
View File
@@ -0,0 +1,89 @@
/**
* What the feed records about a source, from the document it just parsed.
*
* This is one function rather than a few lines inside `scripts/build-feed.ts`
* because of how the rule it carries went wrong. The refresh runner and the
* feed builder both have to tell three zeros apart — a parser that can no
* longer read a redesigned page, a page whose events have all ended, and a page
* that says it currently lists none — and the runner learned the third on
* 2026-09-03 while the builder did not. Nothing caught that: the builder is a
* top-level script that fetches nothing but writes `public/`, so importing it
* from a test runs a build, and the rule sat where no test could reach it.
*
* That is the same argument the `ci.yml` comment already makes about
* `brokenSources`: behaviour belongs where behaviour can be exercised.
* `docs/INGESTION.md` § Stage 1 carries the rule itself.
*/
import type { SourceHealth } from "../shared/feed.ts";
import type { GachaEvent, GameId } from "../shared/schema.ts";
/**
* The part of an `Adapter` this needs. Narrow on purpose — it keeps the
* function testable with a stub and says plainly that nothing here fetches.
*/
export interface HealthAdapter {
id: string;
game: GameId;
url: string;
parse(
html: string,
ctx: { now: string; sourceUrl: string; sourceId: string; game: GameId },
): GachaEvent[];
statesNoEvents?(html: string): boolean;
}
/**
* @param html the document the feed was built from — a live snapshot, or a
* checked-in fixture on a clean checkout.
* @param at when those bytes were last confirmed current, or null when
* we do not know.
* @param eventCount events this source contributed to the feed, after expiry.
*/
export function sourceHealth(
adapter: HealthAdapter,
html: string,
at: string | null,
eventCount: number,
): SourceHealth {
// Parsed a second time as of the document's own capture date, when nothing in
// it had expired yet. That figure is what separates "this parser has stopped
// reading the page" from "this page's events have all finished since it was
// captured" — the two are the same zero once expiry has been applied, and
// only the first means our code is wrong.
//
// Null when we do not know when these bytes were current: there is no date to
// parse "as of", and inventing one would manufacture a figure the check then
// trusts. Unknown is a real answer here, and `brokenSources` declines to fail
// a build on it.
const parsedCount =
at === null
? null
: adapter.parse(html, {
now: at,
sourceUrl: adapter.url,
sourceId: adapter.id,
game: adapter.game,
}).length;
// Asked only of a zero, exactly as `scripts/refresh-sources.ts` asks it. The
// flag qualifies an empty parse — "this zero is the page's own answer" — and
// claims nothing on its own, so a loose implementation that keeps matching
// after a redesign can never excuse a source that is still producing rows.
//
// Null takes the strict reading for the same reason it does above: with no
// date for the bytes there is no parse to qualify.
const statesNoEvents =
parsedCount === 0 && adapter.statesNoEvents?.(html) === true;
return {
sourceId: adapter.id,
game: adapter.game,
url: adapter.url,
// When the bytes were last confirmed live; a fixture's capture date when
// this source has never been refreshed.
lastSuccessAt: at,
eventCount,
parsedCount,
statesNoEvents,
};
}
+27 -1
View File
@@ -34,6 +34,26 @@ export const SourceHealth = z.object({
* than evidence of a fault.
*/
parsedCount: z.number().int().nonnegative().nullable().default(null),
/**
* The page itself says it currently lists no events.
*
* The third of the three ways a source can read zero, and the only one the
* feed could not previously express. A redesigned page the parser can no
* longer read, a page whose events have all ended, and a page printing
* "There are no Events in this category" all arrive as `parsedCount: 0` —
* and the last one is a source *answering*, not failing.
*
* `scripts/refresh-sources.ts` has drawn this distinction since 2026-09-03
* and the feed did not, so a correctly quiet lane reddened CI every build.
* Carried here because `brokenSources` runs against the feed and has nothing
* else to go on: only the parser has seen the page.
*
* **Set from the page's own words, never from a row count** — a redesign
* yields zero rows too, and excusing *that* is the silently emptied calendar
* the check exists for. Defaulted rather than required, for the reason
* `parsedCount` is: an older cached feed must keep validating.
*/
statesNoEvents: z.boolean().default(false),
});
export const EventFeed = z.object({
@@ -120,9 +140,15 @@ export function freshness(
* page and a broken parser arrived as the same zero. Only an explicit zero
* counts here; a null is an older feed that never recorded the figure, and
* failing on missing information would be the same mistake in a new place.
*
* Nor is a page that states its own emptiness, which is that same mistake a
* third time: a gacha calendar goes quiet between versions, and Infinity
* Nikki's wiki says so in words. `statesNoEvents` is the page answering, so it
* is excused here exactly as the refresh runner already excuses it — see that
* field, and `scripts/refresh-sources.ts`.
*/
export function brokenSources(sources: readonly SourceHealth[]): SourceHealth[] {
return sources.filter((s) => s.parsedCount === 0);
return sources.filter((s) => s.parsedCount === 0 && !s.statesNoEvents);
}
/**