diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d369dba4..ab7616c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,41 @@ on: pull_request: jobs: + core: + name: core (node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['22.13.x', '24.x', '26.x'] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: Install from lockfile + run: npm ci + + - name: Check workspace versions + run: npm run check:workspace-versions + + - name: Typecheck core + run: npm run typecheck --workspace=@codeburn/core + + - name: Test core + run: npm test --workspace=@codeburn/core + + - name: Build core + run: npm run build --workspace=@codeburn/core + + - name: Verify every export target exists + run: npm run verify-dist --workspace=@codeburn/core + + - name: Verify package contents + run: npm pack --workspace=@codeburn/core --dry-run + semgrep: runs-on: ubuntu-latest steps: diff --git a/package-lock.json b/package-lock.json index c36a79eb..9087e8ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeburn-monorepo", - "version": "0.9.19", + "version": "0.9.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeburn-monorepo", - "version": "0.9.19", + "version": "0.9.20", "license": "MIT", "workspaces": [ "packages/*" @@ -4116,10 +4116,10 @@ }, "packages/cli": { "name": "codeburn", - "version": "0.9.19", + "version": "0.9.20", "license": "MIT", "dependencies": { - "@codeburn/core": "*", + "@codeburn/core": "0.9.20", "@modelcontextprotocol/sdk": "^1.29.0", "bonjour-service": "^1.4.1", "chalk": "^5.4.1", @@ -4150,7 +4150,7 @@ }, "packages/core": { "name": "@codeburn/core", - "version": "0.9.19", + "version": "0.9.20", "license": "MIT", "dependencies": { "zod": "^3.25.76" diff --git a/package.json b/package.json index 156eaa75..8a8ebd7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeburn-monorepo", - "version": "0.9.19", + "version": "0.9.20", "description": "CodeBurn monorepo — CLI (packages/cli) + @codeburn/core (packages/core)", "private": true, "type": "module", @@ -13,7 +13,8 @@ "build:cli": "npm run build:cli -w codeburn", "build:dash": "npm run build:dash -w codeburn", "dev": "npm run dev -w codeburn", - "test": "npm run test -w codeburn" + "test": "npm run test -w codeburn", + "check:workspace-versions": "node scripts/check-workspace-versions.mjs" }, "engines": { "node": ">=22.13.0" diff --git a/packages/cli/package.json b/packages/cli/package.json index ac76c3cc..1f79d864 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codeburn", - "version": "0.9.19", + "version": "0.9.20", "description": "See where your AI spend goes - by task, tool, model, and project", "type": "module", "main": "./dist/cli.js", @@ -50,7 +50,7 @@ }, "homepage": "https://github.com/getagentseal/codeburn#readme", "dependencies": { - "@codeburn/core": "*", + "@codeburn/core": "0.9.20", "@modelcontextprotocol/sdk": "^1.29.0", "bonjour-service": "^1.4.1", "chalk": "^5.4.1", diff --git a/packages/core/package.json b/packages/core/package.json index 719f5469..16fc4331 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,6 +3,7 @@ "version": "0.9.20", "description": "Pure decode/detect engine for CodeBurn: provider session-log decoding, content-minimized observations, and detector contracts. No I/O, no pricing \u2014 hosts supply both.", "type": "module", + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -177,10 +178,12 @@ "schemas" ], "scripts": { - "build": "tsup", + "build": "tsup && tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", "test": "vitest run", - "emit-schemas": "tsx scripts/emit-schemas.mts" + "emit-schemas": "tsx scripts/emit-schemas.mts", + "verify-dist": "node scripts/verify-dist.mjs", + "prepublishOnly": "npm run build && npm run verify-dist" }, "engines": { "node": ">=22.13.0" diff --git a/packages/core/scripts/verify-dist.mjs b/packages/core/scripts/verify-dist.mjs new file mode 100644 index 00000000..dd21249d --- /dev/null +++ b/packages/core/scripts/verify-dist.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Refuses to let a half-built dist reach npm. +// +// `npm run build` is `tsup && tsc`. tsup runs with clean:true, so it wipes dist +// and writes JavaScript; if tsc then fails, dist holds .js with no declarations. +// The build exits non-zero, but nothing rebuilds at publish time, so a later +// `npm publish` would ship a package whose every `types` target 404s. Verified: +// removing the declarations and running `npm pack --dry-run` succeeds with +// 41/41 exports subpaths pointing at files that are not in the tarball. +// +// This script is the assertion that closes that gap. It runs from +// `prepublishOnly` (after the build) and in CI, so it is exercised on every +// push rather than only on the rare publish. +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) + +const problems = [] +let checked = 0 + +for (const [subpath, entry] of Object.entries(pkg.exports ?? {})) { + for (const condition of ['import', 'types']) { + const relative = entry?.[condition] + if (!relative) { + problems.push(`exports["${subpath}"] declares no "${condition}" target`) + continue + } + checked++ + if (!existsSync(join(pkgRoot, relative))) { + problems.push(`exports["${subpath}"].${condition} -> ${relative} does not exist`) + } + } +} + +// `files` decides what actually ships; a target outside it is unreachable to a +// consumer even when it exists on disk here. +const shipped = pkg.files ?? [] +if (!shipped.includes('dist')) { + problems.push('package.json#files does not include "dist", so no export target would ship') +} + +if (problems.length > 0) { + console.error(`dist verification failed (${problems.length} problem(s)):`) + for (const problem of problems) console.error(` - ${problem}`) + console.error('\nRun `npm run build --workspace=@codeburn/core` and re-check.') + process.exit(1) +} + +console.log( + `dist verified: ${checked} export targets across ${Object.keys(pkg.exports ?? {}).length} subpaths all present`, +) diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 00000000..5f24448c --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + // No declarationMap: `files` ships dist and schemas but not src, so the + // emitted .d.ts.map would point at sources the consumer never receives. + // That is 151 files and ~135 kB of maps that resolve to nothing. + "declarationMap": false, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist", + "sourceMap": false, + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["tests/**/*", "scripts/**/*", "dist", "node_modules"] +} diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 9846e7e8..396313a9 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -52,5 +52,8 @@ export default defineConfig({ clean: true, splitting: false, sourcemap: true, - dts: true, + // Declarations come from `tsc -p tsconfig.build.json` instead. tsup's dts + // worker bundles types for all 41 entries in one pass and exhausts the heap + // (ERR_WORKER_OUT_OF_MEMORY) on Node 22 through 26. + dts: false, }) diff --git a/scripts/check-workspace-versions.mjs b/scripts/check-workspace-versions.mjs new file mode 100644 index 00000000..9be1cd84 --- /dev/null +++ b/scripts/check-workspace-versions.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// Fails when the monorepo root, the CLI, and @codeburn/core drift apart. +// The CLI must depend on the exact core version it ships with, otherwise a +// published CLI can resolve a core it was never tested against. +// +// package-lock.json is checked too. Comparing only the manifests leaves the +// gate blind to the case it exists for: all three manifests can agree while the +// lockfile still records a stale workspace version, and `npm ci` does not +// reject that (verified on npm 10.9.2, 11.12.1, 11.16.0, 11.17.0). A gate that +// cannot catch its own failure mode is decoration. +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') + +const read = (relative) => { + const path = join(repoRoot, relative) + try { + return JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error(`cannot read ${relative}: ${error.message}`) + } +} + +const root = read('package.json') +const cli = read('packages/cli/package.json') +const core = read('packages/core/package.json') +const lock = read('package-lock.json') + +const coreDep = cli.dependencies?.['@codeburn/core'] + +const problems = [] + +if (root.version !== cli.version) { + problems.push(`root version ${root.version} !== cli version ${cli.version}`) +} +if (root.version !== core.version) { + problems.push(`root version ${root.version} !== core version ${core.version}`) +} +if (coreDep !== core.version) { + problems.push( + `packages/cli depends on "@codeburn/core": ${JSON.stringify(coreDep)}, expected the exact core version "${core.version}"`, + ) +} + +// --- lockfile agreement ----------------------------------------------------- + +const lockEntry = (key) => lock.packages?.[key] + +if (!lock.packages) { + problems.push('package-lock.json has no "packages" map; expected lockfileVersion 2 or 3') +} else { + const expected = [ + ['', root.version, 'root'], + ['packages/cli', cli.version, 'packages/cli'], + ['packages/core', core.version, 'packages/core'], + ] + for (const [key, manifestVersion, label] of expected) { + const entry = lockEntry(key) + if (!entry) { + problems.push(`package-lock.json is missing an entry for ${label}`) + continue + } + if (entry.version !== manifestVersion) { + problems.push( + `package-lock.json records ${label} at ${entry.version}, but its manifest says ${manifestVersion}`, + ) + } + } + + const lockedDep = lockEntry('packages/cli')?.dependencies?.['@codeburn/core'] + if (lockedDep !== undefined && lockedDep !== coreDep) { + problems.push( + `package-lock.json records the CLI's core dependency as ${JSON.stringify(lockedDep)}, but packages/cli/package.json says ${JSON.stringify(coreDep)}`, + ) + } +} + +if (problems.length > 0) { + console.error('workspace version check failed:') + for (const problem of problems) console.error(` - ${problem}`) + console.error('\nSet root, packages/cli, and packages/core to the same version,') + console.error('pin the CLI dependency to that exact version, then run:') + console.error(' npm install --package-lock-only --ignore-scripts') + process.exit(1) +} + +console.log(`workspace versions and package-lock.json agree at ${root.version}`)