From a0d39ef624651b04b699b0d86a7c9f23092a7c63 Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 16 Jun 2026 10:46:02 +0200 Subject: [PATCH] =?UTF-8?q?Make=20checkApiVersion=20semver-based=20(todo?= =?UTF-8?q?=20=C2=A72);=20strict=20parseSemver=20via=20official=20semver?= =?UTF-8?q?=20regex=20(no=20dep),=20major/minor=20compatibility=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/plugin-contract.md | 25 +++++++++++-------- src/plugin.test.ts | 24 +++++++++++++----- src/plugin.ts | 55 ++++++++++++++++++++++++++++++++--------- todo.md | 2 +- 5 files changed, 78 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 29ec6c1..890b14d 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ import { definePlugin } from "../../src/plugin.ts"; import { listShifts } from "./shifts.ts"; export default definePlugin({ - apiVersion: 1, // host contract this plugin targets; mismatch fails loud at boot + apiVersion: "1.0.0", // semver of the host contract this targets; mismatch fails loud at boot id: "scheduling", basePath: "/scheduling", diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index 5b6e2f0..885114b 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -70,7 +70,7 @@ plain typed object. All validation happens at discovery. | Field | Required | Notes | | --- | --- | --- | -| `apiVersion` | yes | Host contract major version the plugin targets — see [Versioning](#contract-versioning). | +| `apiVersion` | yes | Semver of the host contract the plugin targets (e.g. `"1.0.0"`) — see [Versioning](#contract-versioning). | | `basePath` | yes | Absolute, no trailing slash (`/scheduling`). Unique; must not prefix-overlap another plugin's. | | `id` | yes | Globally unique slug. Namespaces `views/`, `/public//`, and (by convention) nav/permission tokens. | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Node `id`s must be globally unique. | @@ -156,19 +156,24 @@ optional but recommended (it documents them and lets the bootstrap seed Keto, § ## Contract versioning -Each manifest declares `apiVersion` — the host contract major version it targets — and the host -exposes the current `HOST_API_VERSION`. At discovery the host runs `checkApiVersion`: +Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built +against — and the host exposes the current `HOST_API_VERSION` (e.g. `"1.0.0"`). The host bumps +**major** on a breaking manifest/handler change and **minor** on an additive one. At discovery +the host parses both with `parseSemver` (the official semver core regex — strict: no ranges, +`v` prefixes, or leading zeros) and applies provider/consumer semantics in `checkApiVersion`: | Plugin `apiVersion` vs host | Result | Host action | | --- | --- | --- | -| equal | `ok` | load | -| less than host | `warn` | load, log — review for deprecated behaviour | -| greater than host | `refuse` | **abort boot** — the plugin needs a newer host | -| missing / not a positive integer | `refuse` | **abort boot** — must be declared | +| same major, same minor (patch ignored) | `ok` | load | +| same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist | +| same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host | +| different major | `refuse` | **abort boot** — incompatible contract | +| missing / not a valid semver | `refuse` | **abort boot** — must be declared | -The version is a single integer bumped only on a **breaking** manifest/handler change; additive -changes don't bump it (hence "older → warn, still load"). There are no semver ranges — pinned, -explicit, in keeping with the project's versioning rules. +The plugin pins one exact version (no ranges — in keeping with the project's pinning rules); the +*host* supplies the caret-style compatibility. `parseSemver`/`checkApiVersion` are tight, +dependency-free functions (the `semver` package's ranges/coercion/prerelease-precedence are more +than the contract needs). ## Conflict rules diff --git a/src/plugin.test.ts b/src/plugin.test.ts index bf720f9..af1584c 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -5,6 +5,7 @@ import { definePlugin, findConflicts, HOST_API_VERSION, + parseSemver, type Plugin, } from "./plugin.ts"; @@ -28,16 +29,27 @@ const scheduling: Plugin = definePlugin({ }); test("definePlugin returns the manifest unchanged — it only types; validation is at discovery (§2)", () => { - const m: Plugin = { apiVersion: 1, basePath: "/x", id: "x" }; + const m: Plugin = { apiVersion: "1.0.0", basePath: "/x", id: "x" }; assert.equal(definePlugin(m), m); // identity, not a copy assert.equal(scheduling.routes?.length, 3); }); -test("checkApiVersion: match ok, older warns, newer/malformed refuses (host refuses or warns, never silent)", () => { - assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); - assert.equal(checkApiVersion(1, 2).level, "warn"); // plugin targets an older host - assert.equal(checkApiVersion(HOST_API_VERSION + 1).level, "refuse"); // needs a newer host - for (const bad of [0, -1, 1.5, "1", undefined, null, Number.NaN]) { +test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => { + assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 }); + assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored + for (const bad of ["1", "1.2", "1.2.3.4", "v1.2.3", "^1.2.3", "01.2.3", "1.2.x", "1.2.3 ", "", 3, null, undefined]) { + assert.equal(parseSemver(bad), null, `${String(bad)} is not a semver`); + } +}); + +test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newer-minor/major-mismatch/malformed refuse", () => { + assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); // "1.0.0" vs "1.0.0" + assert.equal(checkApiVersion("1.0.5", "1.0.0").level, "ok"); // patch never affects compatibility + assert.equal(checkApiVersion("1.0.0", "1.2.0").level, "warn"); // older minor still runs (additive), nudge to update + assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has + assert.equal(checkApiVersion("2.0.0", "1.5.0").level, "refuse"); // incompatible major (newer) + assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older) + for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) { assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`); } }); diff --git a/src/plugin.ts b/src/plugin.ts index 3d697e6..8788085 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -7,9 +7,10 @@ import type { RequestContext } from "./context.ts"; import type { NavNode } from "./nav.ts"; -// Host contract major version. Bump on a breaking manifest/handler change; a plugin pins the -// version it targets via `apiVersion` and the host refuses/warns on mismatch (checkApiVersion). -export const HOST_API_VERSION = 1; +// Host contract version (semver). Bump major on a breaking manifest/handler change, minor on an +// additive one. A plugin pins the version it targets via `apiVersion`; the host applies +// provider/consumer semver semantics in checkApiVersion (refuse/warn on mismatch). +export const HOST_API_VERSION = "1.0.0"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; @@ -45,7 +46,7 @@ export interface PluginHooks { } export interface Plugin { - apiVersion: number; // host contract version this plugin targets (= HOST_API_VERSION) + apiVersion: string; // semver of the host contract this plugin targets (e.g. HOST_API_VERSION) basePath: string; // unique mount prefix, e.g. "/scheduling"; must not overlap another plugin's hooks?: PluginHooks; id: string; // globally unique; namespaces views, /public//, and nav/permission tokens @@ -60,22 +61,52 @@ export function definePlugin(plugin: Plugin): Plugin { return plugin; } +export interface Semver { + major: number; + minor: number; + patch: number; +} + +// The official semver.org 2.0.0 core regex (major.minor.patch, optional prerelease/build) — a +// standardized parse with no dependency. We compare only major/minor for compatibility, so the +// prerelease/build groups are matched (to accept valid input) but otherwise ignored. +const SEMVER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/; + +// Parse a strict semver string → {major, minor, patch}, or null. Rejects ranges/prefixes +// (`^1.2.3`, `v1`), leading zeros, whitespace and missing parts — fail loud over coerce. +export function parseSemver(version: unknown): Semver | null { + if (typeof version !== "string") return null; + const m = SEMVER.exec(version); + if (!m) return null; + return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }; +} + export interface VersionCheck { level: "ok" | "refuse" | "warn"; message: string; } -// The versioning rule: equal → ok; plugin older than host → warn (load, review); plugin newer -// or not a positive integer → refuse. Discovery maps refuse→throw, warn→log. -export function checkApiVersion(pluginVersion: unknown, hostVersion: number = HOST_API_VERSION): VersionCheck { - if (typeof pluginVersion !== "number" || !Number.isInteger(pluginVersion) || pluginVersion < 1) { - return { level: "refuse", message: `apiVersion must be a positive integer; got ${JSON.stringify(pluginVersion)}` }; +// The versioning rule (provider/consumer semver): the host provides a contract version, the +// plugin pins the one it targets. Different major → refuse (breaking either way). Same major, +// plugin minor > host → refuse (needs a newer host). Same major, plugin minor < host → warn +// (additive, still runs — nudge to update). Equal major/minor (patch ignored) → ok. Malformed → +// refuse. Discovery maps refuse→throw, warn→log. +export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HOST_API_VERSION): VersionCheck { + const plugin = parseSemver(pluginVersion); + const host = parseSemver(hostVersion); + if (!host) throw new Error(`hostVersion is not a semver: ${JSON.stringify(hostVersion)}`); // invariant, not user input + if (!plugin) { + return { level: "refuse", message: `apiVersion must be a semver string (e.g. "${hostVersion}"); got ${JSON.stringify(pluginVersion)}` }; } - if (pluginVersion > hostVersion) { + if (plugin.major !== host.major) { + return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — incompatible major` }; + } + if (plugin.minor > host.minor) { return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` }; } - if (pluginVersion < hostVersion) { - return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — review for deprecated behaviour` }; + if (plugin.minor < host.minor) { + return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — newer features available` }; } return { level: "ok", message: `apiVersion ${pluginVersion}` }; } diff --git a/todo.md b/todo.md index c082f46..4673600 100644 --- a/todo.md +++ b/todo.md @@ -45,7 +45,7 @@ everything via Docker. - [x] Add to principles that we should have full E2E coverage in the Playwright tests - make sure they can run in parallel to get up some speed. → Added **Full, parallel E2E** core principle (AGENTS.md §6 + README): every user-facing flow gets a Playwright test shipped with it, tests stay side-effect-free so the suite runs `fullyParallel` (already set; verified 7 tests / 7 workers). Led by example: added E2E coverage for the 404 page (the one user-facing gap). Fixed the documented run command to `--build` (the runner bakes in `e2e/`, so spec edits were silently ignored without it). ## 2. Plugin host -- [x] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime. It is a target that plugins should be able to overload as much as possible. Hooks on actions in the system is not bad either, if it is possible. → `src/plugin.ts` is the typed, machine-readable contract (single source of truth: manifest `Plugin`, `Route`/`RouteResult`/`RouteHandler`, `PermissionDecl`, `PluginHooks`, `definePlugin()`, `HOST_API_VERSION`) plus the pure rules the §2 host enforces — `checkApiVersion` (equal→ok, older→warn, newer/malformed→refuse) and `findConflicts` (id/basePath-overlap/route = error, duplicate nav-id = error, shared permission token = warn; never last-write-wins). `docs/plugin-contract.md` is the prose reference (anatomy, manifest fields, handler/RouteResult, `RequestContext` stability guarantee, nav/permission namespacing, versioning, conflicts, hooks, dev/test story). README links it + example gained `apiVersion`. Tests-first (`plugin.test.ts`); typecheck + 80 units green. Discovery/router/view-resolver/static stay as the next §2 items that wire this to FS+HTTP. +- [x] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime. It is a target that plugins should be able to overload as much as possible. Hooks on actions in the system is not bad either, if it is possible. → `src/plugin.ts` is the typed, machine-readable contract (single source of truth: manifest `Plugin`, `Route`/`RouteResult`/`RouteHandler`, `PermissionDecl`, `PluginHooks`, `definePlugin()`, `HOST_API_VERSION`) plus the pure rules the §2 host enforces — `checkApiVersion` (semver via `parseSemver`/official regex, no dep: same major+minor→ok, older minor→warn, newer minor/major-mismatch/malformed→refuse) and `findConflicts` (id/basePath-overlap/route = error, duplicate nav-id = error, shared permission token = warn; never last-write-wins). `docs/plugin-contract.md` is the prose reference (anatomy, manifest fields, handler/RouteResult, `RequestContext` stability guarantee, nav/permission namespacing, versioning, conflicts, hooks, dev/test story). README links it + example gained `apiVersion`. Tests-first (`plugin.test.ts`); typecheck + 80 units green. Discovery/router/view-resolver/static stay as the next §2 items that wire this to FS+HTTP. - [ ] Discovery: scan `plugins/`, import each `plugin.ts` default export, validate. - [ ] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context. - [ ] Per-plugin view resolver (`plugins//views/*.ejs`).