mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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:
co-authored by
Claude Opus 4.7
parent
d8c369f4b0
commit
6fd38519de
+2
-1
@@ -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"
|
||||
|
||||
Executable
+48
@@ -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)"
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user