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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 20 additions & 6 deletions UPDATES-NEEDED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/CONTENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions scripts/check-content-invariants.mjs
Original file line number Diff line number Diff line change
@@ -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 '<number> 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).`
);