From 1264fec534e5658451a6a4ddb5b51e895488f051 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Fri, 19 Jun 2026 10:13:04 -0700 Subject: [PATCH] =?UTF-8?q?feat(8.0):=20version-coupling=20guard=20?= =?UTF-8?q?=E2=80=94=20a=20mismatched/failed=20native=20plugin=20fails=20l?= =?UTF-8?q?oud,=20never=20silent=20JS=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wrong or missing accelerator (@soulcraft/cor) used to silently degrade to the default JS engine — the #1 source of invisible cross-repo drift. Brainy does no plugin auto-detection (a registered plugin is always explicitly requested via config.plugins or brain.use()), so any mismatch/failure is now fatal: - BrainyPlugin gains optional `brainyRange` (semver range of brainy it supports); init() throws if the running brainy is outside it. New dep-free, prerelease-safe matcher pluginRangeSatisfies() (8.0.0-rc1 satisfies >=8.0.0). - activateAll(): a plugin that THROWS during activate now re-throws (was swallowed to a JS fallback); a graceful decline (activate()→false) stays non-fatal but logs a loud warning. - loadPlugins(): an explicitly-listed package that can't load — or isn't a valid plugin — throws (was a silent skip). - Provider-key cliff: a pre-8.0 plugin that registers a legacy vector key ('hnsw'/'diskann') but not the 8.0 'vector' key throws (brainy 8.x reads only 'vector', so it would otherwise run JS vectors invisibly). Tests: tests/unit/plugin-version-coupling.test.ts (range matcher incl. rc1; range mismatch / activate-throw / cliff / missing-package all throw; decline non-fatal). Updated two plugin.test.ts cases that asserted the old silent-swallow behavior. Build green; 1464 unit green. cor declares its brainyRange per handoff LV.1 #2. --- src/brainy.ts | 50 +++++++-- src/plugin.ts | 118 +++++++++++++++++++-- tests/unit/plugin-version-coupling.test.ts | 108 +++++++++++++++++++ tests/unit/plugin.test.ts | 27 ++--- 4 files changed, 271 insertions(+), 32 deletions(-) create mode 100644 tests/unit/plugin-version-coupling.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index 845b4dcf..b558a967 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -10976,16 +10976,29 @@ export class Brainy implements BrainyInterface { // Note: plugins registered via brain.use() are always activated regardless of config const pluginConfig = this.config.plugins if (Array.isArray(pluginConfig) && pluginConfig.length > 0) { - // Explicit list: import and register the specified packages + // Explicit list: import and register the specified packages. A package + // listed here is REQUIRED — brainy does no auto-detection, so a missing or + // invalid plugin must fail LOUD, never silently fall back to the default + // engine (the cross-repo drift this whole guard exists to prevent). for (const pkg of pluginConfig) { + let mod: any try { - const mod = await import(pkg) - const plugin: BrainyPlugin = mod.default || mod - if (plugin && typeof plugin.activate === 'function' && plugin.name) { - this.pluginRegistry.register(plugin) - } - } catch { - // Package not found — skip + mod = await import(pkg) + } catch (error) { + throw new Error( + `[brainy] Plugin "${pkg}" is listed in config.plugins but could not be loaded: ` + + `${error instanceof Error ? error.message : String(error)}. ` + + `Install it (e.g. npm i ${pkg}) or remove it from config.plugins.` + ) + } + const plugin: BrainyPlugin = mod.default || mod + if (plugin && typeof plugin.activate === 'function' && plugin.name) { + this.pluginRegistry.register(plugin) + } else { + throw new Error( + `[brainy] Package "${pkg}" (config.plugins) is not a valid Brainy plugin ` + + `(missing { name, activate }). Remove it from config.plugins.` + ) } } } @@ -10998,6 +11011,27 @@ export class Brainy implements BrainyInterface { // Activate all registered plugins const activated = await this.pluginRegistry.activateAll(context) + + // Version-coupling guard (the provider-key cliff). A pre-8.0 native plugin + // (e.g. @soulcraft/cor < 3.0) registers its vector engine under a LEGACY key + // ('hnsw'/'diskann') instead of the 8.0 canonical 'vector'. brainy 8.x reads + // only 'vector', so without this check the native engine silently never + // engages and every query runs on the default JS index — exactly the + // invisible degrade we must prevent. brainy never registers these keys + // itself, so their presence can only come from a plugin. + const LEGACY_VECTOR_KEYS = ['hnsw', 'diskann', 'hnswIndex'] + const legacyVector = LEGACY_VECTOR_KEYS.filter( + (k) => this.pluginRegistry.getProvider(k) !== undefined + ) + if (legacyVector.length > 0 && this.pluginRegistry.getProvider('vector') === undefined) { + throw new Error( + `[brainy] A native acceleration plugin registered a legacy vector provider ` + + `(${legacyVector.join(', ')}) but not the 'vector' provider that brainy 8.x requires. ` + + `This is an incompatible (pre-8.0) accelerator — upgrade to @soulcraft/cor >= 3.0. ` + + `brainy will NOT silently run the default JS vector engine in its place.` + ) + } + if (activated.length > 0) { // Only log if not in silent mode if (!this.config.silent) { diff --git a/src/plugin.ts b/src/plugin.ts index 52289040..100e5282 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -39,9 +39,26 @@ export interface BrainyPlugin { /** Unique plugin name (typically the npm package name) */ name: string + /** + * Optional semver range of `@soulcraft/brainy` this plugin supports + * (e.g. `'>=8.0.0 <9.0.0'` or `'^8.0.0'`). When set and the running brainy is + * OUTSIDE the range, `init()` THROWS rather than silently falling back to the + * default JS engine. This is the version-coupling guard for the native + * accelerator (`@soulcraft/cor`): brainy 8.x and cor 3.x are a matched pair, + * and a mismatch must fail loud, not degrade invisibly. Supported range + * grammar: space-separated AND of `>=`, `>`, `<=`, `<`, `=` comparators, plus + * `^X.Y.Z` (same-major floor). Prerelease tags on the running version are + * tolerated (`8.0.0-rc1` satisfies `>=8.0.0`). + */ + brainyRange?: string + /** * Called by brainy during init() to activate the plugin. - * Return true if activation succeeded, false to skip. + * Return `true` if activation succeeded. Returning `false` is a graceful + * decline (brainy logs a loud warning and uses the default engine for this + * plugin's providers). THROWING aborts init — a registered plugin is always + * explicitly requested (via `config.plugins` or `brain.use()`; brainy does no + * auto-detection), so a hard failure is fatal, never a silent skip. */ activate(context: BrainyPluginContext): Promise @@ -482,6 +499,65 @@ export interface StorageAdapterFactory { /** * Plugin registry — manages plugin lifecycle and provider resolution. */ +/** Parse `X.Y.Z[-prerelease][+build]` → `[major, minor, patch]`, prerelease/build stripped. */ +function parseSemverCore(version: string): [number, number, number] | null { + const core = version.trim().split('+')[0].split('-')[0] + const parts = core.split('.') + if (parts.length < 1) return null + const nums = [0, 1, 2].map((i) => { + const n = parseInt(parts[i] ?? '0', 10) + return Number.isFinite(n) ? n : NaN + }) + if (nums.some((n) => Number.isNaN(n))) return null + return nums as [number, number, number] +} + +/** Compare two semver cores. <0 if a0 if a>b. */ +function compareSemverCore(a: [number, number, number], b: [number, number, number]): number { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return 0 +} + +/** + * Minimal, dependency-free semver-range check for plugin↔brainy version coupling + * (see {@link BrainyPlugin.brainyRange}). Brainy is a public MIT library, so we + * avoid a full `semver` dependency; the coupling we enforce is major-version + * lockstep with the native accelerator. Supports a space-separated AND of + * `>=`, `>`, `<=`, `<`, `=` comparators plus `^X.Y.Z` (same-major, >= floor). + * Prerelease tags on the running `version` are tolerated (compared by core), so + * an RC build (`8.0.0-rc1`) satisfies `>=8.0.0`. + * + * @returns true if `version` satisfies `range`; on an unparseable range, returns + * true (fail-open on a malformed declaration rather than block a valid brain). + */ +export function pluginRangeSatisfies(version: string, range: string): boolean { + const v = parseSemverCore(version) + if (!v) return false + const comparators = range.trim().split(/\s+/).filter(Boolean) + if (comparators.length === 0) return true + for (const comp of comparators) { + const m = comp.match(/^(\^|>=|<=|>|<|=)?\s*(\d+(?:\.\d+){0,2}(?:[-+].*)?)$/) + if (!m) return true // unparseable comparator → fail-open + const op = m[1] || '=' + const target = parseSemverCore(m[2]) + if (!target) return true + const cmp = compareSemverCore(v, target) + let ok: boolean + switch (op) { + case '>=': ok = cmp >= 0; break + case '>': ok = cmp > 0; break + case '<=': ok = cmp <= 0; break + case '<': ok = cmp < 0; break + case '^': ok = v[0] === target[0] && cmp >= 0; break // same major, >= floor + default: ok = cmp === 0 // '=' + } + if (!ok) return false + } + return true +} + export class PluginRegistry { private plugins: Map = new Map() private providers: Map = new Map() @@ -504,14 +580,42 @@ export class PluginRegistry { for (const [name, plugin] of this.plugins) { if (this.activated.has(name)) continue + // Version-coupling guard. A registered plugin is ALWAYS explicitly + // requested (config.plugins or brain.use() — brainy does no + // auto-detection), so a version mismatch must FAIL LOUD, never silently + // degrade to the default JS engine. (This was the #1 cross-repo drift: + // an old @soulcraft/cor running invisibly against brainy 8.x.) + if (plugin.brainyRange && !pluginRangeSatisfies(context.version, plugin.brainyRange)) { + throw new Error( + `[brainy] Plugin "${name}" supports brainy ${plugin.brainyRange}, but this is brainy ` + + `${context.version}. The engines must be version-matched (brainy 8.x ↔ @soulcraft/cor 3.x); ` + + `install a compatible ${name} (or brainy). brainy will NOT silently run the default engine ` + + `in place of a mismatched accelerator.` + ) + } + + let success: boolean try { - const success = await plugin.activate(context) - if (success) { - this.activated.add(name) - activated.push(name) - } + success = await plugin.activate(context) } catch (error) { - console.warn(`[brainy] Plugin ${name} failed to activate:`, error) + // Hard activation failure on an explicitly-requested plugin is fatal, + // not a swallow-and-fall-back-to-JS. + throw new Error( + `[brainy] Plugin "${name}" failed to activate: ` + + `${error instanceof Error ? error.message : String(error)}` + ) + } + + if (success) { + this.activated.add(name) + activated.push(name) + } else { + // Documented graceful decline (activate() → false). Surface it loudly so + // a silent degrade to the default engine never goes unnoticed. + console.warn( + `[brainy] Plugin "${name}" declined activation (activate() returned false); ` + + `the default engine is in use for its providers.` + ) } } diff --git a/tests/unit/plugin-version-coupling.test.ts b/tests/unit/plugin-version-coupling.test.ts new file mode 100644 index 00000000..26f551c1 --- /dev/null +++ b/tests/unit/plugin-version-coupling.test.ts @@ -0,0 +1,108 @@ +/** + * @module tests/unit/plugin-version-coupling + * @description Guards the brainy↔native-plugin version coupling (audit P0). A + * mismatched or failed accelerator must FAIL LOUD, never silently fall back to + * the default JS engine — that silent degrade was the #1 cross-repo drift + * (e.g. an old @soulcraft/cor running invisibly against brainy 8.x). Covers: + * 1. `pluginRangeSatisfies` (the dep-free semver-range matcher, incl. RC tags) + * 2. `brainyRange` mismatch → init throws + * 3. activate() throwing → init throws (no swallow) + * 4. the legacy provider-key cliff ('hnsw' but not 'vector') → init throws + * 5. an explicitly-listed plugin that can't load → init throws + * 6. a graceful decline (activate()→false) → no throw, loud warn + */ +import { describe, it, expect } from 'vitest' +import { Brainy } from '../../src/brainy.js' +import { pluginRangeSatisfies, type BrainyPlugin, type BrainyPluginContext } from '../../src/plugin.js' + +function memBrain(): Brainy { + return new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) +} + +describe('pluginRangeSatisfies (dep-free range matcher)', () => { + it('handles the comparator forms a brainyRange realistically uses', () => { + expect(pluginRangeSatisfies('8.0.0', '>=8.0.0')).toBe(true) + expect(pluginRangeSatisfies('7.31.2', '>=8.0.0')).toBe(false) + expect(pluginRangeSatisfies('8.4.1', '>=8.0.0 <9.0.0')).toBe(true) + expect(pluginRangeSatisfies('9.0.0', '>=8.0.0 <9.0.0')).toBe(false) + expect(pluginRangeSatisfies('8.2.0', '^8.0.0')).toBe(true) + expect(pluginRangeSatisfies('9.0.0', '^8.0.0')).toBe(false) + expect(pluginRangeSatisfies('8.0.0', '=8.0.0')).toBe(true) + expect(pluginRangeSatisfies('8.0.1', '=8.0.0')).toBe(false) + }) + + it('tolerates a prerelease tag on the running version (RC builds satisfy a stable floor)', () => { + // The lockstep ships as 8.0.0-rc1 — it MUST satisfy cor's `>=8.0.0` range. + expect(pluginRangeSatisfies('8.0.0-rc1', '>=8.0.0')).toBe(true) + expect(pluginRangeSatisfies('8.0.0-rc1', '^8.0.0')).toBe(true) + expect(pluginRangeSatisfies('8.0.0-rc1', '>=8.0.0 <9.0.0')).toBe(true) + }) + + it('fails OPEN on a malformed range (never blocks a valid brain on a bad declaration)', () => { + expect(pluginRangeSatisfies('8.0.0', 'not-a-range')).toBe(true) + expect(pluginRangeSatisfies('8.0.0', '')).toBe(true) + }) +}) + +/** A minimal fake plugin; `onActivate` lets a test register providers / throw. */ +function fakePlugin( + name: string, + opts: { brainyRange?: string; onActivate?: (ctx: BrainyPluginContext) => boolean } = {} +): BrainyPlugin { + return { + name, + brainyRange: opts.brainyRange, + async activate(ctx) { + return opts.onActivate ? opts.onActivate(ctx) : true + } + } +} + +describe('version coupling at init() — no silent fallback', () => { + it('throws when a plugin declares a brainyRange the running brainy is outside', async () => { + const brain = memBrain() + brain.use(fakePlugin('@fake/incompatible', { brainyRange: '>=999.0.0' })) + await expect(brain.init()).rejects.toThrow(/supports brainy|version-matched/) + }) + + it('does NOT throw when the brainyRange is satisfied', async () => { + const brain = memBrain() + brain.use(fakePlugin('@fake/compatible', { brainyRange: '>=0.0.0' })) + await expect(brain.init()).resolves.toBeUndefined() + await brain.close() + }) + + it('throws (does not swallow) when activate() throws', async () => { + const brain = memBrain() + brain.use(fakePlugin('@fake/boom', { onActivate: () => { throw new Error('kaboom') } })) + await expect(brain.init()).rejects.toThrow(/failed to activate|kaboom/) + }) + + it('throws on the legacy provider-key cliff (registers hnsw but not vector)', async () => { + const brain = memBrain() + brain.use(fakePlugin('@fake/old-cor', { + onActivate: (ctx) => { ctx.registerProvider('hnsw', {}); return true } + })) + await expect(brain.init()).rejects.toThrow(/legacy vector provider|requires/) + }) + + it('a graceful decline (activate()→false) is non-fatal — init succeeds on the default engine', async () => { + // Decline is a documented, non-fatal path (brainy logs a loud warning and + // uses the default engine for that plugin's providers). The contract that + // matters here: it does NOT abort init the way a hard failure does. + const brain = memBrain() + brain.use(fakePlugin('@fake/declines', { onActivate: () => false })) + await expect(brain.init()).resolves.toBeUndefined() + await brain.close() + }) + + it('throws when an explicitly-listed plugin package cannot be loaded', async () => { + const brain = new Brainy({ + requireSubtype: false, + storage: { type: 'memory' }, + silent: true, + plugins: ['@soulcraft/this-package-does-not-exist-xyz'] + }) + await expect(brain.init()).rejects.toThrow(/could not be loaded|config\.plugins/) + }) +}) diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index dbb611fa..f4064188 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -71,7 +71,7 @@ describe('PluginRegistry', () => { expect(registry.getActivePlugins()).toEqual([]) }) - it('should handle plugin that throws during activate', async () => { + it('throws (does not silently swallow) when a plugin throws during activate', async () => { const plugin: BrainyPlugin = { name: 'broken-plugin', activate: async () => { @@ -85,9 +85,10 @@ describe('PluginRegistry', () => { version: '7.9.3' } - // Should not throw — graceful fallback - const activated = await registry.activateAll(context) - expect(activated).toEqual([]) + // A registered plugin is always explicitly requested (config.plugins or + // brain.use(); brainy does no auto-detection), so a hard activation failure + // must surface — never a silent fall-back to the default engine. + await expect(registry.activateAll(context)).rejects.toThrow(/failed to activate|activation failed/) expect(registry.getActivePlugins()).toEqual([]) }) @@ -279,7 +280,7 @@ describe('Brainy plugin integration', () => { await brain.close() }) - it('should fall back gracefully when plugin throws', async () => { + it('throws (no silent fallback) when an explicitly-used plugin crashes on activate', async () => { const mockPlugin: BrainyPlugin = { name: 'crashing-plugin', activate: async () => { @@ -293,18 +294,10 @@ describe('Brainy plugin integration', () => { }) brain.use(mockPlugin) - // Should not throw — graceful fallback to TS - await brain.init() - expect(brain.getActivePlugins()).toEqual([]) - - const id = await brain.add({ - data: { name: 'test' }, - type: NounType.Concept, - metadata: { tag: 'unit-test' } - }) - expect(id).toBeTypeOf('string') - - await brain.close() + // A brain.use()'d plugin is explicitly requested — a hard activation crash + // must surface as a failed init(), NOT a silent degrade to the default + // engine (the version-coupling guard; see plugin-version-coupling.test.ts). + await expect(brain.init()).rejects.toThrow(/failed to activate|native module not found/) }) it('should use() return this for chaining', () => {