diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac1399..69814d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **ci**: `.nvmrc` now pins Node 24, matching the CI matrix. It still read `20`, so `nvm use` handed local developers a runtime that cannot load `node:sqlite` and does not satisfy `commander`'s `engines.node >=22.12.0`. +- **build**: `npm run build` now runs a `clean` step first, removing `dist/` and `tsconfig.tsbuildinfo` before compiling. Without it, `tsc` leaves output for sources that no longer exist, so a file rename could ship stale artifacts — verified reproducible: planting `dist/sync/strings-client.js` and rebuilding left both files in `npm pack` output. Removing the build-info file is required too; deleting only `dist/` makes the incremental compiler report the output as up to date and emit nothing. - **deps**: `commander` 14.0.3 → 15.0.0. commander 15 is ESM-only (`"type": "module"`) and requires Node >=22.12.0, so it was unmergeable until the Node 24 baseline landed. Jest's `transformIgnorePatterns` allowlist gains `commander` so ts-jest transforms it under CommonJS test execution; without that, 28 suites fail to load with `SyntaxError: Cannot use import statement outside a module`. - **ci**: Test matrix, release workflow, and security workflow now target Node 24 (previously Node 20 and 22). Node 20 reached end-of-life in April 2026, and Node 24 is the current LTS. This aligns CI with the runtime the project is moving to; the `engines.node` range is unchanged in this entry and is bumped separately. diff --git a/package.json b/package.json index b05af8d..3ebf534 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ }, "scripts": { "dev": "ts-node src/cli/index.ts", - "build": "tsc && chmod +x dist/cli/index.js", + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "build": "npm run clean && tsc && chmod +x dist/cli/index.js", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", diff --git a/tests/unit/package-manifest.test.ts b/tests/unit/package-manifest.test.ts new file mode 100644 index 0000000..32a9ed4 --- /dev/null +++ b/tests/unit/package-manifest.test.ts @@ -0,0 +1,71 @@ +/** + * Tests for package.json publish-surface invariants + * + * These guard properties that are easy to lose in a merge and expensive to + * lose silently: the `clean` script was previously added, lost, and only + * rediscovered when a stale `dist/` was found reaching `npm pack`. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +interface PackageManifest { + scripts: { + clean?: string; + build: string; + prepublishOnly: string; + }; + files: string[]; + bin: Record; +} + +describe('package.json manifest', () => { + let pkg: PackageManifest; + + beforeAll(() => { + const manifestPath = path.join(__dirname, '..', '..', 'package.json'); + pkg = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as PackageManifest; + }); + + describe('clean script', () => { + it('should define a clean script', () => { + expect(pkg.scripts.clean).toBeDefined(); + }); + + it('should remove dist so renames cannot leak stale output to npm publish', () => { + expect(pkg.scripts.clean).toContain('dist'); + }); + + it('should remove the TypeScript build info file', () => { + // Without this, tsc's incremental cache reports the deleted output as + // up-to-date and emits nothing, leaving dist empty after a clean. + expect(pkg.scripts.clean).toContain('tsbuildinfo'); + }); + }); + + describe('build script', () => { + it('should run clean before compiling', () => { + expect(pkg.scripts.build).toMatch(/^npm run clean &&/); + }); + + it('should make the CLI entrypoint executable', () => { + expect(pkg.scripts.build).toContain('chmod +x'); + }); + }); + + describe('publish surface', () => { + it('should rebuild from scratch before publishing', () => { + expect(pkg.scripts.prepublishOnly).toContain('build'); + }); + + it('should exclude source maps and build info from the tarball', () => { + const excluded = pkg.files.filter((entry) => entry.startsWith('!')); + expect(excluded).toContain('!dist/**/*.tsbuildinfo'); + expect(excluded).toContain('!dist/**/*.js.map'); + }); + + it('should point bin at a path inside dist', () => { + expect(Object.values(pkg.bin).every((target) => target.startsWith('dist/'))).toBe(true); + }); + }); +});