diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f89edb7..a58b9e74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - name: Typecheck run: npx tsc --noEmit + - name: Check content invariants + run: npm run check:content + - name: Build static export run: npm run build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c3abf03..810f07ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,6 +159,18 @@ The repository's lint baseline is clean: `npm run lint` prints nothing and exits 0. Pull-request CI runs it, so a new warning or error will fail the build. Fix the finding rather than suppressing it. +When you change people, cohorts, or counts, also run: + +```powershell +npm run check:content +``` + +It asserts what a build cannot: that the cohort head-counts in +`team.researchersCount`, the `cohorts` cards, and `README.md` all match the +number of researchers actually on the roster, and that no two people share a +`/team/[slug]`. Those numbers are maintained by hand in separate places, and +have twice been published wrong. + Always run the production build: ```powershell diff --git a/UPDATES-NEEDED.md b/UPDATES-NEEDED.md index ea745528..e231597a 100644 --- a/UPDATES-NEEDED.md +++ b/UPDATES-NEEDED.md @@ -257,18 +257,32 @@ changed. ### UPD-008 - Add focused smoke checks -**Priority:** Medium +**Priority:** Medium — **started: content invariants added.** There is no automated test script. The production build succeeds, but a build alone does not prove that key routes, base-path assets, publication anchors, or generated slugs are correct. -**Proposed update:** Start with a small check over critical exported files and -known content invariants. Add browser automation only when it protects a -specific high-value interaction and can remain reliable. +`npm run check:content` now runs in CI and asserts the invariants a build +cannot: every cohort card's head-count matches the number of researchers +actually on the roster for that term, `team.researchersCount` and the README's +prose count agree with the current cohort, and no two people share a +`/team/[slug]`. + +This targets a failure this repository has published twice. The Fall 2025 count +read seven when the roster should have held eight, because Pegi Bracaj had +never been added. The README read "eight applied researchers" against a Summer +2026 roster of seven. Both are valid strings, so both survived every build and +lint. The check reads the real `site.ts` exports rather than parsing text, and +was verified against four seeded faults: a wrong README word, a stale cohort +card, a researcher moved between terms, and two people sharing a slug. + +**Still open:** route reachability, base-path asset references, and publication +anchors are not covered. + +**Acceptance:** partially met — invalid generated content mappings and cohort +counts are caught before merge. Not met for routes and assets. -**Acceptance:** CI detects at least a missing critical route, broken local -asset reference, or invalid generated content mapping before merge. ### UPD-009 - Separate website issues from wider CoLab operations diff --git a/docs/CONTENT-GUIDE.md b/docs/CONTENT-GUIDE.md index 2807631b..aa8f8a9a 100644 --- a/docs/CONTENT-GUIDE.md +++ b/docs/CONTENT-GUIDE.md @@ -86,7 +86,9 @@ Each person is a `TeamMember` object. Fields: image. - Changing the cohort size means updating the count in **three** places: `team.researchersCount`, the matching `cohorts` entry's `items`, and - [`README.md`](../README.md). + [`README.md`](../README.md). Run `npm run check:content` to confirm all three + agree with the roster — CI runs it, and it will fail the pull request if they + do not. - Never publish a name, role, affiliation, or biography you have not verified against an authoritative source. diff --git a/package.json b/package.json index 9bc48040..b87d4f37 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "next build", "start": "next start", "lint": "eslint", + "check:content": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/check-content-invariants.mjs", "sync:static": "bash scripts/sync-static-site.sh", "sync:newsletter": "node scripts/sync-newsletter.mjs", "export:haste-paper": "node scripts/export-haste-paper.mjs", diff --git a/scripts/check-content-invariants.mjs b/scripts/check-content-invariants.mjs new file mode 100644 index 00000000..47357a6f --- /dev/null +++ b/scripts/check-content-invariants.mjs @@ -0,0 +1,133 @@ +// Content invariants that a successful build does not prove. +// +// The cohort head-counts are written by hand in three places — the roster +// itself, `team.researchersCount`, the `cohorts` cards, and the README — and +// nothing has ever forced them to agree. Both times this repository has +// published a wrong cohort size, the cause was the same: a researcher was +// added or missed in one place and the other numbers were never touched. A +// build cannot catch that, because every one of those values is a valid +// string. +// +// Run with `npm run check:content`. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const { team, cohorts, cohortTerms } = await import( + new URL("../src/content/site.ts", import.meta.url).href +); + +const failures = []; +const fail = (msg) => failures.push(msg); + +const currentTerm = cohortTerms[cohortTerms.length - 1]; + +/** Researchers actually on the roster for a term. */ +function rosterCount(term) { + return team.researchers.filter((r) => (r.term ?? currentTerm) === term).length; +} + +/** "7 researchers" -> 7 */ +function statedCount(text) { + const m = /^(\d+)\s+researchers?$/.exec(text.trim()); + return m ? Number(m[1]) : null; +} + +// 1. Every cohort card's head-count matches the roster for that term. +for (const cohort of cohorts) { + const counts = cohort.items + .filter((item) => typeof item === "string") + .map(statedCount) + .filter((n) => n !== null); + + if (counts.length === 0) { + fail(`cohorts["${cohort.term}"] has no "N researchers" item to check.`); + continue; + } + for (const stated of counts) { + const actual = rosterCount(cohort.term); + if (stated !== actual) { + fail( + `cohorts["${cohort.term}"] says ${stated} researchers, but the roster ` + + `holds ${actual}. Either a researcher is missing from team.researchers ` + + `or the count is stale.` + ); + } + } +} + +// 2. team.researchersCount matches the current cohort. +{ + const stated = statedCount(team.researchersCount); + const actual = rosterCount(currentTerm); + if (stated === null) { + fail(`team.researchersCount ("${team.researchersCount}") is not "N researchers".`); + } else if (stated !== actual) { + fail( + `team.researchersCount says ${stated}, but the ${currentTerm} roster holds ${actual}.` + ); + } +} + +// 3. The README's prose count matches the current cohort. This is the exact +// claim that was wrong: it read "eight applied researchers" against a roster +// of seven. +{ + const WORDS = [ + "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", + "nine", "ten", "eleven", "twelve", + ]; + const readme = fs.readFileSync(path.join(root, "README.md"), "utf8"); + const m = /(\w+)\s+applied researchers/i.exec(readme); + if (!m) { + fail("README.md no longer states a ' applied researchers' count."); + } else { + const actual = rosterCount(currentTerm); + const stated = WORDS.indexOf(m[1].toLowerCase()); + if (stated !== actual) { + fail( + `README.md says "${m[1]} applied researchers", but the ${currentTerm} ` + + `roster holds ${actual} (${WORDS[actual] ?? actual}).` + ); + } + } +} + +// 4. A slug must identify one person. The same person may legitimately appear +// in more than one collection (a fellow who is also a researcher), but two +// different people sharing a slug would collide on /team/[slug]. +{ + const bySlug = new Map(); + const everyone = [ + team.founder, + ...team.advisors, + ...team.residentFellows, + ...team.researchers, + ...team.collaborators, + ]; + for (const person of everyone) { + if (!person?.slug) continue; + const seen = bySlug.get(person.slug); + if (seen && seen !== person.name) { + fail( + `slug "${person.slug}" is used by two different people: ` + + `"${seen}" and "${person.name}".` + ); + } + bySlug.set(person.slug, person.name); + } +} + +if (failures.length > 0) { + console.error("Content invariants failed:\n"); + for (const f of failures) console.error(" - " + f); + console.error(""); + process.exit(1); +} + +console.log( + `Content invariants OK (${cohorts.length} cohorts, ` + + `${team.researchers.length} researcher records).` +);