diff --git a/.claude/docs/GAPS.md b/.claude/docs/GAPS.md index 1f6c708..6498dc5 100644 --- a/.claude/docs/GAPS.md +++ b/.claude/docs/GAPS.md @@ -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 @@ -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. @@ -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. @@ -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" }`. diff --git a/.claude/rules/rng.md b/.claude/rules/rng.md index eae7e04..fed5ef0 100644 --- a/.claude/rules/rng.md +++ b/.claude/rules/rng.md @@ -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', () => { diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 50a42c9..6f4d597 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -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', () => { diff --git a/.claude/rules/typescript.md b/.claude/rules/typescript.md index d914008..1fab291 100644 --- a/.claude/rules/typescript.md +++ b/.claude/rules/typescript.md @@ -124,9 +124,10 @@ function updateEntity(entity: T, updates: Partial): 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'; ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd61577..25c28a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: @@ -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" diff --git a/CLAUDE.md b/CLAUDE.md index dbe169e..81a70d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,20 +5,25 @@ 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/`. @@ -26,11 +31,14 @@ 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 @@ -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`, 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`, 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 915e250..4010472 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,10 +7,11 @@ for anything larger than a fix. ## Toolchain **[Bun](https://bun.sh) only.** Do not use npm, yarn, or pnpm in this -repository. Bun is the test runner (`bun test`), the bundler (`bun build` -produces `dist/`), the script runner, and the package manager whose lockfile -(`bun.lock`) is committed. Installing with another manager writes a competing -lockfile and produces a different dependency tree than CI resolves. +repository. Bun is the test runner (`bun test`), the script runner, and the +package manager whose lockfile (`bun.lock`) is committed — `dist/` itself is +a per-file `tsc` emit via `bun run build`, not a bundle. Installing with +another manager writes a competing lockfile and produces a different +dependency tree than CI resolves. ```bash bun install # also installs the pre-commit hook @@ -39,9 +40,9 @@ types), `comments.md` (the `// !`, `// ?`, `// *`, `// TODO:` prefixes), `testing.md`, and `rng.md` (the `RNG` contract and MockRNG draw order). A few constraints worth calling out: -- Relative imports inside `src/` carry explicit `.js` extensions. Without them - the emitted `.d.ts` is not resolvable under `moduleResolution: nodenext`, - which `bun run check:package` catches. +- Relative imports inside `src/` carry explicit `.js` extensions — the repo + typechecks under `moduleResolution: nodenext`, which rejects extensionless + specifiers outright. - Nothing in the library may call `Math.random()` directly, and nothing outside `src/cli/` may import from `node:` — the library has to stay browser-safe. - Every code sample in a JSDoc `@example` or in `README.md` must be executed @@ -70,7 +71,8 @@ force-push rather than merging a chain of fixups. The demo site in `site/` is deployed to Cloudflare Pages. ```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). The site + # consumes built dist/ — rerun `bun run build` to see library edits bun site:build # full static build into site/dist/, including the TypeDoc reference bun site:check # verify site/dist/ — assets resolve, no dev paths leaked bun site:preview # build, then serve site/dist/ with host-style routing @@ -101,8 +103,10 @@ Maintainers only. fails without a section for it, and `release:dry` fails at that step. Sections are ordered Added, Changed, Deprecated, Removed, Fixed, Security, Documentation. -2. Bump `package.json` and commit it on its own as - `chore: release v`. Never tag a pre-existing unrelated commit. +2. Bump `package.json`, run `bun run generate:version`, and commit both files + on their own as `chore: release v` — `check:version` fails the + release if `src/version.ts` is stale. Never tag a pre-existing unrelated + commit. 3. Tag that commit `v` and push the tag; `.github/workflows/release.yml` takes it from there. diff --git a/README.md b/README.md index 88ce4f8..225094c 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,27 @@ yarn add roll-parser@next unflagged, so CommonJS consumers can still `require('roll-parser')` even though the published files are ESM. +For TypeScript consumers, `moduleResolution` must be `bundler`, `node16`, or +`nodenext` — the package resolves through `exports` only, so the legacy `node10` +resolution does not find it. + +### CDN / browser without a bundler + +The published files are environment-neutral ES modules, so they load directly +in the browser. Use a bundling CDN endpoint — it serves the whole module graph +as one request: + +```html + +``` + +`https://esm.sh/roll-parser@next` works the same way. Raw file URLs +(`unpkg.com/roll-parser@next`) also work but fetch each module separately. + ## Quick start ```typescript diff --git a/biome.json b/biome.json index 76614ab..3f8f53d 100644 --- a/biome.json +++ b/biome.json @@ -43,5 +43,27 @@ }, "files": { "includes": ["src/**/*.ts", "site/src/**/*.ts", "scripts/**/*.ts", "bench/**/*.ts"] - } + }, + "overrides": [ + { + "includes": ["src/**"], + "linter": { + "rules": { + "correctness": { + "noNodejsModules": "error" + } + } + } + }, + { + "includes": ["src/cli/**"], + "linter": { + "rules": { + "correctness": { + "noNodejsModules": "off" + } + } + } + } + ] } diff --git a/bun.lock b/bun.lock index b7a871c..c6e5a49 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@biomejs/biome": "^2.5.5", "@size-limit/preset-small-lib": "^12.1.0", "@types/bun": "^1.3.14", + "esbuild": "^0.28.1", "fast-check": "^4.9.0", "mitata": "^1.0.34", "nano-staged": "^1.0.2", diff --git a/package.json b/package.json index 2506bbd..6465655 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,8 @@ "version": "3.0.0-beta.0", "description": "High-performance dice notation parser for tabletop RPGs. TypeScript-first, Bun-optimized.", "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "bin": { - "roll-parser": "./dist/cli.js" + "roll-parser": "./dist/cli/index.js" }, "sideEffects": false, "exports": { @@ -20,6 +18,8 @@ }, "./package.json": "./package.json" }, + "unpkg": "./dist/index.js", + "jsdelivr": "./dist/index.js", "files": [ "dist", "src", @@ -29,12 +29,19 @@ "keywords": [ "parser", "dice", + "dice-notation", + "dice-roller", "roll", "rpg", + "ttrpg", "dnd", "d20", "pratt-parser", "typescript", + "esm", + "browser", + "zero-dependency", + "cli", "bun" ], "homepage": "https://github.com/edloidas/roll-parser#readme", @@ -53,17 +60,14 @@ "workspaces": ["scripts/docs"], "scripts": { "prepare": "git config core.hooksPath .githooks", - "typecheck": "tsc --noEmit && tsc --noEmit -p site/tsconfig.json && tsc --noEmit -p scripts/tsconfig.json && tsc --noEmit -p bench/tsconfig.json", + "typecheck": "tsc --noEmit && bun run build && tsc --noEmit -p site/tsconfig.json && tsc --noEmit -p scripts/tsconfig.json && tsc --noEmit -p bench/tsconfig.json", "check": "bun typecheck && biome check .", "check:fix": "bun typecheck && biome check --write .", "check:version": "bun scripts/check-version.ts", "check:changelog": "bun scripts/check-changelog.ts", - "clean": "rm -rf dist coverage", - "build": "bun run clean && bun run build:lib && bun run build:testing && bun run build:cli && bun run build:types", - "build:lib": "bun build src/index.ts --outdir dist --target node --sourcemap=linked", - "build:testing": "bun build src/testing.ts --outdir dist --target node --sourcemap=linked", - "build:cli": "bun build src/cli/index.ts --outdir dist --entry-naming 'cli.[ext]' --target node --sourcemap=linked", - "build:types": "tsc --emitDeclarationOnly -p tsconfig.build.json", + "generate:version": "bun scripts/generate-version.ts", + "clean": "rm -rf dist", + "build": "bun run clean && tsc -p tsconfig.build.json && chmod +x dist/cli/index.js", "check:package": "attw --pack . --profile esm-only && publint", "check:size": "size-limit", "bench": "bun run bench/index.bench.ts", @@ -72,9 +76,9 @@ "bench:evaluate": "bun run bench/evaluate.bench.ts", "bench:roll": "bun run bench/roll.bench.ts", "bench:json": "bun run scripts/bench-json.ts", - "site:build": "bun scripts/build-site.ts", + "site:build": "bun run build && bun scripts/build-site.ts", "site:check": "bun scripts/check-site.ts", - "site:dev": "bun ./site/index.html ./site/reference.html", + "site:dev": "bun run build && bun ./site/index.html ./site/reference.html", "site:preview": "bun scripts/serve-site.ts", "test": "bun test", "test:watch": "bun test --watch", @@ -116,6 +120,7 @@ "@biomejs/biome": "^2.5.5", "@size-limit/preset-small-lib": "^12.1.0", "@types/bun": "^1.3.14", + "esbuild": "^0.28.1", "fast-check": "^4.9.0", "mitata": "^1.0.34", "nano-staged": "^1.0.2", diff --git a/scripts/check-version.ts b/scripts/check-version.ts index 545f043..0aaa2d0 100644 --- a/scripts/check-version.ts +++ b/scripts/check-version.ts @@ -3,8 +3,22 @@ import pkg from '../package.json' with { type: 'json' }; const input = process.argv[2]; const pkgVersion = pkg.version; +// The generated src/version.ts must always match package.json — it is what +// the published library actually exports as VERSION. +const versionFile = Bun.file(new URL('../src/version.ts', import.meta.url)); +const versionSource = (await versionFile.exists()) ? await versionFile.text() : ''; +const srcVersion = versionSource.match(/^export const version = '(.+)';\r?$/m)?.[1]; + +if (srcVersion !== pkgVersion) { + console.error( + `src/version.ts (${srcVersion ?? 'unparseable'}) does not match package.json version (${pkgVersion}).\n` + + 'Run "bun run generate:version" and commit the result.', + ); + process.exit(1); +} + if (input == null) { - console.log(`package.json version: ${pkgVersion}`); + console.log(`package.json version: ${pkgVersion} (src/version.ts in sync)`); process.exit(0); } diff --git a/scripts/generate-version.ts b/scripts/generate-version.ts new file mode 100644 index 0000000..8dd228e --- /dev/null +++ b/scripts/generate-version.ts @@ -0,0 +1,30 @@ +/** + * Regenerates `src/version.ts` from the `package.json` version. + * + * The library cannot import `package.json` directly: per-file tsc emit ships + * the import verbatim, where Node ESM requires a `type: 'json'` attribute and + * browsers cannot load JSON modules at all. A generated, committed constant is + * environment-neutral. Sync is enforced by `scripts/check-version.ts` (release + * gate) and the `VERSION matches the package manifest` test in + * `src/index.test.ts` (CI gate). + * + * Run after every version bump: `bun run generate:version`. + */ + +import pkg from '../package.json' with { type: 'json' }; + +const TARGET = new URL('../src/version.ts', import.meta.url); + +const content = `// Generated by \`bun run generate:version\` from package.json — do not edit. +export const version = '${pkg.version}'; +`; + +const target = Bun.file(TARGET); +const previous = (await target.exists()) ? await target.text() : undefined; + +if (previous === content) { + console.log(`src/version.ts already at ${pkg.version}`); +} else { + await Bun.write(TARGET, content); + console.log(`src/version.ts → ${pkg.version}`); +} diff --git a/site/src/dice.ts b/site/src/dice.ts index 60087f4..0b2151e 100644 --- a/site/src/dice.ts +++ b/site/src/dice.ts @@ -5,7 +5,7 @@ * @module dice */ -import type { DieResult } from '../../src/index.js'; +import type { DieResult } from 'roll-parser'; /** Maximum dice drawn in the tray before collapsing into a `+N` overflow chip. */ export const MAX_TRAY_DICE = 6; diff --git a/site/src/main.ts b/site/src/main.ts index 9b69da3..56dd286 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -5,7 +5,7 @@ * @module main */ -import { isRollParserError, roll, VERSION } from '../../src/index.js'; +import { isRollParserError, roll, VERSION } from 'roll-parser'; import { initTrayToggle, renderLegend, renderTray } from './dice.js'; import { renderErrorSlot, renderResultPanel } from './render.js'; import { initTheme } from './theme.js'; diff --git a/site/src/reference.ts b/site/src/reference.ts index 4da7309..dfb3337 100644 --- a/site/src/reference.ts +++ b/site/src/reference.ts @@ -7,7 +7,7 @@ * @module reference */ -import { isRollParserError, roll, VERSION } from '../../src/index.js'; +import { isRollParserError, roll, VERSION } from 'roll-parser'; import { initTrayToggle, renderTray } from './dice.js'; import { renderErrorSlot, renderResultPanel } from './render.js'; import { initTheme } from './theme.js'; diff --git a/site/src/render.ts b/site/src/render.ts index 4c750ff..e99c2ad 100644 --- a/site/src/render.ts +++ b/site/src/render.ts @@ -12,7 +12,7 @@ import { type RollParserError, type RollPart, type RollResult, -} from '../../src/index.js'; +} from 'roll-parser'; import { dieStates, dieTitle } from './dice.js'; /** Above this die count the breakdown is skipped — itemizing is pointless noise. */ diff --git a/site/tsconfig.json b/site/tsconfig.json index 9e15359..2cac9f8 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -2,7 +2,10 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "../tsconfig.json", "compilerOptions": { - "lib": ["ES2022", "DOM", "DOM.Iterable"] + "lib": ["ES2022", "DOM", "DOM.Iterable"], + // Browser-only sources: keep Bun ambient globals out of scope so a stray + // `Bun.*`/`process.*` fails to typecheck instead of crashing in the browser. + "types": [] }, // Only the browser sources — the site build scripts are plain Bun programs and // belong to `scripts/tsconfig.json`, which checks all of `scripts/` at once. diff --git a/src/cli/index.ts b/src/cli/index.ts index a7260aa..9a09227 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,17 @@ import { main } from './main.js'; +// Minimal host-process surface this entry needs. The library build compiles +// with no Node/Bun type packages (`types: []` in tsconfig.build.json) so that +// runtime globals in library code are type errors; this module-scoped declare +// keeps the one legitimate use local without leaking `process` program-wide. +declare const process: { + argv: string[]; + exitCode: number | undefined; + stdout: { write(text: string): void }; + stderr: { write(text: string): void }; +}; + const exitCode = main({ argv: process.argv.slice(2), stdout: (text) => { diff --git a/src/cli/package-smoke.test.ts b/src/cli/package-smoke.test.ts index 986ac82..7af288a 100644 --- a/src/cli/package-smoke.test.ts +++ b/src/cli/package-smoke.test.ts @@ -1,12 +1,13 @@ import { beforeAll, describe, expect, test } from 'bun:test'; -import { existsSync, statSync } from 'node:fs'; +import { chmodSync, existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; type PackageJson = { bin?: Record; + scripts?: Record; }; -const CLI_BIN = './dist/cli.js'; +const CLI_BIN = './dist/cli/index.js'; async function runCommand( command: string[], @@ -23,11 +24,14 @@ async function runCommand( return { stdout, stderr, exitCode }; } +// ? Mirrors `bun run build` minus `clean` — no need to delete and re-emit the +// whole of `dist/` mid-test-run, and stale dist files are harmless here. beforeAll(async () => { - const { stderr, exitCode } = await runCommand(['bun', 'run', 'build:cli']); + const { stderr, exitCode } = await runCommand(['bunx', 'tsc', '-p', 'tsconfig.build.json']); if (exitCode !== 0) { throw new Error(`Failed to build packaged CLI smoke target:\n${stderr}`); } + chmodSync(CLI_BIN, 0o755); }); describe('packaged CLI smoke', () => { @@ -35,6 +39,11 @@ describe('packaged CLI smoke', () => { const pkg = (await Bun.file('package.json').json()) as PackageJson; expect(pkg.bin?.['roll-parser']).toBe(CLI_BIN); expect(existsSync(join('.', CLI_BIN))).toBe(true); + + // ? tsc does not set the executable bit, so the `build` script must — the + // `beforeAll` chmod above makes the mode assertion below self-fulfilling, + // leaving this contract check as the real regression guard. + expect(pkg.scripts?.build).toContain('chmod +x dist/cli/index.js'); }); test('built CLI keeps node shebang and executable bit', async () => { @@ -44,6 +53,9 @@ describe('packaged CLI smoke', () => { expect(text.startsWith('#!/usr/bin/env node\n')).toBe(true); expect((stats.mode & 0o111) !== 0).toBe(true); + + const direct = await runCommand([CLI_BIN, '--version']); + expect(direct.exitCode).toBe(0); }); test('built CLI runs under Node for help and seeded rolls', async () => { diff --git a/src/index.test.ts b/src/index.test.ts index 4d21c25..e6a29b3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -9,7 +9,7 @@ */ import { describe, expect, test } from 'bun:test'; -import { version } from '../package.json'; +import pkg from '../package.json' with { type: 'json' }; import * as api from './index.js'; /** Runtime values the package promises to export. */ @@ -66,8 +66,10 @@ describe('public API surface', () => { expect(Object.keys(api).sort()).toEqual([...VALUE_EXPORTS].sort()); }); + // ? Drift guard for the generated `src/version.ts` — a package.json bump + // without `bun run generate:version` must fail here, in CI, not at release. test('VERSION matches the package manifest', () => { - expect(api.VERSION).toBe(version); + expect(api.VERSION).toBe(pkg.version); expect(api.VERSION).toMatch(/^\d+\.\d+\.\d+/); }); diff --git a/src/index.ts b/src/index.ts index 68d1764..5c60231 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,7 @@ * @module roll-parser */ -// ? Named import — Bun's bundler tree-shakes the JSON module down to the -// single used property, so the full manifest is not embedded in dist. -import { version } from '../package.json'; +import { version } from './version.js'; export type { ErrorSpan, RollParserErrorCode } from './errors.js'; export { getErrorSpan, isRollParserError, RollParserError } from './errors.js'; @@ -81,9 +79,10 @@ export type { export { DegreeOfSuccess } from './types.js'; /** - * Installed roll-parser version, read from the package manifest at build time - * — the exact string in `package.json`, so pre-releases keep their suffix - * (`'3.0.0-beta.0'`). + * Installed roll-parser version — the exact string in `package.json`, so + * pre-releases keep their suffix (`'3.0.0-beta.0'`). Sourced from the + * generated `src/version.ts`, kept in sync with the manifest by + * `bun run generate:version` and gated by `check:version` and CI. * * Useful in bug reports and for feature-gating against a minimum version. The * CLI prints it for `--version` and in the `--help` header. diff --git a/src/rng/mock.ts b/src/rng/mock.ts index 5ba4168..b0da75c 100644 --- a/src/rng/mock.ts +++ b/src/rng/mock.ts @@ -58,6 +58,11 @@ export class MockRNGExhaustedError extends Error { * - `nextInt` rejects a scripted value outside the requested `[min, max]` * with a `RangeError`, so `createMockRng([7])` cannot satisfy a `d6`. * + * Draw order matters when the notation contains meta-expressions. Keep/drop + * counts (`4d6kh(1d2)`) are drawn *before* the pool; threshold expressions + * (`4d6cs>(1d2)`) are drawn *after* it. The README's Randomness section has + * the full tables. + * * @param values - Values to return, in draw order (die faces for `nextInt`, * floats in `[0, 1)` for `next`) * @returns An `RNG` that replays `values` diff --git a/src/testing.ts b/src/testing.ts index 6613be4..6ab2910 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -1,106 +1,12 @@ /** * Test utilities for roll-parser consumers. * - * Import from `roll-parser/testing` for deterministic dice testing. + * Import from `roll-parser/testing` for deterministic dice testing. The full + * TSDoc lives on the implementations in `./rng/mock.ts`; this entry point is + * a plain re-export barrel. * * @module testing */ -// ! Bun 1.3.11's bundler emits a bare `export { createMockRng, -// MockRNGExhaustedError };` with no bindings when this module is a pure -// `export ... from` re-export, so `dist/testing.js` fails to load under both -// Bun and Node. Re-binding through local consts forces the implementations to -// be inlined. Retry the plain re-export after the next Bun bump. -import { - createMockRng as createMockRngImpl, - MockRNGExhaustedError as MockRNGExhaustedErrorImpl, -} from './rng/mock.js'; - -/** - * Re-export of the core `RNG` interface, so a test file that only imports - * from `roll-parser/testing` can still type a hand-written generator. - * - * @category Testing - */ +export { createMockRng, MockRNGExhaustedError } from './rng/mock.js'; export type { RNG } from './rng/types.js'; - -/** - * Creates a mock {@link RNG} that hands out predefined values in order — the - * way to write dice tests with exact expected totals. - * - * Two deliberate strictnesses, both there to surface a miscounted sequence - * instead of hiding it: - * - * - It never wraps around. Running out throws {@link MockRNGExhaustedError}. - * - `nextInt` rejects a scripted value outside the requested `[min, max]` - * with a `RangeError`, so `createMockRng([7])` cannot satisfy a `d6`. - * - * Draw order matters when the notation contains meta-expressions. Keep/drop - * counts (`4d6kh(1d2)`) are drawn *before* the pool; threshold expressions - * (`4d6cs>(1d2)`) are drawn *after* it. The README's Randomness section has - * the full tables. - * - * @param values - Values to return, in draw order (die faces for `nextInt`, - * floats in `[0, 1)` for `next`) - * @returns An `RNG` that replays `values` - * - * @example - * ```typescript - * import { createMockRng } from 'roll-parser/testing'; - * - * const rng = createMockRng([4, 2, 6]); - * rng.nextInt(1, 6); // 4 - * rng.nextInt(1, 6); // 2 - * rng.nextInt(1, 6); // 6 - * rng.nextInt(1, 6); // throws MockRNGExhaustedError - * ``` - * - * @example Pinning a roll - * ```typescript - * import { roll } from 'roll-parser'; - * import { createMockRng } from 'roll-parser/testing'; - * - * const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) }); - * result.total; // 14 - * result.rendered; // '4d6[3, 6, ~~2~~, 5] = 14' - * ``` - * - * @category Testing - */ -export const createMockRng = createMockRngImpl; - -/** - * Error thrown when a mock RNG runs out of predefined values. - * - * This is intentional behavior, not a limitation: a mock that wrapped around - * would silently pass a test whose expression rolls more dice than the author - * thought. Seeing it means the expression consumed more draws than the test - * supplied — count the dice, including explosions, rerolls, and - * meta-expressions, and check the draw order in the README. - * - * Exported as both a value and a type, so `instanceof` narrowing and - * `catch (error: unknown)` annotations both work. - * - * @example - * ```typescript - * import { roll } from 'roll-parser'; - * import { createMockRng, MockRNGExhaustedError } from 'roll-parser/testing'; - * - * try { - * roll('4d6', { rng: createMockRng([1, 2, 3]) }); - * } catch (error) { - * error instanceof MockRNGExhaustedError; // true - * (error as MockRNGExhaustedError).consumed; // 3 - * } - * ``` - * - * @category Testing - */ -export const MockRNGExhaustedError = MockRNGExhaustedErrorImpl; - -/** - * Instance type of {@link MockRNGExhaustedError}. - * - * @category Testing - */ -export type MockRNGExhaustedError = MockRNGExhaustedErrorImpl; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..400a2a1 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,2 @@ +// Generated by `bun run generate:version` from package.json — do not edit. +export const version = '3.0.0-beta.0'; diff --git a/tsconfig.build.json b/tsconfig.build.json index 69c2b8d..ff30f32 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,9 +2,15 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, - "emitDeclarationOnly": true, "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + // No Node/Bun type packages in the published build: a `node:` import or a + // runtime global in library code must be a type error here, not a silent + // environment dependency. `src/cli/index.ts` declares the minimal + // `process` surface it needs locally. + "types": [] }, + // `exclude` does not merge on `extends` — the child replaces the parent's + // list wholesale, so `node_modules`/`dist` must be restated to add tests. "exclude": ["node_modules", "dist", "**/*.test.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 0baf485..c632efb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,8 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", + "module": "nodenext", + "moduleResolution": "nodenext", "lib": ["ES2022"], "types": ["@types/bun"], @@ -23,11 +23,7 @@ "declaration": true, "declarationMap": true, - "sourceMap": true, - - "paths": { - "@/*": ["./src/*"] - } + "sourceMap": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"]