Establish `tier` as the source of install-profile + cluster membership
(ADR-894 §4). The registry now derives two views: capabilityClusters
(capId → its skills) and profileMembership (capId → {tier, profiles}, where
profiles is the PROFILE_RANK suffix from the capability's tier). A consistency
gate cross-checks them against the hand-authored PROFILES/CLUSTERS: HARD (throws)
on a capId-matching-a-cluster-name with a different skill set; SOFT
(pending-reconciliation stderr warning, not serialized) when a capability skill
isn't yet in the closure-resolved hand-authored profile.
The SOFT gate loads the real skills manifest and resolves each profile's closure
once so transitively-included skills don't false-warn; warnings are de-duped to
one per (capability, skill); both derived views are scoped to skill-owning
capabilities; serialized with a global capId sort for determinism; reserved-name
guards at every write site; lazy requires of the built constants.
Behavior-preserving: install/surface untouched; derived views consumed by
nothing. Full generation rides along with the phase-6 migration.
Closes #942
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1679 lines
66 KiB
JavaScript
1679 lines
66 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');
|
|
|
|
const SCHEMA_VERSION = '1';
|
|
|
|
// ─── 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');
|
|
|
|
// Canonical point order — explicit constant (do NOT rely on Set insertion order).
|
|
// Used for point-ordering semantics in consumes-satisfiability validation and topo-sort.
|
|
const POINT_ORDER = [
|
|
'discuss:pre',
|
|
'discuss:post',
|
|
'plan:pre',
|
|
'plan:post',
|
|
'execute:pre',
|
|
'execute:wave:pre',
|
|
'execute:wave:post',
|
|
'execute:post',
|
|
'verify:pre',
|
|
'verify:post',
|
|
'ship:pre',
|
|
'ship:post',
|
|
];
|
|
|
|
// C1: Artifact availability — host-produced artifacts become available at their step's :post
|
|
// point. Build a map: artifact → earliest POINT_ORDER index at which it is available.
|
|
// (discuss produces CONTEXT.md → discuss:post = index 1;
|
|
// plan produces PLAN.md → plan:post = index 3;
|
|
// execute produces SUMMARY.md → execute:post = index 7;
|
|
// verify produces UAT.md → verify:post = index 9)
|
|
//
|
|
// NOTE: this map covers ONLY host artifacts. Hook-produced artifacts are handled per-run
|
|
// during consumes-satisfiability validation (C2 global pass).
|
|
const HOST_ARTIFACT_EARLIEST_POINT_IDX = (() => {
|
|
const m = Object.create(null);
|
|
for (const entry of LOOP_HOST_CONTRACT) {
|
|
// The :post point is the last point in each step's points array.
|
|
const postPoint = entry.points[entry.points.length - 1];
|
|
const postIdx = POINT_ORDER.indexOf(postPoint);
|
|
for (const artifact of entry.coreArtifacts.produces) {
|
|
// Only record the earliest (should be unique, but take min to be safe).
|
|
if (m[artifact] === undefined || postIdx < m[artifact]) {
|
|
m[artifact] = postIdx;
|
|
}
|
|
}
|
|
}
|
|
return m;
|
|
})();
|
|
|
|
// Flatten all valid loop points into a Set for O(1) validation
|
|
const VALID_LOOP_POINTS = new Set(POINT_ORDER);
|
|
|
|
// Map point → step contract (agentRoles + coreArtifacts)
|
|
const POINT_TO_CONTRACT = new Map();
|
|
for (const entry of LOOP_HOST_CONTRACT) {
|
|
for (const point of entry.points) {
|
|
POINT_TO_CONTRACT.set(point, entry);
|
|
}
|
|
}
|
|
|
|
// ─── Central config-schema loader ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Loads the set of keys from the central config-schema manifest.
|
|
* Returns a Set<string>. Used for collision detection.
|
|
*
|
|
* TODO: distinguish file-not-found (ok, return empty Set) from JSON-parse-error
|
|
* (should warn — a parse error means the schema is broken, not just absent).
|
|
*/
|
|
function loadCentralConfigKeys() {
|
|
try {
|
|
const manifest = JSON.parse(fs.readFileSync(CONFIG_SCHEMA_PATH, 'utf8'));
|
|
return new Set(Array.isArray(manifest.validKeys) ? manifest.validKeys : []);
|
|
} catch (_) {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
// ─── Config-slice validation ──────────────────────────────────────────────────
|
|
|
|
const VALID_CONFIG_SLICE_TYPES = new Set(['boolean', 'string', 'number', 'enum']);
|
|
|
|
/**
|
|
* Validate a single config-slice entry (one key's { type, default, description }).
|
|
* Returns an array of error strings. Empty = valid.
|
|
*
|
|
* @param {string} capId Capability id (for error messages)
|
|
* @param {string} key Config key (for error messages)
|
|
* @param {object} slice The slice object from cap.config[key]
|
|
* @returns {string[]}
|
|
*/
|
|
function validateConfigSliceEntry(capId, key, slice) {
|
|
const errors = [];
|
|
|
|
if (typeof slice !== 'object' || slice === null || Array.isArray(slice)) {
|
|
errors.push('capability "' + capId + '" config["' + key + '"]: slice must be a non-null object');
|
|
return errors;
|
|
}
|
|
|
|
// type must be one of the allowed set
|
|
if (!VALID_CONFIG_SLICE_TYPES.has(slice.type)) {
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: type must be one of ' +
|
|
[...VALID_CONFIG_SLICE_TYPES].join(', ') + ' (got: ' + JSON.stringify(slice.type) + ')',
|
|
);
|
|
}
|
|
|
|
// default must be present
|
|
if (!Object.prototype.hasOwnProperty.call(slice, 'default')) {
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: default is required',
|
|
);
|
|
} else {
|
|
// type-consistency check
|
|
const def = slice.default;
|
|
if (slice.type === 'boolean') {
|
|
if (typeof def !== 'boolean') {
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: default must be a boolean for type:"boolean" (got: ' + typeof def + ')',
|
|
);
|
|
}
|
|
} else if (slice.type === 'string') {
|
|
if (typeof def !== 'string') {
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: default must be a string for type:"string" (got: ' + typeof def + ')',
|
|
);
|
|
}
|
|
} else if (slice.type === 'number') {
|
|
if (typeof def !== 'number') {
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: default must be a number for type:"number" (got: ' + typeof def + ')',
|
|
);
|
|
} else if (!Number.isFinite(def)) {
|
|
// FIX 6a: Reject NaN and non-finite number defaults
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: default for type:"number" must be a finite number (got: ' + String(def) + ')',
|
|
);
|
|
}
|
|
} else if (slice.type === 'enum') {
|
|
// FIX 5a: enum REQUIRES a non-empty values array (all strings), and default must be in it
|
|
if (!Array.isArray(slice.values) || slice.values.length === 0) {
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: type:"enum" requires a non-empty "values" array of strings',
|
|
);
|
|
} else if (!slice.values.every((v) => typeof v === 'string')) {
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: type:"enum" values array must contain only strings',
|
|
);
|
|
}
|
|
if (typeof def !== 'string') {
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: default must be a string for type:"enum" (got: ' + typeof def + ')',
|
|
);
|
|
} else if (Array.isArray(slice.values) && slice.values.length > 0 && !slice.values.includes(def)) {
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: default "' + def +
|
|
'" is not one of the declared enum values [' + slice.values.join(', ') + ']',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// description must be a non-empty string
|
|
if (typeof slice.description !== 'string' || slice.description.length === 0) {
|
|
errors.push(
|
|
'capability "' + capId + '" config["' + key + '"]: description must be a non-empty string (got: ' + JSON.stringify(slice.description) + ')',
|
|
);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
// ─── Per-capability validation ────────────────────────────────────────────────
|
|
|
|
const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
|
|
const VALID_ROLES = new Set(['feature', 'runtime']);
|
|
const VALID_TIERS = new Set(['core', 'standard', 'full']);
|
|
const VALID_ON_ERROR = new Set(['skip', 'halt']);
|
|
|
|
/**
|
|
* Validate a single capability declaration.
|
|
*
|
|
* @param {object} cap The parsed JSON object.
|
|
* @param {string} folderId The folder name (must equal cap.id).
|
|
* @returns {string[]} Array of error strings; empty = valid.
|
|
*/
|
|
function validateCapability(cap, folderId) {
|
|
const errors = [];
|
|
|
|
if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) {
|
|
return ['capability must be a JSON object'];
|
|
}
|
|
|
|
// ── Common envelope ────────────────────────────────────────────────────────
|
|
|
|
if (typeof cap.id !== 'string' || !KEBAB_RE.test(cap.id)) {
|
|
errors.push('id must be a kebab-case string');
|
|
} else if (cap.id !== folderId) {
|
|
errors.push('id "' + cap.id + '" must equal the folder name "' + folderId + '"');
|
|
}
|
|
|
|
if (!VALID_ROLES.has(cap.role)) {
|
|
errors.push('role must be one of: feature, runtime (got: ' + cap.role + ')');
|
|
}
|
|
|
|
if (typeof cap.title !== 'string' || cap.title.length === 0) {
|
|
errors.push('title must be a non-empty string');
|
|
}
|
|
|
|
// C4: description is required
|
|
if (typeof cap.description !== 'string' || cap.description.length === 0) {
|
|
errors.push('description must be a non-empty string');
|
|
}
|
|
|
|
if (!VALID_TIERS.has(cap.tier)) {
|
|
errors.push('tier must be one of: core, standard, full (got: ' + cap.tier + ')');
|
|
}
|
|
|
|
if (!Array.isArray(cap.requires)) {
|
|
errors.push('requires must be an array of capability ids');
|
|
} else {
|
|
for (const req of cap.requires) {
|
|
if (typeof req !== 'string') {
|
|
errors.push('requires entries must be strings (got: ' + JSON.stringify(req) + ')');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Role-specific body ────────────────────────────────────────────────────
|
|
|
|
if (cap.role === 'feature') {
|
|
errors.push(...validateFeatureBody(cap));
|
|
} else if (cap.role === 'runtime') {
|
|
errors.push(...validateRuntimeBody(cap));
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateFeatureBody(cap) {
|
|
const errors = [];
|
|
|
|
if (!Array.isArray(cap.skills)) {
|
|
errors.push('skills must be an array of strings');
|
|
} else {
|
|
for (const s of cap.skills) {
|
|
if (typeof s !== 'string') {
|
|
errors.push('skills entries must be strings');
|
|
} else if (s === '__proto__' || s === 'constructor' || s === 'prototype') {
|
|
// S2a: inline literal reserved-name guard (CodeQL barrier)
|
|
errors.push('skills entry "' + s + '" is a reserved name');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(cap.agents)) {
|
|
errors.push('agents must be an array of strings');
|
|
} else {
|
|
for (const a of cap.agents) {
|
|
if (typeof a !== 'string') {
|
|
errors.push('agents entries must be strings');
|
|
} else if (a === '__proto__' || a === 'constructor' || a === 'prototype') {
|
|
// S2a: inline literal reserved-name guard (CodeQL barrier)
|
|
errors.push('agents entry "' + a + '" is a reserved name');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (typeof cap.config !== 'object' || cap.config === null || Array.isArray(cap.config)) {
|
|
errors.push('config must be an object');
|
|
} else {
|
|
// C5: validate config key names and value shapes
|
|
for (const key of Object.keys(cap.config)) {
|
|
if (key === '' ) {
|
|
errors.push('config keys must be non-empty strings');
|
|
} else if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
// S2a: inline literal reserved-name guard (CodeQL barrier)
|
|
errors.push('config key "' + key + '" is a reserved name');
|
|
}
|
|
const val = cap.config[key];
|
|
if (val === null || typeof val !== 'object' || Array.isArray(val)) {
|
|
errors.push('config["' + key + '"] must be an object (got: ' + (val === null ? 'null' : typeof val) + ')');
|
|
} else if (typeof val.type !== 'string' || val.type.length === 0) {
|
|
errors.push('config["' + key + '"] must have a string "type" field (e.g. "boolean", "string", "number", "enum")');
|
|
}
|
|
}
|
|
}
|
|
|
|
// C4: hooks, when present, must be an array of {event: string, script: string}
|
|
if (cap.hooks !== undefined) {
|
|
if (!Array.isArray(cap.hooks)) {
|
|
errors.push('hooks must be an array of {event, script} objects');
|
|
} else {
|
|
for (let i = 0; i < cap.hooks.length; i++) {
|
|
const h = cap.hooks[i];
|
|
if (typeof h !== 'object' || h === null || Array.isArray(h)) {
|
|
errors.push('hooks[' + i + '] must be an object with event and script keys');
|
|
} else {
|
|
if (typeof h.event !== 'string' || h.event.length === 0) {
|
|
errors.push('hooks[' + i + '].event must be a non-empty string');
|
|
}
|
|
if (typeof h.script !== 'string' || h.script.length === 0) {
|
|
errors.push('hooks[' + i + '].script must be a non-empty string');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(cap.steps)) {
|
|
errors.push('steps must be an array');
|
|
} else {
|
|
for (let i = 0; i < cap.steps.length; i++) {
|
|
errors.push(...validateStep(cap.steps[i], 'steps[' + i + ']'));
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(cap.contributions)) {
|
|
errors.push('contributions must be an array');
|
|
} else {
|
|
for (let i = 0; i < cap.contributions.length; i++) {
|
|
errors.push(...validateContribution(cap.contributions[i], 'contributions[' + i + ']'));
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(cap.gates)) {
|
|
errors.push('gates must be an array');
|
|
} else {
|
|
for (let i = 0; i < cap.gates.length; i++) {
|
|
errors.push(...validateGate(cap.gates[i], 'gates[' + i + ']'));
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
// C3: Validate role:runtime body
|
|
const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']);
|
|
const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks'];
|
|
|
|
function validateRuntimeBody(cap) {
|
|
const errors = [];
|
|
|
|
// C3: feature-only fields must NOT appear on a runtime cap
|
|
for (const field of FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME) {
|
|
if (cap[field] !== undefined) {
|
|
errors.push('role:runtime capability must not have "' + field + '" (feature-only field)');
|
|
}
|
|
}
|
|
|
|
// C3: require a runtime object
|
|
if (typeof cap.runtime !== 'object' || cap.runtime === null || Array.isArray(cap.runtime)) {
|
|
errors.push('role:runtime capability must have a "runtime" object');
|
|
return errors; // can't validate further without the object
|
|
}
|
|
|
|
const r = cap.runtime;
|
|
if (typeof r.configHome !== 'string' || r.configHome.length === 0) {
|
|
errors.push('runtime.configHome must be a non-empty string');
|
|
}
|
|
if (!VALID_CONFIG_FORMATS.has(r.configFormat)) {
|
|
errors.push('runtime.configFormat must be one of: ' + [...VALID_CONFIG_FORMATS].join(', ') + ' (got: ' + r.configFormat + ')');
|
|
}
|
|
if (!Array.isArray(r.artifactLayout)) {
|
|
errors.push('runtime.artifactLayout must be an array');
|
|
}
|
|
if (typeof r.commandStyle !== 'string' || r.commandStyle.length === 0) {
|
|
errors.push('runtime.commandStyle must be a non-empty string');
|
|
}
|
|
if (typeof r.hooksSurface !== 'string' || r.hooksSurface.length === 0) {
|
|
errors.push('runtime.hooksSurface must be a non-empty string');
|
|
}
|
|
if (typeof r.sandboxTier !== 'string' || r.sandboxTier.length === 0) {
|
|
errors.push('runtime.sandboxTier must be a non-empty string');
|
|
}
|
|
if (r.supportTier !== 1 && r.supportTier !== 2) {
|
|
errors.push('runtime.supportTier must be 1 or 2 (got: ' + r.supportTier + ')');
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateStep(step, prefix) {
|
|
const errors = [];
|
|
|
|
if (!VALID_LOOP_POINTS.has(step.point)) {
|
|
errors.push(prefix + '.point "' + step.point + '" is not a valid loop point');
|
|
}
|
|
|
|
if (typeof step.ref !== 'object' || step.ref === null) {
|
|
errors.push(prefix + '.ref must be an object with skill or agent key');
|
|
} else {
|
|
const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill');
|
|
const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent');
|
|
if (!hasSkill && !hasAgent) {
|
|
errors.push(prefix + '.ref must have a "skill" or "agent" key');
|
|
} else if (hasSkill && hasAgent) {
|
|
// Fix #4: ref must be exclusive {skill} XOR {agent}
|
|
errors.push(prefix + '.ref must have exactly one of "skill" or "agent", not both');
|
|
}
|
|
if (hasSkill && typeof step.ref.skill !== 'string') {
|
|
errors.push(prefix + '.ref.skill must be a string');
|
|
}
|
|
if (hasAgent && typeof step.ref.agent !== 'string') {
|
|
errors.push(prefix + '.ref.agent must be a string');
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(step.produces)) {
|
|
errors.push(prefix + '.produces must be an array');
|
|
} else {
|
|
for (const p of step.produces) {
|
|
if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings');
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(step.consumes)) {
|
|
errors.push(prefix + '.consumes must be an array');
|
|
} else {
|
|
for (const c of step.consumes) {
|
|
if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings');
|
|
}
|
|
}
|
|
|
|
if (step.when !== undefined && typeof step.when !== 'string') {
|
|
errors.push(prefix + '.when must be a string if present');
|
|
}
|
|
|
|
if (!VALID_ON_ERROR.has(step.onError)) {
|
|
errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + step.onError + ')');
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateContribution(contrib, prefix) {
|
|
const errors = [];
|
|
|
|
if (!VALID_LOOP_POINTS.has(contrib.point)) {
|
|
errors.push(prefix + '.point "' + contrib.point + '" is not a valid loop point');
|
|
}
|
|
|
|
if (typeof contrib.into !== 'string') {
|
|
errors.push(prefix + '.into must be a string (agent role name)');
|
|
}
|
|
|
|
if (typeof contrib.fragment !== 'object' || contrib.fragment === null) {
|
|
errors.push(prefix + '.fragment must be an object with path or inline key');
|
|
} else {
|
|
const hasPath = Object.prototype.hasOwnProperty.call(contrib.fragment, 'path');
|
|
const hasInline = Object.prototype.hasOwnProperty.call(contrib.fragment, 'inline');
|
|
if (!hasPath && !hasInline) {
|
|
errors.push(prefix + '.fragment must have a "path" or "inline" key');
|
|
}
|
|
// S1: fragment.path traversal guard — must be a relative path with no ".." segments
|
|
if (hasPath) {
|
|
const p = contrib.fragment.path;
|
|
if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) {
|
|
errors.push(prefix + '.fragment.path must be a relative path with no ".." segments');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (contrib.when !== undefined && typeof contrib.when !== 'string') {
|
|
errors.push(prefix + '.when must be a string if present');
|
|
}
|
|
|
|
if (contrib.onError !== undefined && !VALID_ON_ERROR.has(contrib.onError)) {
|
|
errors.push(prefix + '.onError must be "skip" or "halt" if present');
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateGate(gate, prefix) {
|
|
const errors = [];
|
|
|
|
if (!VALID_LOOP_POINTS.has(gate.point)) {
|
|
errors.push(prefix + '.point "' + gate.point + '" is not a valid loop point');
|
|
}
|
|
|
|
if (typeof gate.check !== 'object' || gate.check === null) {
|
|
errors.push(prefix + '.check must be an object');
|
|
} else {
|
|
const hasQuery = Object.prototype.hasOwnProperty.call(gate.check, 'query');
|
|
const hasPredicate = Object.prototype.hasOwnProperty.call(gate.check, 'predicate');
|
|
const hasAgentVerdict = Object.prototype.hasOwnProperty.call(gate.check, 'agentVerdict');
|
|
const count = [hasQuery, hasPredicate, hasAgentVerdict].filter(Boolean).length;
|
|
if (count !== 1) {
|
|
errors.push(prefix + '.check must have exactly one of: query, predicate, agentVerdict');
|
|
}
|
|
// agentVerdict forces blocking: false (advisory only)
|
|
if (hasAgentVerdict && gate.blocking === true) {
|
|
errors.push(
|
|
prefix + '.check.agentVerdict forces blocking: false (non-deterministic checks may not halt the loop)',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (gate.when !== undefined && typeof gate.when !== 'string') {
|
|
errors.push(prefix + '.when must be a string if present');
|
|
}
|
|
|
|
if (typeof gate.blocking !== 'boolean') {
|
|
errors.push(prefix + '.blocking must be a boolean');
|
|
}
|
|
|
|
if (!VALID_ON_ERROR.has(gate.onError)) {
|
|
errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + gate.onError + ')');
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
// ─── Contract validation ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Validate per-capability contract constraints against the Loop Host Contract.
|
|
* This covers:
|
|
* - contribution.into ∈ step's agentRoles
|
|
* - when references a config key in cap.config
|
|
*
|
|
* NOTE: step.consumes satisfiability is NOT checked here — it requires the full
|
|
* set of validated capabilities (cross-capability produces). It runs in
|
|
* validateConsumesGlobal() after loadAndValidate builds capMap.
|
|
*
|
|
* @param {object} cap Validated capability object
|
|
* @param {string} capId Capability id (for error messages)
|
|
*/
|
|
function validateAgainstContract(cap, capId) {
|
|
if (cap.role !== 'feature') return [];
|
|
const errors = [];
|
|
const prefix = 'capability "' + capId + '"';
|
|
|
|
// contribution.into must be in the step's agentRoles
|
|
for (const contrib of cap.contributions) {
|
|
if (!VALID_LOOP_POINTS.has(contrib.point)) continue; // already reported
|
|
const contract = POINT_TO_CONTRACT.get(contrib.point);
|
|
if (contract && !contract.agentRoles.includes(contrib.into)) {
|
|
errors.push(
|
|
prefix + ' contribution.into "' + contrib.into + '" at point "' + contrib.point +
|
|
'" is not in the step\'s agentRoles [' + contract.agentRoles.join(', ') + ']',
|
|
);
|
|
}
|
|
}
|
|
|
|
// when references a plausibly-valid config key (string — we require it's in cap.config)
|
|
for (const step of cap.steps) {
|
|
if (step.when !== undefined) {
|
|
if (typeof step.when !== 'string') continue; // already reported above
|
|
if (
|
|
typeof cap.config === 'object' &&
|
|
cap.config !== null &&
|
|
!Object.prototype.hasOwnProperty.call(cap.config, step.when)
|
|
) {
|
|
errors.push(
|
|
prefix + ' step.when "' + step.when + '" is not defined in capability config keys',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const contrib of cap.contributions) {
|
|
if (contrib.when !== undefined) {
|
|
if (typeof contrib.when !== 'string') continue;
|
|
if (
|
|
typeof cap.config === 'object' &&
|
|
cap.config !== null &&
|
|
!Object.prototype.hasOwnProperty.call(cap.config, contrib.when)
|
|
) {
|
|
errors.push(
|
|
prefix + ' contribution.when "' + contrib.when + '" is not defined in capability config keys',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const gate of cap.gates) {
|
|
if (gate.when !== undefined) {
|
|
if (typeof gate.when !== 'string') continue;
|
|
if (
|
|
typeof cap.config === 'object' &&
|
|
cap.config !== null &&
|
|
!Object.prototype.hasOwnProperty.call(cap.config, gate.when)
|
|
) {
|
|
errors.push(
|
|
prefix + ' gate.when "' + gate.when + '" is not defined in capability config keys',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* C1+C2: Global consumes-satisfiability validation.
|
|
*
|
|
* A hook at point P consuming artifact A is satisfiable iff:
|
|
* - A is a host-produced artifact available from its step's :post point (C1), and
|
|
* that :post point's POINT_ORDER index ≤ P's index; OR
|
|
* - A is produced by any capability hook step at a point whose POINT_ORDER index ≤ P's index
|
|
* (same-point is OK — topoSortSteps enforces intra-point order); OR
|
|
* - A is never produced anywhere → rejected.
|
|
*
|
|
* Runs after capMap is fully built so cross-capability produces are visible.
|
|
*
|
|
* @param {Map<string, object>} capMap Fully-validated capability map.
|
|
* @returns {string[]} Array of error strings.
|
|
*/
|
|
function validateConsumesGlobal(capMap) {
|
|
const errors = [];
|
|
|
|
// Build producedAtPoint: artifact → earliest POINT_ORDER index at which it is produced.
|
|
// Seed with host artifacts (C1: available from their step's :post point).
|
|
// Host-artifact entries are tagged {pointIdx, isHost:true} so they are never excluded by
|
|
// the self-consume check.
|
|
const producedAtPoint = Object.create(null);
|
|
for (const [artifact, postIdx] of Object.entries(HOST_ARTIFACT_EARLIEST_POINT_IDX)) {
|
|
if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue;
|
|
producedAtPoint[artifact] = postIdx;
|
|
}
|
|
|
|
// Build a richer per-artifact producer list for the self-consume check.
|
|
// Each entry: { pointIdx, capId, stepIdx } — identifies which cap+step produced the artifact.
|
|
// Host artifacts are seeded separately (no capId) and always satisfy the consume check.
|
|
// capHookProducers[artifact] = [{pointIdx, capId, stepIdx}, ...]
|
|
const capHookProducers = Object.create(null);
|
|
|
|
// Add hook-produced artifacts from all capabilities.
|
|
for (const [capId, cap] of capMap) {
|
|
if (cap.role !== 'feature') continue;
|
|
for (let si = 0; si < (cap.steps || []).length; si++) {
|
|
const step = cap.steps[si];
|
|
if (!VALID_LOOP_POINTS.has(step.point)) continue;
|
|
const pointIdx = POINT_ORDER.indexOf(step.point);
|
|
for (const artifact of (step.produces || [])) {
|
|
if (typeof artifact !== 'string') continue;
|
|
if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue;
|
|
if (producedAtPoint[artifact] === undefined || pointIdx < producedAtPoint[artifact]) {
|
|
producedAtPoint[artifact] = pointIdx;
|
|
}
|
|
if (!capHookProducers[artifact]) capHookProducers[artifact] = [];
|
|
capHookProducers[artifact].push({ pointIdx, capId, stepIdx: si });
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: duplicate-producer invariant — if two capability steps produce the same artifact
|
|
// at the same point, that's ambiguous. Detect and reject as a follow-up.
|
|
|
|
// Now check every hook step's consumes.
|
|
// Self-consume rule: a step H cannot satisfy its own consumes[A] from its own produces[A].
|
|
// A is satisfiable for H iff:
|
|
// (a) A is a host artifact with pointIdx <= stepPointIdx, OR
|
|
// (b) A is produced by a DIFFERENT cap/step at pointIdx <= stepPointIdx.
|
|
// "Different" means capId != H.capId OR stepIdx != H.stepIdx.
|
|
for (const [capId, cap] of capMap) {
|
|
if (cap.role !== 'feature') continue;
|
|
const prefix = 'capability "' + capId + '"';
|
|
for (let si = 0; si < (cap.steps || []).length; si++) {
|
|
const step = cap.steps[si];
|
|
if (!VALID_LOOP_POINTS.has(step.point)) continue;
|
|
const stepPointIdx = POINT_ORDER.indexOf(step.point);
|
|
for (const artifact of (step.consumes || [])) {
|
|
if (typeof artifact !== 'string') continue;
|
|
|
|
// Check host-artifact satisfaction first (never excluded by self-consume).
|
|
const hostIdx = HOST_ARTIFACT_EARLIEST_POINT_IDX[artifact];
|
|
const hostSatisfied = hostIdx !== undefined && hostIdx <= stepPointIdx;
|
|
if (hostSatisfied) continue; // fast-path: host artifact is available
|
|
|
|
// Check cap-hook producers, excluding this step itself.
|
|
const producers = capHookProducers[artifact];
|
|
if (!producers || producers.length === 0) {
|
|
// Not a host artifact and never produced by any hook.
|
|
errors.push(
|
|
prefix + ' step at point "' + step.point + '" consumes "' + artifact +
|
|
'" which is never produced by any host artifact or capability hook',
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Find any non-self producer at pointIdx <= stepPointIdx.
|
|
const otherEarliestIdx = producers.reduce((best, p) => {
|
|
const isSelf = p.capId === capId && p.stepIdx === si;
|
|
if (isSelf) return best;
|
|
return (best === undefined || p.pointIdx < best) ? p.pointIdx : best;
|
|
}, undefined);
|
|
|
|
if (otherEarliestIdx === undefined) {
|
|
// Only producer is this step itself — self-consume violation.
|
|
errors.push(
|
|
prefix + ' step at point "' + step.point + '" consumes "' + artifact +
|
|
'" which is only produced by this step itself (a step cannot consume its own output)',
|
|
);
|
|
} else if (otherEarliestIdx > stepPointIdx) {
|
|
errors.push(
|
|
prefix + ' step at point "' + step.point + '" consumes "' + artifact +
|
|
'" which is only produced after this point (earliest available at POINT_ORDER index ' +
|
|
otherEarliestIdx + ' = "' + POINT_ORDER[otherEarliestIdx] + '")',
|
|
);
|
|
}
|
|
// else: satisfied by another cap/step at an earlier-or-same point — OK.
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
// ─── Cross-capability invariants ──────────────────────────────────────────────
|
|
|
|
const TIER_RANK = { core: 0, standard: 1, full: 2 };
|
|
|
|
/**
|
|
* Enforce cross-capability invariants.
|
|
*
|
|
* @param {Map<string, object>} capMap id → validated capability object
|
|
* @param {Set<string>} centralKeys Set of keys in the central config-schema
|
|
* @returns {string[]} Array of error strings; empty = all pass.
|
|
*/
|
|
function validateCrossCapability(capMap, centralKeys) {
|
|
const errors = [];
|
|
|
|
// Ownership: one owner per skill stem + agent name
|
|
const skillOwner = new Map(); // skill → capId
|
|
const agentOwner = new Map(); // agent → capId
|
|
for (const [capId, cap] of capMap) {
|
|
if (cap.role !== 'feature') continue;
|
|
for (const skill of cap.skills) {
|
|
if (skillOwner.has(skill)) {
|
|
errors.push(
|
|
'skill "' + skill + '" is owned by both "' + skillOwner.get(skill) + '" and "' + capId + '"',
|
|
);
|
|
} else {
|
|
skillOwner.set(skill, capId);
|
|
}
|
|
}
|
|
for (const agent of cap.agents) {
|
|
if (agentOwner.has(agent)) {
|
|
errors.push(
|
|
'agent "' + agent + '" is owned by both "' + agentOwner.get(agent) + '" and "' + capId + '"',
|
|
);
|
|
} else {
|
|
agentOwner.set(agent, capId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Config key ownership: exclusive AND absent from central schema
|
|
const configKeyOwner = new Map(); // key → capId
|
|
for (const [capId, cap] of capMap) {
|
|
if (cap.role !== 'feature' || typeof cap.config !== 'object' || cap.config === null) continue;
|
|
for (const key of Object.keys(cap.config)) {
|
|
if (configKeyOwner.has(key)) {
|
|
errors.push(
|
|
'config key "' + key + '" is owned by both "' + configKeyOwner.get(key) + '" and "' + capId + '"',
|
|
);
|
|
} else {
|
|
configKeyOwner.set(key, capId);
|
|
}
|
|
if (centralKeys.has(key)) {
|
|
errors.push(
|
|
'config key "' + key + '" is declared in capability "' + capId +
|
|
'" AND exists in the central config-schema — migration mid-flight: ' +
|
|
'remove from central config-schema before adding to the capability',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// requires: all ids exist
|
|
for (const [capId, cap] of capMap) {
|
|
if (!Array.isArray(cap.requires)) continue;
|
|
for (const req of cap.requires) {
|
|
if (!capMap.has(req)) {
|
|
errors.push(
|
|
'capability "' + capId + '" requires "' + req + '" which does not exist',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// requires: acyclic
|
|
const cycleErrors = detectRequiresCycles(capMap);
|
|
errors.push(...cycleErrors);
|
|
|
|
// requires: tier-monotone (core may not require standard/full; standard may not require full)
|
|
for (const [capId, cap] of capMap) {
|
|
if (!Array.isArray(cap.requires) || !VALID_TIERS.has(cap.tier)) continue;
|
|
const myRank = TIER_RANK[cap.tier];
|
|
for (const req of cap.requires) {
|
|
const reqCap = capMap.get(req);
|
|
if (!reqCap || !VALID_TIERS.has(reqCap.tier)) continue;
|
|
const reqRank = TIER_RANK[reqCap.tier];
|
|
if (reqRank > myRank) {
|
|
errors.push(
|
|
'tier-monotone violation: capability "' + capId + '" (tier: ' + cap.tier +
|
|
') requires "' + req + '" (tier: ' + reqCap.tier +
|
|
') — a capability may not require a higher-tier capability',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Detect cycles in the requires graph using DFS.
|
|
*/
|
|
function detectRequiresCycles(capMap) {
|
|
const errors = [];
|
|
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
const color = new Map([...capMap.keys()].map((k) => [k, WHITE]));
|
|
|
|
function dfs(id, stack) {
|
|
if (color.get(id) === GRAY) {
|
|
const cycleStr = [...stack, id].join(' → ');
|
|
errors.push('requires cycle detected: ' + cycleStr);
|
|
return;
|
|
}
|
|
if (color.get(id) === BLACK) return;
|
|
color.set(id, GRAY);
|
|
stack.push(id);
|
|
const cap = capMap.get(id);
|
|
if (cap && Array.isArray(cap.requires)) {
|
|
for (const req of cap.requires) {
|
|
if (capMap.has(req)) dfs(req, stack);
|
|
}
|
|
}
|
|
stack.pop();
|
|
color.set(id, BLACK);
|
|
}
|
|
|
|
for (const id of capMap.keys()) {
|
|
if (color.get(id) === WHITE) dfs(id, []);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
// ─── requiresClosure ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Compute the transitive requires closure for a capability id.
|
|
* Returns a Set<string> of all transitively required capability ids.
|
|
*
|
|
* @param {string} id
|
|
* @param {Map<string, object>} capMap
|
|
*/
|
|
function computeRequiresClosure(id, capMap) {
|
|
const visited = new Set();
|
|
const queue = [id];
|
|
while (queue.length > 0) {
|
|
const current = queue.shift();
|
|
const cap = capMap.get(current);
|
|
if (!cap || !Array.isArray(cap.requires)) continue;
|
|
for (const req of cap.requires) {
|
|
if (!visited.has(req)) {
|
|
visited.add(req);
|
|
queue.push(req);
|
|
}
|
|
}
|
|
}
|
|
return visited;
|
|
}
|
|
|
|
// ─── Topological ordering ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Topologically sort steps at a given point by produces/consumes.
|
|
* Capability-id tiebreak for determinism.
|
|
*
|
|
* @param {{ capId: string, step: object }[]} entries
|
|
* @returns {{ capId: string, step: object }[]}
|
|
*/
|
|
function topoSortSteps(entries) {
|
|
if (entries.length <= 1) return entries;
|
|
|
|
// Build adjacency: entry A must come before entry B if B consumes something A produces
|
|
const n = entries.length;
|
|
const inDegree = new Array(n).fill(0);
|
|
const adj = Array.from({ length: n }, () => []);
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const producesI = new Set(entries[i].step.produces || []);
|
|
for (let j = 0; j < n; j++) {
|
|
if (i === j) continue;
|
|
const consumesJ = entries[j].step.consumes || [];
|
|
for (const artifact of consumesJ) {
|
|
if (producesI.has(artifact)) {
|
|
adj[i].push(j);
|
|
inDegree[j]++;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Kahn's algorithm with stable tiebreak on capId
|
|
const queue = [];
|
|
for (let i = 0; i < n; i++) {
|
|
if (inDegree[i] === 0) queue.push(i);
|
|
}
|
|
// Sort queue by capId for determinism
|
|
queue.sort((a, b) => entries[a].capId.localeCompare(entries[b].capId));
|
|
|
|
const result = [];
|
|
while (queue.length > 0) {
|
|
// Take the first (sorted) ready node
|
|
const idx = queue.shift();
|
|
result.push(entries[idx]);
|
|
const newReady = [];
|
|
for (const neighbor of adj[idx]) {
|
|
inDegree[neighbor]--;
|
|
if (inDegree[neighbor] === 0) newReady.push(neighbor);
|
|
}
|
|
newReady.sort((a, b) => entries[a].capId.localeCompare(entries[b].capId));
|
|
queue.push(...newReady);
|
|
}
|
|
|
|
// Fix #2: if result.length < n, Kahn's could not complete — there is a produces/consumes
|
|
// cycle. Do NOT silently fall back to declaration order; throw a clear error.
|
|
if (result.length < n) {
|
|
const sortedIds = entries.map((e) => e.capId).join(', ');
|
|
throw new Error(
|
|
'produces/consumes cycle detected in steps at point "' +
|
|
(entries[0] && entries[0].step ? entries[0].step.point : '?') +
|
|
'" among capabilities [' + sortedIds + ']: ' +
|
|
'a cycle in hook produces/consumes prevents deterministic ordering',
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ─── ADR-857 Phase 4a: Derived views ─────────────────────────────────────────
|
|
|
|
// FIX 5 (lazy requires): paths are declared at top level but the actual require()
|
|
// calls are deferred into lazy accessor functions so importing this generator for
|
|
// its other exports does NOT fail at module-load time on a fresh/unbuilt worktree.
|
|
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;
|
|
}
|
|
|
|
// ─── Registry builder ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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: group by into, then capability-id order within group
|
|
const contribs = pointContribs.get(point);
|
|
contribs.sort((a, b) => {
|
|
const intoCompare = a.contrib.into.localeCompare(b.contrib.into);
|
|
if (intoCompare !== 0) return intoCompare;
|
|
return a.capId.localeCompare(b.capId);
|
|
});
|
|
byLoopPoint[point].contributions = contribs.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-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);
|
|
|
|
return {
|
|
version: SCHEMA_VERSION,
|
|
capabilities,
|
|
bySkill,
|
|
byAgent,
|
|
byLoopPoint,
|
|
configKeys,
|
|
configSchema,
|
|
runtimes,
|
|
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-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(' 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 ─────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fix #3: Emit pending-migration WARNINGs for config keys that collide with the central
|
|
* config-schema. Per ADR-894 staged cutover, a collision during the registry-only phase is
|
|
* NOT a hard error — the capability pipeline is being established before the atomic cutover
|
|
* PR for each feature. The registry still generates; the warning tells the maintainer which
|
|
* keys need to be moved out of the central schema at cutover time.
|
|
*
|
|
* A NEW unexpected collision (a key that shouldn't be in both) is also surfaced — the
|
|
* maintainer sees it in build output rather than it being silently swallowed.
|
|
*
|
|
* Reference: ADR-894 §4 "config-key ownership exclusive AND complete — presence in both =
|
|
* collision = a mid-flight migration; finish the move."
|
|
*
|
|
* @param {string[]} crossErrors Errors from validateCrossCapability (may include collision msgs)
|
|
* @param {Map<string, object>} capMap
|
|
* @returns {{ hardErrors: string[], pendingMigrationWarnings: string[] }}
|
|
*/
|
|
function classifyCrossErrors(crossErrors) {
|
|
const hardErrors = [];
|
|
const pendingMigrationWarnings = [];
|
|
const collisionRe = /config key "([^"]+)" is declared in capability "([^"]+)" AND exists in the central config-schema/;
|
|
|
|
for (const e of crossErrors) {
|
|
const m = collisionRe.exec(e);
|
|
if (m) {
|
|
// Collision = pending-migration warning, not a hard error during 3a-impl staged cutover
|
|
pendingMigrationWarnings.push(
|
|
'⚠ pending-migration: capability \'' + m[2] + '\' declares config key \'' + m[1] +
|
|
'\' still present in central config-schema; finish the move at cutover',
|
|
);
|
|
} else {
|
|
hardErrors.push(e);
|
|
}
|
|
}
|
|
return { hardErrors, pendingMigrationWarnings };
|
|
}
|
|
|
|
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,
|
|
validateAgainstContract,
|
|
validateConsumesGlobal,
|
|
validateCrossCapability,
|
|
classifyCrossErrors,
|
|
loadAndValidate,
|
|
buildRegistry,
|
|
serializeRegistry,
|
|
computeRequiresClosure,
|
|
topoSortSteps,
|
|
normalizeLineEndings,
|
|
validateConfigSliceEntry,
|
|
VALID_CONFIG_SLICE_TYPES,
|
|
LOOP_HOST_CONTRACT,
|
|
VALID_LOOP_POINTS,
|
|
POINT_ORDER,
|
|
POINT_TO_CONTRACT,
|
|
HOST_ARTIFACT_EARLIEST_POINT_IDX,
|
|
SCHEMA_VERSION,
|
|
// ADR-857 phase 4a: derived views + gates
|
|
deriveCapabilityClusters,
|
|
deriveProfileMembership,
|
|
runConsistencyGate,
|
|
// 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);
|
|
}
|