Make checkApiVersion semver-based (todo §2); strict parseSemver via official semver regex (no dep), major/minor compatibility rules

This commit is contained in:
2026-06-16 10:46:02 +02:00
parent 3be67ff8e4
commit a0d39ef624
5 changed files with 78 additions and 30 deletions

View File

@@ -186,7 +186,7 @@ import { definePlugin } from "../../src/plugin.ts";
import { listShifts } from "./shifts.ts"; import { listShifts } from "./shifts.ts";
export default definePlugin({ 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", id: "scheduling",
basePath: "/scheduling", basePath: "/scheduling",

View File

@@ -70,7 +70,7 @@ plain typed object. All validation happens at discovery.
| Field | Required | Notes | | 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. | | `basePath` | yes | Absolute, no trailing slash (`/scheduling`). Unique; must not prefix-overlap another plugin's. |
| `id` | yes | Globally unique slug. Namespaces `views/`, `/public/<id>/`, and (by convention) nav/permission tokens. | | `id` | yes | Globally unique slug. Namespaces `views/`, `/public/<id>/`, and (by convention) nav/permission tokens. |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Node `id`s must be globally unique. | | `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 ## Contract versioning
Each manifest declares `apiVersion`the host contract major version it targets — and the host Each manifest declares `apiVersion`a **semver** string naming the host contract it was built
exposes the current `HOST_API_VERSION`. At discovery the host runs `checkApiVersion`: 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 | | Plugin `apiVersion` vs host | Result | Host action |
| --- | --- | --- | | --- | --- | --- |
| equal | `ok` | load | | same major, same minor (patch ignored) | `ok` | load |
| less than host | `warn` | load, log — review for deprecated behaviour | | same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist |
| greater than host | `refuse` | **abort boot** the plugin needs a newer host | | same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host |
| missing / not a positive integer | `refuse` | **abort boot**must be declared | | 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 The plugin pins one exact version (no ranges — in keeping with the project's pinning rules); the
changes don't bump it (hence "older → warn, still load"). There are no semver ranges — pinned, *host* supplies the caret-style compatibility. `parseSemver`/`checkApiVersion` are tight,
explicit, in keeping with the project's versioning rules. dependency-free functions (the `semver` package's ranges/coercion/prerelease-precedence are more
than the contract needs).
## Conflict rules ## Conflict rules

View File

@@ -5,6 +5,7 @@ import {
definePlugin, definePlugin,
findConflicts, findConflicts,
HOST_API_VERSION, HOST_API_VERSION,
parseSemver,
type Plugin, type Plugin,
} from "./plugin.ts"; } 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)", () => { 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(definePlugin(m), m); // identity, not a copy
assert.equal(scheduling.routes?.length, 3); assert.equal(scheduling.routes?.length, 3);
}); });
test("checkApiVersion: match ok, older warns, newer/malformed refuses (host refuses or warns, never silent)", () => { test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => {
assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 });
assert.equal(checkApiVersion(1, 2).level, "warn"); // plugin targets an older host assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored
assert.equal(checkApiVersion(HOST_API_VERSION + 1).level, "refuse"); // needs a newer host 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]) {
for (const bad of [0, -1, 1.5, "1", undefined, null, Number.NaN]) { 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`); assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`);
} }
}); });

View File

@@ -7,9 +7,10 @@
import type { RequestContext } from "./context.ts"; import type { RequestContext } from "./context.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "./nav.ts";
// Host contract major version. Bump on a breaking manifest/handler change; a plugin pins the // Host contract version (semver). Bump major on a breaking manifest/handler change, minor on an
// version it targets via `apiVersion` and the host refuses/warns on mismatch (checkApiVersion). // additive one. A plugin pins the version it targets via `apiVersion`; the host applies
export const HOST_API_VERSION = 1; // 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"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
@@ -45,7 +46,7 @@ export interface PluginHooks {
} }
export interface Plugin { 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 basePath: string; // unique mount prefix, e.g. "/scheduling"; must not overlap another plugin's
hooks?: PluginHooks; hooks?: PluginHooks;
id: string; // globally unique; namespaces views, /public/<id>/, and nav/permission tokens id: string; // globally unique; namespaces views, /public/<id>/, and nav/permission tokens
@@ -60,22 +61,52 @@ export function definePlugin(plugin: Plugin): Plugin {
return 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 { export interface VersionCheck {
level: "ok" | "refuse" | "warn"; level: "ok" | "refuse" | "warn";
message: string; message: string;
} }
// The versioning rule: equal → ok; plugin older than host → warn (load, review); plugin newer // The versioning rule (provider/consumer semver): the host provides a contract version, the
// or not a positive integer → refuse. Discovery maps refuse→throw, warn→log. // plugin pins the one it targets. Different major → refuse (breaking either way). Same major,
export function checkApiVersion(pluginVersion: unknown, hostVersion: number = HOST_API_VERSION): VersionCheck { // plugin minor > host → refuse (needs a newer host). Same major, plugin minor < host → warn
if (typeof pluginVersion !== "number" || !Number.isInteger(pluginVersion) || pluginVersion < 1) { // (additive, still runs — nudge to update). Equal major/minor (patch ignored) → ok. Malformed →
return { level: "refuse", message: `apiVersion must be a positive integer; got ${JSON.stringify(pluginVersion)}` }; // 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` }; return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
} }
if (pluginVersion < hostVersion) { if (plugin.minor < host.minor) {
return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion}review for deprecated behaviour` }; return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion}newer features available` };
} }
return { level: "ok", message: `apiVersion ${pluginVersion}` }; return { level: "ok", message: `apiVersion ${pluginVersion}` };
} }

View File

@@ -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). - [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 ## 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. - [ ] 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. - [ ] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context.
- [ ] Per-plugin view resolver (`plugins/<id>/views/*.ejs`). - [ ] Per-plugin view resolver (`plugins/<id>/views/*.ejs`).