Files
msd-core/scripts/gen-capability-registry.cjs
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

887 lines
37 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* gen-capability-registry.cjs — generates gsd-core/bin/lib/capability-registry.cjs
* from every capabilities/<id>/capability.json declaration.
*
* Usage:
* node scripts/gen-capability-registry.cjs # print to stdout
* node scripts/gen-capability-registry.cjs --write # write capability-registry.cjs
* node scripts/gen-capability-registry.cjs --check # exit 1 if committed registry is stale
*
* ADR-894 phase 3a-impl. Validates each capability against the schema, enforces
* cross-capability invariants, materializes hook ordering, and emits a role-
* partitioned CommonJS registry module.
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.resolve(__dirname, '..');
const CAPABILITIES_DIR = path.join(ROOT, 'capabilities');
const REGISTRY_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs');
const CONFIG_SCHEMA_PATH = path.join(ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json');
// ─── Loop Host Contract ───────────────────────────────────────────────────────
//
// Generated from workflow markers by scripts/gen-loop-host-contract.cjs (ADR-894 §3).
// Require the committed gsd-core/bin/lib/loop-host-contract.cjs artifact so the
// registry generator and the loop-host-contract generator share one source of truth.
const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs');
// Wired-points helper — tells us which points actually have render-hooks call sites.
const { getWiredLoopPoints } = require('./gen-loop-host-contract.cjs');
// Capability validator — shared runtime-callable module extracted per ADR-1244 D2.
const capValidator = require('../gsd-core/bin/lib/capability-validator.cjs');
// Destructure only what the generator's own function bodies reference directly.
// Everything else is re-exported from capValidator in module.exports below.
const {
POINT_ORDER,
HOST_ARTIFACT_EARLIEST_POINT_IDX,
VALID_LOOP_POINTS,
POINT_TO_CONTRACT,
VALID_CONFIG_SLICE_TYPES,
VALID_TIERS,
SEMVER_RE,
SEMVER_RANGE_RE,
SHA512_INTEGRITY_RE,
VALID_CONVERTER_NAMES,
VALID_CONFIG_HOME_KINDS,
VALID_COMMAND_STYLES,
VALID_HOOKS_SURFACES,
VALID_HOOK_EVENTS,
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,
VALID_INSTALL_SURFACES,
VALID_PERMISSION_WRITERS,
VALID_EXTENDED_HOOK_EVENTS,
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES,
INSTALL_SURFACE_TO_CONFIG_FORMAT,
SCHEMA_VERSION,
validateVersionEnvelope,
validateCapability,
validateCommandEntry,
validateRuntimeCompat,
validateConfigHome,
validateArtifactKindEntry,
validateArtifactLayout,
validateRuntimeBody,
materializeHookFragments,
validateAgainstContract,
validateConsumesGlobal,
validateCrossCapability,
computeRequiresClosure,
topoSortSteps,
topoSortContributions,
validateHooksWired,
validateConfigSliceEntry,
classifyCrossErrors,
runConfigFormatParityGate,
} = capValidator;
// ─── Central config-schema loader ────────────────────────────────────────────
/**
* Loads the set of keys from the central config-schema manifest.
* Returns a Set<string>. Used for collision detection.
*
* Contract:
* - ENOENT (file not found): returns empty Set silently — legitimate absent case.
* - Any other read error OR JSON parse error: writes a prominent warning to stderr
* naming the schema path and the underlying error, then throws ExitError(1).
* A parse error clearly states the schema is broken (not merely absent).
*
* @param {string} [schemaPath] Path to the config-schema manifest. Defaults to
* CONFIG_SCHEMA_PATH (the real production path).
* Overridable for unit testing with fixture paths.
* @returns {Set<string>}
*/
function loadCentralConfigKeys(schemaPath = CONFIG_SCHEMA_PATH) {
let raw;
try {
raw = fs.readFileSync(schemaPath, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') {
return new Set();
}
process.stderr.write(
' ERROR Failed to read config-schema manifest at ' + schemaPath + ': ' + err.message + '\n',
);
throw new ExitError(1, 'could not read config-schema manifest');
}
let manifest;
try {
manifest = JSON.parse(raw);
} catch (err) {
process.stderr.write(
' ERROR Config-schema manifest at ' + schemaPath + ' is broken (JSON parse error): ' + err.message + '\n',
);
throw new ExitError(1, 'config-schema manifest JSON is malformed');
}
return new Set(Array.isArray(manifest.validKeys) ? manifest.validKeys : []);
}
// ─── ADR-857 Phase 4a: Derived views ─────────────────────────────────────────
// (Config-slice validation, per-capability validators, contract validators,
// cross-capability validators, topo-sort helpers, and classifyCrossErrors have
// been moved to gsd-core/bin/lib/capability-validator.cjs per ADR-1244 D2.)
const INSTALL_PROFILES_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs');
const CLUSTERS_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'clusters.cjs');
let _installProfilesMod = null;
let _clustersMod = null;
function getInstallProfiles() {
if (!_installProfilesMod) _installProfilesMod = require(INSTALL_PROFILES_PATH);
return _installProfilesMod;
}
function getClusters() {
if (!_clustersMod) _clustersMod = require(CLUSTERS_PATH);
return _clustersMod;
}
/**
* Derive capabilityClusters: { <capId>: [<skill stems>] }
* Each capability's own skills array, sorted for determinism.
*
* FIX 3: scope rule = "capabilities that own skills" (non-empty skills array).
* Both capabilityClusters and profileMembership use this same predicate so a
* future non-feature role carrying skills is treated identically in both, and a
* feature cap with no skills appears in neither.
*
* @param {Map<string, object>} capMap
* @returns {object} Object.create(null) — prototype-pollution safe
*/
function deriveCapabilityClusters(capMap) {
const result = Object.create(null);
for (const [capId, cap] of capMap) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
// FIX 3: include any cap that owns skills (non-empty skills array), regardless of role
if (!Array.isArray(cap.skills) || cap.skills.length === 0) continue;
// Sort for determinism
const sorted = [...cap.skills].sort();
result[capId] = sorted;
}
return result;
}
/**
* Derive profileMembership: { <capId>: { tier: <t>, profiles: [<names>] } }
* profiles = suffix of PROFILE_RANK starting at the capability's tier index.
* tier 'core' → ['core', 'standard', 'full']
* tier 'standard' → ['standard', 'full']
* tier 'full' → ['full']
*
* FIX 3: scope rule = "capabilities that own skills" (non-empty skills array),
* consistent with deriveCapabilityClusters. Both derived views cover the same set.
*
* FIX 5: tierIdx === -1 means VALID_TIERS and PROFILE_RANK have drifted; throw
* loudly instead of silently producing ['full'] for the affected capability.
*
* @param {Map<string, object>} capMap
* @returns {object} Object.create(null) — prototype-pollution safe
*/
function deriveProfileMembership(capMap) {
const { PROFILE_RANK } = getInstallProfiles();
const result = Object.create(null);
for (const [capId, cap] of capMap) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
if (!VALID_TIERS.has(cap.tier)) continue;
// FIX 3: consistent scope — only capabilities that own skills (non-empty skills array)
if (!Array.isArray(cap.skills) || cap.skills.length === 0) continue;
const tierIdx = PROFILE_RANK.indexOf(cap.tier);
// FIX 5: throw loudly on VALID_TIERS/PROFILE_RANK drift (was silent continue)
if (tierIdx === -1) {
throw new Error(
'deriveProfileMembership: capability "' + capId + '" tier "' + cap.tier +
'" is in VALID_TIERS but not in PROFILE_RANK — VALID_TIERS/PROFILE_RANK drift detected',
);
}
const profiles = PROFILE_RANK.slice(tierIdx);
result[capId] = { tier: cap.tier, profiles: [...profiles] };
}
return result;
}
/**
* Run consistency gates:
* - HARD: for each capId that matches a CLUSTERS key, derived skills must match
* the hand-authored CLUSTERS[capId] set (order-insensitive). Throws on mismatch.
* - SOFT: for each capability, for each skill not yet in all non-full profiles it
* belongs to (closure-resolved), emit ONE pending-reconciliation warning listing
* the missing profiles together. Warnings are collected and returned — NOT thrown.
*
* FIX 1: load the REAL skills manifest (same as bin/install.js) so resolveProfile
* expands requires:-closure. Loaded once and reused across all capabilities.
*
* FIX 3: iterate capabilityClusters (which already covers "capabilities that own
* skills") rather than profileMembership, so both derived views share one scope.
*
* FIX 4: one warning per (capability, skill) gap, listing all missing non-full
* profiles together, instead of one warning per (capability, skill, profile).
*
* @param {object} capabilityClusters From deriveCapabilityClusters()
* @param {object} profileMembership From deriveProfileMembership()
* @param {Map<string, object>} capMap Original capMap for skill lists
* @returns {string[]} Array of pending-reconciliation warning strings
*/
function runConsistencyGate(capabilityClusters, profileMembership, capMap) {
const { CLUSTERS: clustersObj } = getClusters();
const { resolveProfile, loadSkillsManifest } = getInstallProfiles();
// ── HARD gate: cluster set comparison ──────────────────────────────────────
for (const capId of Object.keys(capabilityClusters)) {
// S2b: inline literal guard (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
// Only check if a CLUSTERS entry with the same name exists
if (!Object.prototype.hasOwnProperty.call(clustersObj, capId)) continue;
const derivedSet = new Set(capabilityClusters[capId]);
const handAuthored = clustersObj[capId];
const handAuthoredSet = new Set(handAuthored);
// Compare sets (order-insensitive)
let mismatch = derivedSet.size !== handAuthoredSet.size;
if (!mismatch) {
for (const s of derivedSet) {
if (!handAuthoredSet.has(s)) { mismatch = true; break; }
}
}
if (mismatch) {
throw new Error(
'capability-cluster consistency gate FAILED for capId "' + capId + '":\n' +
' derived set: [' + [...derivedSet].sort().join(', ') + ']\n' +
' hand-authored set: [' + [...handAuthoredSet].sort().join(', ') + ']\n' +
'The capability\'s skills array must match the hand-authored CLUSTERS["' + capId + '"] at cutover.',
);
}
}
// ── SOFT gate: profile reconciliation warnings ─────────────────────────────
// FIX 1: load the REAL skills manifest once (same path as bin/install.js uses),
// so resolveProfile expands requires:-closure and the effective set is accurate.
const commandsGsdDir = path.join(ROOT, 'commands', 'gsd');
const skillsManifest = loadSkillsManifest(commandsGsdDir);
// FIX 1: resolve each profile's effective set once and cache — don't reload per-capability.
const profileEffectiveSetCache = Object.create(null);
function getEffectiveSet(profileName) {
if (profileName in profileEffectiveSetCache) return profileEffectiveSetCache[profileName];
const resolved = resolveProfile({ modes: [profileName], manifest: skillsManifest });
const effectiveSet = resolved.skills === '*' ? null : resolved.skills;
profileEffectiveSetCache[profileName] = effectiveSet;
return effectiveSet;
}
const warnings = [];
// FIX 3: iterate capabilityClusters (same set as profileMembership after FIX 3 scoping).
for (const capId of Object.keys(capabilityClusters)) {
// S2b: inline literal guard (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
const membership = profileMembership[capId];
if (!membership) continue; // no profile membership (e.g. cap has skills but invalid tier)
const cap = capMap.get(capId);
if (!cap || !Array.isArray(cap.skills)) continue;
// Collect the non-full profiles for this capability
const nonFullProfiles = membership.profiles.filter((p) => p !== 'full');
// FIX 4: one warning per (capability, skill) gap — list all missing profiles together
for (const skill of cap.skills) {
// S2b: inline literal guard (CodeQL barrier)
if (skill === '__proto__' || skill === 'constructor' || skill === 'prototype') continue;
const missingProfiles = [];
for (const profileName of nonFullProfiles) {
const effectiveSet = getEffectiveSet(profileName);
if (effectiveSet === null) continue; // profile resolved to full (unexpected but safe)
if (!effectiveSet.has(skill)) {
missingProfiles.push(profileName);
}
}
if (missingProfiles.length > 0) {
warnings.push(
'⚠ pending-reconciliation: capability \'' + capId + '\' (tier ' + membership.tier + ')' +
' skill \'' + skill + '\' not yet in hand-authored profile(s): <' + missingProfiles.join(', ') +
'>; add at cutover',
);
}
}
}
return warnings;
}
/**
* Read + validate all capabilities/<id>/capability.json files.
* Returns { capMap, errors } where capMap is Map<id, cap>.
*
* @param {Set<string>} [centralKeys] Keys in central config-schema for collision detection.
* If omitted, reads from disk. Pass new Set() to skip central-collision checks
* (used during 3a-impl while migration is in-progress).
* @param {string} [capabilitiesDir] Override capabilities dir (for testing with fixtures).
*/
function loadAndValidate(centralKeys, capabilitiesDir) {
const resolvedCentralKeys = centralKeys !== undefined ? centralKeys : loadCentralConfigKeys();
const resolvedCapDir = capabilitiesDir !== undefined ? capabilitiesDir : CAPABILITIES_DIR;
const errors = [];
const capMap = new Map();
if (!fs.existsSync(resolvedCapDir)) {
return { capMap, errors };
}
// Compute wired points ONCE before iterating capabilities so the filesystem
// scan is not repeated per-capability. ROOT is the repo root (defined at top of file).
const wiredSet = getWiredLoopPoints(ROOT);
const folderEntries = fs.readdirSync(resolvedCapDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
for (const folderId of folderEntries) {
const capPath = path.join(resolvedCapDir, folderId, 'capability.json');
if (!fs.existsSync(capPath)) continue;
let cap;
try {
cap = JSON.parse(fs.readFileSync(capPath, 'utf8'));
} catch (err) {
errors.push(folderId + '/capability.json: JSON parse error: ' + String(err.message));
continue;
}
const capErrors = validateCapability(cap, folderId);
if (capErrors.length > 0) {
for (const e of capErrors) errors.push(folderId + '/capability.json: ' + e);
continue; // skip cross-validation if basic schema fails
}
const contractErrors = validateAgainstContract(cap, cap.id);
if (contractErrors.length > 0) {
for (const e of contractErrors) errors.push(folderId + '/capability.json: ' + e);
// Fix #6: do NOT add contract-invalid caps to capMap — validateCrossCapability should
// only see fully-valid capabilities so its invariants are meaningful.
continue;
}
// Gen-time wired guard: reject hooks that declare a valid point with no call site.
const wiredErrors = validateHooksWired(cap, wiredSet);
if (wiredErrors.length > 0) {
for (const e of wiredErrors) errors.push(folderId + '/capability.json: ' + e);
continue;
}
const fragmentErrors = materializeHookFragments(cap, path.dirname(capPath));
if (fragmentErrors.length > 0) {
for (const e of fragmentErrors) errors.push(folderId + '/capability.json: ' + e);
continue;
}
capMap.set(cap.id, cap);
}
// Cross-capability invariants — capMap contains only fully-valid capabilities at this point.
const crossErrors = validateCrossCapability(capMap, resolvedCentralKeys);
errors.push(...crossErrors);
// C2: Global consumes-satisfiability — runs after capMap is fully built so cross-capability
// produces are visible. A capability with consumes errors is kept in capMap (it passed per-cap
// validation) but the errors are surfaced so the build fails.
const consumesErrors = validateConsumesGlobal(capMap);
errors.push(...consumesErrors);
return { capMap, errors };
}
/**
* Build the registry object from a validated capMap.
*
* @param {Map<string, object>} capMap
*/
function buildRegistry(capMap) {
// S2b: Use Object.create(null) for all accumulator maps so prototype-pollution
// can't touch Object.prototype even if a reserved name slips through validation.
const capabilities = Object.create(null);
const bySkill = Object.create(null);
const byAgent = Object.create(null);
const byLoopPoint = Object.create(null);
const configKeys = Object.create(null);
const configSchema = Object.create(null);
const runtimes = Object.create(null);
// Initialize byLoopPoint for all valid points
for (const point of VALID_LOOP_POINTS) {
byLoopPoint[point] = { steps: [], contributions: [], gates: [] };
}
// Phase 1: collect per-point entries grouped by point
const pointSteps = new Map(); // point → [{ capId, step }]
const pointContribs = new Map(); // point → [{ capId, contrib }]
const pointGates = new Map(); // point → [{ capId, gate }]
for (const point of VALID_LOOP_POINTS) {
pointSteps.set(point, []);
pointContribs.set(point, []);
pointGates.set(point, []);
}
for (const [capId, cap] of capMap) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
capabilities[capId] = cap;
if (cap.role === 'feature') {
for (const skill of (cap.skills || [])) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (skill === '__proto__' || skill === 'constructor' || skill === 'prototype') continue;
bySkill[skill] = capId;
}
for (const agent of (cap.agents || [])) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (agent === '__proto__' || agent === 'constructor' || agent === 'prototype') continue;
byAgent[agent] = capId;
}
for (const key of Object.keys(cap.config || {})) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
configKeys[key] = capId;
// Build configSchema entry — validate the slice first (throw on violation)
const slice = (cap.config || {})[key];
const sliceErrors = validateConfigSliceEntry(capId, key, slice);
if (sliceErrors.length > 0) {
throw new Error(
'configSchema validation failed during registry build:\n' +
sliceErrors.map((e) => ' ' + e).join('\n'),
);
}
// S2b: inline literal guard for configSchema write site
if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
configSchema[key] = {
owner: capId,
type: slice.type,
default: slice.default,
description: slice.description,
};
// Preserve values array for enum types if present
if (slice.type === 'enum' && Array.isArray(slice.values)) {
configSchema[key].values = slice.values;
}
}
}
for (const step of (cap.steps || [])) {
if (VALID_LOOP_POINTS.has(step.point)) {
pointSteps.get(step.point).push({ capId, step });
}
}
for (const contrib of (cap.contributions || [])) {
if (VALID_LOOP_POINTS.has(contrib.point)) {
// Group contributions by into, then cap-id order
pointContribs.get(contrib.point).push({ capId, contrib });
}
}
for (const gate of (cap.gates || [])) {
if (VALID_LOOP_POINTS.has(gate.point)) {
pointGates.get(gate.point).push({ capId, gate });
}
}
} else if (cap.role === 'runtime') {
// S2b: inline literal guard at each write site (CodeQL barrier) — capId already guarded above
runtimes[capId] = cap;
}
}
// Phase 2: materialize ordering
for (const point of VALID_LOOP_POINTS) {
// Steps: topological sort by produces/consumes, cap-id tiebreak
const sortedSteps = topoSortSteps(pointSteps.get(point));
byLoopPoint[point].steps = sortedSteps.map((e) => ({
capId: e.capId,
...e.step,
}));
// Contributions: topological sort by produces/consumes, cap-id tiebreak
const sortedContribs = topoSortContributions(pointContribs.get(point));
byLoopPoint[point].contributions = sortedContribs.map((e) => ({
capId: e.capId,
...e.contrib,
}));
// Gates: as declared (stable by capId order)
const gates = pointGates.get(point);
gates.sort((a, b) => a.capId.localeCompare(b.capId));
byLoopPoint[point].gates = gates.map((e) => ({
capId: e.capId,
...e.gate,
}));
}
// ── ADR-959: commandFamilies index ─────────────────────────────────────────
// family → { capId, module, router }
// Built from all feature capabilities' commands arrays.
const commandFamilies = Object.create(null);
for (const [capId, cap] of capMap) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
if (cap.role !== 'feature' || !Array.isArray(cap.commands)) continue;
for (const cmd of cap.commands) {
if (typeof cmd.family !== 'string' || cmd.family.length === 0) continue;
// S2b: inline literal guard at family key write site (CodeQL barrier)
if (cmd.family === '__proto__' || cmd.family === 'constructor' || cmd.family === 'prototype') continue;
if (typeof cmd.module !== 'string' || cmd.module.length === 0) continue;
if (typeof cmd.router !== 'string' || cmd.router.length === 0) continue;
commandFamilies[cmd.family] = { capId, module: cmd.module, router: cmd.router };
}
}
// ── ADR-857 phase 4a: derived views ────────────────────────────────────────
const capabilityClusters = deriveCapabilityClusters(capMap);
const profileMembership = deriveProfileMembership(capMap);
// runConsistencyGate: hard gate throws on mismatch; returns soft warning strings.
// Warnings are returned in the registry object so callers can emit them to stderr
// without affecting the serialized file content (determinism gate stays clean).
const reconciliationWarnings = runConsistencyGate(capabilityClusters, profileMembership, capMap);
// ADR-857 phase 5e: configFormat ↔ installSurface parity gate.
// HARD gate — throws on mismatch; SOFT skip if adapter module not loadable.
runConfigFormatParityGate(capMap);
return {
version: SCHEMA_VERSION,
capabilities,
bySkill,
byAgent,
byLoopPoint,
configKeys,
configSchema,
runtimes,
commandFamilies,
capabilityClusters,
profileMembership,
// warnings are NOT serialized — returned only for caller consumption via stderr
_reconciliationWarnings: reconciliationWarnings,
};
}
// ─── Registry serialization ───────────────────────────────────────────────────
/**
* Serialize the registry to a CommonJS module string.
*
* @param {object} registry The registry object from buildRegistry()
* @param {Map<string, object>} capMap Used for requiresClosure()
*/
function serializeRegistry(registry, capMap) {
const lines = [];
lines.push("'use strict';");
lines.push('');
lines.push('/**');
lines.push(' * capability-registry.cjs — generated by scripts/gen-capability-registry.cjs');
lines.push(' * DO NOT EDIT BY HAND. Run: node scripts/gen-capability-registry.cjs --write');
lines.push(' * ADR-894 §5 — role-partitioned Capability Registry.');
lines.push(' */');
lines.push('');
// Serialize each section as a variable to keep the file readable
lines.push('const capabilities = ' + JSON.stringify(registry.capabilities, null, 2) + ';');
lines.push('');
lines.push('const bySkill = ' + JSON.stringify(registry.bySkill, null, 2) + ';');
lines.push('');
lines.push('const byAgent = ' + JSON.stringify(registry.byAgent, null, 2) + ';');
lines.push('');
lines.push('const byLoopPoint = ' + JSON.stringify(registry.byLoopPoint, null, 2) + ';');
lines.push('');
lines.push('const configKeys = ' + JSON.stringify(registry.configKeys, null, 2) + ';');
lines.push('');
lines.push('const configSchema = ' + JSON.stringify(registry.configSchema, null, 2) + ';');
lines.push('');
lines.push('const runtimes = ' + JSON.stringify(registry.runtimes, null, 2) + ';');
lines.push('');
// ADR-959: commandFamilies index — sort family keys for determinism.
const sortedCommandFamilies = Object.create(null);
const commandFamilyKeys = Object.keys(registry.commandFamilies || {}).sort();
for (const family of commandFamilyKeys) {
// S2b: inline literal guard at write site (CodeQL barrier)
if (family === '__proto__' || family === 'constructor' || family === 'prototype') continue;
sortedCommandFamilies[family] = registry.commandFamilies[family];
}
lines.push('const commandFamilies = ' + JSON.stringify(sortedCommandFamilies, null, 2) + ';');
lines.push('');
// ADR-857 phase 4a: derived views — globally sorted capIds for determinism.
// FIX 2: collect ALL capIds across both views and sort globally so feature + runtime
// capIds interleave correctly when both are present (phase 5 readiness).
const allClusterCapIds = new Set(Object.keys(registry.capabilityClusters));
const allProfileCapIds = new Set(Object.keys(registry.profileMembership));
const allCapIds = new Set([...allClusterCapIds, ...allProfileCapIds]);
// FIX 5: inline literal guard at write sites (CodeQL barrier)
allCapIds.delete('__proto__');
allCapIds.delete('constructor');
allCapIds.delete('prototype');
const globalSortedCapIds = [...allCapIds].sort();
const sortedCapabilityClusters = Object.create(null);
for (const capId of globalSortedCapIds) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
if (registry.capabilityClusters[capId] !== undefined) {
sortedCapabilityClusters[capId] = registry.capabilityClusters[capId];
}
}
lines.push('const capabilityClusters = ' + JSON.stringify(sortedCapabilityClusters, null, 2) + ';');
lines.push('');
const sortedProfileMembership = Object.create(null);
for (const capId of globalSortedCapIds) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
if (registry.profileMembership[capId] !== undefined) {
sortedProfileMembership[capId] = registry.profileMembership[capId];
}
}
lines.push('const profileMembership = ' + JSON.stringify(sortedProfileMembership, null, 2) + ';');
lines.push('');
// Inline the requires graph so requiresClosure() works without re-reading files
const requiresGraph = {};
for (const [id, cap] of capMap) {
requiresGraph[id] = Array.isArray(cap.requires) ? cap.requires : [];
}
lines.push('const _requiresGraph = ' + JSON.stringify(requiresGraph, null, 2) + ';');
lines.push('');
// requiresClosure function
lines.push('function requiresClosure(id) {');
lines.push(' const visited = new Set();');
lines.push(' const queue = [id];');
lines.push(' while (queue.length > 0) {');
lines.push(' const current = queue.shift();');
lines.push(' const reqs = _requiresGraph[current] || [];');
lines.push(' for (const req of reqs) {');
lines.push(' if (!visited.has(req)) {');
lines.push(' visited.add(req);');
lines.push(' queue.push(req);');
lines.push(' }');
lines.push(' }');
lines.push(' }');
lines.push(' return visited;');
lines.push('}');
lines.push('');
lines.push('module.exports = {');
lines.push(" version: '" + registry.version + "',");
lines.push(' capabilities,');
lines.push(' bySkill,');
lines.push(' byAgent,');
lines.push(' byLoopPoint,');
lines.push(' configKeys,');
lines.push(' configSchema,');
lines.push(' runtimes,');
lines.push(' commandFamilies,');
lines.push(' capabilityClusters,');
lines.push(' profileMembership,');
lines.push(' requiresClosure,');
lines.push('};');
lines.push('');
return lines.join('\n');
}
// ─── --check diff helper ──────────────────────────────────────────────────────
/**
* Compare committed registry with live registry (for --check).
* Strips the generated comment line for comparison.
*/
function stripGeneratedComment(content) {
return content
.split('\n')
.filter((line) => !line.includes('generated by scripts/gen-capability-registry.cjs'))
.join('\n');
}
/**
* Normalize line endings to LF.
* The generator always writes LF, but Windows git (autocrlf) checks out committed files with
* CRLF. The --check comparison must be line-ending-agnostic so it only fails on REAL content
* differences, not on checkout-introduced whitespace differences.
*
* @param {string} content
* @returns {string}
*/
function normalizeLineEndings(content) {
return content.replace(/\r/g, '');
}
// ─── Main ─────────────────────────────────────────────────────────────────────
function main() {
const flag = process.argv[2];
if (flag === '--check') {
// Fix #3: read the REAL central config keys so collision detection fires and is visible.
const centralKeys = loadCentralConfigKeys();
const { capMap, errors } = loadAndValidate(centralKeys);
// Separate pending-migration warnings from hard errors
const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(errors);
for (const w of pendingMigrationWarnings) process.stderr.write(w + '\n');
if (hardErrors.length > 0) {
for (const e of hardErrors) process.stderr.write(' ERROR ' + e + '\n');
throw new ExitError(1, 'capability validation failed (' + hardErrors.length + ' error(s))');
}
const registry = buildRegistry(capMap);
// ADR-857 phase 4a: emit pending-reconciliation warnings to stderr only
// (they do NOT affect the generated file content, so --check stays clean)
for (const w of (registry._reconciliationWarnings || [])) process.stderr.write(w + '\n');
const live = serializeRegistry(registry, capMap);
if (!fs.existsSync(REGISTRY_PATH)) {
process.stderr.write(
'gsd-core/bin/lib/capability-registry.cjs does not exist. Run:\n' +
' node scripts/gen-capability-registry.cjs --write\n',
);
throw new ExitError(1);
}
const committed = fs.readFileSync(REGISTRY_PATH, 'utf8');
if (normalizeLineEndings(stripGeneratedComment(committed)) !== normalizeLineEndings(stripGeneratedComment(live))) {
process.stderr.write(
'gsd-core/bin/lib/capability-registry.cjs is stale. Run:\n' +
' node scripts/gen-capability-registry.cjs --write\n',
);
throw new ExitError(1);
}
process.stdout.write('gsd-core/bin/lib/capability-registry.cjs is up to date.\n');
} else if (flag === '--write') {
// Fix #3: read the REAL central config keys so collision detection fires and is visible.
const centralKeys = loadCentralConfigKeys();
const { capMap, errors } = loadAndValidate(centralKeys);
// Separate pending-migration warnings from hard errors
const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(errors);
for (const w of pendingMigrationWarnings) process.stderr.write(w + '\n');
if (hardErrors.length > 0) {
for (const e of hardErrors) process.stderr.write(' ERROR ' + e + '\n');
throw new ExitError(1, 'capability validation failed — registry not written');
}
const registry = buildRegistry(capMap);
// ADR-857 phase 4a: emit pending-reconciliation warnings to stderr only
for (const w of (registry._reconciliationWarnings || [])) process.stderr.write(w + '\n');
const content = serializeRegistry(registry, capMap);
// Fix #5: mkdir-p before writing so --write doesn't ENOENT in a fresh worktree.
fs.mkdirSync(path.dirname(REGISTRY_PATH), { recursive: true });
fs.writeFileSync(REGISTRY_PATH, content, 'utf8');
process.stdout.write('Wrote ' + REGISTRY_PATH + '\n');
} else {
// Default: print to stdout — use real central keys for visibility
const centralKeys = loadCentralConfigKeys();
const { capMap, errors } = loadAndValidate(centralKeys);
const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(errors);
for (const w of pendingMigrationWarnings) process.stderr.write(w + '\n');
if (hardErrors.length > 0) {
for (const e of hardErrors) process.stderr.write(' ERROR ' + e + '\n');
throw new ExitError(1, 'capability validation failed');
}
const registry = buildRegistry(capMap);
// ADR-857 phase 4a: emit pending-reconciliation warnings to stderr only
for (const w of (registry._reconciliationWarnings || [])) process.stderr.write(w + '\n');
process.stdout.write(serializeRegistry(registry, capMap) + '\n');
}
}
// ─── Exports (for tests) ──────────────────────────────────────────────────────
module.exports = {
validateCapability,
// ADR-1244 D1: versioned-manifest envelope validation (reused by the runtime overlay, D2)
validateVersionEnvelope,
SEMVER_RE,
SEMVER_RANGE_RE,
SHA512_INTEGRITY_RE,
validateAgainstContract,
validateConsumesGlobal,
validateCrossCapability,
classifyCrossErrors,
loadCentralConfigKeys,
loadAndValidate,
buildRegistry,
serializeRegistry,
computeRequiresClosure,
topoSortSteps,
normalizeLineEndings,
stripGeneratedComment,
validateConfigSliceEntry,
VALID_CONFIG_SLICE_TYPES,
LOOP_HOST_CONTRACT,
VALID_LOOP_POINTS,
POINT_ORDER,
POINT_TO_CONTRACT,
HOST_ARTIFACT_EARLIEST_POINT_IDX,
SCHEMA_VERSION,
validateHooksWired,
// ADR-857 phase 4a: derived views + gates
deriveCapabilityClusters,
deriveProfileMembership,
runConsistencyGate,
// ADR-959: command entry validation
validateCommandEntry,
validateRuntimeCompat,
// ADR-1016 phase 5a: runtime body validators + closed-vocab sets
validateConfigHome,
validateArtifactLayout,
validateArtifactKindEntry,
VALID_CONFIG_HOME_KINDS,
VALID_COMMAND_STYLES,
VALID_HOOKS_SURFACES,
VALID_HOOK_EVENTS,
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,
// ADR-857 phase 5e: closed ConverterName enum
VALID_CONVERTER_NAMES,
// ADR-857 phase 5e: configFormat ↔ installSurface parity gate
runConfigFormatParityGate,
INSTALL_SURFACE_TO_CONFIG_FORMAT,
// ADR-857 phase 5f: cross-field consistency gates
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES,
VALID_INSTALL_SURFACES,
VALID_EXTENDED_HOOK_EVENTS,
VALID_PERMISSION_WRITERS,
validateRuntimeBody,
// FIX 5 (lazy): PROFILE_RANK and CLUSTERS are loaded on first access via getters
// so importing the generator on a fresh/unbuilt worktree doesn't fail at module load.
get PROFILE_RANK() { return getInstallProfiles().PROFILE_RANK; },
get CLUSTERS() { return getClusters().CLUSTERS; },
};
// ─── CLI entry point ──────────────────────────────────────────────────────────
if (require.main === module) {
runMain(main);
}