Skip to content

Lint & Tests

Terminal window
bun run test # everything
bun run test src/server/agent.test.ts # one suite

CI runs these in order, and each covers something the others do not:

Command Catches
bun run lint ESLint at --max-warnings=0: the side-effect seal and the design gate
bun run lint:usestate ast-grep: the React #185 rules and inline tint pairings
bun run lint:comments The no-code-comments rule across src/, scripts/, e2e/
bun run check:arch The architecture budget ratchet
bun run lint:limits Proves the complexity ceilings are still tight
bun run typecheck TypeScript 7, by explicit path
bun run build The client build
bun run check:bundle Bundle size and CJS interop
bun run test The suite

bun run check chains typecheck → lint → lint:comments → build:client → check:bundle, which is the fastest single command that catches most of it. bunx ast-grep test runs the fixtures for the rules in rules/, and bun run scan:secrets runs gitleaks over the working tree — wire the pre-commit hook once with bun run setup:hooks.

Two more workflows gate a PR: gitleaks (any finding blocks the merge) and semgrep.

First-party TypeScript under src/, scripts/ and e2e/ carries no comments — not a //, not a /* */, not a /** */, not a {/* */} JSX container. A comment is verified by nothing, so it rots in place and outlives the code it describes; anything that cannot live in a name, a type or a small named function belongs in CLAUDE.md, a .c3/ fact, an ADR, or this wiki, where a reader can search it and a build can hold it to account.

The exceptions are comments a tool executeseslint-disable, /// <reference />, @ts-expect-error, @vite-ignore, prettier-ignore, @license, and a #! shebang. Deleting one of those is a behavior change, not a cleanup, so the checker leaves them alone. Give an eslint-disable its rationale after ESLint’s own -- separator rather than in a second comment.

The gate parses each file with the TypeScript scanner rather than a regex, which cannot tell a comment from "https://…" inside a string literal. Re-strip an offending tree with bun run strip:comments; wiki/, rules/, rule-tests/, .c3/ and the root config files are deliberately out of scope.

eslint.config.js pins four complexity ceilings at today’s maxima. A ceiling nothing reaches gates nothing — pinned at 141 while the worst function is 90 leaves 50 points of free regression. lint:limits re-runs ESLint with every ceiling lowered by one and requires each rule to report at least one violation, which proves the ceiling is still binding.

The previous complexity program closed all seven of its workstreams as COMPLETED while its own metrics moved the wrong way — modules over 700 lines went 18 → 21 → 23 and production LOC rose by ~4,000. Nothing in CI could observe that, so nothing objected.

src/ops/architecture/budget.ts now pins each defect population. A breach names the filed issue your change just made worse. When one fires, check whether the cheapest way to satisfy it is a rename — if it is, the pattern is measuring a spelling rather than the defect, and the pattern is what needs fixing.

bun run lint also enforces the visual system (DESIGN.md) across src/client/** and src/shared/**. Banned outright:

  • Arbitrary hex Tailwind utilities (bg-[#…]) — use a token class
  • Raw hex colour literals, including the #000 / #fff family — use CSS vars
  • backdrop-blur / backdrop-filter — the No-Glassmorphism rule
  • Native title on intrinsic elements — use the project Tooltip

There is no escape valve; do not add eslint-disable comments.

Contrast is gated twice: lint:usestate bans inline tinted-pill pairings, and bun run test src/server/design/tone-pairings.test.ts asserts WCAG AA for every pairing in both themes. A raw semantic token (--warning, --info, --success) is a background, never inkbg-warning on a dot is right, text-warning on a label is a bug, and a separate guard fails the build on it.

A use*Store selector must return a stable reference. Inline ?? [] or ?? {} produces a fresh ref on every call and triggers React error #185:

const EMPTY: Subagent[] = []
useStore((state) => state.list ?? EMPTY)
// or
useStore(useShallow((state) => state.list ?? []))

Components with effects that write stores should be covered by renderForLoopCheck in src/client/lib/testing/.

Every React root a test mounts must be unmounted

Section titled “Every React root a test mounts must be unmounted”

This one is enforced, and the failure it prevents is genuinely baffling.

happy-dom gives the whole Bun process one document, and the test preload wipes document.body after each test. That wipe cannot reach a React root the test left alive — and any portal that root opened (Radix Dialog, Popover, Select, createPortal) had document.body itself as its container. The next time that root commits, React tries to remove a node the wipe already took, and happy-dom throws removeChild: The node to be removed is not a child of this node — blaming whichever test happens to be running at that moment, in a different file.

File order is the filesystem’s, so it reproduces on CI’s ext4 and not on APFS. In PR #646 a leaked SharePopover root crashed CardDrawer two files later, with the full suite green locally.

The afterEach sweep now fails the test that actually leaked, naming the nodes. Call root.unmount()container.remove() is not enough.

When a test spawns git or another subprocess:

  • Set stdin: "ignore"
  • Set GIT_TERMINAL_PROMPT=0
  • Give it an explicit timeout: test(name, fn, 30_000)

A hung credential prompt otherwise eats the whole test timeout.

bun run test:e2e (Playwright, real Chrome) is deliberately not wired into CI — it needs a real browser, not the happy-dom that bun test runs against. Run it on demand.