From e6c758449c00e05abe526dc737e4833884bd9815 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:21:28 -0400 Subject: [PATCH] ci: check cohort counts against the roster This repository has published a wrong cohort size twice. The Fall 2025 card 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. Neither was catchable. The head-count is written by hand in four places - the roster itself, team.researchersCount, the cohorts card, and the README - and nothing forced them to agree. Every wrong value is a valid string, so the build and the linter were always going to pass. Adds scripts/check-content-invariants.mjs, run in CI as `npm run check:content`. It imports the real site.ts exports rather than parsing text (Node 24 strips the types natively, and the module has no imports of its own) and asserts: - each cohort card's "N researchers" matches the roster for that term; - team.researchersCount matches the current cohort; - the README's prose count matches the current cohort; - no two people share a /team/[slug]. Verified against four seeded faults - a wrong README number word, a stale cohort card, a researcher moved between terms, and two people sharing a slug - each failing with a message naming the file and both numbers. The current tree passes. The CI job name is deliberately unchanged: it is the context the branch ruleset requires, so renaming it would silently detach the gate. Partially closes UPD-008. Route reachability, base-path asset references, and publication anchors are still uncovered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + CONTRIBUTING.md | 12 +++ UPDATES-NEEDED.md | 26 ++++-- docs/CONTENT-GUIDE.md | 4 +- package.json | 1 + scripts/check-content-invariants.mjs | 133 +++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 scripts/check-content-invariants.mjs 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).` +);