build(core): stop shipping sourcemaps, expose schemas, tighten the barrel

Three defects in what `@codeburn/core` publishes. 0.9.20 is already on npm, so
an external consumer meets all three today.

**Sourcemaps.** tsup emitted them and `files` ships `dist`, so 41 maps rode
along — about 1.2 MB against 420 kB of JavaScript. They are self-contained
(esbuild embeds sourcesContent), so this is a weight argument, not a broken
one: what is lost is stepping into `@codeburn/core` frames in an app and
symbolicating consumer stack traces. `dist` is unminified, so that loss is
small.

**schemas/.** `files` shipped the directory but the exports map had no entry
for it, and an exports map blocks whatever it does not list — so the published
JSON Schemas, whose whole point is letting non-TypeScript consumers validate
observations and findings, could not be resolved at all.

**The barrel.** `src/index.ts` omitted detectors even though `./detectors` is a
documented public subpath, so the detector surface was invisible to anyone
importing the package root. Added as a named list rather than `export *`: the
detectors module re-exports its own helpers, and star-exporting would have made
`clamp01`, `READ_TOOL_NAMES` and `AVG_TOKENS_PER_READ` part of the root API,
where renaming one or tuning a constant becomes a visible break after 1.0.
They remain reachable through `./detectors`.

Two support changes fall out. `verify-dist` learned to resolve wildcard export
patterns — including the case where the literal prefix has no separator, which
previously scanned a truncated directory and reported a misleading error — and
walks recursively including dotfiles, because Node's `*` spans `/` and accepts
them, so a shallower check would leave a future `schemas/v2/` importable but
unverified.

A new test resolves a concrete schema through the exports map in a spawned
child and loads it as a JSON module. It lives outside import-smoke on purpose:
that suite blocks I/O to prove import-time purity, and reading a JSON module
needs exactly the I/O it forbids.
This commit is contained in:
ozymandiashh 2026-08-05 03:49:47 +03:00
parent c49fa23590
commit 5cf58b1154
7 changed files with 140 additions and 9 deletions

View file

@ -36,6 +36,7 @@
"types": "./dist/detectors/index.d.ts",
"import": "./dist/detectors/index.js"
},
"./schemas/*": "./schemas/*",
"./providers/claude": {
"types": "./dist/providers/claude/index.d.ts",
"import": "./dist/providers/claude/index.js"

View file

@ -11,9 +11,9 @@
// 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 { existsSync, readFileSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { dirname, join, relative } from 'node:path'
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'))
@ -21,16 +21,55 @@ 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 [subpath, rawEntry] of Object.entries(pkg.exports ?? {})) {
// A plain string target (e.g. "./schemas/*": "./schemas/*") applies to every
// condition; normalize it so the import/types checks below stay uniform.
const entry = typeof rawEntry === 'string' ? { import: rawEntry, types: rawEntry } : rawEntry
for (const condition of ['import', 'types']) {
const relative = entry?.[condition]
if (!relative) {
const pattern = entry?.[condition]
if (!pattern) {
problems.push(`exports["${subpath}"] declares no "${condition}" target`)
continue
}
checked++
if (!existsSync(join(pkgRoot, relative))) {
problems.push(`exports["${subpath}"].${condition} -> ${relative} does not exist`)
if (!pattern.includes('*')) {
if (!existsSync(join(pkgRoot, pattern))) {
problems.push(`exports["${subpath}"].${condition} -> ${pattern} does not exist`)
}
continue
}
// Subpath pattern: every file it can reach on disk must exist, or some
// consumer resolves the export to nothing. Node's `*` spans "/" and does
// not special-case dotfiles, so the walk is recursive and does not skip
// dots — a shallower scan would let a nested tree (e.g. schemas/v2/) or a
// dotfile pass the validator while remaining reachable through the export.
const star = pattern.indexOf('*')
const literal = pattern.slice(0, star)
const suffix = pattern.slice(star + 1)
// A literal prefix with no "/" at all (e.g. "foo*") means the scan starts
// at the package root. lastIndexOf('/') would return -1 here, and
// slice(0, -1) on it would truncate the name into a bogus directory, so
// the "does not exist" report below would point at the wrong place.
const slash = literal.lastIndexOf('/')
const dir = slash === -1 ? pkgRoot : join(pkgRoot, literal.slice(0, slash))
if (!existsSync(dir)) {
problems.push(`exports["${subpath}"].${condition} -> ${pattern} directory does not exist`)
continue
}
const reachable = []
const walk = (d) => {
for (const name of readdirSync(d, { withFileTypes: true })) {
const full = join(d, name.name)
if (name.isDirectory()) walk(full)
else {
const rel = './' + relative(pkgRoot, full)
if (rel.startsWith(literal) && rel.endsWith(suffix)) reachable.push(rel)
}
}
}
walk(dir)
if (reachable.length === 0) {
problems.push(`exports["${subpath}"].${condition} -> ${pattern} matches no files`)
}
}
}

View file

@ -3,3 +3,22 @@ export * from './observations.js'
export * from './diagnostics.js'
export * from './fingerprint.js'
export * from './contracts.js'
// Detectors: re-export only the documented API — the detector functions, the
// stable `detectors` list, and their id/algorithm-version constants. The
// ./detectors subpath also re-exports ./shared.js, which drags internal
// helpers (AVG_TOKENS_PER_READ, JUNK_RESOURCE_CLASSES, READ/EDIT_TOOL_NAMES,
// clamp01, forEachCall) into any `export *` of it; those stay out of the root
// barrel so tuning a constant or renaming a helper is not a 1.0 break. They
// remain reachable via the './detectors' subpath.
export {
detectors,
junkReadsDetector,
duplicateReadsDetector,
contextBloatDetector,
JUNK_READS_DETECTOR_ID,
JUNK_READS_ALGORITHM_VERSION,
DUPLICATE_READS_DETECTOR_ID,
DUPLICATE_READS_ALGORITHM_VERSION,
CONTEXT_BLOAT_DETECTOR_ID,
CONTEXT_BLOAT_ALGORITHM_VERSION,
} from './detectors/index.js'

View file

@ -0,0 +1,21 @@
// Resolves a concrete schema subpath through @codeburn/core's own exports map
// via Node's self-reference (the nearest package.json up from this file has
// "name" + "exports"), then loads it as a JSON module — exactly the resolution
// a consumer's `import '@codeburn/core/schemas/...'` performs. Exits non-zero
// if the subpath is not exported, the file is missing, or the JSON is invalid.
import { fileURLToPath } from 'node:url'
const subpath = process.argv[2]
if (!subpath) {
console.error('schema-resolve: expected a package subpath argument')
process.exit(2)
}
const mod = await import(subpath, { with: { type: 'json' } })
const schema = mod.default
const version = schema?.definitions?.ObservationEnvelope?.properties?.schemaVersion?.const
if (version == null) {
console.error(`schema-resolve: ${subpath} loaded but is not the observation envelope schema`)
process.exit(3)
}
console.log(`SCHEMA_EXPORT_OK ${version}`)

View file

@ -25,8 +25,15 @@ function exportsTargets(): string[] {
const pkg = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8'))
const targets: string[] = []
for (const [subpath, entry] of Object.entries<Record<string, string>>(pkg.exports)) {
const rel = entry.import
const rel = typeof entry === 'string' ? entry : entry.import
// Every export entry must declare an import target: assert that FIRST so
// a malformed entry fails loudly instead of being skipped. Only after the
// assertion do we skip non-module subpaths — wildcard patterns (e.g.
// `./schemas/*`) and JSON schema data files, which ship as data rather
// than modules (importing .json would need import attributes the child
// does not use, and the I/O guardrail only covers code that runs).
expect(rel, `exports["${subpath}"] must declare an import target`).toBeTruthy()
if (rel.includes('*') || rel.endsWith('.json')) continue
targets.push(resolve(pkgRoot, rel))
}
// Barrel first so the child finds the full export set quickly.

View file

@ -0,0 +1,35 @@
import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* SCHEMAS EXPORT GUARDRAIL.
*
* import-smoke proves the package's code imports with all I/O blocked; this
* file proves the *data* subpath actually serves a real schema. It resolves a
* concrete subpath through the package's exports map via Node's self-reference
* (the nearest package.json has `exports`), exactly as a consumer's import
* would, instead of reading the file directly from disk so a dropped
* `./schemas/*` entry, a missing file, or a malformed JSON module all fail
* the child process. It deliberately lives outside the import-smoke preload:
* loading a JSON module is inherent fs I/O, and that guardrail is about
* import-time purity of code while this one is about reachability of shipped
* data.
*/
const here = dirname(fileURLToPath(import.meta.url))
const pkgRoot = resolve(here, '..')
const childScript = resolve(here, 'harness/schema-resolve-child.mjs')
describe('schemas exports map', () => {
it('resolves a concrete published schema through the exports map and loads it', () => {
const result = spawnSync(
process.execPath,
[childScript, '@codeburn/core/schemas/observation-0.2.0.json'],
{ cwd: pkgRoot, encoding: 'utf8' },
)
expect(result.status, `status ${result.status}\nstderr:\n${result.stderr}`).toBe(0)
expect(result.stdout).toContain('SCHEMA_EXPORT_OK 0.2.0')
})
})

View file

@ -51,7 +51,16 @@ export default defineConfig({
outDir: 'dist',
clean: true,
splitting: false,
sourcemap: true,
// No source maps. esbuild embeds sourcesContent by default, so the maps are
// self-contained: debugging the published package works with or without them,
// and the decision is about weight, not resolvability. Measured on this entry
// set, 41 maps total ~1.2 MB against ~420 kB of JavaScript — nearly 3x the
// shipped JS bytes. What the maps would buy: stepping into @codeburn/core
// frames while debugging an app, and symbolication of consumer stack traces.
// dist is unminified, so the loss is small: names and line numbers survive,
// only the original TS sources are absent. Local debugging runs src via
// vitest/tsx, never dist, so nothing in-repo consumes them either.
sourcemap: false,
// 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.