TL-M0-26 · Rule format and the rule check
Why: D27 makes apps/docs the source of truth: each rule is written there with an ID before its test, and each test names the ID it proves. This step adds the page that defines the format and pnpm docs:rules, the check that keeps rules and tests linked, and runs it in CI, so the Docs Engineer's first pull request is checked from the start.
ID TL-M0-26 · Level L0 · Run order row 14a
pnpm docs:rules passes here and in CI, and apps/docs has the page that defines a rule.
Before
Section titled “Before”TL-M0-14 is Done, and git status on main shows nothing to commit.
1 · A branch with the check, and the pnpm script that runs it. The setopt line stops zsh from reading the ! signs in the script as history commands
Section titled “1 · A branch with the check, and the pnpm script that runs it. The setopt line stops zsh from reading the ! signs in the script as history commands”setopt no_bang_histgit switch -c tools/docs-rulesmkdir -p tools/docscat > tools/docs/check-rules.mts <<'EOF'// Checks the link between the rules in apps/docs and the tests that prove them (D27).// Run it from the repository root with pnpm docs:rules. CI runs it on every pull request.import { type Dirent, readdirSync, readFileSync } from "node:fs";import { join } from "node:path";
const DOCS = "apps/docs/src/content/docs";const TESTS = ["supabase/tests", "apps/web/e2e"];const ID = /\bR-[A-Z]{2,5}-\d{2,3}\b/g;const RULE = /^###\s+(R-[A-Z]{2,5}-\d{2,3})\b/;const STATUS = /^-\s+\*\*Status:\*\*\s+(planned|built|retired)\b/;
type Rule = { id: string; where: string; status?: string };
function filesIn(dir: string, endings: string[]): string[] { let entries: Dirent[]; try { entries = readdirSync(dir, { recursive: true, withFileTypes: true }); } catch { return []; } return entries .filter((e) => e.isFile() && endings.some((end) => e.name.endsWith(end))) .map((e) => join(e.parentPath, e.name)) .sort();}
const rules = new Map<string, Rule>();const errors: string[] = [];
for (const file of filesIn(DOCS, [".md", ".mdx"])) { let rule: Rule | undefined; let inCode = false; readFileSync(file, "utf8") .split("\n") .forEach((line, i) => { if (/^\s*(```|~~~)/.test(line)) inCode = !inCode; if (inCode) return; const heading = RULE.exec(line); if (heading) { const id = heading[1]; const where = `${file}:${i + 1}`; const first = rules.get(id); if (first) errors.push(`${id} is defined twice: ${first.where} and ${where}`); rule = { id, where }; rules.set(id, rule); return; } if (/^#{1,3}\s/.test(line)) { rule = undefined; return; } const status = STATUS.exec(line.trim()); if (rule && status && !rule.status) rule.status = status[1]; });}
const citedBy = new Map<string, string[]>();for (const dir of TESTS) { for (const file of filesIn(dir, [".sql", ".ts"])) { for (const id of new Set(readFileSync(file, "utf8").match(ID) ?? [])) { citedBy.set(id, [...(citedBy.get(id) ?? []), file]); } }}
for (const rule of rules.values()) { if (!rule.status) errors.push(`${rule.id} has no Status line: ${rule.where}`); if (rule.status === "built" && !citedBy.has(rule.id)) { errors.push(`${rule.id} is built, but no test cites it: ${rule.where}`); }}for (const [id, files] of citedBy) { const rule = rules.get(id); if (!rule) { errors.push(`${id} is cited by ${files.join(", ")}, but no rule defines it`); } else if (rule.status === "retired") { errors.push(`${id} is retired, but ${files.join(", ")} cites it`); }}
const count = (status: string) => [...rules.values()].filter((r) => r.status === status).length;console.log( `Rules: ${rules.size} (${count("planned")} planned, ${count("built")} built, ${count("retired")} retired). Rules cited by tests: ${citedBy.size}.`,);for (const error of errors) console.error(error);process.exit(errors.length > 0 ? 1 : 0);EOFpnpm pkg set 'scripts["docs:rules"]=node tools/docs/check-rules.mts'pnpm docs:rules2 · The page that defines the rule format, and a build to prove it renders
Section titled “2 · The page that defines the rule format, and a build to prove it renders”mkdir -p apps/docs/src/content/docs/contributingcat > apps/docs/src/content/docs/contributing/rules.md <<'EOF'---title: How rules are writtendescription: The format every rule in these docs follows, and how tests cite them (D27).---
These docs are the source of truth for what Opleet v2 does (D27). A rule is written here first, then the test that proves it, then the code that passes the test.
## The format
Each rule is a level-three heading that starts with its ID, followed by a short list:
```md### R-XX-01 · One sentence that states the rule
- **Status:** planned- **Example:** given …, when …, then ….- **Refusal:** the error key raised when the rule refuses an action (D10), or none- **Who:** the roles the rule applies to- **Source:** the decision, spec or v1 behaviour it comes from```
- **One rule, one testable statement.** If the sentence needs "and", it is two rules.- **IDs** are R, a domain code and a two-digit number, such as R-BC-03. The Docs Engineer keeps the list of domain codes. An ID is never reused.- **Status** is planned (written, not yet built), built (a test proves it and the code passes that test) or retired (no longer true, kept so its ID is not reused).
## Who changes what
- **The Docs Engineer** writes and edits rules, through pull requests that zuki reviews.- **The Backend and Frontend Engineers** change one thing in a rule: its Status, from planned to built, in the pull request that adds its test and its code.- **A rule that turns out wrong or impossible** goes back to the Docs Engineer as a docs pull request, or to the register as a change request when it conflicts with a decision. It is never changed quietly to fit the code.
## How tests cite rules
Every test starts its description with the ID of the rule it proves:
- **pgTAP,** in supabase/tests: the last argument of the assertion, for example 'R-BC-03: a second approval is refused'.- **Playwright,** in apps/web/e2e: the test title, for example test('R-BC-03: a second approval is refused', …).
## The check
pnpm docs:rules runs in CI on every pull request. It reads every .md and .mdx page here, skipping code blocks, and every test in supabase/tests and apps/web/e2e. It fails when:
- an ID is defined twice;- a rule has no Status line;- a built rule has no test that cites it;- a test cites an ID that no rule defines;- a test cites a retired rule.
Pages under reference/ are generated from the database and are never edited by hand.EOFpnpm --filter docs build3 · Run the check in CI, right after Biome
Section titled “3 · Run the check in CI, right after Biome”cat > .github/workflows/ci.yml <<'EOF'name: CI
on: pull_request: push: branches: [main]
concurrency: group: ci-${{ github.ref }} cancel-in-progress: true
env: NEXT_TELEMETRY_DISABLED: 1 ASTRO_TELEMETRY_DISABLED: 1 TURBO_TELEMETRY_DISABLED: 1
jobs: checks: name: checks runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: node-version-file: .node-version cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm biome ci . - run: pnpm docs:rules - run: pnpm turbo run typecheck build ${{ github.event_name == 'pull_request' && '--affected' || '' }}EOFgit diff .github/workflows/ci.yml4 · Commit, open the pull request, and merge it when the checks pass
Section titled “4 · Commit, open the pull request, and merge it when the checks pass”git add tools/docs package.json apps/docs/src/content/docs/contributing .github/workflows/ci.ymlgit commit -m "build(tools): check that docs rules and tests stay linked"git push -u origin tools/docs-rulesgh pr create --fillgh pr checks --watchgh pr merge --squash --delete-branchgit switch maingit pullExpect
Section titled “Expect”- Block 1: “Rules: 0 (0 planned, 0 built, 0 retired). Rules cited by tests: 0.”
- Block 2: the build ends with “[build] Complete!”.
- Block 3: the diff adds one line, “- run: pnpm docs:rules”. If it shows any other change, your ci.yml differs from TL-M0-13's: stop and paste the diff into a CTO session.
- Block 4: checks and pr-title pass, and the pull request merges.
pnpm docs:rulesgh run list --workflow CI --branch main --limit 1The Rules line again, and the latest CI run on main shows completed and success.
If it fails
Section titled “If it fails”- Node warns that the module type is not specified: the script was saved as check-rules.ts. Rename it to check-rules.mts and update the script in package.json.
- Node can't run the file: node --version isn't 24 in this folder; check TL-M0-02.
- The docs build fails, or the checks job fails at pnpm docs:rules: paste the output (for CI, gh run view --log-failed) into a CTO session.
Before merging: gh pr close --delete-branch. After merging: revert it with a new pull request.
Done 2026-10-06 (zuki)