fix(core): make instanceof on SDK error classes work across bundled copies (#2384)
This commit is contained in:
parent
e6cc5dc633
commit
ce2f65db0e
1 changed files with 1020 additions and 26 deletions
89
packages/server/test/server/errorBrandConformance.test.ts
Normal file
89
packages/server/test/server/errorBrandConformance.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import * as barrel from '../../src/index';
|
||||
|
||||
/**
|
||||
* Server-side twin of the client's errorBrandConformance test: every error
|
||||
* class exported from this package's public surface must carry an own
|
||||
* `mcpBrand` static and resolve `instanceof` through a branded
|
||||
* `Symbol.hasInstance`, so a new exported error class cannot silently regress
|
||||
* to prototype-identity `instanceof` across bundled copies. See
|
||||
* `core-internal/src/errors/crossBundleBrand.ts` for the participation
|
||||
* criterion.
|
||||
*/
|
||||
|
||||
/** Error classes exported on purpose without a brand. Justify every entry. */
|
||||
const UNBRANDED_ALLOWLIST: ReadonlySet<string> = new Set([]);
|
||||
|
||||
function exportedErrorClasses(mod: Record<string, unknown>): Array<[string, Function]> {
|
||||
return Object.entries(mod)
|
||||
.filter((entry): entry is [string, Function] => {
|
||||
const v = entry[1];
|
||||
return typeof v === 'function' && v !== Error && v.prototype instanceof Error;
|
||||
})
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
describe('error brand conformance (server export surface)', () => {
|
||||
const classes = exportedErrorClasses(barrel as Record<string, unknown>);
|
||||
|
||||
it('finds the export surface (guards against a broken walker)', () => {
|
||||
expect(classes.length).toBeGreaterThanOrEqual(5);
|
||||
expect(classes.map(([n]) => n)).toContain('ProtocolError');
|
||||
});
|
||||
|
||||
it('every exported error class is branded (or explicitly allowlisted)', () => {
|
||||
const unbranded = classes
|
||||
.filter(([name]) => !UNBRANDED_ALLOWLIST.has(name))
|
||||
.filter(([, cls]) => !Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'))
|
||||
.map(([name]) => name);
|
||||
expect(
|
||||
unbranded,
|
||||
`unbranded exported error classes (add the static mcpBrand block, or allowlist with a justification): ${unbranded.join(', ')}`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('every branded class resolves instanceof through a branded hasInstance', () => {
|
||||
const missing = classes
|
||||
.filter(([, cls]) => Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'))
|
||||
.filter(([, cls]) => (cls as never as Record<symbol, unknown>)[Symbol.hasInstance] === Function.prototype[Symbol.hasInstance])
|
||||
.map(([name]) => name);
|
||||
expect(missing, `branded classes whose hierarchy root does not install brandedHasInstance: ${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('every branded class exposes an isInstance guard that agrees with instanceof', () => {
|
||||
const branded = classes.filter(([, cls]) => Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'));
|
||||
const missing = branded.filter(([, cls]) => typeof (cls as { isInstance?: unknown }).isInstance !== 'function').map(([n]) => n);
|
||||
expect(missing, `branded classes without a static isInstance guard: ${missing.join(', ')}`).toEqual([]);
|
||||
for (const [, cls] of branded) {
|
||||
const guard = (cls as unknown as { isInstance: (v: unknown) => boolean }).isInstance;
|
||||
for (const v of [new Error('plain'), null, undefined, 0, '', {}]) {
|
||||
expect(guard.call(cls, v)).toBe(v instanceof (cls as never as new () => unknown));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Core-internal brand strings are pinned in core-internal's
|
||||
// errorSurfacePins.test.ts. A server-local branded error class must add its
|
||||
// brand string here so a rename cannot land silently.
|
||||
const CORE_PINNED = new Set([
|
||||
'MissingRequiredClientCapabilityError',
|
||||
'OAuthError',
|
||||
'ProtocolError',
|
||||
'ResourceNotFoundError',
|
||||
'SdkError',
|
||||
'SdkHttpError',
|
||||
'UnsupportedProtocolVersionError',
|
||||
'UrlElicitationRequiredError'
|
||||
]);
|
||||
const SERVER_LOCAL_BRANDS: Record<string, string> = {};
|
||||
|
||||
it('pins every server-local brand string (add new entries here — renames must be deliberate)', () => {
|
||||
const brands = Object.fromEntries(
|
||||
classes
|
||||
.filter(([name, cls]) => !CORE_PINNED.has(name) && Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'))
|
||||
.map(([name, cls]) => [name, (cls as never as { mcpBrand: string }).mcpBrand])
|
||||
);
|
||||
expect(brands).toEqual(SERVER_LOCAL_BRANDS);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue