Skip to content

Commit 479fa8b

Browse files
committed
test(core): narrow the refusal helper, and state the change for consumers
`kernel.use(x).catch((e) => e as Error)` resolves to `Kernel | Error`, so a case whose input stopped being refused would assert against a kernel and report a property miss rather than "this loaded". The helper throws instead. The changeset states the published-behaviour change the ruling names: an unknown `type`, an invalid `slug` or an invalid `homepage` is now refused at load, and `version` is deliberately not enforced from the schema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent 382076b commit 479fa8b

2 files changed

Lines changed: 44 additions & 4 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
`kernel.use()` now enforces the declared plugin contract. A plugin object with an **unknown `type`**, an **invalid `slug`** or an **invalid `homepage`** is refused at load instead of being stored and mounted.
6+
7+
**This refuses input the runtime accepted before**, which is why it is not a `patch`: `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) had zero runtime callers, so every constraint it declared beyond `name`, `init` and semver was a declaration with nothing behind it. The sharpest reading of that gap, one input and two answers: `defineStack` accepted `type: 'ui-plugin'` while `PluginSchema.safeParse` refused it — and only one of those answers was on the path a real plugin takes. Maintainer ruling of 2026-09-06 (ADR-0049 enforce-or-remove): the protocol is the baseline, the runtime aligns to it.
8+
9+
**What a refusal looks like.** It travels the loader's existing plugin-load error path — no new error channel — carrying the stable code `PLUGIN_CONTRACT_VIOLATION` at the head of the message and on the error's `code` property, and naming the plugin plus the first violated key:
10+
11+
```
12+
PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared
13+
plugin contract at 'type': Invalid option: expected one of "standard"|"ui"|…
14+
```
15+
16+
A wrong `type` is therefore diagnosable at boot rather than at route mount. The code is a **boot refusal**, not wire vocabulary: it is raised before any HTTP boundary exists, and no door answers with it.
17+
18+
**What does NOT change.**
19+
20+
- The plugin object is validated, never replaced. `safeParse` is read for `success` and its output discarded, because a copy destroys the prototype chain of class-based plugins — the reason `PluginLoader.toPluginMetadata` is a cast. A class-based plugin's identity, prototype and prototype methods surviving `use()` is pinned by test, not asserted in prose.
21+
- `PluginSchema`'s `.default('standard')` is **not** written back: a plugin declaring no `type` still loads and still stores no `type`.
22+
- **`version` is deliberately excluded from this enforcement.** The schema spells it `/^\d+\.\d+\.\d+$/`, which refuses the prerelease and build-metadata forms SemVer 2.0.0 defines, while the loader's own `isValidSemanticVersion` implements the full grammar and accepts them — and does so deliberately, pinned by `plugin-loader.test.ts`. Enforcing the narrower spelling would retire that capability silently, so the loader's check remains authoritative for `version` and `1.0.0-alpha.1` / `1.0.0+20230101` still load. Reconciling the two spellings is spec work, tracked separately.
23+
24+
**Blast radius, measured rather than assumed.** Every in-repo plugin object declares a `type` inside the closed set (`standard` ×62, `server` ×2, `driver` ×2, `objectql`, `app`), and the repo contains no producer of `slug` or `homepage` on a plugin object at all — so no in-repo plugin changes behaviour. Externally authored plugins are the population this reaches, and they are exactly the population that never met the compile-time `Plugin.type` union either.

packages/core/src/plugin-contract-enforcement.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ type Fixture = Plugin & {
6060
staticPath?: string;
6161
};
6262

63+
/**
64+
* The refusal `promise` produced, or a loud failure if it produced none.
65+
*
66+
* ⛔ Not `promise.catch((e) => e as Error)`: that resolves to `Kernel | Error`,
67+
* so a case whose input STOPPED being refused would go on asserting against a
68+
* kernel and report a confusing property miss instead of "this loaded".
69+
*/
70+
async function refusal(promise: Promise<unknown>): Promise<Error> {
71+
try {
72+
await promise;
73+
} catch (e) {
74+
return e as Error;
75+
}
76+
throw new Error('expected the plugin to be refused, but it loaded');
77+
}
78+
6379
function fixture(overrides: Partial<Fixture> & { name: string }): Fixture {
6480
return {
6581
version: '1.0.0',
@@ -84,7 +100,7 @@ describe('A — the legacy `ui-plugin` value is refused at kernel.use() (#15638,
84100
// The envelope, not merely "it threw": a bare `toThrow()` would stay
85101
// green if the kernel started refusing this input for an unrelated
86102
// reason, which is the failure mode this card was filed about.
87-
const err = await kernel.use(legacy).catch((e: unknown) => e as Error);
103+
const err = await refusal(kernel.use(legacy));
88104
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
89105
expect(err.message).toContain('@os-fixture/legacy-ui');
90106
expect(err.message).toContain("at 'type'");
@@ -194,7 +210,7 @@ describe('D — the other two refusals the changeset states', () => {
194210
const kernel = makeKernel();
195211
const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', slug: 'Not A Slug' });
196212

197-
const err = await kernel.use(bad).catch((e: unknown) => e as Error);
213+
const err = await refusal(kernel.use(bad));
198214
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
199215
expect(err.message).toContain("at 'slug'");
200216
});
@@ -210,7 +226,7 @@ describe('D — the other two refusals the changeset states', () => {
210226
const kernel = makeKernel();
211227
const bad = fixture({ name: '@os-fixture/bad-homepage', homepage: 'not-a-url' });
212228

213-
const err = await kernel.use(bad).catch((e: unknown) => e as Error);
229+
const err = await refusal(kernel.use(bad));
214230
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
215231
expect(err.message).toContain("at 'homepage'");
216232
});
@@ -250,7 +266,7 @@ describe('E — `version` is DELIBERATELY not enforced from the schema', () => {
250266

251267
// Unchanged message and unchanged owner: this refusal is
252268
// `validatePluginStructure`'s, not the contract check's.
253-
const err = await kernel.use(bad).catch((e: unknown) => e as Error);
269+
const err = await refusal(kernel.use(bad));
254270
expect(err.message).toContain('Invalid semantic version');
255271
expect(err.message).not.toContain('PLUGIN_CONTRACT_VIOLATION');
256272
});

0 commit comments

Comments
 (0)