diff --git a/.changeset/plugin-version-semver-grammar.md b/.changeset/plugin-version-semver-grammar.md new file mode 100644 index 0000000000..4cd3a9306a --- /dev/null +++ b/.changeset/plugin-version-semver-grammar.md @@ -0,0 +1,45 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": minor +--- + +`PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces. + +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: + +| Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` | +|---|---|---| +| `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** | +| `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** | + +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. + +**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. + +**`@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. + +**`@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: + +- **`id`** — a non-string, or the empty string. +- **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. +- **`staticPath`** — a non-string. +- **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. +- **`default`** — a non-boolean. +- **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.** +- **`description`** — a non-string. +- **`author`** — a non-string. +- **`homepage`** — a non-string, or a string that is not a URL. + +**`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. + +⚠️ **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. + +**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. + +**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. + +⚠️ **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. + +**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. + + diff --git a/packages/core/src/plugin-contract-enforcement.test.ts b/packages/core/src/plugin-contract-enforcement.test.ts index 3f281fe290..2f93aea32e 100644 --- a/packages/core/src/plugin-contract-enforcement.test.ts +++ b/packages/core/src/plugin-contract-enforcement.test.ts @@ -44,7 +44,7 @@ import { ObjectKernel } from './kernel.js'; import { LiteKernel } from './lite-kernel.js'; import { PluginLoader } from './plugin-loader.js'; import { ObjectLogger } from './logger.js'; -import { PLUGIN_UI_REQUIRED_KEY_MISSING } from '@objectstack/spec/kernel'; +import { PLUGIN_UI_REQUIRED_KEY_MISSING, PluginSchema } from '@objectstack/spec/kernel'; import type { Plugin, PluginContext } from './types.js'; /** 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 }); }); -describe('E — `version` is DELIBERATELY not enforced from the schema', () => { +describe('E — `version` is the NINTH enforced key, and admitting it refused nothing (#16365)', () => { /** - * `PluginSchema.version` is `/^\d+\.\d+\.\d+$/` and refuses the prerelease - * and build-metadata forms SemVer 2.0.0 defines, while the loader's own - * `isValidSemanticVersion` — the check that has always run — accepts them, - * and `plugin-loader.test.ts` pins that acceptance deliberately. Enforcing - * the schema's narrower spelling would retire a pinned capability under a - * card that ruled on `type`, so the loader's check stays authoritative for - * this one key. These cases pin the exclusion so a later change to it is a - * decision rather than an accident. + * `version` used to be filtered out of this check. It was, because the two + * declarations disagreed: `PluginSchema.version` was `/^\d+\.\d+\.\d+$/` + * and refused the prerelease and build-metadata forms SemVer 2.0.0 defines, + * while `PluginLoader.isValidSemanticVersion` — the check the boot path has + * always run — accepted them, deliberately, pinned by `plugin-loader.test.ts`. + * + * #16365 settled that in `packages/spec` by WIDENING the schema onto the + * loader's grammar, character for character, so the exclusion had nothing + * left to exclude and `assertPluginContract` dropped it. These cases are the + * measurement that dropping it cost nothing: the three versions that were + * only loading BECAUSE of the exclusion still load without it. */ it.each(['1.0.0-alpha.1', '1.0.0+20230101', '0.0.0-fixture'])( 'still loads a plugin versioned %s', @@ -364,12 +367,23 @@ describe('E — `version` is DELIBERATELY not enforced from the schema', () => { }, ); - it('and a version the LOADER refuses is still refused, by the loader', async () => { + it('a version the SCHEMA now accepts is one `PluginSchema` itself accepts — not just the loader', () => { + // The convergence, read at its source rather than inferred from a boot + // that has two checks in it. Were the schema still the narrow spelling, + // this would fail here while `use()` above stayed green on the loader. + for (const version of ['1.0.0-alpha.1', '1.0.0+20230101', '0.0.0-fixture']) { + expect(PluginSchema.safeParse({ name: 'x', version, init: () => {} }).success).toBe(true); + } + }); + + it('and a malformed version is STILL refused by the loader, with its own message', async () => { const kernel = makeKernel(); const bad = fixture({ name: 'com.example.bad-version', version: 'v1.0.0' }); - // Unchanged message and unchanged owner: this refusal is - // `validatePluginStructure`'s, not the contract check's. + // Unchanged message and unchanged owner. `validatePluginStructure` runs + // BEFORE the contract check, so on this kernel a malformed `version` is + // still the loader's refusal even though the schema would now refuse it + // too — the ORDER is the observable thing, and it did not move. const err = await refusal(kernel.use(bad)); expect(err.message).toContain('Invalid semantic version'); expect(err.message).not.toContain('PLUGIN_CONTRACT_VIOLATION'); @@ -477,18 +491,41 @@ describe('G — the SAME contract on LiteKernel.use() (#16721)', () => { }); it.each(['1.0.0-alpha.1', '1.0.0+20230101', '0.0.0-fixture'])( - '`version` stays excluded from the schema check here too — %s loads', + '`version` is judged here too now, and %s passes it', (version) => { - // The convergence is on the SCHEMA. `LiteKernel` has never judged - // `version` (that is `PluginLoader.validatePluginStructure`'s, on the - // other kernel) and still does not; the exclusion group E pins for the - // loader holds on this path for the same measured reason. + // The convergence is on the SCHEMA, so #16365 reaches this kernel by + // the same route as the other eight keys. These three loaded before + // because `version` was excluded; they load now because the schema + // was widened onto the grammar that always accepted them. const kernel = makeLiteKernel(); expect(kernel.use(fixture({ name: `com.example.lite-v-${version}`, version }))).toBe(kernel); }, ); - it('a version-less plugin loads — `version` is not among the eight keys', () => { + it('⭐ a malformed `version` is refused HERE for the first time — the last key where the kernels disagreed', () => { + // This is the behaviour change #16365 lands on `LiteKernel`, stated as a + // test because it is the one thing dropping the exclusion NARROWS. + // + // `LiteKernel` never ran `validatePluginStructure`, so while `version` + // was excluded from the schema check it was the ONE declared key this + // kernel did not judge at all: `version: 'v1.0.0'` registered here and + // was refused by `ObjectKernel` at boot — precisely the green-in-vitest, + // refused-in-production split #16721 converged the other eight keys to + // close. It now travels the ordinary envelope. + const kernel = makeLiteKernel(); + const bad = fixture({ name: 'com.example.lite-bad-version', version: 'v1.0.0' }); + + const err = refusalSync(() => kernel.use(bad)); + expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message).toContain("at 'version'"); + expect((err as Error & { code?: string }).code).toBe('PLUGIN_CONTRACT_VIOLATION'); + }); + + it('a version-less plugin loads — `version` is among the nine keys, but it is `.optional()`', () => { + // Absence is not a violation. `version` became the ninth ENFORCED key at + // #16365, which judges the value an author writes; `.optional()` is what + // admits writing none. ⇒ this case survived that change unaltered, and + // says why rather than counting keys. const kernel = makeLiteKernel(); const versionless: Plugin = { name: 'com.example.lite-versionless', init: () => {} }; expect(kernel.use(versionless)).toBe(kernel); diff --git a/packages/core/src/plugin-contract.ts b/packages/core/src/plugin-contract.ts index fd5204bf81..e207c159bf 100644 --- a/packages/core/src/plugin-contract.ts +++ b/packages/core/src/plugin-contract.ts @@ -31,27 +31,29 @@ import type { Plugin } from './types.js'; * module converges EXISTING enforcement onto the second kernel; it does not * mint a public validation API. Both kernels import it by relative path. * - * ## What this refuses: the EIGHT declared keys, and `null` on any of them + * ## What this refuses: the NINE declared keys, and `null` on any of them * * `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) declares nine - * optional keys; the filter below drops `version` (see below), so the - * accept-set narrowing this function performs covers exactly these eight, each - * reported as `at ''`: + * optional keys, and since #16365 this function reaches ALL NINE — the + * `version` filter that stood here was a stopgap and is gone (see below). Each + * is reported as `at ''`: * * - `id` — a non-string, or the empty string (`z.string().min(1)`). * - `type` — outside the closed set `'standard'` + `CORE_PLUGIN_TYPES`. * - `staticPath` — a non-string. * - `slug` — a non-string, or not matching `/^[a-z0-9-_]+$/`. * - `default` — a non-boolean. + * - `version` — a non-string, or a string outside the SemVer 2.0.0 grammar + * `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/`. * - `description` — a non-string. * - `author` — a non-string; an object such as `{ name }` is refused. * - `homepage` — a non-string, or a string that is not a URL. * - * All eight are `.optional()`, which admits absence and `undefined` but - * never an explicit `null` — so `null` on any of the eight is refused too. + * All nine are `.optional()`, which admits absence and `undefined` but + * never an explicit `null` — so `null` on any of the nine is refused too. * * Since #16334 the schema carries ONE conditional requirement on top of - * the eight: `type: 'ui'` owes `staticPath` and `slug`, and `PluginSchema` + * the nine: `type: 'ui'` owes `staticPath` and `slug`, and `PluginSchema` * refuses a `ui` plugin missing either with `PLUGIN_UI_REQUIRED_KEY_MISSING` * at the head of the issue message (`packages/spec/src/kernel/plugin.zod.ts`). * That refusal rides this function's envelope unchanged — reported as @@ -60,12 +62,16 @@ import type { Plugin } from './types.js'; * else. `plugin-contract-enforcement.test.ts` group F pins the surfacing on * `ObjectKernel`; group G pins the same envelope on `LiteKernel`. * - * ⛔ ENUMERATE ALL EIGHT wherever this is restated. The changeset ships to + * ⛔ ENUMERATE ALL NINE wherever this is restated. The changeset ships to * consumers as `CHANGELOG.md` and is what an upgrading author greps after * the refusal, so a shorter enumeration there does not merely omit keys — * it tells an author refused `at 'author'` that their key is not enforced. - * This comment, the #16049 changeset and the `PLUGIN_CONTRACT_VIOLATION` row - * in `dispatcher-error-vocabulary.ts` are the three places that restate it. + * ⚠️ `CHANGELOG.md` is NOT a live document and is deliberately not corrected: + * `@objectstack/core@17.4.0` shipped with both enforcement entries enumerating + * EIGHT and saying `version` is excluded, which is what that release did. The + * #16365 changeset carries the ninth key and supersedes them BY VERSION rather + * than by rewriting them. ⇒ This comment is the authority on the CURRENT set; + * a released entry is the authority on the release it names. * * What this does NOT refuse, which is what bounds the narrowing: UNKNOWN * keys. `PluginSchema` is a plain `z.object` with no `.strict()` — the @@ -112,37 +118,41 @@ import type { Plugin } from './types.js'; * boundary exists, and `dispatcher-error-vocabulary.ts` classifies it * `door: 'none'` / `boot-refusal` for that reason. * - * ## Why `version` is excluded, and why that is not a weakening - * - * MEASURED, not assumed. `PluginSchema.version` is `/^\d+\.\d+\.\d+$/`, which - * refuses the prerelease and build-metadata forms SemVer 2.0.0 defines — while - * `PluginLoader.isValidSemanticVersion`, the check the loader has always run, - * implements the full grammar and accepts them. Two declarations in this - * repository disagree about what a version is, and `plugin-loader.test.ts` - * pins the wider one deliberately: "should accept versions with pre-release - * tags" (`1.0.0-alpha.1`) and "should accept versions with build metadata" - * (`1.0.0+20230101`). Two in-repo class-based plugin fixtures ship - * `version = '0.0.0-fixture'` and boot through the real kernel. - * - * So enforcing the schema's `version` here would not enforce the protocol — - * it would RETIRE a pinned capability, silently, under a card that ruled on - * `type`. Version is not among the eight keys enumerated above. On - * `ObjectKernel` the loader's own `validatePluginStructure` still judges - * `version` with the wider grammar; `LiteKernel` has never judged `version` - * and, under this convergence, still does not — the convergence is on the - * SCHEMA (#16721 ruled on `PluginSchema`), not on the loader's structural - * checks (`name`, `init`, semver), which stay `PluginLoader`'s own. - * Reconciling the two `version` spellings belongs in `packages/spec` beside - * #16334; until then this exclusion is declared here rather than performed by - * leaving the disagreement unmeasured. + * ## `version`: the ninth key, and why admitting it refused nothing new + * + * This function used to filter `version` issues out. It did so because the two + * declarations disagreed: `PluginSchema.version` was `/^\d+\.\d+\.\d+$/` and + * refused the prerelease and build-metadata forms SemVer 2.0.0 defines, while + * `PluginLoader.isValidSemanticVersion` — the check the loader has always run — + * implemented the full grammar and accepted them. Enforcing the narrow spelling + * would have RETIRED a pinned capability under a card that ruled on `type`, so + * the disagreement was declared here rather than performed. + * + * #16365 settled it in `packages/spec`, the direction its triage ruled: the + * SPEC widened. `PluginSchema.version` now carries the loader's grammar + * character for character, so the filter had nothing left to filter and is + * gone. ⭐ The widening is a strict SUPERSET of the regex it replaced, so + * admitting `version` to this function's reach refused NOTHING that loaded + * before — the direct measurement is that the three versions group E and group + * G pin (`1.0.0-alpha.1`, `1.0.0+20230101`, `0.0.0-fixture`) still load, on + * both kernels, with the filter removed. + * + * What did NOT converge, deliberately: the loader's STRUCTURAL checks. On + * `ObjectKernel`, `validatePluginStructure` judges `version` before this + * function is reached and refuses `v1.0.0` with its own `Invalid semantic + * version` message, not `PLUGIN_CONTRACT_VIOLATION`; that ordering is + * unchanged and is pinned. `LiteKernel` has never run `validatePluginStructure` + * and still does not — so on that kernel a malformed `version` is refused for + * the first time here, by the schema, which is exactly the convergence #16721 + * ruled for the other eight keys. */ const PLUGIN_CONTRACT_VIOLATION_CODE = 'PLUGIN_CONTRACT_VIOLATION'; /** * Refuse `plugin` when the DECLARED plugin contract refuses it; return when * it does not. Reads `PluginSchema.safeParse` for `success` and for the first - * non-`version` issue, and NOTHING else — see the module comment for the - * eight keys this reaches, the `version` exclusion and the envelope. + * issue, and NOTHING else — see the module comment for the nine keys this + * reaches and the envelope. * * @throws an `Error` whose `code` is `PLUGIN_CONTRACT_VIOLATION` and whose * message carries the same code at its head, the plugin's name (and @@ -154,12 +164,7 @@ export function assertPluginContract(plugin: Plugin): void { return; } - const issues = result.error.issues.filter((issue) => issue.path[0] !== 'version'); - if (issues.length === 0) { - return; - } - - const first = issues[0]; + const first = result.error.issues[0]; const at = first.path.length > 0 ? first.path.join('.') : '(root)'; const id = (plugin as { id?: unknown }).id; const named = typeof id === 'string' && id.length > 0 diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index fda7363810..d323ebb221 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -420,9 +420,9 @@ export class PluginLoader { * aligns to it"). * * The check itself — `PluginSchema.safeParse` for validation only, the - * eight keys it reaches, the `version` exclusion and the - * `PLUGIN_CONTRACT_VIOLATION` envelope — lives in `plugin-contract.ts`, - * because since #16721 it is ONE statement run by BOTH kernels: + * nine keys it reaches and the `PLUGIN_CONTRACT_VIOLATION` envelope — + * lives in `plugin-contract.ts`, because since #16721 it is ONE statement + * run by BOTH kernels: * `LiteKernel.use()` calls it directly, and `ObjectKernel.use()` reaches * it here, through `loadPlugin`. That module's comment is the authority on * what is refused; this method adds nothing to it and subtracts nothing. @@ -431,6 +431,19 @@ export class PluginLoader { * structural checks one call up ({@link validatePluginStructure} — * `name`, `init`, semver) and the version-compatibility check below. * The convergence is on the schema, not on the loader. + * + * ⭐ Those structural checks no longer DISAGREE with the schema, which for + * `version` they used to. #16365 gave `PluginSchema.version` the grammar + * {@link isValidSemanticVersion} implements, character for character, and + * `plugin-contract.ts` dropped the `version` exclusion it carried while the + * two spellings differed. Both now judge `version` by the same regex, so + * this method and the one above it can only agree on that key; the + * structural checks stay here because they cover `name` and `init` too, + * which `PluginSchema` does not declare — not because they judge `version` + * differently. ⚠️ Their ORDER is still observable and still pinned: + * `validatePluginStructure` runs first, so a malformed `version` on this + * kernel is refused as `Invalid semantic version`, never as + * `PLUGIN_CONTRACT_VIOLATION`. */ private validatePluginContract(plugin: PluginMetadata): void { assertPluginContract(plugin); diff --git a/packages/spec/src/kernel/plugin.test.ts b/packages/spec/src/kernel/plugin.test.ts index f82e819592..de12490a44 100644 --- a/packages/spec/src/kernel/plugin.test.ts +++ b/packages/spec/src/kernel/plugin.test.ts @@ -98,6 +98,77 @@ describe('PluginSchema (descriptor only)', () => { }); }); +describe('`version` accepts the whole of the SemVer 2.0.0 grammar (#16365)', () => { + /** + * The key is described `'Semantic Version'`, with no qualifier, and SemVer + * 2.0.0 defines prerelease and build metadata as PARTS of a semantic version + * — so the regex that shipped, `/^\d+\.\d+\.\d+$/`, refused strings the + * key's own declaration called valid. #16365 gave the regex the grammar the + * describe already claimed. + * + * ⭐ The grammar adopted is `PluginLoader.isValidSemanticVersion`'s + * (`packages/core`), character for character, and NOT a third spelling: that + * is the check the boot path has always run, so the two declarations now + * converge exactly and `packages/core`'s `assertPluginContract` could drop the + * `version` exclusion it carried while they differed. + */ + const parses = (version: string) => PluginSchema.safeParse({ version }).success; + + it.each([ + // Every example the SemVer 2.0.0 spec text itself lists as valid that the + // old regex refused, plus the two the `packages/core` loader pins by name + // and the string two in-repo class-based plugin fixtures actually boot with. + '1.0.0-alpha', '1.0.0-alpha.1', '1.0.0-alpha.beta', '1.0.0-0A.is.legal', + '1.0.0-alpha0.valid', '1.0.0-alpha.0valid', '1.2.3-beta', + '1.0.0+20230101', '1.1.2+meta', '1.0.0+0.build.1-rc.10000aaa-kk-0.1', + '1.1.2-prerelease+meta', '1.0.0-rc.1+build.1', '2.0.0-rc.1+build.123', + '0.0.0-fixture', + ])('accepts %s, which the pre-#16365 regex refused', (version) => { + expect(parses(version)).toBe(true); + }); + + it.each(['0.0.4', '1.2.3', '10.20.30', '1.0.0'])( + 'still accepts the plain release form %s — this was a widening, not a swap', + (version) => { + expect(parses(version)).toBe(true); + }, + ); + + it.each(['1.0', '1', 'v1.0.0', 'invalid', '1.0.0-', '1.0.0+', ''])( + 'still refuses %s', + (version) => { + expect(parses(version)).toBe(false); + }, + ); + + it('is a strict SUPERSET of the regex it replaced — nothing that parsed stops parsing', () => { + // The property the #16365 ruling turns on, asserted rather than asserted + // ABOUT: the old grammar's language is contained in the new one. Both + // spellings are written out here so the containment is checked, not + // narrated — a future tightening of the key fails this line. + const before = /^\d+\.\d+\.\d+$/; + const after = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/; + for (const version of ['0.0.4', '1.2.3', '10.20.30', '01.1.1', '1.01.1', '1.1.01', '0.0.0']) { + expect(before.test(version), `${version} is the OLD grammar's`).toBe(true); + expect(after.test(version), `${version} must survive the widening`).toBe(true); + expect(parses(version), `${version} must still parse`).toBe(true); + } + }); + + it('⚠️ and is wider than SemVer 2.0.0 itself, in a fringe #16365 did not introduce', () => { + // Stated so the next reader does not mistake this key for the official + // SemVer grammar. Leading zeroes in the numeric core were accepted by BOTH + // spellings before this change — tightening to the official regex would + // have NARROWED the key, which is what #16365 forbade — and the prerelease + // and build classes the loader's grammar admits are looser than SemVer's + // dot-separated-identifier rules. Closing this fringe is its own card, on + // this key and `isValidSemanticVersion` together. + for (const version of ['01.1.1', '1.0.0-alpha..1', '1.0.0-0123', '1.0.0+.']) { + expect(parses(version)).toBe(true); + } + }); +}); + describe('the lifecycle-hook family stays retired (#4212)', () => { it('PluginSchema declares none of the five hooks', () => { // The kernel's plugin contract is `init`/`start`/`destroy` diff --git a/packages/spec/src/kernel/plugin.zod.ts b/packages/spec/src/kernel/plugin.zod.ts index a5a4e9131f..198572126d 100644 --- a/packages/spec/src/kernel/plugin.zod.ts +++ b/packages/spec/src/kernel/plugin.zod.ts @@ -174,7 +174,31 @@ export const PluginSchema = lazySchema(() => z.object({ slug: z.string().regex(/^[a-z0-9-_]+$/).optional().describe('URL path segment (Required for type="ui")'), default: z.boolean().optional().describe('Serve at root path (Only one "ui" plugin can be default)'), - version: z.string().regex(/^\d+\.\d+\.\d+$/).optional().describe('Semantic Version'), + // #16365 — the grammar SemVer 2.0.0 actually defines, prerelease and build + // metadata included, which is what `describe('Semantic Version')` has said + // without qualification all along. The regex it replaces, `/^\d+\.\d+\.\d+$/`, + // refused `1.0.0-alpha.1` and `1.0.0+20230101` — a declaration refusing part + // of what it declared. + // + // ⭐ This is `PluginLoader.isValidSemanticVersion`'s spelling character for + // character (`packages/core/src/plugin-loader.ts`), deliberately, and not a + // third grammar invented here. That check is the one the boot path has always + // run, so adopting it makes the two declarations converge EXACTLY — which is + // what let `assertPluginContract` drop the `version` exclusion it carried as a + // stopgap, and is why nothing that loads today is refused now. + // + // ⚠️ MEASURED, not assumed, in both directions. It is a strict SUPERSET of the + // regex it replaces (same three-segment core, two OPTIONAL suffix groups), so + // the accept set only grows. It is ALSO wider than SemVer 2.0.0 itself, in a + // fringe this change neither introduces nor widens: 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 additionally accepts the + // degenerate identifier forms SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, + // `1.0.0+.`). Tightening to the official SemVer 2.0.0 regex would therefore + // have NARROWED this key — refusing `01.1.1`, which it accepts today — which + // is the one thing the #16365 ruling forbids. Closing that fringe is its own + // card, on the loader and this key together. + version: z.string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/).optional().describe('Semantic Version'), description: z.string().optional(), author: z.string().optional(), homepage: z.string().url().optional(),