Skip to content

Commit d9e1587

Browse files
os-billclaude
andauthored
fix(spec): state the grammar PluginSchema.version enforces instead of claiming SemVer (#17463)
`PluginSchema.version` (`kernel/plugin.zod.ts`) was described `"Semantic Version"`, bare, and `PluginLoader.isValidSemanticVersion` (`packages/core`) carried that name — while the one regex they share, character for character, accepts eight strings SemVer 2.0.0 forbids: §2 leading zeroes in the numeric core — 01.1.1, 1.01.1, 1.1.01 §9 empty / leading-zero prerelease ids — 1.0.0-0123, 1.0.0-alpha..1, 1.0.0-alpha.., 1.0.0-. §10 degenerate build metadata — 1.0.0+. Neither regex moves here, in either direction, and all eight keep parsing. The accept set is frozen: the leading-zero half predates the widening that gave this key its suffix groups — the original /^\d+\.\d+\.\d+$/ admitted 01.1.1 too, because \d+ always has — so narrowing to the official SemVer regex would refuse plugin objects that load today, which the ruling on this key forbids. With one side of the declared/enforced pair frozen, the repair is on the other side: the claim. - the describe() states the shape (major.minor.patch, optional -prerelease and +build) and disclaims the standard it exceeds, following ManifestSchema.version, which already spells (major.minor.patch) rather than leaning on the word SemVer - isValidSemanticVersion becomes isSemverShapedVersion, because a predicate named for SemVer that answers a wider grammar gets misused by the next caller no matter what its docblock says. The symbol is private and package-internal — measured against the built dist/index.d.ts: TS2305 on a named import, TS2341 on member access, while a public member on the same class compiles — so nothing published is removed - all eight forms are pinned as ACCEPTED on both declarations, so a future edit that "corrects" the grammar fails on purpose, and the describe() is pinned against reverting to the bare claim content/docs/references/kernel/plugin.mdx is regenerated (build-docs.ts); packages/spec/json-schema/ is gitignored and carries the same string. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92865f6 commit d9e1587

8 files changed

Lines changed: 198 additions & 24 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`PluginSchema.version` now describes the grammar it actually enforces instead of calling itself `"Semantic Version"`.
6+
7+
The key's regex accepts **every** SemVer 2.0.0-valid string and, additionally, eight strings SemVer 2.0.0 forbids:
8+
9+
| SemVer 2.0.0 rule | Strings this key accepts anyway |
10+
|---|---|
11+
| §2 — numeric identifiers MUST NOT include leading zeroes | `01.1.1`, `1.01.1`, `1.1.01` |
12+
| §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-.` |
13+
| §10 — build-metadata identifiers MUST NOT be empty | `1.0.0+.` |
14+
15+
**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.
16+
17+
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".
18+
19+
**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.
20+
21+
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.
22+
23+
`@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.

content/docs/references/kernel/plugin.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const result = PluginSchema.parse(data);
3131
| **staticPath** | `string` | optional | Absolute path to static assets (Required for type="ui") |
3232
| **slug** | `string` | optional | URL path segment (Required for type="ui") |
3333
| **default** | `boolean` | optional | Serve at root path (Only one "ui" plugin can be default) |
34-
| **version** | `string` | optional | Semantic Version |
34+
| **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. |
3535
| **description** | `string` | optional | |
3636
| **author** | `string` | optional | |
3737
| **homepage** | `string` | optional | |

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ describe('E — `version` is the NINTH enforced key, and admitting it refused no
348348
* `version` used to be filtered out of this check. It was, because the two
349349
* declarations disagreed: `PluginSchema.version` was `/^\d+\.\d+\.\d+$/`
350350
* and refused the prerelease and build-metadata forms SemVer 2.0.0 defines,
351-
* while `PluginLoader.isValidSemanticVersion` — the check the boot path has
351+
* while `PluginLoader.isSemverShapedVersion` — the check the boot path has
352352
* always run — accepted them, deliberately, pinned by `plugin-loader.test.ts`.
353353
*
354354
* #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
376376
}
377377
});
378378

379+
/**
380+
* #17070 — the two declarations still share ONE grammar, measured over the
381+
* eight strings SemVer 2.0.0 forbids and both of them accept.
382+
*
383+
* ⭐ This is the convergence assertion for the pair, and it is the reason
384+
* #17070 could repair the CLAIM on both sides from a single card: schema and
385+
* loader are one accept set with two names on it. If a future edit moves one
386+
* spelling and not the other, this fails — and both docblocks that promise
387+
* "character for character" become false at the same moment.
388+
*
389+
* ⛔ The direction here is deliberate and frozen. #16365 ruled widen-never-
390+
* narrow, so these eight are pinned as ACCEPTED, not as a defect awaiting
391+
* cleanup; `01.1.1` loaded before either card existed. What #17070 changed
392+
* is the description on the spec key and the name of the loader's predicate
393+
* (`isSemverShapedVersion`), so that the accept set and the claim about it
394+
* finally agree.
395+
*/
396+
const SEMVER_FORBIDS = [
397+
'01.1.1', '1.01.1', '1.1.01', // §2
398+
'1.0.0-0123', '1.0.0-alpha..1', '1.0.0-alpha..', '1.0.0-.', // §9
399+
'1.0.0+.', // §10
400+
];
401+
402+
it.each(SEMVER_FORBIDS)('`PluginSchema` accepts %s — the spec half of the shared grammar', (version) => {
403+
expect(PluginSchema.safeParse({ name: 'x', version, init: () => {} }).success).toBe(true);
404+
});
405+
406+
it.each(SEMVER_FORBIDS)('and `kernel.use()` boots it — the loader half agrees on %s', async (version) => {
407+
const kernel = makeKernel();
408+
409+
await expect(kernel.use(fixture({ name: `com.example.fringe-${version}`, version }))).resolves.toBe(kernel);
410+
});
411+
379412
it('and a malformed version is STILL refused by the loader, with its own message', async () => {
380413
const kernel = makeKernel();
381414
const bad = fixture({ name: 'com.example.bad-version', version: 'v1.0.0' });

packages/core/src/plugin-contract.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ import type { Plugin } from './types.js';
123123
* This function used to filter `version` issues out. It did so because the two
124124
* declarations disagreed: `PluginSchema.version` was `/^\d+\.\d+\.\d+$/` and
125125
* refused the prerelease and build-metadata forms SemVer 2.0.0 defines, while
126-
* `PluginLoader.isValidSemanticVersion` — the check the loader has always run —
126+
* `PluginLoader.isSemverShapedVersion` — the check the loader has always run —
127127
* implemented the full grammar and accepted them. Enforcing the narrow spelling
128128
* would have RETIRED a pinned capability under a card that ruled on `type`, so
129129
* the disagreement was declared here rather than performed.

packages/core/src/plugin-loader.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,43 @@ describe('PluginLoader', () => {
124124
expect(result.success).toBe(false);
125125
}
126126
});
127+
128+
/**
129+
* #17070 — the eight strings SemVer 2.0.0 forbids that this loader
130+
* LOADS, all of them, pinned as accepted.
131+
*
132+
* ⭐ The predicate behind these is `isSemverShapedVersion`, renamed in
133+
* #17070 off the name `isValidSemanticVersion`, because a predicate
134+
* named for SemVer that answers a wider grammar gets misused by the next
135+
* caller no matter what its docblock says. This block is what makes the
136+
* new name TRUE rather than merely better-worded: the grammar is a
137+
* strict superset of SemVer 2.0.0, and here is the part that exceeds it.
138+
*
139+
* ⛔ These pass on purpose. `01.1.1` has loaded since before #16365 —
140+
* the pre-#16365 `/^\d+\.\d+\.\d+$/` admitted it too — and #16365
141+
* ruled that nothing which loads today may stop loading. Narrowing this
142+
* check to the official SemVer regex reverses that ruling and breaks
143+
* every plugin published against the wider grammar; it is a published
144+
* behaviour change wanting its own card, not a cleanup.
145+
*/
146+
it.each([
147+
// §2 — numeric identifiers MUST NOT include leading zeroes.
148+
'01.1.1', '1.01.1', '1.1.01',
149+
// §9 — prerelease identifiers MUST NOT be empty or carry leading zeroes.
150+
'1.0.0-0123', '1.0.0-alpha..1', '1.0.0-alpha..', '1.0.0-.',
151+
// §10 — build-metadata identifiers MUST NOT be empty.
152+
'1.0.0+.',
153+
])('loads a plugin versioned %s, which SemVer 2.0.0 forbids — deliberately', async (version) => {
154+
const plugin: Plugin = {
155+
name: `semver-fringe-${version}`,
156+
version,
157+
init: async () => {},
158+
};
159+
160+
const result = await loader.loadPlugin(plugin);
161+
expect(result.success).toBe(true);
162+
});
163+
127164
});
128165

129166
describe('Service Factory Registration', () => {

packages/core/src/plugin-loader.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ export class PluginLoader {
409409
throw new Error('Plugin init function is required');
410410
}
411411

412-
if (!this.isValidSemanticVersion(plugin.version)) {
412+
if (!this.isSemverShapedVersion(plugin.version)) {
413413
throw new Error(`Invalid semantic version: ${plugin.version}`);
414414
}
415415
}
@@ -434,7 +434,7 @@ export class PluginLoader {
434434
*
435435
* ⭐ Those structural checks no longer DISAGREE with the schema, which for
436436
* `version` they used to. #16365 gave `PluginSchema.version` the grammar
437-
* {@link isValidSemanticVersion} implements, character for character, and
437+
* {@link isSemverShapedVersion} implements, character for character, and
438438
* `plugin-contract.ts` dropped the `version` exclusion it carried while the
439439
* two spellings differed. Both now judge `version` by the same regex, so
440440
* this method and the one above it can only agree on that key; the
@@ -454,7 +454,7 @@ export class PluginLoader {
454454
// In a real implementation, this would check against kernel version
455455
const version = plugin.version;
456456

457-
if (!this.isValidSemanticVersion(version)) {
457+
if (!this.isSemverShapedVersion(version)) {
458458
return {
459459
compatible: false,
460460
pluginVersion: version,
@@ -468,7 +468,40 @@ export class PluginLoader {
468468
};
469469
}
470470

471-
private isValidSemanticVersion(version: string): boolean {
471+
/**
472+
* Does `version` have the SHAPE this loader accepts — `major.minor.patch`
473+
* with an optional `-prerelease` and an optional `+build` suffix?
474+
*
475+
* ⛔ This is NOT a SemVer 2.0.0 conformance check, and was renamed off that
476+
* claim in #17070 precisely so the next caller does not read it as one. The
477+
* grammar below is a strict SUPERSET of SemVer 2.0.0: it accepts every
478+
* SemVer-valid string — there is no gap in that direction — and ADDITIONALLY
479+
* accepts eight forms SemVer 2.0.0 forbids:
480+
*
481+
* - leading zeroes in the numeric core (§2) — `01.1.1`, `1.01.1`, `1.1.01`
482+
* - leading-zero / empty prerelease identifiers (§9) — `1.0.0-0123`,
483+
* `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.`
484+
* - degenerate build metadata (§10) — `1.0.0+.`
485+
*
486+
* ⭐ Those eight are accepted DELIBERATELY and are pinned as accepted in
487+
* `plugin-loader.test.ts`. `01.1.1` predates every card here — the original
488+
* `/^\d+\.\d+\.\d+$/` admitted it too, because `\d+` always has — and
489+
* #16365's ruling (widen, never narrow: nothing that loads today stops
490+
* loading) froze the accept set. So #17070 moved the CLAIM instead of the
491+
* grammar: the regex below is byte-for-byte what it has been, and this
492+
* method's name and this docblock are what changed.
493+
*
494+
* ⚠️ Need real SemVer 2.0.0 conformance — ordering, precedence, or a
495+
* standards-compliant verdict? This is not that predicate; do not reach for
496+
* it. `dependency-resolver.ts` parses and COMPARES versions and is the
497+
* module to extend.
498+
*
499+
* ⭐ This regex is `PluginSchema.version`'s spelling character for character
500+
* (`@objectstack/spec`, `kernel/plugin.zod.ts`) — the convergence #16365
501+
* created, and a property `plugin-loader.test.ts` asserts rather than
502+
* narrates. Change one spelling and you must change both.
503+
*/
504+
private isSemverShapedVersion(version: string): boolean {
472505
const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
473506
return semverRegex.test(version);
474507
}

packages/spec/src/kernel/plugin.test.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,21 @@ describe('PluginSchema (descriptor only)', () => {
100100

101101
describe('`version` accepts the whole of the SemVer 2.0.0 grammar (#16365)', () => {
102102
/**
103-
* The key is described `'Semantic Version'`, with no qualifier, and SemVer
103+
* The key WAS described `'Semantic Version'`, with no qualifier, and SemVer
104104
* 2.0.0 defines prerelease and build metadata as PARTS of a semantic version
105105
* — so the regex that shipped, `/^\d+\.\d+\.\d+$/`, refused strings the
106106
* key's own declaration called valid. #16365 gave the regex the grammar the
107107
* describe already claimed.
108108
*
109-
* ⭐ The grammar adopted is `PluginLoader.isValidSemanticVersion`'s
109+
* ⭐ The grammar adopted is `PluginLoader.isSemverShapedVersion`'s
110110
* (`packages/core`), character for character, and NOT a third spelling: that
111111
* is the check the boot path has always run, so the two declarations now
112112
* converge exactly and `packages/core`'s `assertPluginContract` could drop the
113113
* `version` exclusion it carried while they differed.
114+
*
115+
* ⚠️ #17070 then found the OTHER half of the same mismatch and moved the
116+
* describe(), not the regex — see the eight-form pin at the bottom of this
117+
* block for what the key actually accepts and why that is deliberate.
114118
*/
115119
const parses = (version: string) => PluginSchema.safeParse({ version }).success;
116120

@@ -155,17 +159,50 @@ describe('`version` accepts the whole of the SemVer 2.0.0 grammar (#16365)', ()
155159
}
156160
});
157161

158-
it('⚠️ and is wider than SemVer 2.0.0 itself, in a fringe #16365 did not introduce', () => {
159-
// Stated so the next reader does not mistake this key for the official
160-
// SemVer grammar. Leading zeroes in the numeric core were accepted by BOTH
161-
// spellings before this change — tightening to the official regex would
162-
// have NARROWED the key, which is what #16365 forbade — and the prerelease
163-
// and build classes the loader's grammar admits are looser than SemVer's
164-
// dot-separated-identifier rules. Closing this fringe is its own card, on
165-
// this key and `isValidSemanticVersion` together.
166-
for (const version of ['01.1.1', '1.0.0-alpha..1', '1.0.0-0123', '1.0.0+.']) {
167-
expect(parses(version)).toBe(true);
168-
}
162+
/**
163+
* #17070 — the eight strings SemVer 2.0.0 forbids that this key accepts, all
164+
* of them, pinned as ACCEPTED.
165+
*
166+
* ⭐ This block asserts the accept set is WIDER than the standard on purpose.
167+
* Read it as a fixture of the ruling, not as a description of a defect: a
168+
* future edit that "fixes" the grammar to be standards-correct fails here, and
169+
* that failure is the point. #16365 ruled widen-never-narrow on the ground
170+
* that nothing which loads today may stop loading, and `01.1.1` has loaded
171+
* since before either card — the ORIGINAL `/^\d+\.\d+\.\d+$/` admitted it
172+
* too, because `\d+` has always admitted a leading zero. So the accept set is
173+
* frozen in both directions, and #17070 repaired the mismatch from the only
174+
* side left free: the key's own description.
175+
*
176+
* ⛔ Do not narrow this key to the official SemVer 2.0.0 regex to make these
177+
* cases pass "properly" — that reverses a recorded ruling and is a published
178+
* behaviour change on both this schema and `PluginLoader`. Widening the accept
179+
* set needs its own card too; this pin is the tripwire for both directions.
180+
*/
181+
it.each([
182+
// SemVer 2.0.0 §2 — numeric identifiers MUST NOT include leading zeroes.
183+
'01.1.1', '1.01.1', '1.1.01',
184+
// §9 — prerelease identifiers MUST NOT be empty, and numeric ones MUST NOT
185+
// carry leading zeroes.
186+
'1.0.0-0123', '1.0.0-alpha..1', '1.0.0-alpha..', '1.0.0-.',
187+
// §10 — build-metadata identifiers MUST NOT be empty.
188+
'1.0.0+.',
189+
])('accepts %s, which SemVer 2.0.0 forbids — deliberately, and the describe() now says so', (version) => {
190+
expect(parses(version)).toBe(true);
191+
});
192+
193+
it('the describe() no longer claims a standard this key does not implement (#17070)', () => {
194+
// The honesty, enforced instead of narrated. The old text was the bare
195+
// `'Semantic Version'`; a reader took that as SemVer 2.0.0 conformance and
196+
// was wrong for all eight strings above. The replacement has to do two
197+
// things: state the grammar in a form an author can predict a verdict from,
198+
// and stop asserting conformance to the standard it exceeds.
199+
const description = PluginSchema.shape.version.description ?? '';
200+
201+
expect(description).not.toBe('Semantic Version');
202+
// States the shape...
203+
expect(description).toContain('major.minor.patch');
204+
// ...and disclaims the standard rather than merely omitting the word.
205+
expect(description).toMatch(/looser than SemVer 2\.0\.0/i);
169206
});
170207
});
171208

packages/spec/src/kernel/plugin.zod.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ export const PluginSchema = lazySchema(() => z.object({
180180
// refused `1.0.0-alpha.1` and `1.0.0+20230101` — a declaration refusing part
181181
// of what it declared.
182182
//
183-
// ⭐ This is `PluginLoader.isValidSemanticVersion`'s spelling character for
183+
// ⭐ This is `PluginLoader.isSemverShapedVersion`'s spelling character for
184184
// character (`packages/core/src/plugin-loader.ts`), deliberately, and not a
185185
// third grammar invented here. That check is the one the boot path has always
186186
// run, so adopting it makes the two declarations converge EXACTLY — which is
@@ -196,9 +196,20 @@ export const PluginSchema = lazySchema(() => z.object({
196196
// degenerate identifier forms SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`,
197197
// `1.0.0+.`). Tightening to the official SemVer 2.0.0 regex would therefore
198198
// have NARROWED this key — refusing `01.1.1`, which it accepts today — which
199-
// is the one thing the #16365 ruling forbids. Closing that fringe is its own
200-
// card, on the loader and this key together.
201-
version: z.string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/).optional().describe('Semantic Version'),
199+
// is the one thing the #16365 ruling forbids.
200+
//
201+
// #17070 — so the DESCRIPTION moved instead, and the regex did not. With the
202+
// accept set frozen by #16365's ruling, the only side of the declared/enforced
203+
// pair still free to move is the claim, and `'Semantic Version'` — bare, with
204+
// no qualifier — was the false half: it named a standard this key does not
205+
// implement. The describe() below states the grammar actually enforced, in the
206+
// shape `ManifestSchema.version` already uses (`kernel/manifest.zod.ts`, whose
207+
// TSDoc spells `(major.minor.patch)` rather than leaning on the word SemVer),
208+
// so an author reading it can predict the verdict on their own string. The
209+
// eight forbidden forms are pinned as ACCEPTED in `plugin.test.ts` — stated
210+
// and enforced, not narrated — and `PluginLoader`'s predicate was renamed
211+
// `isSemverShapedVersion` in the same change, for the same reason.
212+
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.'),
202213
description: z.string().optional(),
203214
author: z.string().optional(),
204215
homepage: z.string().url().optional(),

0 commit comments

Comments
 (0)