diff --git a/package.json b/package.json index 5536a24b6..8b92b89a9 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,13 @@ "build:all": "bun build --compile --target=bun-darwin-arm64 --outfile bin/gbrain-darwin-arm64 src/cli.ts && bun build --compile --target=bun-linux-x64 --outfile bin/gbrain-linux-x64 src/cli.ts", "build:schema": "bash scripts/build-schema.sh", "build:llms": "bun run scripts/build-llms.ts", - "test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && scripts/check-wasm-embedded.sh && bun run typecheck && bun test --timeout=60000", + "test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && scripts/check-wasm-embedded.sh && scripts/check-exports-count.sh && bun run typecheck && bun test --timeout=60000", "check:wasm": "scripts/check-wasm-embedded.sh", "test:e2e": "bash scripts/run-e2e.sh", "typecheck": "tsc --noEmit", "check:jsonb": "scripts/check-jsonb-pattern.sh", "check:progress": "scripts/check-progress-to-stdout.sh", + "check:exports-count": "scripts/check-exports-count.sh", "postinstall": "command -v gbrain >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive || echo '[gbrain] postinstall skipped. If installed via bun install -g github:...: run `gbrain doctor` and `gbrain apply-migrations --yes` manually. See https://github.com/garrytan/gbrain/issues/218' 1>&2", "prepublish:clawhub": "bun run build:all", "publish:clawhub": "clawhub package publish . --family bundle-plugin" diff --git a/scripts/check-exports-count.sh b/scripts/check-exports-count.sh new file mode 100755 index 000000000..160c4bf14 --- /dev/null +++ b/scripts/check-exports-count.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# CI guard: the public exports surface never shrinks silently (v0.21.0). +# +# Precedent: scripts/check-jsonb-pattern.sh + check-progress-to-stdout.sh +# are grep-based structural guards wired into `bun run test`. This one +# counts the entries in package.json "exports" and fails when the count +# drops below the v0.21.0 baseline (17 entries). +# +# Policy (from CLAUDE.md): +# "Removing any of these is a breaking change going forward." +# +# If you're legitimately removing a public export: bump gbrain's minor +# version, note the removal in CHANGELOG.md under a "Breaking changes" +# bullet, then bump EXPECTED_COUNT below. Anything else is a regression. +# +# Adding a new export: update EXPECTED_COUNT to match AND extend the +# EXPECTED_EXPORTS list in test/public-exports.test.ts so the runtime +# contract test pins the canary symbol. + +set -euo pipefail + +EXPECTED_COUNT=17 + +# Count top-level keys in the exports object. `node -e` parses JSON +# reliably without needing jq (which isn't in every CI environment). +ACTUAL=$(node -e " + const pkg = require('./package.json'); + console.log(Object.keys(pkg.exports || {}).length); +") + +if [ "$ACTUAL" -lt "$EXPECTED_COUNT" ]; then + echo "❌ public-exports guard: package.json exports shrank from $EXPECTED_COUNT to $ACTUAL" + echo " Removing a public export is a breaking change (see CLAUDE.md)." + echo " If intentional: bump gbrain minor version + update EXPECTED_COUNT in" + echo " scripts/check-exports-count.sh and EXPECTED_EXPORTS in" + echo " test/public-exports.test.ts, AND add a CHANGELOG 'Breaking changes' bullet." + exit 1 +fi + +if [ "$ACTUAL" -gt "$EXPECTED_COUNT" ]; then + echo "⚠️ public-exports guard: package.json exports grew from $EXPECTED_COUNT to $ACTUAL" + echo " Additive public API change. Update EXPECTED_COUNT in this script + the" + echo " EXPECTED_EXPORTS list in test/public-exports.test.ts to lock the new" + echo " canary symbols." + exit 1 +fi + +echo "✓ public-exports guard: $ACTUAL entries (matches baseline $EXPECTED_COUNT)" diff --git a/test/public-exports.test.ts b/test/public-exports.test.ts new file mode 100644 index 000000000..954f13073 --- /dev/null +++ b/test/public-exports.test.ts @@ -0,0 +1,102 @@ +/** + * Public exports contract test (v0.21.0 — Lane 2 / R2). + * + * Reads package.json "exports" at runtime, imports each subpath via the + * package name ("gbrain/") so it actually exercises the + * resolver — then asserts each resolves AND has at least one canary + * symbol. Importing from relative paths (e.g. "../src/core/engine.ts") + * would bypass the exports map and miss resolver/wiring breakage. + * + * The canary symbols are concrete values each module re-exports today. + * If a refactor renames or removes one, this test fails in CI so the + * downstream consumer (gbrain-evals) doesn't silently break first. + * + * To add a new public export: extend `EXPECTED_EXPORTS` below. To + * REMOVE one: bump gbrain's minor (breaking-interface per CLAUDE.md + * "Removing any of these is a breaking change going forward"). + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { describe, expect, test } from 'bun:test'; + +interface ExpectedExport { + /** Subpath key as it appears in package.json exports. */ + subpath: string; + /** At least one named export that MUST exist at runtime. Chosen from the + * module's current surface; if it goes away, that's a breaking change. */ + canary: string[]; +} + +/** + * Canary symbols pinned to the v0.21.0 contract. Changes to this list + * are intentional breaking changes to the public exports surface. + */ +const EXPECTED_EXPORTS: ExpectedExport[] = [ + { subpath: 'gbrain', canary: [] }, // root "." export; no single canary — just require import success + { subpath: 'gbrain/engine', canary: ['clampSearchLimit', 'MAX_SEARCH_LIMIT'] }, + { subpath: 'gbrain/types', canary: ['GBrainError'] }, + { subpath: 'gbrain/operations', canary: ['operations', 'OperationError'] }, + { subpath: 'gbrain/minions', canary: [] }, // barrel module; re-exports many names + { subpath: 'gbrain/engine-factory', canary: [] }, // factory exports a default creator + { subpath: 'gbrain/pglite-engine', canary: ['PGLiteEngine'] }, + { subpath: 'gbrain/link-extraction', canary: ['extractEntityRefs', 'extractPageLinks'] }, + { subpath: 'gbrain/import-file', canary: ['importFromContent'] }, + { subpath: 'gbrain/transcription', canary: [] }, + { subpath: 'gbrain/embedding', canary: ['embed'] }, + { subpath: 'gbrain/config', canary: ['loadConfig'] }, + { subpath: 'gbrain/markdown', canary: ['splitBody', 'parseMarkdown', 'serializeMarkdown'] }, + { subpath: 'gbrain/backoff', canary: [] }, + { subpath: 'gbrain/search/hybrid', canary: ['hybridSearch', 'rrfFusion'] }, + { subpath: 'gbrain/search/expansion', canary: ['expandQuery'] }, + { subpath: 'gbrain/extract', canary: [] }, +]; + +function readPackageExports(): Record { + const pkgPath = resolve(import.meta.dir, '..', 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + return pkg.exports as Record; +} + +describe('public exports — package.json exports map', () => { + test('has the expected number of subpaths (v0.21.0 locks the surface)', () => { + const exports = readPackageExports(); + const count = Object.keys(exports).length; + // Adding new exports: increment this + add to EXPECTED_EXPORTS below. + // Removing exports: see CLAUDE.md "Removing any of these is a + // breaking change going forward" — bump minor and update this count. + expect(count).toBe(17); + }); + + test('EXPECTED_EXPORTS list matches the exports map exactly (no drift)', () => { + const exports = readPackageExports(); + const exportedSubpaths = Object.keys(exports).map(k => (k === '.' ? 'gbrain' : `gbrain${k.slice(1)}`)).sort(); + const expectedSubpaths = EXPECTED_EXPORTS.map(e => e.subpath).sort(); + expect(expectedSubpaths).toEqual(exportedSubpaths); + }); +}); + +describe('public exports — every subpath resolves via package name', () => { + for (const entry of EXPECTED_EXPORTS) { + test(`${entry.subpath} imports without throwing`, async () => { + // Package-path import goes through the exports map — bypassing a + // broken/removed subpath surfaces here. Importing "../src/..." + // would resolve via filesystem and miss the contract. + const mod = await import(entry.subpath); + expect(mod).toBeDefined(); + expect(typeof mod).toBe('object'); + }); + + if (entry.canary.length > 0) { + test(`${entry.subpath} exports canary symbols: ${entry.canary.join(', ')}`, async () => { + const mod = await import(entry.subpath); + for (const name of entry.canary) { + expect(mod).toHaveProperty(name); + // Must be something truthy — value, class, or function. Not + // just a TypeScript type (those don't exist at runtime). + expect(mod[name]).toBeDefined(); + } + }); + } + } +});