Skip to content

Commit 07150b3

Browse files
os-billclaude
andauthored
fix(spec, core): give PluginSchema.version the loader grammar its describe declares, and enforce it as the ninth key (#17067)
* fix(spec, core): give PluginSchema.version the SemVer 2.0.0 grammar its describe declares `PluginSchema.version` was `/^\d+\.\d+\.\d+$/` while its `describe()` said "Semantic Version" without qualification — and SemVer 2.0.0 defines prerelease and build metadata as parts of a semantic version. The declaration refused part of what it declared, and `PluginLoader.isValidSemanticVersion`, the check the boot path has always run, accepted the whole grammar. The spec adopts the loader's spelling character for character rather than a third grammar, so the two declarations converge exactly. Measured: it is a strict superset of the regex it replaces, so nothing that parsed stops parsing. With the spellings converged, `assertPluginContract` drops the `version` exclusion it carried as a stopgap; `version` becomes the ninth enforced key. On `ObjectKernel` nothing moves (`validatePluginStructure` still runs first and still owns that refusal); on `LiteKernel`, which never ran the structural checks, a malformed `version` is refused for the first time — the last key on which the two kernels disagreed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * docs(core): cite the shipped 17.4.0 enumeration by version, not as pending A release landed between writing the changeset and merging main: the two enforcement entries that enumerate eight keys and declare the `version` exclusion shipped in @objectstack/core@17.4.0 rather than sitting unreleased. They describe what that release did and stay as written; the supersession is now stated by version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * fix(core): declare the LiteKernel narrowing with the BREAKING banner the gate reads check-adr-0087-registration detects breaking-ness by matching /\*\*BREAKING/, so a changeset that says "NARROWS" only in prose is scored non-breaking and its ADR-0087 disposition marker is never judged at all. The core paragraph now carries the banner in the phrasing the two 17.4.0 precedents for this exact class use; both package levels stay minor and the spec paragraph is untouched. Also corrects a test title that still counted eight keys: a version-less plugin loads because `version` is `.optional()`, not because it is unenforced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0da638c commit 07150b3

6 files changed

Lines changed: 259 additions & 64 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
---
5+
6+
`PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces.
7+
8+
Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema:
9+
10+
| Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` |
11+
|---|---|---|
12+
| `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** |
13+
| `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** |
14+
15+
SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved.
16+
17+
**The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now.
18+
19+
**`@objectstack/spec` — a WIDENING of a published contract.** `Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts.
20+
21+
**`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.** **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** `assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message:
22+
23+
- **`id`** — a non-string, or the empty string.
24+
- **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
25+
- **`staticPath`** — a non-string.
26+
- **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`.
27+
- **`default`** — a non-boolean.
28+
- **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.**
29+
- **`description`** — a non-string.
30+
- **`author`** — a non-string.
31+
- **`homepage`** — a non-string, or a string that is not a URL.
32+
33+
**`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope.
34+
35+
⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything.
36+
37+
**What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth.
38+
39+
**What is unchanged.** `1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods.
40+
41+
⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here.
42+
43+
**Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key.
44+
45+
<!-- adr-0087: not-required (no-migration-prescription) A regex widening on one declared key of `PluginSchema`, plus the removal of a runtime filter that had excluded that key from an existing check. No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or given a different name, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. Stored metadata is untouched; what moves is which plugin OBJECTS a boot accepts — strictly more of them at the schema, and on `LiteKernel` the malformed-`version` objects `ObjectKernel` already refused. The channel that reaches an affected plugin author is the refusal itself, which names the plugin and the offending key at `use()`, and which value a malformed `version` should carry is authoring intent no ledger entry can decide. -->

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

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import { ObjectKernel } from './kernel.js';
4444
import { LiteKernel } from './lite-kernel.js';
4545
import { PluginLoader } from './plugin-loader.js';
4646
import { ObjectLogger } from './logger.js';
47-
import { PLUGIN_UI_REQUIRED_KEY_MISSING } from '@objectstack/spec/kernel';
47+
import { PLUGIN_UI_REQUIRED_KEY_MISSING, PluginSchema } from '@objectstack/spec/kernel';
4848
import type { Plugin, PluginContext } from './types.js';
4949

5050
/** A kernel that registers plugins and installs no process signal handlers. */
@@ -343,16 +343,19 @@ describe('F — a `ui` plugin owes `staticPath` and `slug`, refused at kernel.us
343343
});
344344
});
345345

346-
describe('E — `version` is DELIBERATELY not enforced from the schema', () => {
346+
describe('E — `version` is the NINTH enforced key, and admitting it refused nothing (#16365)', () => {
347347
/**
348-
* `PluginSchema.version` is `/^\d+\.\d+\.\d+$/` and refuses the prerelease
349-
* and build-metadata forms SemVer 2.0.0 defines, while the loader's own
350-
* `isValidSemanticVersion` — the check that has always run — accepts them,
351-
* and `plugin-loader.test.ts` pins that acceptance deliberately. Enforcing
352-
* the schema's narrower spelling would retire a pinned capability under a
353-
* card that ruled on `type`, so the loader's check stays authoritative for
354-
* this one key. These cases pin the exclusion so a later change to it is a
355-
* decision rather than an accident.
348+
* `version` used to be filtered out of this check. It was, because the two
349+
* declarations disagreed: `PluginSchema.version` was `/^\d+\.\d+\.\d+$/`
350+
* and refused the prerelease and build-metadata forms SemVer 2.0.0 defines,
351+
* while `PluginLoader.isValidSemanticVersion` — the check the boot path has
352+
* always run — accepted them, deliberately, pinned by `plugin-loader.test.ts`.
353+
*
354+
* #16365 settled that in `packages/spec` by WIDENING the schema onto the
355+
* loader's grammar, character for character, so the exclusion had nothing
356+
* left to exclude and `assertPluginContract` dropped it. These cases are the
357+
* measurement that dropping it cost nothing: the three versions that were
358+
* only loading BECAUSE of the exclusion still load without it.
356359
*/
357360
it.each(['1.0.0-alpha.1', '1.0.0+20230101', '0.0.0-fixture'])(
358361
'still loads a plugin versioned %s',
@@ -364,12 +367,23 @@ describe('E — `version` is DELIBERATELY not enforced from the schema', () => {
364367
},
365368
);
366369

367-
it('and a version the LOADER refuses is still refused, by the loader', async () => {
370+
it('a version the SCHEMA now accepts is one `PluginSchema` itself accepts — not just the loader', () => {
371+
// The convergence, read at its source rather than inferred from a boot
372+
// that has two checks in it. Were the schema still the narrow spelling,
373+
// this would fail here while `use()` above stayed green on the loader.
374+
for (const version of ['1.0.0-alpha.1', '1.0.0+20230101', '0.0.0-fixture']) {
375+
expect(PluginSchema.safeParse({ name: 'x', version, init: () => {} }).success).toBe(true);
376+
}
377+
});
378+
379+
it('and a malformed version is STILL refused by the loader, with its own message', async () => {
368380
const kernel = makeKernel();
369381
const bad = fixture({ name: 'com.example.bad-version', version: 'v1.0.0' });
370382

371-
// Unchanged message and unchanged owner: this refusal is
372-
// `validatePluginStructure`'s, not the contract check's.
383+
// Unchanged message and unchanged owner. `validatePluginStructure` runs
384+
// BEFORE the contract check, so on this kernel a malformed `version` is
385+
// still the loader's refusal even though the schema would now refuse it
386+
// too — the ORDER is the observable thing, and it did not move.
373387
const err = await refusal(kernel.use(bad));
374388
expect(err.message).toContain('Invalid semantic version');
375389
expect(err.message).not.toContain('PLUGIN_CONTRACT_VIOLATION');
@@ -477,18 +491,41 @@ describe('G — the SAME contract on LiteKernel.use() (#16721)', () => {
477491
});
478492

479493
it.each(['1.0.0-alpha.1', '1.0.0+20230101', '0.0.0-fixture'])(
480-
'`version` stays excluded from the schema check here too — %s loads',
494+
'`version` is judged here too now, and %s passes it',
481495
(version) => {
482-
// The convergence is on the SCHEMA. `LiteKernel` has never judged
483-
// `version` (that is `PluginLoader.validatePluginStructure`'s, on the
484-
// other kernel) and still does not; the exclusion group E pins for the
485-
// loader holds on this path for the same measured reason.
496+
// The convergence is on the SCHEMA, so #16365 reaches this kernel by
497+
// the same route as the other eight keys. These three loaded before
498+
// because `version` was excluded; they load now because the schema
499+
// was widened onto the grammar that always accepted them.
486500
const kernel = makeLiteKernel();
487501
expect(kernel.use(fixture({ name: `com.example.lite-v-${version}`, version }))).toBe(kernel);
488502
},
489503
);
490504

491-
it('a version-less plugin loads — `version` is not among the eight keys', () => {
505+
it('⭐ a malformed `version` is refused HERE for the first time — the last key where the kernels disagreed', () => {
506+
// This is the behaviour change #16365 lands on `LiteKernel`, stated as a
507+
// test because it is the one thing dropping the exclusion NARROWS.
508+
//
509+
// `LiteKernel` never ran `validatePluginStructure`, so while `version`
510+
// was excluded from the schema check it was the ONE declared key this
511+
// kernel did not judge at all: `version: 'v1.0.0'` registered here and
512+
// was refused by `ObjectKernel` at boot — precisely the green-in-vitest,
513+
// refused-in-production split #16721 converged the other eight keys to
514+
// close. It now travels the ordinary envelope.
515+
const kernel = makeLiteKernel();
516+
const bad = fixture({ name: 'com.example.lite-bad-version', version: 'v1.0.0' });
517+
518+
const err = refusalSync(() => kernel.use(bad));
519+
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
520+
expect(err.message).toContain("at 'version'");
521+
expect((err as Error & { code?: string }).code).toBe('PLUGIN_CONTRACT_VIOLATION');
522+
});
523+
524+
it('a version-less plugin loads — `version` is among the nine keys, but it is `.optional()`', () => {
525+
// Absence is not a violation. `version` became the ninth ENFORCED key at
526+
// #16365, which judges the value an author writes; `.optional()` is what
527+
// admits writing none. ⇒ this case survived that change unaltered, and
528+
// says why rather than counting keys.
492529
const kernel = makeLiteKernel();
493530
const versionless: Plugin = { name: 'com.example.lite-versionless', init: () => {} };
494531
expect(kernel.use(versionless)).toBe(kernel);

0 commit comments

Comments
 (0)