Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
53 changes: 46 additions & 7 deletions packages/core/scripts/verify-dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,65 @@
// 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'))

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`)
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 21 additions & 0 deletions packages/core/tests/harness/schema-resolve-child.mjs
Original file line number Diff line number Diff line change
@@ -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}`)
9 changes: 8 additions & 1 deletion packages/core/tests/import-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions packages/core/tests/schema-exports.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
11 changes: 10 additions & 1 deletion packages/core/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading