Make checkApiVersion semver-based (todo §2); strict parseSemver via official semver regex (no dep), major/minor compatibility rules
This commit is contained in:
@@ -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",
|
||||
|
||||
|
||||
@@ -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/<id>/`, 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
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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/<id>/, 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}` };
|
||||
}
|
||||
|
||||
2
todo.md
2
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/<id>/views/*.ejs`).
|
||||
|
||||
Reference in New Issue
Block a user