diff --git a/AGENTS.md b/AGENTS.md index f985680..2724bd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,7 @@ re-verify a sample against the live page afterward. src/shared/ schema.ts (the contract), time.ts, daily.ts, effort.ts, games.ts, feed.ts custom.ts — reader-authored games and events, and their key spaces src/ingest/ html.ts, dates.ts (sixteen formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts + health.ts — which of the three empties a source's zero was; pure parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts, holodori.ts, iopwiki.ts, stellasora.ts — keyed by SITE, not game adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam @@ -643,6 +644,18 @@ and both generalise past this wiki: on the page's own words — a redesign yields zero rows too, and storing *that* is the silent emptying the zero-events gate exists to prevent. Only this template implements it; the other three say nothing either way when empty and keep the strict gate. +- **Two things judge a zero, and telling only one of them is half a fix.** The runner learned this on + 2026-09-03; `scripts/build-feed.ts` did not, and CI fails the build on `brokenSources` + (`parsedCount === 0`). So for four days every green refresh was followed by a red CI run over a lane + that was correctly empty — the same rule, contradicting itself across two scripts. 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. `SourceHealth.statesNoEvents` carries it, + `src/ingest/health.ts` sets it, and both ends now ask it the same way — of an empty parse only, from + the page's own words only. + It lives in a module because `build-feed.ts` writes `public/`, so importing it from a test runs a + build and the rule sat where no test could reach it. That is how the two ends drifted, and it is + the lesson `brokenSources` itself already carried: it was inline in `ci.yml` and pinned by grepping + that file for a string, which proved the check existed and never that it was right. An empty Nikki lane is therefore now the truth rather than a gap, exactly as GFL2's thin weeks are. The lane refills on its own when 2.8 is listed, with no parser change. diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 133fb06..ade861e 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -608,7 +608,22 @@ partly applied. ## Schema versioning -`/api/events` responses carry `{ schemaVersion: 1, generatedAt, events: [...] }`. The client -refuses to render a `schemaVersion` it does not know and shows a "refresh the page" prompt instead -of guessing at unfamiliar fields. Additive fields do not bump the version; removing or retyping a -field does. +`/api/events` responses carry `{ schemaVersion: 1, generatedAt, events: [...], sources: [...] }`. +The client refuses to render a `schemaVersion` it does not know and shows a "refresh the page" +prompt instead of guessing at unfamiliar fields. Additive fields do not bump the version; removing +or retyping a field does. + +`sources` is a `SourceHealth` per registered source (`src/shared/feed.ts`), and it is what the +footer's freshness line and CI's build gate both read. Three of its fields describe one source's +last document and only make sense together: `eventCount` is what it contributed after expired events +were dropped, `parsedCount` is what the same bytes yield when parsed as of their own capture date, +and `statesNoEvents` says the page itself declared it currently lists none. That triple is what +separates a parser that has stopped reading a redesigned page (a fault) from one whose events have +all since ended, and from a game between versions (neither) — see `docs/INGESTION.md` § Stage 1. + +**Every field added here is defaulted, and that is a rule rather than a habit.** The service worker +serves the last feed it downloaded, so a required field fails validation on every cached feed and +takes the offline promise down with it. A default must also be the *strict* reading of silence: +`parsedCount` defaults to `null` — an older feed never recorded the figure, which is an absence of +information and not evidence of health — and `statesNoEvents` to `false`, because a feed built +before the field existed never asked the page and cannot be read as having got an answer. diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 0d4c6d2..141832a 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -406,6 +406,28 @@ the page's statement and never a row count, so a redesign still rejects, and the 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. +**The fact has to reach the feed, because the runner is not the only thing that judges a zero.** +`scripts/build-feed.ts` parses the same snapshot again and records a `SourceHealth` per source, and +CI fails the build on `brokenSources` — `parsedCount === 0`. For four days from 2026-09-03 the +runner excused Infinity Nikki's quiet page and the feed build did not, so every green refresh was +followed by a red CI run on a lane that was correctly empty. The runner's verdict could not travel: +only a parser has seen the page, and by the time `brokenSources` runs there is nothing left but the +feed. So `SourceHealth.statesNoEvents` carries it, `src/ingest/health.ts` sets it, and the same rule +applies at both ends — asked only of an empty parse, taken only from the page's own words. + +Two properties of that field are load-bearing rather than incidental. It is **defaulted, never +required**, for the reason `parsedCount` is: the service worker serves the last feed it downloaded, +and a required field would fail every cached feed's validation and take the offline promise with it. +And an absent value reads as `false` — the strict answer — because a feed built before the field +existed never asked the question and must not be read as having answered it. + +**The rule lives in a module rather than in the script, and that is the point.** +`scripts/build-feed.ts` writes `public/`, so importing it from a test runs a build; the rule sat +where no test could reach it, which is exactly how the two ends drifted apart. `src/ingest/health.ts` +is a pure function over an adapter and a document, and `test/feed.test.ts` exercises it. Same lesson +as `brokenSources` itself, which was inline in `ci.yml` and pinned by grepping that file for a +string — proof the check existed, never that it was right. + ## Stage 2 — parse Hash the raw body (sha256) → `content_hash`. **If it matches `sources.content_hash`, end as diff --git a/scripts/build-feed.ts b/scripts/build-feed.ts index 5dbc06b..fefe0fe 100644 --- a/scripts/build-feed.ts +++ b/scripts/build-feed.ts @@ -10,6 +10,7 @@ * bun run build:feed */ import { ADAPTERS } from "../src/ingest/adapters/index.ts"; +import { sourceHealth } from "../src/ingest/health.ts"; import { mergeEvents } from "../src/ingest/merge.ts"; import { SnapshotStore, freshnessAt } from "../src/ingest/snapshots.ts"; import { EventFeed, SCHEMA_VERSION, type SourceHealth } from "../src/shared/feed.ts"; @@ -69,44 +70,22 @@ for (const adapter of ADAPTERS) { game: adapter.game, }); - // 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; + // Which of the three empties this is, decided in a module a test can reach + // rather than here — see `src/ingest/health.ts` for why that matters. + const health = sourceHealth(adapter, html, at, events.length); + const { parsedCount } = health; const groups = byGame.get(adapter.game) ?? []; groups.push(events); byGame.set(adapter.game, groups); - sources.push({ - 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: events.length, - parsedCount, - }); + sources.push(health); - // A source that parsed events and then lost them all to the calendar says - // so on the build log, because a bare "0 events" reads as a fault. - const note = - events.length === 0 && parsedCount !== null && parsedCount > 0 + // A source that came back with nothing says which nothing it was, because a + // bare "0 events" reads as a fault and two of the three are not one. + const note = health.statesNoEvents + ? " (the page states it currently lists none)" + : events.length === 0 && parsedCount !== null && parsedCount > 0 ? ` (all ${parsedCount} have ended — stale page)` : ""; console.log( diff --git a/src/ingest/health.ts b/src/ingest/health.ts new file mode 100644 index 0000000..a0c47f4 --- /dev/null +++ b/src/ingest/health.ts @@ -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, + }; +} diff --git a/src/shared/feed.ts b/src/shared/feed.ts index ce23052..5286b4d 100644 --- a/src/shared/feed.ts +++ b/src/shared/feed.ts @@ -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); } /** diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx index 984581e..500af49 100644 --- a/test/custom-ui.test.tsx +++ b/test/custom-ui.test.tsx @@ -240,6 +240,7 @@ describe("Colophon freshness notice (PRD F7)", () => { eventCount: 9, parsedCount: 9, + statesNoEvents: false, }; test("states when the data was refreshed, unprompted", () => { diff --git a/test/feed.test.ts b/test/feed.test.ts index 0b01d03..4db9a6c 100644 --- a/test/feed.test.ts +++ b/test/feed.test.ts @@ -4,9 +4,10 @@ import { freshness, staleSources, STALE_AFTER_MS, - type SourceHealth, + SourceHealth, } from "../src/shared/feed.ts"; -import type { GameId } from "../src/shared/schema.ts"; +import { sourceHealth } from "../src/ingest/health.ts"; +import type { GachaEvent, GameId } from "../src/shared/schema.ts"; /** * Freshness disclosure (PRD F7). @@ -34,6 +35,7 @@ function source( eventCount: 3, parsedCount: 3, + statesNoEvents: false, }; } @@ -132,6 +134,7 @@ describe("telling a broken source from a stale one", () => { lastSuccessAt: "2026-08-19T00:00:00.000Z", eventCount: 0, parsedCount: 7, + statesNoEvents: false, ...over, }); @@ -143,6 +146,29 @@ describe("telling a broken source from a stale one", () => { ]); }); + test("a source whose page states it lists none is not broken", () => { + // The third empty, and the one the refresh runner already knew about while + // the feed did not. Infinity Nikki's wiki replaced both event tables with + // "There are no Events in this category" between 2.7 and 2.8, so the page + // parses to zero and is *answering*. A redesign yields zero too, which is + // why only the page's own words may say which — never the row count. + expect( + brokenSources([health({ parsedCount: 0, statesNoEvents: true })]), + ).toEqual([]); + }); + + test("a page that states its emptiness still fails once it parses nothing at all", () => { + // Guard on the guard. `statesNoEvents` excuses an empty parse, so a parser + // whose selectors all broke must not be able to reach it: the flag is only + // ever set from the page's own declaration, and a source claiming both a + // declaration and rows is a contradiction we do not have to honour. + expect( + brokenSources([health({ parsedCount: 0, statesNoEvents: false })]).map( + (s) => s.sourceId, + ), + ).toEqual(["nikki-fandom-events"]); + }); + test("a source whose events have all ended is not broken", () => { // The Nikki case exactly. The parser did its job; the calendar moved past // everything the page still lists. @@ -164,6 +190,23 @@ describe("telling a broken source from a stale one", () => { ]); }); + test("a feed cached before the field existed is not called broken", () => { + // The service worker serves the last feed it downloaded, so a feed built + // before `statesNoEvents` shipped still has to validate and still has to + // mean what it meant. Absent parses to `false`, which is the strict + // reading — an old feed cannot vouch for a page it never asked. + const cached = SourceHealth.parse({ + sourceId: "nikki-fandom-events", + game: "nikki", + url: "https://example.test/nikki", + lastSuccessAt: "2026-08-19T00:00:00.000Z", + eventCount: 0, + parsedCount: 7, + }); + expect(cached.statesNoEvents).toBe(false); + expect(brokenSources([cached])).toEqual([]); + }); + test("a feed that never recorded the count is not called broken", () => { // An older feed — one the service worker cached before this field existed // — says nothing either way, and absence of information is not evidence of @@ -172,3 +215,79 @@ describe("telling a broken source from a stale one", () => { expect(staleSources([health({ parsedCount: null })])).toEqual([]); }); }); + +/** + * What the feed builder records about a source it just parsed. + * + * The distinction above is only worth having if something sets it, and this is + * the seam where it was missing: `scripts/refresh-sources.ts` had asked + * `statesNoEvents` since 2026-09-03 while `scripts/build-feed.ts` never did, so + * a page declaring itself empty reached CI as a bare zero and failed the build. + * The rule was in a script nothing could import, which is why it now lives in a + * module and is exercised here rather than grepped for. + */ +describe("recording a source's health at build time", () => { + const EMPTY = "

There are no Events in this category

"; + const FULL = "

Song of the Wandering Sky

"; + + const adapter = (over: Partial[0]> = {}) => ({ + id: "nikki-fandom-events", + game: "nikki" as GameId, + url: "https://example.test/nikki", + parse: (html: string) => (html === FULL ? ([{}] as unknown as GachaEvent[]) : []), + statesNoEvents: (html: string) => html.includes("There are no Events"), + ...over, + }); + + test("a page that declares itself empty is recorded as having answered", () => { + const health = sourceHealth(adapter(), EMPTY, "2026-09-06T15:39:19.376Z", 0); + + expect(health.parsedCount).toBe(0); + expect(health.statesNoEvents).toBe(true); + expect(brokenSources([health])).toEqual([]); + }); + + test("a page that reads empty without saying so is left to fail", () => { + // The redesign case, and the whole reason the flag may not be inferred + // from the row count: this parse is zero too. + const health = sourceHealth( + adapter({ statesNoEvents: () => false }), + EMPTY, + "2026-09-06T15:39:19.376Z", + 0, + ); + + expect(health.statesNoEvents).toBe(false); + expect(brokenSources([health]).map((s) => s.sourceId)).toEqual([ + "nikki-fandom-events", + ]); + }); + + test("a source with rows is never marked as declaring itself empty", () => { + // A loose `statesNoEvents` — one matching prose that survives a redesign — + // must not be able to excuse a source that is working. The flag qualifies + // an empty parse and states nothing on its own, so it is asked only of a + // zero, exactly as the refresh runner asks it. + const health = sourceHealth( + adapter({ statesNoEvents: () => true }), + FULL, + "2026-09-06T15:39:19.376Z", + 1, + ); + + expect(health.parsedCount).toBe(1); + expect(health.statesNoEvents).toBe(false); + }); + + test("a document of unknown age records neither count nor declaration", () => { + // No capture date means no "as of" to parse against, and inventing one + // manufactures a figure the check then trusts. `parsedCount` is null for + // that reason and the declaration goes with it: both answer a question + // about bytes we cannot date. + const health = sourceHealth(adapter(), EMPTY, null, 0); + + expect(health.parsedCount).toBeNull(); + expect(health.statesNoEvents).toBe(false); + expect(brokenSources([health])).toEqual([]); + }); +}); diff --git a/test/issue-templates.test.tsx b/test/issue-templates.test.tsx index af0ca4d..67a12d8 100644 --- a/test/issue-templates.test.tsx +++ b/test/issue-templates.test.tsx @@ -168,6 +168,7 @@ describe("the app's links into them", () => { eventCount: 9, parsedCount: 9, + statesNoEvents: false, }, ]} now={NOW}