diff --git a/packages/core/package.json b/packages/core/package.json index 16fc4331..6260aa8c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" diff --git a/packages/core/scripts/verify-dist.mjs b/packages/core/scripts/verify-dist.mjs index dd21249d..cdd5ec32 100644 --- a/packages/core/scripts/verify-dist.mjs +++ b/packages/core/scripts/verify-dist.mjs @@ -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`) } } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ddfa1655..1dc0a744 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/tests/harness/schema-resolve-child.mjs b/packages/core/tests/harness/schema-resolve-child.mjs new file mode 100644 index 00000000..3c87195b --- /dev/null +++ b/packages/core/tests/harness/schema-resolve-child.mjs @@ -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}`) diff --git a/packages/core/tests/import-smoke.test.ts b/packages/core/tests/import-smoke.test.ts index 5db308f8..58765837 100644 --- a/packages/core/tests/import-smoke.test.ts +++ b/packages/core/tests/import-smoke.test.ts @@ -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>(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. diff --git a/packages/core/tests/schema-exports.test.ts b/packages/core/tests/schema-exports.test.ts new file mode 100644 index 00000000..ab155483 --- /dev/null +++ b/packages/core/tests/schema-exports.test.ts @@ -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') + }) +}) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 396313a9..8af7b2db 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -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.