Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/app/blog/what-we-got-wrong/page.tsx
Original file line number Diff line number Diff line change
@@ -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')!

Expand Down Expand Up @@ -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 (
<Section title={correction.where.join(', ')}>
<Section
title={correction.where.map((entry) => resolveWhere(entry)?.label ?? entry).join(', ')}
>
<p>
<strong>It said:</strong> {correction.said}
</p>
{pinned.length > 0 ? (
<p>
<strong>The words as they served:</strong>{' '}
{pinned.map((entry, i) => (
<span key={entry}>
{i > 0 ? ', ' : null}
<A href={entry}>{resolveWhere(entry)?.label ?? entry}</A>
</span>
))}
</p>
) : null}
<p>
<strong>It was wrong because:</strong> {correction.wrong}
</p>
Expand Down
44 changes: 42 additions & 2 deletions src/config/corrections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
79 changes: 68 additions & 11 deletions tests/corrections.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
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
// mistakes, which makes getting it wrong the single funniest failure available
// 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}$/

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 })) {
Expand All @@ -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) {
Expand Down