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
20 changes: 12 additions & 8 deletions .claude/docs/GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,22 @@ Math is correct; `floor()` is the Stage 2 solution.

## High — Build/Publish

### 6. Test `.d.ts` files leak into `dist/` *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))*
### ~~6. Test `.d.ts` files leak into `dist/`~~ *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))* ✓ Resolved

`tsconfig.json` includes `src/**/*.ts` which captures `*.test.ts` files. The
`build:types` script emits 9 test declaration files + 9 `.d.ts.map` files into
`dist/`. These ship to npm consumers.

### 7. `files` in `package.json` includes `src` *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))*
### ~~7. `files` in `package.json` includes `src`~~ *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))* ✓ Resolved

All source files including 9 test files, the mock RNG, and CLI internals ship
to npm. Unnecessary package bloat.

### 8. ESM build uses `--target bun` *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))*
> Resolution note: tests are excluded (`!src/**/*.test.ts`), and shipping the
> rest of `src/` is now a deliberate decision — `declarationMap`/`sourceMap`
> in `dist/` point at it (see CONTRIBUTING.md).

### ~~8. ESM build uses `--target bun`~~ *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))* ✓ Resolved

`build:esm` uses `bun build ... --target bun` which may emit Bun-specific APIs.
The ESM entry is exposed via the `"import"` export condition, which Node.js
Expand Down Expand Up @@ -170,11 +174,11 @@ Test job now runs in parallel with build (`needs: check` instead of
A dedicated `node-smoke` job now verifies CJS and ESM import compatibility
under Node.js LTS. Bun is pinned to `1.3.10` for reproducibility.

### 28. Missing `sideEffects: false` in `package.json` *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))*
### ~~28. Missing `sideEffects: false` in `package.json`~~ *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))* ✓ Resolved

Tree-shaking-friendly libraries should declare this for bundler optimization.

### 29. `biome.json` has dead `bin/**/*.ts` include path *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))*
### ~~29. `biome.json` has dead `bin/**/*.ts` include path~~ *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))* ✓ Resolved

The `bin/` directory contains no TypeScript files. The glob matches nothing.

Expand All @@ -189,13 +193,13 @@ sets `lineWidth: 100`.

Phase markers updated in commit 27d9cb6.

### 32. `tsconfig.json` has confusing `noEmit: true` with `declaration: true` *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))*
### ~~32. `tsconfig.json` has confusing `noEmit: true` with `declaration: true`~~ *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))* ✓ Resolved

The base config sets both, but `noEmit` prevents declarations from being
emitted. Only works because `build:types` overrides with `--noEmit false` on
the CLI. A separate `tsconfig.build.json` would be cleaner.

### 33. No `outDir` in base tsconfig *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))*
### ~~33. No `outDir` in base tsconfig~~ *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))* ✓ Resolved

If someone runs `tsc` without `--noEmit`, declarations would be emitted into
the source tree.
Expand All @@ -204,7 +208,7 @@ the source tree.

Issue #1 closed.

### 35. `bin` field uses shorthand form *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))*
### ~~35. `bin` field uses shorthand form~~ *(→ [#22](https://github.com/edloidas/roll-parser/issues/22))* ✓ Resolved

`"bin": "./dist/cli.js"` infers the command name from package name.
More explicit: `"bin": { "roll-parser": "./dist/cli.js" }`.
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/rng.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Crit threshold resolves to `>1`; all four dice qualify as critical successes. To
```typescript
import { describe, it, expect } from 'bun:test';
// Internal tests use relative imports:
import { createMockRng } from '@/rng/mock';
import { createMockRng } from '../rng/mock.js';
// npm consumers use: import { createMockRng } from 'roll-parser/testing';

describe('roll', () => {
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Use `createMockRng` for all deterministic dice tests. See `rng.md`.

```typescript
// Internal tests use relative imports:
import { createMockRng } from '../rng/mock';
import { createMockRng } from '../rng/mock.js';
// npm consumers use: import { createMockRng } from 'roll-parser/testing';

test('keeps highest die from pool', () => {
Expand Down
9 changes: 5 additions & 4 deletions .claude/rules/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,10 @@ function updateEntity<T extends { id: string }>(entity: T, updates: Partial<T>):
export { RollParser, DiceRoller };

// Use `import type` for type-only imports (required by verbatimModuleSyntax)
import type { RollResult, DiceConfig } from '../types';
import type { RollResult, DiceConfig } from '../types.js';

// Use path aliases for imports outside the current directory
import { createMockRng } from '@/rng/mock';
import { utils } from './utils'; // Same directory: relative paths are fine
// Relative imports only, always with the explicit `.js` extension —
// `moduleResolution: nodenext` rejects extensionless specifiers
import { createMockRng } from '../rng/mock.js';
import { utils } from './utils.js';
```
163 changes: 131 additions & 32 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,10 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

# ! Not a pure test step: `src/cli/package-smoke.test.ts` runs `build:cli`
# ! in `beforeAll`, so this job compiles the CLI itself. That is why it
# ! needs only `check` — it deliberately does not consume the `build`
# ! job's artifact, keeping the two paths independent.
# ! Not a pure test step: `src/cli/package-smoke.test.ts` runs the tsc
# ! emit in `beforeAll`, so this job compiles the package itself. That is
# ! why it needs only `check` — it deliberately does not consume the
# ! `build` job's artifact, keeping the two paths independent.
- name: Run tests with coverage gate
run: bun test:ci

Expand Down Expand Up @@ -203,56 +203,118 @@ jobs:
path: .tmp/bench-results.json
retention-days: 7

# Needs only the dist artifact and Node — the built CLI has zero runtime
# dependencies, so there is no Bun setup or install here on purpose.
cli:
name: CLI Smoke Test
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version

- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/

- name: Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
# Run under Node, matching the bin shebang — the artifact download drops
# file modes, so the entry is invoked explicitly rather than executed.
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: |
bun-${{ runner.os }}-

- name: Install dependencies
run: bun install --frozen-lockfile
node-version: lts/*

- name: Test CLI help
run: bun run dist/cli.js --help
run: node dist/cli/index.js --help

- name: Test CLI version
run: bun run dist/cli.js --version
run: node dist/cli/index.js --version

- name: Test CLI roll
run: bun run dist/cli.js 2d6
run: node dist/cli/index.js 2d6

- name: Test CLI verbose
run: bun run dist/cli.js 4d6kh3 --verbose --seed test
run: node dist/cli/index.js 4d6kh3 --verbose --seed test

# Tests the packed tarball, not the working tree: `files` + `exports` + bin
# wiring + real Node resolution, across the supported Node range.
node-smoke:
name: Node.js Smoke Test
name: Node.js Smoke Test (${{ matrix.node-version }})
runs-on: ubuntu-latest
needs: build
strategy:
fail-fast: false
matrix:
# Exact `engines` floor first — the require(esm) claim must hold there.
# 24.x is listed explicitly so it keeps coverage after `lts/*` moves on.
node-version: ['22.12.0', '22.x', '24.x', 'lts/*']
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node-version }}

- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/

- name: Pack tarball
run: npm pack --pack-destination "$RUNNER_TEMP"

- name: Install tarball in scratch project
run: |
mkdir "$RUNNER_TEMP/smoke"
cd "$RUNNER_TEMP/smoke"
npm init -y > /dev/null
npm install --no-audit --no-fund "$RUNNER_TEMP"/roll-parser-*.tgz

- name: Test ESM import and testing subpath
working-directory: ${{ runner.temp }}/smoke
run: |
node --input-type=module -e "import { roll, VERSION } from 'roll-parser'; import { createMockRng, MockRNGExhaustedError } from 'roll-parser/testing'; const r = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) }); if (r.total !== 14) throw new Error('pinned total ' + r.total); if (typeof MockRNGExhaustedError !== 'function') throw new Error('missing error export'); console.log('ESM OK:', VERSION, r.total)"

- name: Test require(esm) import
# ESM-only package — require() works on Node >=22.12 via require(esm).
working-directory: ${{ runner.temp }}/smoke
run: |
node -e "const { roll } = require('roll-parser'); const { createMockRng } = require('roll-parser/testing'); const r = roll('2d6', { rng: createMockRng([3, 4]) }); if (r.total !== 7) throw new Error('pinned total ' + r.total); console.log('require(esm) OK:', r.total)"

- name: Test installed bin
working-directory: ${{ runner.temp }}/smoke
run: ./node_modules/.bin/roll-parser 4d6kh3 --verbose --seed test

# The neutrality gate Bun's bundler cannot provide: esbuild with
# `--platform=browser` hard-errors on any Node builtin import (Bun's
# `--target browser` silently stubs them instead), the minified bundle is
# grepped for runtime globals, and the result is executed.
browser-smoke:
name: Browser Smoke Test
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version

- name: Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: |
bun-${{ runner.os }}-

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

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand All @@ -267,9 +329,46 @@ jobs:
name: dist
path: dist/

- name: Test require(esm) import
# ESM-only package — require() works on Node >=22 via require(esm).
run: node -e "const { roll } = require('roll-parser'); const r = roll('2d6'); if (typeof r.total !== 'number') process.exit(1); console.log('require(esm) OK:', r.total)"

- name: Test ESM import
run: node --input-type=module -e "import { roll } from 'roll-parser'; const r = roll('2d6'); if (typeof r.total !== 'number') process.exit(1); console.log('ESM OK:', r.total)"
- name: Assert dist library files carry no host-runtime specifiers
# The quote-prefixed pattern also catches side-effect and dynamic
# imports of builtins, which a `from`-anchored grep would miss.
run: |
test -d dist
if grep -rEl "'(node|bun):|require\(" dist --include='*.js' | grep -v '^dist/cli/'; then
echo 'Found Node/Bun specifiers in library dist files (listed above).'
exit 1
fi

- name: Install tarball in scratch project
run: |
npm pack --pack-destination "$RUNNER_TEMP"
mkdir "$RUNNER_TEMP/browser-smoke"
cd "$RUNNER_TEMP/browser-smoke"
npm init -y > /dev/null
npm install --no-audit --no-fund "$RUNNER_TEMP"/roll-parser-*.tgz

- name: Write browser fixture importing both entry points
working-directory: ${{ runner.temp }}/browser-smoke
run: |
printf '%s\n' \
"import { roll, SeededRNG, VERSION } from 'roll-parser';" \
"import { createMockRng } from 'roll-parser/testing';" \
"const pinned = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });" \
"if (pinned.total !== 14) throw new Error('pinned total ' + pinned.total);" \
"const seeded = roll('2d6', { rng: new SeededRNG('ci') });" \
"console.log('browser-smoke OK', VERSION, pinned.total, seeded.total);" \
> fixture.mjs

- name: Bundle fixture for the browser
run: ./node_modules/.bin/esbuild "$RUNNER_TEMP/browser-smoke/fixture.mjs" --bundle --platform=browser --format=esm --minify --outfile="$RUNNER_TEMP/browser-smoke/bundle.js"

- name: Assert bundle is free of Node globals
run: |
test -f "$RUNNER_TEMP/browser-smoke/bundle.js"
if grep -qE "node:|process\.|createRequire" "$RUNNER_TEMP/browser-smoke/bundle.js"; then
echo 'Browser bundle references Node runtime internals.'
exit 1
fi

- name: Execute browser bundle
run: node "$RUNNER_TEMP/browser-smoke/bundle.js"
22 changes: 15 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,40 @@ Dice roll notation parser. TypeScript library and CLI. Built with Bun.
## Commands

```bash
bun check:fix # Typecheck + biome check --write (lint + format + import sort) — use during iteration
bun check:fix # Typecheck + biome check --write (lint + format + import sort) — use during
# iteration; the typecheck step rebuilds dist/ as a side effect
bun test # Run tests
bun validate # Full pre-release gate: check + build + check:package + test:ci
bun validate # Full pre-release gate: check + build + check:package + check:size + test:ci
```

Demo site (`site/`, deployed to Cloudflare Pages by `.github/workflows/deploy-site.yml`):

```bash
bun site:dev # Bun HTML dev server with HMR — no TypeDoc, so /docs/ is absent
bun site:dev # Bun HTML dev server — no TypeDoc, so /docs/ is absent
bun site:build # Full static build into site/dist/, including the TypeDoc reference
bun site:check # Verify site/dist/ — asset references resolve, no dev paths leaked
bun site:preview # site:build, then serve site/dist/ locally with host-style routing
```

The site consumes the built package (`site/src` imports `'roll-parser'`, which
resolves to `dist/` via the self-reference) — site HTML/CSS/TS edits hot-reload
under `site:dev`, but library `src/` edits need a `bun run build` to show up.

A pre-commit hook auto-fixes staged `.ts`/`.tsx` files (nano-staged runs
`biome check --write` on them, then re-stages). It installs automatically on
`bun install` — the `prepare` script points `core.hooksPath` at `.githooks/`.
Commits with unfixable lint errors are blocked.

## Constraints

- Runtime: Bun — never use npm, yarn, or pnpm
- Runtime: Bun — never use npm, yarn, or pnpm (the smoke CI jobs, which test the packed tarball the way consumers install it, and the release publish step are the only places npm runs)
- Target: ES2022, TypeScript only
- Library + CLI, ESM-only compiled JS (Node ≥22.12 consumes via `import` or `require(esm)` — 22.12 is where `require(esm)` is unflagged)
- Relative imports in `src/` carry explicit `.js` extensions — required for nodenext-safe `.d.ts` output (verified by `bun run check:package`)
- Do not pass `--sourcemap` with `--outfile` to `bun build` — Bun 1.3.x silently writes nothing; use `--outdir` (+ `--entry-naming` for the CLI)
- `dist/` is a per-file `tsc` emit (`tsconfig.build.json`), not a bundle — JS, `.d.ts`, and both map kinds come from one compiler pass; `bun build` is not used for the package. Do not reintroduce a bundler: Bun ≤1.3.11 emits broken output for pure re-export entrypoints (e.g. `src/testing.ts`) and `--target browser` silently stubs `node:` builtins instead of erroring
- Relative imports in `src/` carry explicit `.js` extensions — enforced by `moduleResolution: nodenext` at typecheck time
- Library code must stay environment-neutral: no Node/Bun globals or `node:` imports outside `src/cli/` — enforced by Biome `noNodejsModules`, `types: []` in `tsconfig.build.json`, and the `browser-smoke` CI job
- `src/version.ts` is generated from `package.json` by `bun run generate:version` — never edit it by hand; `check:version` and the `index.test.ts` drift test gate the sync
- In `scripts/build-site.ts`, do not pass `sourcemap` with a single `outfile` to `Bun.build` — Bun 1.3.x silently writes nothing; use `outdir`
- TypeDoc lives in the `scripts/docs` workspace, not the root, and is invoked from
`scripts/docs/node_modules/.bin/typedoc`. TypeDoc 0.28.x peers at TypeScript
`<=6.0.x` and throws on the root `typescript@7`, so that workspace nests a
Expand Down Expand Up @@ -94,5 +102,5 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `bui

Use the `/npm-release` skill. Project-specific conventions the skill must honor:

- **Dedicated release commit**: bump `package.json`, commit as `chore: release v<version>`, then tag that commit. Never tag a pre-existing unrelated commit — if the version already matches the target, stop and ask before proceeding.
- **Dedicated release commit**: bump `package.json`, run `bun run generate:version` and include the regenerated `src/version.ts` in the same commit, commit as `chore: release v<version>`, then tag that commit. Never tag a pre-existing unrelated commit — if the version already matches the target, stop and ask before proceeding. (`check:version` fails the release if `src/version.ts` is stale.)
- **CHANGELOG gate**: update `CHANGELOG.md` via the local `release-changelog` skill before bumping — `bun run release:dry` fails at `check:changelog` without it.
Loading