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
93 changes: 93 additions & 0 deletions .claude/rules/bun-native-apis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
paths: ['src/**/*.ts']
globs: ['src/**/*.ts']
---

# Bun-native APIs

This project runs on Bun. Reach for `Bun.*` globals before the Node equivalent. Training-data bias makes the Node API the default suggestion; this rule lists the cases where the Bun shape is materially shorter or more idiomatic and should win.

For anything not covered below, the canonical reference is <https://bun.sh/llms-full.txt> — it concatenates Bun's full docs into a single markdown file.

## File system reads

`Bun.file(path).text() / .json() / .bytes()` instead of `readFileSync` + `JSON.parse`. Reads are lazy: no I/O until the accessor is awaited.

```ts
// Good
const content = await Bun.file(argPath).text();
const config = await Bun.file(configPath).json<Config>();

// Avoid
const content = readFileSync(argPath, 'utf8');
const config = JSON.parse(readFileSync(configPath, 'utf8')) as Config;
```

## File system writes

`Bun.write(path, data)` instead of `writeFileSync`. Auto-creates parent directories, accepts strings, `Bun.file` handles, `ArrayBuffer`, or `Response`.

```ts
// Good
await Bun.write(outPath, rendered);

// Avoid
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, rendered, 'utf8');
```

## Globbing & directory walks

`new Bun.Glob(pattern).scanSync(root)` (or async `scan`) instead of `readdirSync({ recursive: true })` and instead of any hand-rolled recursive `walk()`. The recursive `readdirSync` flag works but doesn't pattern-filter; rolling a walk yourself is the most common Node carry-over.

```ts
// Good
const files = Array.from(new Bun.Glob('**/*.md').scanSync(rootDir)).sort();

// Avoid
function walk(dir: string): string[] {
/* recursive readdirSync... */
}
const files = walk(rootDir).filter((p) => p.endsWith('.md'));
```

## Subprocesses

`Bun.spawn` / `Bun.spawnSync` instead of `child_process.spawn` / `exec`. Use `Bun.which` instead of probing `$PATH` manually.

```ts
// Good
const proc = Bun.spawn({ cmd: [bin, ...args], stdout: 'pipe', stderr: 'pipe' });
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;

// Avoid
const { stdout } = await promisify(execFile)(bin, args);
```

## HTTP servers

`Bun.serve` instead of Express, Fastify, or `node:http`.

## SQLite

`bun:sqlite` instead of `better-sqlite3` or `node:sqlite`.

## Compile-time evaluation

Two related Bun-only import attribute forms; reach for them whenever the value is fixed at bundle time.

- `with { type: 'text' }` to embed a file's contents as a string literal at build.
- `with { type: 'macro' }` to evaluate a function at build time and inline its return value. Right shape for version stamps, fixture generation, build-time enumeration of files, and anything else that's a pure function of the source tree.

If you catch yourself writing a `readFileSync(...)` or a constant lookup at the top of a module to compute something the bundler could compute once, it's a macro.

## What not to use

- `fs.readdirSync(..., { recursive: true })` walks → use `Bun.Glob`.
- Hand-rolled recursive `walk()` helpers → use `Bun.Glob`.
- `child_process.exec` / `execFile` → use `Bun.spawn`.
- `node:http` for new servers → use `Bun.serve`.
- `better-sqlite3` / `node:sqlite` → use `bun:sqlite`.

`readFileSync` and `writeFileSync` are not banned outright — they're fine in a tight synchronous loop where `await` would force a redundant restructure — but `Bun.file` / `Bun.write` should be the default.
47 changes: 47 additions & 0 deletions .claude/rules/bun-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
paths: ['src/**/*.test.ts']
globs: ['src/**/*.test.ts']
---

# Bun testing conventions

Conventions specific to `bun:test` that are non-obvious and have bitten us.

## `.rejects` / `.resolves` matchers are synchronous — don't `await`, don't `async`, don't `nextTick`

`expect(promise).rejects.toX()` and `expect(promise).resolves.toX()` **block synchronously at runtime** via `globalThis.bunVM().waitForPromise(...)`. `bun-types` types them as returning `void` — the types are wrong and [upstream closed the fix as "not planned"](https://github.com/oven-sh/bun/issues/15457).

**Correct pattern:**

```ts
it('rejects on X', () => {
expect(fn(...)).rejects.toBeInstanceOf(SomeError);
expect(io.stderr.text()).toContain('expected message');
});
```

No `async`, no `await`, no manual `nextTick` flush.

**Why:** adapting Jest habits to Bun produces an antagonistic lint/TS pair:

- With `await expect(...).rejects.toX()` → TS80007 "`await` has no effect on the type of this expression" (the matcher is typed `void`).
- Without `await` → `@typescript-eslint/require-await` flags `async` as pointless.

Dropping both resolves both. The matcher still works — it already blocks the thread.

**`nextTick` is also unnecessary.** Some older tests have:

```ts
// CARGO-CULT — DO NOT COPY
it('rejects on X', async () => {
expect(fn(...)).rejects.toBeInstanceOf(SomeError);
await nextTick();
expect(readErrorLogs(logs).some(...)).toBe(true);
});
```

That helper is a leftover from Jest patterns. `waitForPromise` already settled the promise, and stream-backed reads are ready immediately on the next line.

**Keep `async` only when the body has a real `await`** — e.g. `await runHandler(ctx)` on a happy path, or async fixture setup. Don't keep it "just in case."

**Don't try to patch the types.** `Matchers.resolves` / `Matchers.rejects` are typed as property accessors on an interface; TS declaration merging can only add, not override. A local wrapper helper would work, but Bun's synchronous behavior makes it unnecessary.
14 changes: 14 additions & 0 deletions .claude/rules/context-interfaces-and-fakes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
paths: ['src/**/*.ts']
globs: ['src/**/*.ts']
---

# Context Interfaces and Fakes

- **Injectable dependencies should have a public contract and separate implementations.** When adding a dependency that performs I/O, external API calls, filesystem access, or other side effects, define a narrow public interface for the behavior callers depend on. Callers accept the interface; production code wires up the real implementation.

- **Name the production implementation `XxxImpl`.** The real implementation implements the public interface and carries the environment-specific behavior. Consumer parameters and struct fields should be typed as the interface, not the concrete implementation.

- **Name the test implementation `FakeXxx`.** Shared test helpers implement the same public interface with deterministic, inspectable behavior. Keep fakes focused on the interface contract so tests can swap them in without casts, module mocking, or duplicating production implementation details.

- **Pass dependencies as plain function arguments or a small `Context` object.** Don't reach for module-level mutable singletons or globals; they make tests order-dependent and force module mocking. Collectors and other long-running operations should accept their client/IO dependencies as arguments.
34 changes: 34 additions & 0 deletions .claude/rules/error-handling-neverthrow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
paths: ['src/**/*.ts', 'src/**/*.tsx']
globs: ['src/**/*.ts', 'src/**/*.tsx']
---

# Error Handling Neverthrow

- **Use neverthrow for safe wrappers around fallible I/O.** Wrap operations that can throw (file reads, JSON parsing, API calls) in `Result` types via `fromThrowable` for sync work and `ResultAsync.fromPromise` for promises. This keeps error handling explicit and chainable without try/catch blocks scattered through business logic.

**Good:**

```typescript
import { fromThrowable, Result, ResultAsync } from 'neverthrow';
import { toError } from '#src/errors.ts';

const safeRead = fromThrowable((path: string) => readFileSync(path, 'utf8').trim());
const existing = safeRead(path).unwrapOr('');

export const safeJsonParse = Result.fromThrowable((text: string) => JSON.parse(text) as unknown, toError);

function fetchUser(id: string): ResultAsync<User, FetchError> {
return ResultAsync.fromPromise(client.users.get(id), toFetchError);
}
```

- **Use discriminated-union errors at module boundaries.** Each module that produces errors defines a small `XxxError` union with a `kind` discriminator. Downstream callers narrow by `kind` instead of `instanceof`. Keep the union flat — don't nest causes inside other unions.

- **Collectors and other long-running operations chain with `.andThen` / `.map` / `.mapErr`.** They never `throw`, never wrap a bare `await` over a fallible Promise, and never `try/catch`. If a sub-call returns `ResultAsync`, chain it; don't unwrap mid-flight.

- **The CLI entrypoint is the only place that unwraps.** It pattern-matches on the error type to format a human message, then sets a non-zero exit code. Do not use `unwrapOr` inside business logic when the fallback would silently hide a real bug — only at presentation boundaries where partial data is the intentional behavior.

- **Partial-failure boundaries are explicit.** When a per-item operation may fail and the run should continue (e.g. per-repo crawl where one repo's 403 shouldn't kill the report), the boundary that accepts partial failure handles the `Err` explicitly: log it, push a warning into the slice, and proceed with the successful items. Inside each per-item collector, no try/catch.

- **Tests assert on `Result` shapes.** Use `.isOk()`, `.isErr()`, `.unwrapOr(default)`, and value/error inspection — not try/catch. A test that catches a thrown error is a sign the production code should be returning a `Result`.
35 changes: 35 additions & 0 deletions .claude/rules/testing-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
paths: ['src/**/*.test.ts']
globs: ['src/**/*.test.ts']
---

# Testing Patterns

- **Test factories use Fishery with `.build()` invocations.** Test data is constructed via Fishery `Factory.define<T>()` factories, never hand-rolled `createXxx()` helpers with inline object literals. Factories live next to the type they produce (e.g. `src/<area>/testFactories.ts`). Tests call `.build({ overrides })` to get fixture data.

**Good:**

```typescript
import { Factory } from 'fishery';
import type { RepoMeta } from '#src/types.ts';

export const repoMeta = Factory.define<RepoMeta>(() => ({
owner: 'acme',
name: 'widgets',
visibility: 'private',
archived: false,
defaultBranch: 'main',
primaryLanguage: 'TypeScript',
pushedAt: '2026-01-01T00:00:00Z',
dependabotSecurityUpdates: true,
}));

// In test:
const repo = repoMeta.build({ archived: true });
```

- **Prefer `toMatchObject` for structured payload assertions.** When a test verifies several fields on the same object or nested payload, use one `expect(value).toMatchObject({ ... })` instead of a run of field-by-field assertions. Keep separate assertions for orthogonal behavior, clearer failure messages, or values that need a specialized matcher.

- **Use a shared deferred-promise helper; do not hand-roll it.** Tests that need manual promise resolution should import a single `createDeferred` helper. Do not recreate local `Deferred` types or `new Promise` wrappers in individual test files.

- **Assert on `Result` shapes, not on thrown errors.** Use `.isOk()`, `.isErr()`, `.unwrapOr(default)`, and value/error inspection — not try/catch. A test that catches a thrown error is a sign the production code should be returning a `Result` (see `error-handling-neverthrow.md`).
52 changes: 52 additions & 0 deletions .github/actions/bootstrap/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Bootstrap
description: Install Bun via asdf and project dependencies

runs:
using: 'composite'
steps:
- name: Setup asdf
uses: asdf-vm/actions/setup@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1
with:
asdf_version: '0.18.0'

- name: Restore asdf cache
id: asdf-cache-restore
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
key: asdf-tools-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.tool-versions') }}
path: |
~/.asdf/installs
~/.asdf/plugins
~/.asdf/shims
restore-keys: asdf-tools-v1-${{ runner.os }}-${{ runner.arch }}-

- name: Install asdf plugins
if: ${{ steps.asdf-cache-restore.outputs.cache-hit != 'true' }}
uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1
with:
asdf_version: '0.18.0'

- name: Reshim asdf
shell: bash
run: asdf reshim

- name: Save asdf cache
if: always() && steps.asdf-cache-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
key: ${{ steps.asdf-cache-restore.outputs.cache-primary-key }}
path: |
~/.asdf/installs
~/.asdf/plugins
~/.asdf/shims

- name: Restore Bun install cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
key: bun-install-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('bun.lock') }}
path: ~/.bun/install/cache
restore-keys: bun-install-${{ runner.os }}-${{ runner.arch }}-

- name: Install dependencies
shell: bash
run: bun install --frozen-lockfile
16 changes: 16 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Main Branch

on:
push:
branches: [main]

concurrency:
group: main
cancel-in-progress: false

jobs:
test:
name: Tests
uses: ./.github/workflows/test.yml
permissions:
contents: read
15 changes: 15 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: PR

on:
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
test:
name: Tests
uses: ./.github/workflows/test.yml
permissions:
contents: read
54 changes: 54 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Test

on:
workflow_call:

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/bootstrap
- name: Check formatting
run: bun run format:check
- name: Restore ESLint cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .eslintcache
key: eslint-${{ runner.os }}-${{ github.sha }}
restore-keys: eslint-${{ runner.os }}-
- name: Run ESLint
run: bun run lint
- name: Typecheck
run: bun run typecheck

test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/bootstrap
- name: Get Playwright version
id: playwright-version
run: echo "version=$(jq -r '.devDependencies.playwright' package.json)" >> "$GITHUB_OUTPUT"
- name: Get Ubuntu version
id: ubuntu-version
run: echo "version=$(lsb_release -rs)" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: /home/runner/.cache/ms-playwright
key: ${{ runner.os }}-${{ steps.ubuntu-version.outputs.version }}-playwright-browsers-${{ steps.playwright-version.outputs.version }}
restore-keys: |
${{ runner.os }}-${{ steps.ubuntu-version.outputs.version }}-playwright-browsers-
- name: Install Playwright browsers
run: bunx playwright install --with-deps chromium
- name: Run tests
run: bun run test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ out
dist
*.tgz

# dev-only fixture for the report web app
src/report/web/data/fixture.json

# code coverage
coverage
*.lcov
Expand Down
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bunx lint-staged
9 changes: 9 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
bun run lint
bun run typecheck

# AI agent only — set IS_AI_AGENT=1 to enable
if [ "$IS_AI_AGENT" = "1" ]; then
bun test
else
echo "tests skipped (set IS_AI_AGENT=1 to run on pre-push)"
fi
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
dist/
node_modules/
coverage/
claude-tmp/
bun.lock
.husky/_/
Loading
Loading