mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 23:14:33 +00:00
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:
parent
c49fa23590
commit
5cf58b1154
7 changed files with 140 additions and 9 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
21
packages/core/tests/harness/schema-resolve-child.mjs
Normal file
21
packages/core/tests/harness/schema-resolve-child.mjs
Normal 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}`)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
35
packages/core/tests/schema-exports.test.ts
Normal file
35
packages/core/tests/schema-exports.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue