From 76c9987d515e9416cc02ac34b99aa51aee27b845 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:10:08 +0000 Subject: [PATCH 01/12] build: generate src/version.ts instead of importing package.json #125 Added `scripts/generate-version.ts` and the committed `src/version.ts` Rewrote `src/index.ts` to import the generated constant Rewrote the `src/index.test.ts` manifest import with a JSON attribute, keeping it as the drift guard Extended `scripts/check-version.ts` to gate `src/version.ts` sync in both modes Added the `generate:version` script Co-Authored-By: Claude Fable 5 --- package.json | 1 + scripts/check-version.ts | 15 ++++++++++++++- scripts/generate-version.ts | 29 +++++++++++++++++++++++++++++ src/index.test.ts | 6 ++++-- src/index.ts | 11 +++++------ src/version.ts | 2 ++ 6 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 scripts/generate-version.ts create mode 100644 src/version.ts diff --git a/package.json b/package.json index 2506bbd..c9b3f55 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "check": "bun typecheck && biome check .", "check:fix": "bun typecheck && biome check --write .", "check:version": "bun scripts/check-version.ts", + "generate:version": "bun scripts/generate-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", diff --git a/scripts/check-version.ts b/scripts/check-version.ts index 545f043..1a9bc03 100644 --- a/scripts/check-version.ts +++ b/scripts/check-version.ts @@ -3,8 +3,21 @@ 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 versionSource = await Bun.file(new URL('../src/version.ts', import.meta.url)).text(); +const srcVersion = versionSource.match(/^export const version = '(.+)';$/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..ddf788e --- /dev/null +++ b/scripts/generate-version.ts @@ -0,0 +1,29 @@ +/** + * 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 previous = (await Bun.file(TARGET).exists()) ? await Bun.file(TARGET).text() : null; + +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/src/index.test.ts b/src/index.test.ts index 4d21c25..6d7d464 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/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'; From bc63a6e9f6b72f1804c6de603c4627496c215729 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:11:04 +0000 Subject: [PATCH 02/12] style: polish version scripts after review #125 Swapped the drift-guard comment prefix to `// ?` per comments.md Hardened `check-version.ts` against CRLF and a missing `src/version.ts` Preferred `undefined` and a single `Bun.file` handle in the generator Restored the `check:*` script grouping in package.json Co-Authored-By: Claude Fable 5 --- package.json | 2 +- scripts/check-version.ts | 5 +++-- scripts/generate-version.ts | 3 ++- src/index.test.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c9b3f55..8c4bf5b 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,8 @@ "check": "bun typecheck && biome check .", "check:fix": "bun typecheck && biome check --write .", "check:version": "bun scripts/check-version.ts", - "generate:version": "bun scripts/generate-version.ts", "check:changelog": "bun scripts/check-changelog.ts", + "generate:version": "bun scripts/generate-version.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", diff --git a/scripts/check-version.ts b/scripts/check-version.ts index 1a9bc03..0aaa2d0 100644 --- a/scripts/check-version.ts +++ b/scripts/check-version.ts @@ -5,8 +5,9 @@ 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 versionSource = await Bun.file(new URL('../src/version.ts', import.meta.url)).text(); -const srcVersion = versionSource.match(/^export const version = '(.+)';$/m)?.[1]; +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( diff --git a/scripts/generate-version.ts b/scripts/generate-version.ts index ddf788e..8dd228e 100644 --- a/scripts/generate-version.ts +++ b/scripts/generate-version.ts @@ -19,7 +19,8 @@ const content = `// Generated by \`bun run generate:version\` from package.json export const version = '${pkg.version}'; `; -const previous = (await Bun.file(TARGET).exists()) ? await Bun.file(TARGET).text() : null; +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}`); diff --git a/src/index.test.ts b/src/index.test.ts index 6d7d464..e6a29b3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -66,7 +66,7 @@ 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 + // ? 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(pkg.version); From e9eb9ac8f34f4301fd2cf80ea6d3fb0910e0a992 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:13:59 +0000 Subject: [PATCH 03/12] build: emit per-file library output with tsc #125 Switched tsconfig to `nodenext` and removed the dead `@/*` paths alias Replaced the four build steps with a single `tsc -p tsconfig.build.json` emit Dropped Node/Bun type packages from the build program via `types: []` Declared the CLI entry's minimal `process` surface module-locally Restored plain re-exports in `testing.ts`, moving TSDoc to `rng/mock.ts` Pointed `bin` at `dist/cli/index.js` and set its executable bit in `build` Updated `package-smoke.test.ts` and the CI cli job for the new layout Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 23 +++++--- package.json | 8 +-- src/cli/index.ts | 11 ++++ src/cli/package-smoke.test.ts | 10 +++- src/rng/mock.ts | 5 ++ src/testing.ts | 102 ++-------------------------------- tsconfig.build.json | 10 +++- tsconfig.json | 10 +--- 8 files changed, 55 insertions(+), 124 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd61577..ebe9a9f 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 @@ -233,17 +233,24 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + # 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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + 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 node-smoke: name: Node.js Smoke Test diff --git a/package.json b/package.json index 8c4bf5b..ac22435 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "roll-parser": "./dist/cli.js" + "roll-parser": "./dist/cli/index.js" }, "sideEffects": false, "exports": { @@ -60,11 +60,7 @@ "check:changelog": "bun scripts/check-changelog.ts", "generate:version": "bun scripts/generate-version.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", + "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", 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..bcb8c2d 100644 --- a/src/cli/package-smoke.test.ts +++ b/src/cli/package-smoke.test.ts @@ -1,12 +1,12 @@ 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; }; -const CLI_BIN = './dist/cli.js'; +const CLI_BIN = './dist/cli/index.js'; async function runCommand( command: string[], @@ -23,11 +23,15 @@ async function runCommand( return { stdout, stderr, exitCode }; } +// ? Mirrors `bun run build` minus `clean` — deleting `coverage/` mid-run from +// inside a `bun test --coverage` session is not worth the risk, 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', () => { 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/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"] From 09bd443ab929e61e51febed569202e934be8a86e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:21:36 +0000 Subject: [PATCH 04/12] fix: harden build gates after per-file emit review #125 Asserted the `chmod +x` build-script contract in the CLI smoke test Executed the built CLI directly to exercise shebang and mode together Added a `types: []` build-config pass to `typecheck` so neutrality errors surface in `check` Dropped dead Bun setup steps from the CI cli job Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 21 ++------------------- package.json | 2 +- src/cli/package-smoke.test.ts | 9 +++++++++ 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebe9a9f..e781f8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,36 +203,19 @@ 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 - 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 - # 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 diff --git a/package.json b/package.json index ac22435..c2a957f 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "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 && tsc --noEmit -p tsconfig.build.json && 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", diff --git a/src/cli/package-smoke.test.ts b/src/cli/package-smoke.test.ts index bcb8c2d..7132029 100644 --- a/src/cli/package-smoke.test.ts +++ b/src/cli/package-smoke.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; type PackageJson = { bin?: Record; + scripts?: Record; }; const CLI_BIN = './dist/cli/index.js'; @@ -39,6 +40,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 () => { @@ -48,6 +54,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 () => { From 2e68bedc6cc8105c616b568c9ca7b25288df7534 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:22:58 +0000 Subject: [PATCH 05/12] build: modernize package manifest for browser and CDN use #125 Dropped the redundant node10-era `main` and `types` fields (`exports` is the source of truth) Added `unpkg`/`jsdelivr` fields pointing at `dist/index.js` Extended keywords with discovery terms for dice, ESM, and browser use Co-Authored-By: Claude Fable 5 --- package.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c2a957f..71704b2 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,6 @@ "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/index.js" }, @@ -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", From 4c7acf8507050d325799a6a6270820b1fe6580b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:24:28 +0000 Subject: [PATCH 06/12] refactor: point demo site at the built package #125 Replaced `../../src` imports in `site/src` with the `roll-parser` self-reference Made `typecheck` build the library so the site project resolves `dist` types Prepended the library build to `site:build` and `site:dev` Co-Authored-By: Claude Fable 5 --- package.json | 6 +++--- site/src/dice.ts | 2 +- site/src/main.ts | 2 +- site/src/reference.ts | 2 +- site/src/render.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 71704b2..aef8fc2 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "workspaces": ["scripts/docs"], "scripts": { "prepare": "git config core.hooksPath .githooks", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json && 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", @@ -76,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", 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. */ From 4978f53f3cef68737dbd843e1cb94398f9d20370 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:33:04 +0000 Subject: [PATCH 07/12] fix: apply metadata and site review feedback #125 Stopped `clean` from deleting `coverage/` during the `check` iteration loop Kept Bun ambient globals out of the browser-only site typecheck via `types: []` Co-Authored-By: Claude Fable 5 --- package.json | 3 ++- site/tsconfig.json | 5 ++++- src/cli/package-smoke.test.ts | 5 ++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index aef8fc2..6465655 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "check:version": "bun scripts/check-version.ts", "check:changelog": "bun scripts/check-changelog.ts", "generate:version": "bun scripts/generate-version.ts", - "clean": "rm -rf dist coverage", + "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", @@ -120,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/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/package-smoke.test.ts b/src/cli/package-smoke.test.ts index 7132029..7af288a 100644 --- a/src/cli/package-smoke.test.ts +++ b/src/cli/package-smoke.test.ts @@ -24,9 +24,8 @@ async function runCommand( return { stdout, stderr, exitCode }; } -// ? Mirrors `bun run build` minus `clean` — deleting `coverage/` mid-run from -// inside a `bun test --coverage` session is not worth the risk, and stale -// dist files are harmless here. +// ? 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(['bunx', 'tsc', '-p', 'tsconfig.build.json']); if (exitCode !== 0) { From 3a8cf4695e5467cd8806eaa14265602455a87298 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:33:04 +0000 Subject: [PATCH 08/12] ci: verify the published tarball across Node and browsers #126 Rewrote `node-smoke` to npm-pack the tarball, install it in a scratch project, and exercise both entry points and the bin across Node 22.12.0, 22.x, and lts/* Added a `browser-smoke` job: esbuild `--platform=browser` bundle of both entries from the tarball install, hard-failing on Node builtins, grepped for runtime globals, then executed Asserted dist library files carry no `node:`/`bun:`/`require` specifiers Enabled Biome `noNodejsModules` for library sources, exempting `src/cli` Pinned `esbuild` as an explicit devDependency for the browser-smoke bundle Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 120 ++++++++++++++++++++++++++++++++++++--- biome.json | 24 +++++++- bun.lock | 1 + 3 files changed, 137 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e781f8f..6177edb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,14 +235,87 @@ jobs: - name: Test CLI verbose 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. + # `lts/*` is the current LTS line; 24.x is not listed separately + # because it currently is `lts/*`. + node-version: ['22.12.0', '22.x', 'lts/*'] + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node-version }} + + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.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: @@ -257,9 +330,42 @@ 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 + run: | + if grep -rEl "from '(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: | + 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/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", From f15e5ece91618d11c29a442d50a2b37e6504ef26 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:56:05 +0000 Subject: [PATCH 09/12] docs: align docs and rules with the tsc-emit build #125 Documented the per-file emit, neutrality enforcement, and `src/version.ts` in CLAUDE.md constraints Added the `generate:version` step to the release-commit convention Noted the site consumes built `dist/` and library edits need a rebuild Replaced dead `@/` path-alias import examples in rules with `.js`-suffixed relative imports Added README notes for CDN usage and the minimum TypeScript `moduleResolution` Co-Authored-By: Claude Fable 5 --- .claude/rules/rng.md | 2 +- .claude/rules/testing.md | 2 +- .claude/rules/typescript.md | 9 +++++---- CLAUDE.md | 17 ++++++++++++----- CONTRIBUTING.md | 3 ++- README.md | 21 +++++++++++++++++++++ 6 files changed, 42 insertions(+), 12 deletions(-) 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/CLAUDE.md b/CLAUDE.md index dbe169e..4f7e05f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,12 +13,16 @@ bun validate # Full pre-release gate: check + build + check:package + 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 +30,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 `node-smoke` CI job is the one place npm runs, to test the packed tarball the way consumers install it) - 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 +101,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..004f156 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,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 diff --git a/README.md b/README.md index 88ce4f8..f848906 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`) also work but fetch each module separately. + ## Quick start ```typescript From d3f8fb922e3a4fb780ca10eff6c44348cca470f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 13:35:35 +0000 Subject: [PATCH 10/12] ci: harden smoke-job assertions after review #126 Added an explicit `24.x` matrix leg so LTS drift cannot silently drop it Broadened the dist grep to catch side-effect and dynamic builtin imports Guarded both grep assertions against missing paths passing silently Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6177edb..418c460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,9 +245,8 @@ jobs: fail-fast: false matrix: # Exact `engines` floor first — the require(esm) claim must hold there. - # `lts/*` is the current LTS line; 24.x is not listed separately - # because it currently is `lts/*`. - node-version: ['22.12.0', '22.x', 'lts/*'] + # 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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -331,8 +330,11 @@ jobs: path: dist/ - 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: | - if grep -rEl "from '(node|bun):|require\(" dist --include='*.js' | grep -v '^dist/cli/'; then + 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 @@ -362,6 +364,7 @@ jobs: - 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 From f0ffd91d1f20e120643ea4358297eda9cf67a25e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 13:41:57 +0000 Subject: [PATCH 11/12] docs: fix contradictions found in the docs review #125 Corrected CONTRIBUTING's bundler claim, `.js`-extension rationale, and release steps Scoped CLAUDE.md's npm exception to all smoke jobs and the publish step Completed the `validate` description and noted `check`'s dist rebuild side effect Pinned the raw unpkg example to the `@next` tag Struck the resolved #22 items in GAPS.md, noting `src/` shipping is now deliberate Co-Authored-By: Claude Fable 5 --- .claude/docs/GAPS.md | 20 ++++++++++++-------- CLAUDE.md | 7 ++++--- CONTRIBUTING.md | 21 ++++++++++++--------- README.md | 2 +- 4 files changed, 29 insertions(+), 21 deletions(-) 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.md b/CLAUDE.md index 4f7e05f..81a70d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,10 @@ 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`): @@ -30,7 +31,7 @@ Commits with unfixable lint errors are blocked. ## Constraints -- Runtime: Bun — never use npm, yarn, or pnpm (the `node-smoke` CI job is the one place npm runs, to test the packed tarball the way consumers install it) +- 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) - `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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 004f156..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 @@ -102,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 f848906..225094c 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ as one request: ``` `https://esm.sh/roll-parser@next` works the same way. Raw file URLs -(`unpkg.com/roll-parser`) also work but fetch each module separately. +(`unpkg.com/roll-parser@next`) also work but fetch each module separately. ## Quick start From 6e1a146cd00940021861cde6da9f466b2d85d177 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 14:04:23 +0000 Subject: [PATCH 12/12] ci: align new smoke jobs with upgraded action pins #126 Bumped checkout, cache, download-artifact, and setup-node SHAs in the rebased smoke jobs to the majors master upgraded in #149 Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 418c460..25c28a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,7 +219,7 @@ jobs: # 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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: lts/* @@ -249,15 +249,15 @@ jobs: node-version: ['22.12.0', '22.x', '24.x', 'lts/*'] steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist/ @@ -305,7 +305,7 @@ jobs: bun-version-file: .bun-version - name: Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.bun/install/cache key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}