* chore(#2795): reviewer manifest body + registry harvest, validation, forward-compat Phase 2 of epic #2782 under ADR-2782. Delivers D1, D2, D3, D7, D8 and the four Phase-1 vocabulary amendments (A1-A4). - VALID_ROLES gains "reviewer"; the reviewer body is admissible on role:runtime (a host that is also a reviewer keeps one manifest) and on the new role:reviewer (a lane that is not an install target). A reviewer body on role:feature is an error: declaring one is an assertion of lane-ness. - validateReviewerBody + validateLaneProbe + validateLaneInvoke: nine closed enums, a transport discriminator selecting mutually-exclusive invoke sub-shapes, bounded probes (D7), and outputArg required-iff outputChannel is file-arg and forbidden otherwise. - Absent-safe (D4.1): only `undefined` is absent. null/{}/[]/false/0 are malformed assertions and error. 39 of 39 shipped capabilities depend on this. - collectReviewerWarnings: an unknown field inside the body warns, never errors, so a forward-built manifest degrades visibly instead of failing the build. - D8 uniqueness (slug / flags / reviewsSection) lives in validateCrossCapability, so it is enforced at build time over first-party AND at load time over the merged first-party union overlay set, with first-party-wins falling out of the loader's existing ordering rather than a new provenance check. - Config harvest widened past the role==="feature" branch in both the generator and the ownership loop. The often-cited cause of the stranded reviewer config keys -- the runtime body forbidding feature-only fields -- is not the mechanism: `config` is not in FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME. The cause is two harvest sites that never read it. Verified inert: no shipped capability declares config on a non-feature role, and the generated registry is unchanged. Three ADR corrections are folded in (Phase 1 set the precedent of amending in-phase): the misattributed config-stranding cause, D3's inverted profile-membership claim, and the specified capability folder names for lm_studio / llama_cpp, which would have failed the id kebab-case invariant. Closes #2795 * chore(#2795): collapse nine enum checks into one validateEnumField helper Standards-axis review findings, both applied: - Duplicated Code: the enum-membership + enumerate-the-members error shape repeated near-verbatim at nine call sites. Routing them through one helper makes "the error names its valid members" structural rather than a convention repeated nine times, where it would drift. That property is load-bearing until Phase 6 ships the prose reference, because these errors are currently the only documentation of the vocabulary. - Speculative Generality: the isReservedName() pre-check on every enum field was inert. A VALID_* set never contains __proto__/constructor/prototype, so membership alone already rejects them, and "must be one of: ..." is more actionable than "is a reserved name". The literal guards remain where they do real work -- the key-derived write sites in the registry generator and the claim() accumulator. The reserved-name test now asserts all three reserved names are rejected via enum membership, rather than one name via a branch that no longer exists. * fix(#2795): align lane slug grammar with Phase 1 and wire the load-time diagnostic channel Spec-axis review findings, both applied. (1) The slug grammar had diverged from Phase 1's core descriptor. Phase 1 exports LANE_SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/ (leading digit permitted); the manifest validator required a leading LETTER. A slug the core descriptor accepts -- a model-named lane such as 4o-mini -- would have been rejected by the manifest validator, which is exactly the translation layer ADR-2782 exists to delete. It was inert only because all eleven shipped slugs begin with a letter, so nothing else would have caught it until a third party shipped such a lane. The grammar cannot be reduced to one definition: Phase 1's module compiles to gitignored build output, and capability-validator.cjs is a committed plain .cjs that must load on a fresh worktree before build:lib has ever run. That makes this the repo's DEFECT.GENERATIVE-FIX class, so the duplication now carries a parity assertion -- laneSlugGrammarMatchesPhase1Descriptor -- which compares both the source grammar and the accept/reject verdict for a shared input set, and fails if the two ever drift again. (2) collectReviewerWarnings had exactly one caller: the build-time generator, which only ever sees first-party in-repo manifests. The real third-party overlay loader never called it and ValidatorModule did not declare it, so ADR-2782 D4.3 -- an unknown field inside a reviewer body is ignored WITH A WARNING -- surfaced nowhere at runtime, which is precisely the case D4.3 exists for. loadRegistry now collects those diagnostics on the accept path, behind a typeof-guard (an older built validator without the function still loads) and a try/catch (ADR-1244 D2's never-crash contract outranks a diagnostic). They land in a NEW OverlayMeta.diagnostics field rather than OverlayMeta.warnings, because warnings records capabilities that were SKIPPED and a consumer treating every entry as inactive would mislabel a working lane. Covered end-to-end by overlayLaneWithUnknownFieldIsAcceptedAndDiagnosed, which drives a real global-scope overlay through loadRegistry and asserts the lane is accepted, produces no skip warning, and yields a diagnostic naming the field. * fix(#2795): make the reviewer validators honour their documented totality contract Isolated adversarial review finding (MAJOR), reproduced by execution. validateReviewerBody documents itself as "TOTAL: returns an array of error strings for ANY input and never throws", and the overlay loader contracts every validator to RETURN errors -- #1461 OVL-1 records a validator that THREW and would have crashed every consumer of loadRegistry. The contract was false at ten sites: JSON.stringify throws on a BigInt and on a circular structure, and every enum/scalar rejection path interpolated the rejected value into its own rejection message. Reading the value could throw too, before any message was built, via a throwing getter or a Proxy get/ownKeys trap. Not reachable through a capability.json today -- every ingestion path is a plain JSON.parse of file text, which cannot express any of those shapes. Fixed anyway: the contract is stated on an EXPORTED function, and a caller must not have to re-derive today's reachability analysis to know whether it holds. Two layers, because serialization safety alone is insufficient: - describeValue() renders any value without throwing, so messages stay useful (a BigInt now reads "got: 10n" rather than degrading to a generic fallback). - A structural try/catch around validateReviewerBody and collectReviewerWarnings makes the guarantee absolute rather than argued, covering read-time throws that fire before any message exists. The same review found the property test guarding this contract was FALSE CONFIDENCE, which is the more important half. fc.anything() at default constraints emits no BigInt, no circular reference, no getter and no Proxy -- 20,000 sampled draws produced zero of each -- so the test was named for a contract its generator could not reach. Even withBigInt is insufficient under whole-value fuzzing, because the defect needs an exotic value in a specifically NAMED field and random key names never land on one. The property is now field-targeted across all twelve reviewer fields, and a companion test enumerates the shapes fast-check cannot generate at all (BigInt, circular, throwing getter, symbol, function, null-prototype) across scalar positions, array-element positions, and read-time traps. Verified red-before-green: with the fix reverted both property tests fail; with it restored all 119 pass. * chore(#2795): backfill changeset pr number to 2823
916 lines
38 KiB
JavaScript
916 lines
38 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,
|
|
collectReviewerWarnings,
|
|
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();
|
|
// ADR-2782 D4 — non-fatal diagnostics (e.g. an unknown field inside a reviewer
|
|
// body). These NEVER fail the build; they surface on stderr so a forward-built
|
|
// manifest degrades visibly instead of silently.
|
|
const warnings = [];
|
|
|
|
if (!fs.existsSync(resolvedCapDir)) {
|
|
return { capMap, errors, warnings };
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Collected BEFORE the error short-circuit below so a manifest that is both
|
|
// forward-built and invalid still reports why it looked unfamiliar.
|
|
for (const w of collectReviewerWarnings(cap)) warnings.push(folderId + '/capability.json: ' + w);
|
|
|
|
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, warnings };
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// Federated config slice — harvested from ANY role that declares one.
|
|
//
|
|
// ADR-2782 D1/D9: this loop was nested inside the `role === 'feature'` branch,
|
|
// so a `role: "runtime"` capability's `config` was read by nothing and dropped
|
|
// in silence — the actual reason reviewer config keys are stranded in the
|
|
// central schema. (The often-cited reason, that the runtime body forbids
|
|
// feature-only fields, does not apply: `config` is NOT in
|
|
// FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME.) Owning a config slice is a property of
|
|
// DECLARING one, not of being a feature. Verified inert at introduction — no
|
|
// shipped capability declares `config` on a non-feature role — so this changes
|
|
// no existing key; it stops a latent silent drop and unblocks Phase 4 (#2797).
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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, warnings } = loadAndValidate(centralKeys);
|
|
|
|
// ADR-2782 D4 — non-fatal manifest diagnostics. Emitted BEFORE the hard-error
|
|
// exit so a forward-built manifest still explains itself on a failing build.
|
|
for (const w of warnings) process.stderr.write(w + '\n');
|
|
|
|
// 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, warnings } = loadAndValidate(centralKeys);
|
|
|
|
// ADR-2782 D4 — non-fatal manifest diagnostics. Emitted BEFORE the hard-error
|
|
// exit so a forward-built manifest still explains itself on a failing build.
|
|
for (const w of warnings) process.stderr.write(w + '\n');
|
|
|
|
// 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);
|
|
}
|