Files
msd-core/src/semver-compare.cts
Tom Boucher 353f63d170 feat(#1431): runtime capability registry overlay (ADR-1244 Phase 2) (#1440)
* feat(#1431): runtime capability registry overlay (ADR-1244 Phase 2)

Promote the registry from a frozen data file to loadRegistry({includeInstalled}),
composing the first-party registry with a validated installed overlay (ADR-1244 D2):

- Extract the conformance validator to a shared runtime-callable module
  (gsd-core/bin/lib/capability-validator.cjs); the generator re-exports it
  verbatim, guarded by a generative-parity test (no build-time/runtime drift).
- capability-loader.cts: loadRegistry({includeInstalled}) composes first-party
  ∪ validated overlay from $GSD_HOME/.gsd/capabilities (global) and
  <root>/.gsd/capabilities (project) via the canonical buildRegistry. First-party
  always wins (id/skill/agent/config/command-family + reserved gsd-/anthropic-
  prefixes); full merged-set cross-capability validation; engines.gsd load-time
  re-gate (skip-with-warning); gate-kind capabilities FAIL CLOSED; fragment-path
  escapes rejected.
- semverSatisfies (hand-written, no dep) for the engines.gsd gate, fail-closed.
- Wire surface/state + loop to the overlay; loop injects a blocking gate for each
  skipped gate-kind overlay (fail-closed).
- cwd-aware overlay config-key federation: config-loader _federatedConfigSchema(cwd)
  + config-schema isValidConfigKey(key, cwd) compose the overlay per loadConfig/
  config-set call (never eager at module load, never wrong-cwd); first-party path
  unchanged with no cwd.
- run-tests.cjs sandboxes GSD_HOME (idempotent — nested spawns reuse it) for test
  hermeticity; capability-loader.cjs git+eslint-ignored (tsc artifact);
  capability-validator.cjs stays linted (#551 migration coverage).

Closes #1431

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1431): add changeset for runtime capability registry overlay

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1431): kill config-schema cwd-aware federation mutants (Stryker ≥52)

The cwd-aware overlay config-key federation added to config-schema.cts
(_capabilityConfigSchema(cwd) + isCapabilityConfigKey/isValidConfigKey cwd
threading) introduced mutable surface uncovered by config-schema's mutation
test set, dropping its score to 39.58% (below the 52 break threshold). Add a
real-overlay-fixture describe block exercising every branch (cwd guard, overlay
loadRegistry, found-branch, first-party fallback, cwd threading); local Stryker
score 39.58% -> 77.08%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:41:20 -04:00

175 lines
7.3 KiB
TypeScript

/**
* Shared semver comparison utility (ADR-457 pilot: first hand-written
* bin/lib/*.cjs collapsed to a TypeScript source of truth).
*
* Logic is preserved byte-for-behaviour from the prior hand-written
* `gsd-core/bin/lib/semver-compare.cjs`; only types are added. The
* normalization policy here is locked by `tests/semver-compare.test.cjs` and
* consumed by update-check, statusline dev-install detection, and changeset
* range compare (`scripts/changeset/cli.cjs`).
*/
/** [major, minor, patch] — non-negative integers, never NaN. */
export type SemverTuple = [number, number, number];
/** Comparison result: -1 (a < b), 0 (equal), 1 (a > b). */
export type CompareResult = -1 | 0 | 1;
/**
* What callers actually pass: a version string (`"1.2.3"`, `"v1.2.3-rc.1"`), a
* bare number, or a missing value. The old hand-written `.cjs` typed these as
* `unknown` and leaned on `String()` — which the type-aware lint flagged as an
* `[object Object]` hazard. Narrowing to the real domain type is the fix.
*/
export type VersionInput = string | number | null | undefined;
export function toNumericTuple(input: VersionInput): SemverTuple {
const cleaned = String(input == null ? '' : input).trim().replace(/^v/, '');
const base = cleaned.replace(/[-+].*$/, '');
const parts = base.split('.');
const major = Number.parseInt(parts[0], 10) || 0;
const minor = Number.parseInt(parts[1], 10) || 0;
const patch = Number.parseInt(parts[2], 10) || 0;
return [major, minor, patch];
}
export function compareSemverCore(a: VersionInput, b: VersionInput): CompareResult {
const [a0, a1, a2] = toNumericTuple(a);
const [b0, b1, b2] = toNumericTuple(b);
if (a0 !== b0) return a0 > b0 ? 1 : -1;
if (a1 !== b1) return a1 > b1 ? 1 : -1;
if (a2 !== b2) return a2 > b2 ? 1 : -1;
return 0;
}
export function isSemverNewer(a: VersionInput, b: VersionInput): boolean {
return compareSemverCore(a, b) > 0;
}
export function isStableTripletSemver(v: VersionInput): boolean {
return /^\d+\.\d+\.\d+$/.test(String(v || '').replace(/^v/, ''));
}
// ─── Range satisfaction (ADR-1244 D2 — engines.gsd load-time gate) ────────────
//
// A minimal, hand-written `semverSatisfies(version, range)` — deliberately NOT
// the `semver` npm package (no new dependency / supply-chain surface in core,
// consistent with this module's hand-written heritage). It supports the operator
// subset capability `engines.gsd` ranges actually use: `>= <= > < =` (exact),
// caret `^`, tilde `~`, OR via `||`, AND via whitespace, partials (`1`, `1.2`)
// and wildcards (`*`, `1.x`). Satisfaction is computed on the numeric
// major.minor.patch core (prerelease-insensitive), matching this module's
// existing `toNumericTuple` policy. CRITICAL: any comparator it cannot parse
// makes the whole check FAIL CLOSED (returns false) — an unparseable engines
// range must never silently pass the load-time gate.
type RangeOp = '>=' | '<=' | '>' | '<' | '=';
interface Primitive { op: RangeOp; t: SemverTuple; }
function compareTuples(a: SemverTuple, b: SemverTuple): CompareResult {
if (a[0] !== b[0]) return a[0] > b[0] ? 1 : -1;
if (a[1] !== b[1]) return a[1] > b[1] ? 1 : -1;
if (a[2] !== b[2]) return a[2] > b[2] ? 1 : -1;
return 0;
}
// Parse a version-ish token into a tuple + how many leading numeric parts were
// specified (0 = bare wildcard "*"/"x", 1 = "1", 2 = "1.2", 3 = "1.2.3").
// Returns null if the token is not a parseable partial/full version.
function parseVersionToken(token: string): { tuple: SemverTuple; specified: 0 | 1 | 2 | 3 } | null {
const clean = token.trim().replace(/^v/, '').replace(/[-+].*$/, '');
if (clean === '' || clean === '*' || clean === 'x' || clean === 'X') return { tuple: [0, 0, 0], specified: 0 };
const parts = clean.split('.');
if (parts.length > 3) return null;
const nums: number[] = [];
let sawWildcard = false;
for (const p of parts) {
if (p === 'x' || p === 'X' || p === '*') { sawWildcard = true; continue; }
// A concrete segment after a wildcard ("1.x.2", "1.*.2") is malformed → fail closed.
if (sawWildcard) return null;
if (!/^\d+$/.test(p)) return null;
nums.push(Number.parseInt(p, 10));
}
if (nums.length === 0) return { tuple: [0, 0, 0], specified: 0 };
return { tuple: [nums[0] || 0, nums[1] || 0, nums[2] || 0], specified: nums.length as 1 | 2 | 3 };
}
// Expand a single comparator into primitive (op, tuple) constraints, or null if
// unparseable (→ fail closed).
function expandComparator(c: string): Primitive[] | null {
const trimmed = c.trim();
if (trimmed === '' || trimmed === '*' || trimmed === 'x' || trimmed === 'X') return [{ op: '>=', t: [0, 0, 0] }];
const m = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(trimmed);
if (!m) return null;
const op = m[1] || '';
const pv = parseVersionToken(m[2]);
if (!pv) return null;
const { tuple, specified } = pv;
const [maj, min, pat] = tuple;
if (op === '^') {
let upper: SemverTuple;
if (maj > 0) upper = [maj + 1, 0, 0];
else if (min > 0) upper = [0, min + 1, 0];
else upper = [0, 0, pat + 1];
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
}
if (op === '~') {
const upper: SemverTuple = specified >= 2 ? [maj, min + 1, 0] : [maj + 1, 0, 0];
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
}
if (op === '' || op === '=') {
if (specified === 0) return [{ op: '>=', t: [0, 0, 0] }]; // "*" → any
if (specified === 3) return [{ op: '=', t: tuple }];
const upper: SemverTuple = specified === 1 ? [maj + 1, 0, 0] : [maj, min + 1, 0];
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
}
// >= <= > < with an explicit version
if (specified === 0) return null; // e.g. ">=*" is meaningless → fail closed
return [{ op: op as RangeOp, t: tuple }];
}
function satisfiesPrimitive(v: SemverTuple, prim: Primitive): boolean {
const cmp = compareTuples(v, prim.t);
switch (prim.op) {
case '>=': return cmp >= 0;
case '<=': return cmp <= 0;
case '>': return cmp > 0;
case '<': return cmp < 0;
case '=': return cmp === 0;
default: return false;
}
}
// One whitespace-separated comparator set (ANDed). Fail closed if any comparator
// is unparseable.
function satisfiesSet(v: SemverTuple, set: string): boolean {
const trimmed = set.trim();
if (trimmed === '') return false;
const comparators = trimmed.split(/\s+/).filter(Boolean);
if (comparators.length === 0) return false;
for (const c of comparators) {
const prims = expandComparator(c);
if (prims === null) return false; // unparseable → fail closed
for (const prim of prims) {
if (!satisfiesPrimitive(v, prim)) return false;
}
}
return true;
}
/**
* Does `version` satisfy the semver `range`? OR-composed across `||`, AND-composed
* across whitespace. Fail-closed: an empty range, or any comparator this minimal
* implementation cannot parse, returns false. Comparison is on the numeric
* major.minor.patch core (prerelease tags are stripped, per `toNumericTuple`).
*/
export function semverSatisfies(version: VersionInput, range: VersionInput): boolean {
const r = String(range == null ? '' : range).trim();
if (r === '') return false;
const v = toNumericTuple(version);
const orSets = r.split('||').map((s) => s.trim()).filter((s) => s.length > 0);
if (orSets.length === 0) return false;
return orSets.some((set) => satisfiesSet(v, set));
}