Files
website/server/src/modules/semver.js
wtclaude ec1ca7e794
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 10m4s
feat(modules): the filesystem module loader
Phase 2 PR 2 of docs/website/MODULE_SYSTEM.md 2.7. Adds
server/src/modules/{loader,semver,version}.js: the synchronous scan of
MODULES_DIR, manifest validation, prefix and table-name collision
rejection, per-module try/catch and the mount into the three tier
routers behind the MODULE_API.md 4.5 dispatch guard.

Two decisions the contract left open, both now written up there:

- The load trigger is one explicit modules.load(tierRouters) call in
  app.js, not a lazy scan (API 7.6). Accessors throw until it has run,
  because "no modules installed" is a real answer a caller must not be
  handed by accident.
- Whether core owns a prefix is asked of the live tier routers via
  express's own layer.match(), skipping root-mounted layers, rather than
  a hardcoded table -- the spike's was already stale when written
  (API 4.3).

Mounting is a second pass after every module is validated. Doing it
inside the scan loop makes the first module's layers indistinguishable
from core's, so the second module claiming a taken prefix is told it
collided with core and the module-versus-module check is unreachable.

registerExtension/NotificationStreams/AnnounceLeg and onBoot/onShutdown
throw "not available until phase 2 PR 4/5" rather than no-op; an
accepting stub would let a module believe it had registered something.
No schema replay, no boot dispatch, no installed_modules reconcile --
those are PRs 3 and 5, and until PR 5 a record's state is in memory only.

No module ships on the volume, so nothing an operator or client can see
changes: 842 tests pass, routes.manifest.json is unchanged at 229 routes
and swagger-output.json regenerates byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 14:35:27 -05:00

48 lines
1.7 KiB
JavaScript

// A deliberately tiny semver range check — enough for `coreApi` and no more.
//
// Supports `*`, an exact `x.y.z`, `^x.y.z` and `~x.y.z`. That is the whole
// grammar a module manifest is allowed to use (MODULE_API.md §1.1), so pulling
// in the `semver` package for it would add a dependency to the server for a
// twenty-line job. A range this parser does not understand is REJECTED rather
// than assumed to match — an unparseable range must not silently load a module
// against an API it was never tested on.
const PARTS = /^(\d+)\.(\d+)\.(\d+)$/
function parse(version) {
const m = PARTS.exec(String(version).trim())
if (!m) return null
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
}
const gte = (a, b) => {
if (a.major !== b.major) return a.major > b.major
if (a.minor !== b.minor) return a.minor > b.minor
return a.patch >= b.patch
}
/**
* Does `version` satisfy `range`?
* @param {string} version an exact x.y.z
* @param {string} range `*` | `x.y.z` | `^x.y.z` | `~x.y.z`
* @returns {boolean} false for anything unparseable, on either side
*/
function satisfies(version, range) {
const v = parse(version)
if (!v) return false
const raw = String(range).trim()
if (raw === '*') return true
const op = raw[0] === '^' || raw[0] === '~' ? raw[0] : ''
const b = parse(op ? raw.slice(1) : raw)
if (!b) return false
if (op === '') return v.major === b.major && v.minor === b.minor && v.patch === b.patch
if (!gte(v, b)) return false
// ^ allows minor+patch within the same major; ~ allows patch within the same minor.
if (op === '^') return v.major === b.major
return v.major === b.major && v.minor === b.minor
}
module.exports = { satisfies, parse }