diff --git a/.changeset/plugin-version-honest-grammar-claim.md b/.changeset/plugin-version-honest-grammar-claim.md new file mode 100644 index 0000000000..3f7e059ef2 --- /dev/null +++ b/.changeset/plugin-version-honest-grammar-claim.md @@ -0,0 +1,23 @@ +--- +"@objectstack/spec": patch +--- + +`PluginSchema.version` now describes the grammar it actually enforces instead of calling itself `"Semantic Version"`. + +The key's regex accepts **every** SemVer 2.0.0-valid string and, additionally, eight strings SemVer 2.0.0 forbids: + +| SemVer 2.0.0 rule | Strings this key accepts anyway | +|---|---| +| §2 — numeric identifiers MUST NOT include leading zeroes | `01.1.1`, `1.01.1`, `1.1.01` | +| §9 — prerelease identifiers MUST NOT be empty or carry leading zeroes | `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` | +| §10 — build-metadata identifiers MUST NOT be empty | `1.0.0+.` | + +**No accepted value moved, in either direction.** The regex is byte-for-byte what it was; the `describe()` string is what changed. The leading-zero half is older than the recent widening — the original `/^\d+\.\d+\.\d+$/` admitted `01.1.1` too, because `\d+` always has — so tightening the key to the official SemVer regex would refuse plugin objects that load today, which the ruling on this key forbids. With the accept set frozen, the only side of the declared/enforced pair still free to move is the claim, and the bare `"Semantic Version"` was the false half: it named a standard this key does not implement. + +The replacement states the shape an author can predict a verdict from — `major.minor.patch` with an optional `-prerelease` and an optional `+build` suffix — and disclaims the standard it exceeds rather than merely dropping the word. This follows `ManifestSchema.version`, which already spells `(major.minor.patch)` explicitly rather than leaning on "SemVer". + +**What consumers see.** The `description` on `version` in the shipped `json-schema/` tree and on the generated `kernel/plugin` reference page. No `pattern`, no `type`, no accepted or rejected value changes, so a tool that validates against this schema behaves identically. + +All eight forms are now pinned as **accepted** — in `packages/spec` (`plugin.test.ts`) and in `packages/core` (`plugin-loader.test.ts`, `plugin-contract-enforcement.test.ts`) — so the honesty is enforced rather than narrated, and a future edit that "corrects" the grammar to be standards-compliant fails those pins on purpose. + +`@objectstack/core` is deliberately **not** listed above. Its `PluginLoader` predicate was renamed `isValidSemanticVersion` to `isSemverShapedVersion` in the same change, for the same reason, but the symbol is `private` and package-internal: measured against the built `dist/index.d.ts`, `import { isValidSemanticVersion } from '@objectstack/core'` is TS2305 (no exported member) and `loader.isValidSemanticVersion` is TS2341 (private), while a public member on the same class compiles. Nothing published moves. diff --git a/content/docs/references/kernel/plugin.mdx b/content/docs/references/kernel/plugin.mdx index 0e8a7cc6d3..49b7472244 100644 --- a/content/docs/references/kernel/plugin.mdx +++ b/content/docs/references/kernel/plugin.mdx @@ -31,7 +31,7 @@ const result = PluginSchema.parse(data); | **staticPath** | `string` | optional | Absolute path to static assets (Required for type="ui") | | **slug** | `string` | optional | URL path segment (Required for type="ui") | | **default** | `boolean` | optional | Serve at root path (Only one "ui" plugin can be default) | -| **version** | `string` | optional | Semantic Version | +| **version** | `string` | optional | Version: major.minor.patch, with an optional -prerelease and an optional +build suffix. Looser than SemVer 2.0.0 — leading zeroes (01.1.1) and empty identifiers (1.0.0-alpha..1) are accepted. | | **description** | `string` | optional | | | **author** | `string` | optional | | | **homepage** | `string` | optional | | diff --git a/packages/core/src/plugin-contract-enforcement.test.ts b/packages/core/src/plugin-contract-enforcement.test.ts index 2f93aea32e..af3567159c 100644 --- a/packages/core/src/plugin-contract-enforcement.test.ts +++ b/packages/core/src/plugin-contract-enforcement.test.ts @@ -348,7 +348,7 @@ describe('E — `version` is the NINTH enforced key, and admitting it refused no * `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 + * while `PluginLoader.isSemverShapedVersion` — 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 @@ -376,6 +376,39 @@ describe('E — `version` is the NINTH enforced key, and admitting it refused no } }); + /** + * #17070 — the two declarations still share ONE grammar, measured over the + * eight strings SemVer 2.0.0 forbids and both of them accept. + * + * ⭐ This is the convergence assertion for the pair, and it is the reason + * #17070 could repair the CLAIM on both sides from a single card: schema and + * loader are one accept set with two names on it. If a future edit moves one + * spelling and not the other, this fails — and both docblocks that promise + * "character for character" become false at the same moment. + * + * ⛔ The direction here is deliberate and frozen. #16365 ruled widen-never- + * narrow, so these eight are pinned as ACCEPTED, not as a defect awaiting + * cleanup; `01.1.1` loaded before either card existed. What #17070 changed + * is the description on the spec key and the name of the loader's predicate + * (`isSemverShapedVersion`), so that the accept set and the claim about it + * finally agree. + */ + const SEMVER_FORBIDS = [ + '01.1.1', '1.01.1', '1.1.01', // §2 + '1.0.0-0123', '1.0.0-alpha..1', '1.0.0-alpha..', '1.0.0-.', // §9 + '1.0.0+.', // §10 + ]; + + it.each(SEMVER_FORBIDS)('`PluginSchema` accepts %s — the spec half of the shared grammar', (version) => { + expect(PluginSchema.safeParse({ name: 'x', version, init: () => {} }).success).toBe(true); + }); + + it.each(SEMVER_FORBIDS)('and `kernel.use()` boots it — the loader half agrees on %s', async (version) => { + const kernel = makeKernel(); + + await expect(kernel.use(fixture({ name: `com.example.fringe-${version}`, version }))).resolves.toBe(kernel); + }); + 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' }); diff --git a/packages/core/src/plugin-contract.ts b/packages/core/src/plugin-contract.ts index e207c159bf..9fb90612be 100644 --- a/packages/core/src/plugin-contract.ts +++ b/packages/core/src/plugin-contract.ts @@ -123,7 +123,7 @@ import type { Plugin } from './types.js'; * 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 — + * `PluginLoader.isSemverShapedVersion` — 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. diff --git a/packages/core/src/plugin-loader.test.ts b/packages/core/src/plugin-loader.test.ts index d70638da2b..b548bb9d6a 100644 --- a/packages/core/src/plugin-loader.test.ts +++ b/packages/core/src/plugin-loader.test.ts @@ -124,6 +124,43 @@ describe('PluginLoader', () => { expect(result.success).toBe(false); } }); + + /** + * #17070 — the eight strings SemVer 2.0.0 forbids that this loader + * LOADS, all of them, pinned as accepted. + * + * ⭐ The predicate behind these is `isSemverShapedVersion`, renamed in + * #17070 off the name `isValidSemanticVersion`, because a predicate + * named for SemVer that answers a wider grammar gets misused by the next + * caller no matter what its docblock says. This block is what makes the + * new name TRUE rather than merely better-worded: the grammar is a + * strict superset of SemVer 2.0.0, and here is the part that exceeds it. + * + * ⛔ These pass on purpose. `01.1.1` has loaded since before #16365 — + * the pre-#16365 `/^\d+\.\d+\.\d+$/` admitted it too — and #16365 + * ruled that nothing which loads today may stop loading. Narrowing this + * check to the official SemVer regex reverses that ruling and breaks + * every plugin published against the wider grammar; it is a published + * behaviour change wanting its own card, not a cleanup. + */ + it.each([ + // §2 — numeric identifiers MUST NOT include leading zeroes. + '01.1.1', '1.01.1', '1.1.01', + // §9 — prerelease identifiers MUST NOT be empty or carry leading zeroes. + '1.0.0-0123', '1.0.0-alpha..1', '1.0.0-alpha..', '1.0.0-.', + // §10 — build-metadata identifiers MUST NOT be empty. + '1.0.0+.', + ])('loads a plugin versioned %s, which SemVer 2.0.0 forbids — deliberately', async (version) => { + const plugin: Plugin = { + name: `semver-fringe-${version}`, + version, + init: async () => {}, + }; + + const result = await loader.loadPlugin(plugin); + expect(result.success).toBe(true); + }); + }); describe('Service Factory Registration', () => { diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index d323ebb221..dfe0ddff2d 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -409,7 +409,7 @@ export class PluginLoader { throw new Error('Plugin init function is required'); } - if (!this.isValidSemanticVersion(plugin.version)) { + if (!this.isSemverShapedVersion(plugin.version)) { throw new Error(`Invalid semantic version: ${plugin.version}`); } } @@ -434,7 +434,7 @@ export class PluginLoader { * * ⭐ 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 + * {@link isSemverShapedVersion} 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 @@ -454,7 +454,7 @@ export class PluginLoader { // In a real implementation, this would check against kernel version const version = plugin.version; - if (!this.isValidSemanticVersion(version)) { + if (!this.isSemverShapedVersion(version)) { return { compatible: false, pluginVersion: version, @@ -468,7 +468,40 @@ export class PluginLoader { }; } - private isValidSemanticVersion(version: string): boolean { + /** + * Does `version` have the SHAPE this loader accepts — `major.minor.patch` + * with an optional `-prerelease` and an optional `+build` suffix? + * + * ⛔ This is NOT a SemVer 2.0.0 conformance check, and was renamed off that + * claim in #17070 precisely so the next caller does not read it as one. The + * grammar below is a strict SUPERSET of SemVer 2.0.0: it accepts every + * SemVer-valid string — there is no gap in that direction — and ADDITIONALLY + * accepts eight forms SemVer 2.0.0 forbids: + * + * - leading zeroes in the numeric core (§2) — `01.1.1`, `1.01.1`, `1.1.01` + * - leading-zero / empty prerelease identifiers (§9) — `1.0.0-0123`, + * `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` + * - degenerate build metadata (§10) — `1.0.0+.` + * + * ⭐ Those eight are accepted DELIBERATELY and are pinned as accepted in + * `plugin-loader.test.ts`. `01.1.1` predates every card here — the original + * `/^\d+\.\d+\.\d+$/` admitted it too, because `\d+` always has — and + * #16365's ruling (widen, never narrow: nothing that loads today stops + * loading) froze the accept set. So #17070 moved the CLAIM instead of the + * grammar: the regex below is byte-for-byte what it has been, and this + * method's name and this docblock are what changed. + * + * ⚠️ Need real SemVer 2.0.0 conformance — ordering, precedence, or a + * standards-compliant verdict? This is not that predicate; do not reach for + * it. `dependency-resolver.ts` parses and COMPARES versions and is the + * module to extend. + * + * ⭐ This regex is `PluginSchema.version`'s spelling character for character + * (`@objectstack/spec`, `kernel/plugin.zod.ts`) — the convergence #16365 + * created, and a property `plugin-loader.test.ts` asserts rather than + * narrates. Change one spelling and you must change both. + */ + private isSemverShapedVersion(version: string): boolean { const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/; return semverRegex.test(version); } diff --git a/packages/spec/src/kernel/plugin.test.ts b/packages/spec/src/kernel/plugin.test.ts index de12490a44..0fa6185cfa 100644 --- a/packages/spec/src/kernel/plugin.test.ts +++ b/packages/spec/src/kernel/plugin.test.ts @@ -100,17 +100,21 @@ 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 + * The key WAS 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 + * ⭐ The grammar adopted is `PluginLoader.isSemverShapedVersion`'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. + * + * ⚠️ #17070 then found the OTHER half of the same mismatch and moved the + * describe(), not the regex — see the eight-form pin at the bottom of this + * block for what the key actually accepts and why that is deliberate. */ const parses = (version: string) => PluginSchema.safeParse({ version }).success; @@ -155,17 +159,50 @@ describe('`version` accepts the whole of the SemVer 2.0.0 grammar (#16365)', () } }); - 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); - } + /** + * #17070 — the eight strings SemVer 2.0.0 forbids that this key accepts, all + * of them, pinned as ACCEPTED. + * + * ⭐ This block asserts the accept set is WIDER than the standard on purpose. + * Read it as a fixture of the ruling, not as a description of a defect: a + * future edit that "fixes" the grammar to be standards-correct fails here, and + * that failure is the point. #16365 ruled widen-never-narrow on the ground + * that nothing which loads today may stop loading, and `01.1.1` has loaded + * since before either card — the ORIGINAL `/^\d+\.\d+\.\d+$/` admitted it + * too, because `\d+` has always admitted a leading zero. So the accept set is + * frozen in both directions, and #17070 repaired the mismatch from the only + * side left free: the key's own description. + * + * ⛔ Do not narrow this key to the official SemVer 2.0.0 regex to make these + * cases pass "properly" — that reverses a recorded ruling and is a published + * behaviour change on both this schema and `PluginLoader`. Widening the accept + * set needs its own card too; this pin is the tripwire for both directions. + */ + it.each([ + // SemVer 2.0.0 §2 — numeric identifiers MUST NOT include leading zeroes. + '01.1.1', '1.01.1', '1.1.01', + // §9 — prerelease identifiers MUST NOT be empty, and numeric ones MUST NOT + // carry leading zeroes. + '1.0.0-0123', '1.0.0-alpha..1', '1.0.0-alpha..', '1.0.0-.', + // §10 — build-metadata identifiers MUST NOT be empty. + '1.0.0+.', + ])('accepts %s, which SemVer 2.0.0 forbids — deliberately, and the describe() now says so', (version) => { + expect(parses(version)).toBe(true); + }); + + it('the describe() no longer claims a standard this key does not implement (#17070)', () => { + // The honesty, enforced instead of narrated. The old text was the bare + // `'Semantic Version'`; a reader took that as SemVer 2.0.0 conformance and + // was wrong for all eight strings above. The replacement has to do two + // things: state the grammar in a form an author can predict a verdict from, + // and stop asserting conformance to the standard it exceeds. + const description = PluginSchema.shape.version.description ?? ''; + + expect(description).not.toBe('Semantic Version'); + // States the shape... + expect(description).toContain('major.minor.patch'); + // ...and disclaims the standard rather than merely omitting the word. + expect(description).toMatch(/looser than SemVer 2\.0\.0/i); }); }); diff --git a/packages/spec/src/kernel/plugin.zod.ts b/packages/spec/src/kernel/plugin.zod.ts index 198572126d..bba7d062e9 100644 --- a/packages/spec/src/kernel/plugin.zod.ts +++ b/packages/spec/src/kernel/plugin.zod.ts @@ -180,7 +180,7 @@ export const PluginSchema = lazySchema(() => z.object({ // 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 + // ⭐ This is `PluginLoader.isSemverShapedVersion`'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 @@ -196,9 +196,20 @@ export const PluginSchema = lazySchema(() => z.object({ // 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'), + // is the one thing the #16365 ruling forbids. + // + // #17070 — so the DESCRIPTION moved instead, and the regex did not. With the + // accept set frozen by #16365's ruling, the only side of the declared/enforced + // pair still free to move is the claim, and `'Semantic Version'` — bare, with + // no qualifier — was the false half: it named a standard this key does not + // implement. The describe() below states the grammar actually enforced, in the + // shape `ManifestSchema.version` already uses (`kernel/manifest.zod.ts`, whose + // TSDoc spells `(major.minor.patch)` rather than leaning on the word SemVer), + // so an author reading it can predict the verdict on their own string. The + // eight forbidden forms are pinned as ACCEPTED in `plugin.test.ts` — stated + // and enforced, not narrated — and `PluginLoader`'s predicate was renamed + // `isSemverShapedVersion` in the same change, for the same reason. + version: z.string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/).optional().describe('Version: major.minor.patch, with an optional -prerelease and an optional +build suffix. Looser than SemVer 2.0.0 — leading zeroes (01.1.1) and empty identifiers (1.0.0-alpha..1) are accepted.'), description: z.string().optional(), author: z.string().optional(), homepage: z.string().url().optional(),