From ff7c9294fe815d1bab92cf27b7cce00e25584a1d Mon Sep 17 00:00:00 2001 From: "pip-robot[bot]" Date: Sun, 30 Aug 2026 22:52:05 +0000 Subject: [PATCH] feat(corrections): a correction can point at a document, not only a page The registry could only guard claims that served on a route. It resolved every `where` entry to `src/app/page.tsx` and walked only `src` for banned fragments, so a false claim on /privacy was listed, dated and guarded, and the identical false claim in ROADMAP.md was fixed silently. ROADMAP.md is linked from the blog and from the launch copy. A page titled "everything we have published that was wrong" that exempts it is describing our tooling rather than the reader. Two changes: - `where` also takes a GitHub blob URL pinned to a full commit SHA. A branch link is rejected on purpose: `blob/main/ROADMAP.md` shows the corrected text, so it would be evidence against the row it sits under. A pinned URL is checkable by anybody, forever, which a site path is not once the page is fixed and which a repo-relative path never was. - the `gone` walk covers ROADMAP.md and README.md as well as `src`, so a fragment is guarded in the file it was actually in. `resolveWhere` is unit-tested on all three forms rather than only through the live rows, because no row uses the document form yet: the two ROADMAP rows get backfilled alongside the next real correction rather than as a PR of their own. Nothing renders differently today. Every current row is a site path, its label is the path itself, and the pinned-evidence line only appears on a row that has a document in it. Verified against the built page. --- src/app/blog/what-we-got-wrong/page.tsx | 21 ++++++- src/config/corrections.ts | 44 +++++++++++++- tests/corrections.test.ts | 79 +++++++++++++++++++++---- 3 files changed, 129 insertions(+), 15 deletions(-) diff --git a/src/app/blog/what-we-got-wrong/page.tsx b/src/app/blog/what-we-got-wrong/page.tsx index d019573..0786a2f 100644 --- a/src/app/blog/what-we-got-wrong/page.tsx +++ b/src/app/blog/what-we-got-wrong/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next' import { A, Item, LegalPage, List, Section } from '@/components/marketing/LegalPage' import { BLOG_POSTS, formatPostDate, postMetadata } from '@/config/blog' -import { CORRECTIONS, type Correction, daysLive } from '@/config/corrections' +import { CORRECTIONS, type Correction, daysLive, resolveWhere } from '@/config/corrections' const post = BLOG_POSTS.find((p) => p.slug === 'what-we-got-wrong')! @@ -30,11 +30,28 @@ function Life({ correction }: { correction: Correction }) { } function Entry({ correction }: { correction: Correction }) { + // A row about a document carries a link to the file at the commit that had the + // wrong words in it. A row about a page does not need one: its path is the + // address, and the page there now shows the corrected text by design. + const pinned = correction.where.filter((entry) => !entry.startsWith('/')) return ( -
+
resolveWhere(entry)?.label ?? entry).join(', ')} + >

It said: {correction.said}

+ {pinned.length > 0 ? ( +

+ The words as they served:{' '} + {pinned.map((entry, i) => ( + + {i > 0 ? ', ' : null} + {resolveWhere(entry)?.label ?? entry} + + ))} +

+ ) : null}

It was wrong because: {correction.wrong}

diff --git a/src/config/corrections.ts b/src/config/corrections.ts index e670014..a8db2aa 100644 --- a/src/config/corrections.ts +++ b/src/config/corrections.ts @@ -15,12 +15,18 @@ // to, so neither date is the day somebody noticed. // // `gone` is the load-bearing field. It is a fragment of the false sentence -// chosen so that it appears nowhere on the site any more, and corrections.test.ts +// chosen so that it appears nowhere we publish any more, and corrections.test.ts // fails if it comes back. Pick it from the part that was actually wrong: the // rarity claim, for instance, still contains "holds exactly, all the way down // the list" in its corrected form, and what was wrong was saying it without // "on five cards" in front. // +// Not everything we publish is a route. ROADMAP.md is linked from the blog and +// from the launch copy, and a false claim in it is as readable as a false claim +// on /privacy. So `where` takes a document as well as a page, the walk that +// enforces `gone` covers those documents too, and this page stops being a list +// of the errors our tooling happened to be able to see. +// // Two rows do not work that way, and both are marked rather than excused. // A blog post is a dated record, so a wrong one keeps its sentence and gains a // correction note. And a row can be `fixedInProduct`, meaning the words were @@ -32,7 +38,11 @@ export interface Correction { /** Stable handle. Used as the anchor and the test's failure message. */ id: string - /** Where it served, as paths on the site. */ + /** + * Where it served: a path on the site, or a GitHub blob URL pinned to the + * commit that carried the wrong words. See `resolveWhere` for why a document + * gets a URL and a page does not. + */ where: readonly string[] /** The claim, quoted. */ said: string @@ -176,6 +186,36 @@ export const CORRECTIONS: readonly Correction[] = [ }, ] +/** + * A blob URL on this repository, pinned to a full commit SHA. A branch name is + * deliberately not accepted: `blob/main/ROADMAP.md` shows whatever the file says + * today, which after a fix is the corrected text, so it would be a link that + * disproves the row it is filed under. + */ +const PINNED_BLOB = + /^https:\/\/github\.com\/playpip\/pip-web\/blob\/[0-9a-f]{40}\/([^#?\s]+)(?:#L\d+(?:-L\d+)?)?$/ + +/** + * Where a claim served, resolved to the file in this repository that carried it + * and a short label to print. `null` if the entry is neither form, which the + * test turns into a failure rather than a quiet skip. + * + * A page gets a site path, because the path is the address and the file behind + * it is the page's own source. A document gets a pinned blob URL instead, and + * that asymmetry is the point: for a page, "what it used to say" is recoverable + * from this registry, but for a document the honest evidence is the file at the + * commit that carried the wrong text, which anybody can read forever and which + * we cannot quietly change. A repo-relative path would be a thing only we can + * resolve; a pinned URL is checkable from outside. + */ +export function resolveWhere(entry: string): { file: string; label: string } | null { + if (entry.startsWith('/')) { + return { file: entry === '/' ? 'src/app/page.tsx' : `src/app${entry}/page.tsx`, label: entry } + } + const file = PINNED_BLOB.exec(entry)?.[1] + return file ? { file, label: file } : null +} + /** Whole days a claim served, `null` while it is still serving. */ export function daysLive(correction: Correction): number | null { if (!correction.fixedOn) return null diff --git a/tests/corrections.test.ts b/tests/corrections.test.ts index 86d9e9b..ba44cff 100644 --- a/tests/corrections.test.ts +++ b/tests/corrections.test.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import test from 'ava' import { BLOG_POSTS } from '@/config/blog' -import { CORRECTIONS, daysLive } from '@/config/corrections' +import { CORRECTIONS, daysLive, resolveWhere } from '@/config/corrections' import { BAND_ORDER, HAND_BANDS } from '@/config/startingHands' // The corrections list is the one page on the site whose subject is our own @@ -9,9 +9,10 @@ import { BAND_ORDER, HAND_BANDS } from '@/config/startingHands' // to us. So it gets more checking than anything it lists. // // The load-bearing test is the last one: a fixed row names a fragment of what it -// used to say, and that fragment must appear nowhere in the source but the -// registry and the post itself. That turns every row into a live guard rather -// than a memory of one. +// used to say, and that fragment must appear nowhere we publish but the registry +// and the post itself. That turns every row into a live guard rather than a +// memory of one. "Publish" means the site's source and the documents at the root, +// not just the site's source, because ROADMAP.md is read by people too. const ISO = /^\d{4}-\d{2}-\d{2}$/ @@ -121,15 +122,53 @@ test('every guard names a test file that exists', (t) => { } }) -test('every row points at a page that exists', (t) => { +test('every row points at something that exists', (t) => { for (const c of CORRECTIONS) { - for (const path of c.where) { - const page = path === '/' ? 'src/app/page.tsx' : `src/app${path}/page.tsx` - t.true(existsSync(repoFile(page)), `${c.id}: ${path} has no page at ${page}`) + for (const entry of c.where) { + const resolved = resolveWhere(entry) + if (!resolved) { + t.fail( + entry.startsWith('https://github.com/') + ? `${c.id}: ${entry} is not a blob URL on this repository pinned to a commit SHA, so it would show the corrected text` + : `${c.id}: ${entry} is neither a site path nor a pinned blob URL`, + ) + continue + } + t.true( + existsSync(repoFile(resolved.file)), + `${c.id}: ${entry} has no file at ${resolved.file}`, + ) } } }) +/** + * No row uses the document form yet, so without this the branch that handles it + * is untested code waiting for the first person to need it. The pinning rule is + * the part worth holding: a branch link renders the file as it is today, which + * for a fixed row is the correction, so it would quietly become evidence against + * the row it sits under. + */ +test('where takes a site path, and a blob URL only when it is pinned to a commit', (t) => { + t.is(resolveWhere('/')?.file, 'src/app/page.tsx') + t.is(resolveWhere('/privacy')?.file, 'src/app/privacy/page.tsx') + t.is(resolveWhere('/privacy')?.label, '/privacy') + + const sha = '0'.repeat(40) + const pinned = resolveWhere(`https://github.com/playpip/pip-web/blob/${sha}/ROADMAP.md#L42-L48`) + t.is(pinned?.file, 'ROADMAP.md') + t.is(pinned?.label, 'ROADMAP.md') + t.is( + resolveWhere(`https://github.com/playpip/pip-web/blob/${sha}/docs/brand.md`)?.file, + 'docs/brand.md', + ) + + t.is(resolveWhere('https://github.com/playpip/pip-web/blob/main/ROADMAP.md'), null) + t.is(resolveWhere(`https://github.com/playpip/pip-web/blob/${sha.slice(0, 7)}/ROADMAP.md`), null) + t.is(resolveWhere(`https://github.com/playpip/marketing/blob/${sha}/ROADMAP.md`), null) + t.is(resolveWhere('ROADMAP.md'), null) +}) + test('open rows come first, then fixed rows newest first', (t) => { const fixed = CORRECTIONS.map((c) => c.fixedOn) const firstFixed = fixed.findIndex((d) => d !== null) @@ -178,9 +217,17 @@ test('the post never types its own live state', (t) => { t.false(source.includes('as this goes up'), 'the publication-day tense is back in the post') }) -// Everything below walks the source. The registry quotes the false sentences and -// the post prints them, so those two are the only places they are allowed to be. +// Everything below walks what we publish. The registry quotes the false +// sentences and the post prints them, so those two are the only places they are +// allowed to be. +// +// It covers the documents at the root as well as src, because both are published. +// ROADMAP.md in particular is linked from the blog and from the launch copy, and +// a claim in it reaches a reader exactly the way a claim on a route does. Walking +// only src would have made `gone` a rule about which files our tooling could +// reach rather than about which words we are still saying. const EXEMPT = new Set(['src/config/corrections.ts', 'src/app/blog/what-we-got-wrong/page.tsx']) +const ROOT_DOCS = ['ROADMAP.md', 'README.md'] function sources(dir: string, out: string[] = []): string[] { for (const entry of readdirSync(repoFile(dir), { withFileTypes: true })) { @@ -191,8 +238,18 @@ function sources(dir: string, out: string[] = []): string[] { return out } +// A renamed document would drop out of the walk above without failing anything, +// which is the quiet way a guard stops guarding. Pinned so it has to be noticed. +test('the documents the walk covers are still where it looks for them', (t) => { + for (const path of ROOT_DOCS) { + t.true(existsSync(repoFile(path)), `${path} has moved, so the walk below no longer reads it`) + } +}) + test('nothing we corrected is still being said', (t) => { - const files = sources('src').map((path) => [path, readFileSync(repoFile(path), 'utf-8')] as const) + const files = [...sources('src'), ...ROOT_DOCS].map( + (path) => [path, readFileSync(repoFile(path), 'utf-8')] as const, + ) for (const c of CORRECTIONS) { if (!c.gone) continue for (const [path, source] of files) {