feat(v0.22.0): public-exports contract test + CI count guard (Lane 2 / R2)

Master locks 17 public subpath exports as gbrain's stable third-party
contract. Zero enforcement existed. This PR locks the surface in two
layers:

1. test/public-exports.test.ts — runtime contract test.
   Reads package.json "exports" at startup. For each subpath, imports
   via the package name ("gbrain/engine"), NOT the relative filesystem
   path — that's the difference between exercising the actual resolver
   and bypassing it. Every subpath gets a canary symbol pinned (e.g.
   gbrain/search/hybrid must export hybridSearch + rrfFusion) so a
   refactor that renames or removes one fails CI before downstream
   consumers (gbrain-evals) silently break.

2. scripts/check-exports-count.sh — CI structural guard.
   Wired into `bun test` after check-jsonb-pattern.sh +
   check-progress-to-stdout.sh + check-wasm-embedded.sh per master's
   precedent. EXPECTED_COUNT=17 baseline — shrinks fail loudly,
   growth also fails so the new canary must be pinned in the runtime
   test deliberately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-25 21:56:05 -07:00
co-authored by Claude Opus 4.7
parent d8c369f4b0
commit 6fd38519de
3 changed files with 152 additions and 1 deletions
+2 -1
View File
@@ -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"
+48
View File
@@ -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)"
+102
View File
@@ -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/<subpath>") 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<string, string> {
const pkgPath = resolve(import.meta.dir, '..', 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
return pkg.exports as Record<string, string>;
}
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();
}
});
}
}
});