Skip to content
Open
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
109 changes: 65 additions & 44 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ concurrency:
cancel-in-progress: ${{ github.event_name != 'push' }}

jobs:
# Prettier and eslint run un-cached in CI: restored result caches can mark
# files clean against a stale tool version or config, masking real failures.
# The --cache flags in the package.json lint script remain for local speed.
prettier:
# Prettier, eslint, and typecheck share one runner: one checkout and one
# install instead of three of each. Prettier and eslint still run un-cached
# in CI: restored result caches can mark files clean against a stale tool
# version or config, masking real failures. The --cache flags in the
# package.json lint script remain for local speed.
static-analysis:
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -29,43 +31,42 @@ jobs:
uses: actions/cache@v4
with:
path: node_modules
# The exact key keeps hits honest: only a cache built from this
# bun.lock restores. restore-keys falls back to the newest cache
# when the lockfile changed, so a dependency bump reinstalls the
# delta instead of cold-installing on every job at once. bun
# install --frozen-lockfile reconciles a stale tree to the new
# lockfile, so a partial hit never leaves wrong deps behind.
key: bun-${{ hashFiles('bun.lock') }}
restore-keys: |
bun-

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Prettier
run: bunx prettier --check .

eslint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"

- name: Cache dependencies
uses: actions/cache@v4
with:
path: node_modules
key: bun-${{ hashFiles('bun.lock') }}

- name: Install dependencies
run: bun install --frozen-lockfile

- name: ESLint
run: bunx eslint .

typecheck:
- name: Typecheck
run: bun run typecheck

# Build runs beside the suite instead of before it: tests import ./src
# directly and never read ./dist, so serializing build ahead of test put
# build time on the critical path for no dependency reason.
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
Expand All @@ -76,15 +77,32 @@ jobs:
with:
path: node_modules
key: bun-${{ hashFiles('bun.lock') }}
restore-keys: |
bun-

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Typecheck
run: bun run typecheck
- name: Build
run: bun run build

build-and-test:
# The suite is sharded so the slowest slice, not the whole suite, sets the
# wall clock. Every shard still goes through check:projects-dir-guard: the
# guard forwards these path filters to the suite it wraps, and the union of
# the shards' filters is exactly ./src ./tests ./evals, so the gate covers
# the same tests as before, all of them sandboxed.
test:
runs-on: ubuntu-latest
strategy:
# A red shard must not cancel the other; both results are the signal.
fail-fast: false
matrix:
shard:
- name: src
paths: ./src
- name: tests-and-evals
paths: ./tests ./evals
name: test (${{ matrix.shard.name }})
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -99,29 +117,32 @@ jobs:
with:
bun-version: "1.3.14"

# The runner image has no ripgrep, so the grep plugin silently exercised
# its fallback walker and left the ripgrep path untested.
- name: Install ripgrep
run: sudo apt-get install -y ripgrep

- name: Cache dependencies
uses: actions/cache@v4
with:
path: node_modules
key: bun-${{ hashFiles('bun.lock') }}

# The runner image has no ripgrep, so the grep plugin silently exercised
# its fallback walker and left the ripgrep path untested.
- name: Install ripgrep
run: sudo apt-get install -y ripgrep
restore-keys: |
bun-

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build
run: bun run build

# Same script the local `bun run check` gate runs: the projects-dir
# guard wraps `bun run test`, which is the seeded, randomized suite
# (bun test ./src ./tests ./evals --randomize --seed 424242) defined
# once in package.json. Randomized order catches tests that only pass
# in the default file order (shared module-level state, an unrestored
# global mock, a leaked env var); the seed is fixed so a failure here
# reproduces locally with `bun run test`.
# The same script the local `bun run check` gate runs, with the shard's
# path filters forwarded through the guard to the suite. The guard
# routes a filtered run through test:paths, which carries the same
# seeded flags as the `test` script; bun test filters are additive, so
# appending filters to `bun run test` could not narrow it. Randomized
# order catches tests that only pass in the default file order (shared
# module-level state, an unrestored global mock, a leaked env var).
# The seed stays 424242 in every shard rather than varying per shard:
# the shards already run disjoint file sets, and a fixed seed keeps
# any failure reproducible locally with the same
# `bun run test:paths <paths>`.
- name: Test
run: bun run check:projects-dir-guard
run: bun run check:projects-dir-guard ${{ matrix.shard.paths }}
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ When refactoring replaces an old path, delete the old one. No back-compat shims,
- `tests/unit/` shared unit tests and helpers · co-located `src/**/*.test.ts` for module logic · `tests/fixtures/` fixture repos · `tests/integration/` reactor/permission harness. Planned: `tests/e2e/` (fixture-repo runs).
- A test must not depend on another file having run, or on the default file order. It must pass under `bun test ./src ./tests ./evals --randomize`. If a test mutates module-level state or calls `mock.module`, it must restore that state itself (`afterEach`/`afterAll`), not rely on the process happening to reset it. When capturing a module's real exports to restore later, shallow-copy them (`{ ...moduleNamespace }`) at capture time, whether the namespace came from `await import(path)` or a static `import * as ns from "path"` — Bun mutates the live namespace object in place when the module is mocked, so holding a bare reference to it (either form) silently turns into the mocked exports.
- Never call `mock.module` directly. Bun runs every test file in one process, so a `mock.module` call without its own teardown stays installed for the rest of the run and silently replaces the real module for other files — producing failures in files the change never touched, with no obvious link to the cause and no signal from `tsc` or a per-file run (CL-6967). Use `withMockedModule`/`withMockedModuleDuring` from `tests/helpers/mock-module.ts`, which capture the real module and register their own restore. An eslint rule (`no-restricted-syntax` in `eslint.config.js`) rejects bare `mock.module` calls in `*.test.ts` files.
- A test earns its place only if a real behavior change can fail it. Document copy, brand colors, marketing assets, and splash text are not behavior: assertions that pin an asset's literal wording, an exact palette hex/ANSI value, or rendered copy fail on copy/design edits and catch no regressions — assert the contract instead (parsing, formatting, ranges, aliases, invariants). Tests are code too: pinning a source file's own text is the same trap. An eslint rule (`corbits/no-content-pin-tests`, defined in `scripts/eslint-rules/no-content-pin-tests.ts`) rejects the known shapes in `*.test.ts` files; it is a heuristic shape match, not a semantic check, and its header documents what it does not catch.

## Build & Validation

Expand Down
14 changes: 14 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import noContentPinTests from "./scripts/eslint-rules/no-content-pin-tests.ts";

export default tseslint.config(
{
Expand Down Expand Up @@ -77,4 +78,17 @@ export default tseslint.config(
],
},
},
{
// Content-pin tests — assertions that pin literal document wording, brand
// hex values, or palette indexes — fail on copy/design edits and catch no
// behavior regression. The rule is a heuristic shape match; see its header
// for what it covers and what it deliberately does not.
files: ["**/*.test.ts"],
plugins: {
corbits: { rules: { "no-content-pin-tests": noContentPinTests } },
},
rules: {
"corbits/no-content-pin-tests": "error",
},
},
);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"build:bin": "bun build ./src/index.ts --compile --minify --define process.env.NODE_ENV='\"production\"' --outfile ./dist/corbits && bun scripts/copy-repo-plugins.ts",
"typecheck": "tsc --noEmit",
"test": "bun test ./src ./tests ./evals --randomize --seed 424242",
"test:paths": "bun test --randomize --seed 424242",
"lint": "prettier --check --cache . && eslint --cache .",
"check:projects-dir-guard": "bun scripts/guard-real-projects-dir.ts",
"check": "bun run lint && bun run typecheck && bun run build && bun run check:projects-dir-guard",
Expand Down
Loading
Loading