Files
msd-core/gsd-core/bin/lib/capability-validator.cjs
2026-06-23 12:10:21 -04:00

2068 lines
85 KiB
JavaScript

'use strict';
/**
* capability-validator.cjs — shared, runtime-callable capability validator.
*
* Extracted from scripts/gen-capability-registry.cjs per ADR-1244 D2 so that
* both the build-time generator and the runtime overlay loader can require the
* validator WITHOUT pulling in the generator's build-time-only machinery
* (ExitError, config-schema.manifest.json, install-profiles.cjs, clusters.cjs,
* gen-loop-host-contract.cjs, etc.).
*
* This is a COMMITTED plain .cjs (not built from .cts) so it is available on a
* fresh worktree before `npm run build:lib` has run.
*/
const path = require('node:path');
const { LOOP_HOST_CONTRACT } = require('./loop-host-contract.cjs');
// ─── Shared schema-version constant ──────────────────────────────────────────
const SCHEMA_VERSION = '1';
// ─── Loop Host Contract ───────────────────────────────────────────────────────
// 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);
}
}
// ─── 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']);
const RUNTIME_COMPAT_WILDCARD = '*';
// ── ADR-1244 D1: versioned-manifest envelope ─────────────────────────────────
// Official strict SemVer 2.0.0 grammar (https://semver.org). Rejects partials
// ("1.0"), prefixes ("v1.0.0"), leading-zero segments ("01.2.3"), numeric
// prerelease identifiers with leading zeros ("1.2.3-01"), empty identifiers
// ("1.2.3-..") and — critically — prerelease/build identifiers containing
// anything outside [0-9A-Za-z-] (so a version can never smuggle shell
// metacharacters, spaces or unicode into a downstream `git tag v<version>` or
// path). Accepts "1.2.3-dev.0", "1.2.3-rc.1", "1.2.3+build.5".
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
// Permissive *shape* check for a semver range (engines.gsd / compatVersions
// values). Range SATISFACTION is enforced by the runtime overlay (ADR-1244 D2);
// here we only reject empty/garbage and shell metacharacters.
const SEMVER_RANGE_RE = /^[0-9A-Za-z.\-+ |<>=~^*()]+$/;
// Subresource-integrity hash: "sha512-" + base64 of a 64-byte digest (86 base64
// chars + "==" padding). Exact length so malformed pins ("sha512-abc") fail.
const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/;
// #1460 (R) HIGH — shell-safe hook-script allowlist. A hook `script` is resolved to an
// ABSOLUTE path and written verbatim as the hook `command` STRING in settings.json, which a
// host runtime consumes through a shell (first-party hooks emit `node "${...}/hooks/x.js"`).
// A manifest-controlled name like `run.sh; touch /tmp/pwn` (filenames may legally contain
// `;`, spaces, `$`, backtick, `|`, newline on POSIX) would inject a second command — even
// though the file genuinely exists inside the bundle and so passes path-confinement. We
// therefore restrict the relative script path to a CONSERVATIVE allowlist: only
// [A-Za-z0-9._/-], with no leading `-` on any segment (option-injection), no `..` segment,
// and not absolute. Anything else (whitespace, any shell metacharacter, control/NUL) is a
// hard validation error — fail closed so the capability install/load is rejected loudly.
const SAFE_HOOK_SCRIPT_RE = /^[A-Za-z0-9._/-]+$/;
/**
* #1460 (R): true when a relative hook-script path is shell-safe (see SAFE_HOOK_SCRIPT_RE).
* Rejects absolute paths, `..` segments, a leading `-` on any path segment, and any char
* outside the allowlist (whitespace / shell metacharacters / control / NUL).
*/
function isSafeHookScriptPath(script) {
if (typeof script !== 'string' || script.length === 0) return false;
if (!SAFE_HOOK_SCRIPT_RE.test(script)) return false;
if (path.isAbsolute(script)) return false;
const segments = script.split(/[/\\]/);
if (segments.includes('..')) return false;
// A leading '-' on any segment would be parsed as an option by the shell/`node`.
for (const seg of segments) {
if (seg.startsWith('-')) return false;
}
return true;
}
// A syntactically plausible semver range (shape-only — see SEMVER_RANGE_RE).
// Requires a digit or a bare wildcard so pure-alpha garbage ("abcx", "()x") is
// rejected; full range satisfaction is the runtime overlay's job (ADR-1244 D2).
function isPlausibleRange(s) {
if (typeof s !== 'string') return false;
const t = s.trim();
if (t.length === 0 || !SEMVER_RANGE_RE.test(s)) return false;
return /\d/.test(t) || t === '*' || t === 'x' || t === 'X';
}
/**
* ADR-1244 D1: validate the versioned-manifest envelope.
* - version REQUIRED semver string (the registry rejects a manifest
* without one).
* - engines optional object; engines.gsd optional semver-range string.
* - compatVersions optional object mapping a capability version (semver) to a
* gsd version range.
* - integrity optional "sha512-<base64>" string.
* - provenance optional { sourceRepo, commit } strings.
*
* Shape only — range satisfaction and integrity verification are enforced by
* the source resolver / runtime overlay (ADR-1244 D2/D3).
*
* @param {object} cap The parsed JSON object.
* @returns {string[]} Array of error strings; empty = valid.
*/
function validateVersionEnvelope(cap) {
const errors = [];
if (typeof cap.version !== 'string' || !SEMVER_RE.test(cap.version)) {
errors.push('version must be a semver string (e.g. "1.2.3"); got: ' + JSON.stringify(cap.version));
}
if (cap.engines !== undefined) {
if (typeof cap.engines !== 'object' || cap.engines === null || Array.isArray(cap.engines)) {
errors.push('engines must be an object (e.g. { "gsd": ">=1.6.0" })');
} else if (cap.engines.gsd !== undefined && !isPlausibleRange(cap.engines.gsd)) {
errors.push('engines.gsd must be a semver range string; got: ' + JSON.stringify(cap.engines.gsd));
}
}
if (cap.compatVersions !== undefined) {
if (typeof cap.compatVersions !== 'object' || cap.compatVersions === null || Array.isArray(cap.compatVersions)) {
errors.push('compatVersions must be an object mapping capability versions to gsd version ranges');
} else {
for (const [k, v] of Object.entries(cap.compatVersions)) {
if (!SEMVER_RE.test(k)) errors.push('compatVersions key "' + k + '" must be a semver string');
if (!isPlausibleRange(v)) errors.push('compatVersions["' + k + '"] must be a semver range string');
}
}
}
if (cap.integrity !== undefined && (typeof cap.integrity !== 'string' || !SHA512_INTEGRITY_RE.test(cap.integrity))) {
errors.push('integrity must be a "sha512-<base64>" string');
}
if (cap.provenance !== undefined) {
const p = cap.provenance;
if (typeof p !== 'object' || p === null || Array.isArray(p)) {
errors.push('provenance must be an object { sourceRepo, commit }');
} else {
if (typeof p.sourceRepo !== 'string' || p.sourceRepo.length === 0) {
errors.push('provenance.sourceRepo must be a non-empty string');
}
if (typeof p.commit !== 'string' || p.commit.length === 0) {
errors.push('provenance.commit must be a non-empty string');
}
}
}
return errors;
}
/**
* 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) + ')');
}
}
}
// ── Versioned-manifest envelope (ADR-1244 D1) ──────────────────────────────
errors.push(...validateVersionEnvelope(cap));
// ── Role-specific body ────────────────────────────────────────────────────
if (cap.role === 'feature') {
errors.push(...validateFeatureBody(cap));
} else if (cap.role === 'runtime') {
errors.push(...validateRuntimeBody(cap));
}
return errors;
}
/**
* ADR-959: Validate a single commands[] entry on a feature-role capability.
* { family: string, module: string, router: string, subcommands?: string[] }
*
* - family: non-empty string, no reserved names
* - module: non-empty string, no path traversal, no absolute paths, no "/"
* segments other than a bare basename (expected form: "foo.cjs")
* - router: non-empty string
* - subcommands: optional array of strings (doc/introspection only)
*
* @param {string} capId Capability id (for error messages)
* @param {*} entry The entry to validate
* @param {string} prefix Path prefix (e.g. "commands[0]")
* @returns {string[]} Array of error strings; empty = valid.
*/
function validateCommandEntry(capId, entry, prefix) {
const errors = [];
const ctx = 'capability "' + capId + '" ' + prefix;
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
errors.push(ctx + ' must be an object with family, module, and router');
return errors;
}
// family: non-empty string, no reserved names
if (typeof entry.family !== 'string' || entry.family.length === 0) {
errors.push(ctx + '.family must be a non-empty string');
} else if (entry.family === '__proto__' || entry.family === 'constructor' || entry.family === 'prototype') {
// S2a: inline literal reserved-name guard (CodeQL barrier)
errors.push(ctx + '.family "' + entry.family + '" is a reserved name');
}
// module: must be a safe bare basename matching /^[A-Za-z0-9._-]+\.cjs$/ —
// no path separators, no "..", no NUL bytes, no absolute paths, ends in .cjs.
// This conservative pattern subsumes all earlier traversal/absolute/separator checks.
if (typeof entry.module !== 'string' || entry.module.length === 0) {
errors.push(ctx + '.module must be a non-empty string');
} else {
const mod = entry.module;
const SAFE_BASENAME = /^[A-Za-z0-9._-]+\.cjs$/;
if (!SAFE_BASENAME.test(mod)) {
errors.push(
ctx + '.module must be a safe bare basename (pattern: /^[A-Za-z0-9._-]+\\.cjs$/, no path separators, no "..", no NUL bytes, must end in ".cjs"); got: ' +
JSON.stringify(mod),
);
}
}
// router: non-empty string
if (typeof entry.router !== 'string' || entry.router.length === 0) {
errors.push(ctx + '.router must be a non-empty string');
}
// subcommands: optional array of non-empty strings (doc/introspection only)
if (entry.subcommands !== undefined) {
if (!Array.isArray(entry.subcommands)) {
errors.push(ctx + '.subcommands must be an array of strings if present');
} else {
for (let i = 0; i < entry.subcommands.length; i++) {
if (typeof entry.subcommands[i] !== 'string') {
errors.push(ctx + '.subcommands[' + i + '] must be a string');
} else if (entry.subcommands[i].length === 0) {
errors.push(ctx + '.subcommands[' + i + '] must be a non-empty string');
}
}
}
}
return errors;
}
function validateRuntimeCompat(capId, runtimeCompat) {
const errors = [];
const ctx = 'capability "' + capId + '" runtimeCompat';
if (typeof runtimeCompat !== 'object' || runtimeCompat === null || Array.isArray(runtimeCompat)) {
errors.push(ctx + ' must be an object with supported and unsupported arrays');
return errors;
}
const validateRuntimeArray = (field, { allowWildcard }) => {
const value = runtimeCompat[field];
if (!Array.isArray(value)) {
errors.push(ctx + '.' + field + ' must be an array of runtime ids' + (allowWildcard ? ' or ["*"]' : ''));
return;
}
if (field === 'supported' && value.length === 0) {
errors.push(ctx + '.supported must be a non-empty array');
}
let hasWildcard = false;
for (let i = 0; i < value.length; i++) {
const entry = value[i];
if (typeof entry !== 'string' || entry.length === 0) {
errors.push(ctx + '.' + field + '[' + i + '] must be a non-empty string');
continue;
}
if (entry === '__proto__' || entry === 'constructor' || entry === 'prototype') {
errors.push(ctx + '.' + field + '[' + i + '] "' + entry + '" is a reserved name');
}
if (entry === RUNTIME_COMPAT_WILDCARD) {
if (!allowWildcard) {
errors.push(ctx + '.' + field + ' must not include wildcard "*"');
}
hasWildcard = true;
} else if (!KEBAB_RE.test(entry)) {
errors.push(ctx + '.' + field + '[' + i + '] must be a kebab-case runtime id or "*"');
}
}
if (hasWildcard && value.length > 1) {
errors.push(ctx + '.' + field + ' wildcard "*" cannot be mixed with runtime ids');
}
};
validateRuntimeArray('supported', { allowWildcard: true });
validateRuntimeArray('unsupported', { allowWildcard: false });
if (runtimeCompat.notes !== undefined) {
if (typeof runtimeCompat.notes !== 'object' || runtimeCompat.notes === null || Array.isArray(runtimeCompat.notes)) {
errors.push(ctx + '.notes must be an object of runtime id to string if present');
} else {
for (const [key, value] of Object.entries(runtimeCompat.notes)) {
if (key !== RUNTIME_COMPAT_WILDCARD && !KEBAB_RE.test(key)) {
errors.push(ctx + '.notes key "' + key + '" must be a kebab-case runtime id or "*"');
}
if (typeof value !== 'string' || value.length === 0) {
errors.push(ctx + '.notes["' + key + '"] must be a non-empty string');
}
}
}
}
return errors;
}
function validateFeatureBody(cap) {
const errors = [];
errors.push(...validateRuntimeCompat(cap.id || '(unknown)', cap.runtimeCompat));
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');
}
}
}
// ADR-959: optional commands array
if (cap.commands !== undefined) {
if (!Array.isArray(cap.commands)) {
errors.push('commands must be an array of {family, module, router} objects');
} else {
for (let i = 0; i < cap.commands.length; i++) {
errors.push(...validateCommandEntry(cap.id || cap.role, cap.commands[i], 'commands[' + i + ']'));
}
}
}
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');
} else if (!isSafeHookScriptPath(h.script)) {
// #1460 (R) HIGH: the script becomes an absolute hook `command` consumed by a shell;
// reject any unsafe character (shell metacharacters/whitespace/control), a leading `-`,
// an absolute path, or a `..` segment so a manifest can never inject a second command.
errors.push(
'hooks[' + i + '].script must be a relative path containing only [A-Za-z0-9._/-] ' +
'(no whitespace, shell metacharacters (e.g. ; | & $ ` ( ) < > * ? newline), leading "-", ' +
'absolute path, or ".." segment) — it contains unsafe characters: ' + JSON.stringify(h.script),
);
}
}
}
}
}
// Build the declared skill/agent sets for ref membership checks (used in validateStep).
// Only build these if the arrays are valid (already validated above).
const declaredSkills = Array.isArray(cap.skills) ? new Set(cap.skills.filter((s) => typeof s === 'string')) : null;
const declaredAgents = Array.isArray(cap.agents) ? new Set(cap.agents.filter((a) => typeof a === 'string')) : null;
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 + ']', declaredSkills, declaredAgents));
}
}
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 + ']'));
}
}
// activationKey: optional string naming the dotted config key that gates this capability.
// If present: must be a non-empty string that is declared in this capability's own config slice.
if (cap.activationKey !== undefined) {
if (typeof cap.activationKey !== 'string' || cap.activationKey.length === 0) {
errors.push(
'capability "' + (cap.id || '(unknown)') + '" activationKey must be a non-empty string (got: ' +
JSON.stringify(cap.activationKey) + ')',
);
} else if (cap.activationKey === '__proto__' || cap.activationKey === 'constructor' || cap.activationKey === 'prototype') {
// Prototype-pollution guard (inline literal, CodeQL barrier)
errors.push(
'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey +
'" is a reserved JavaScript property name and cannot be used as an activationKey',
);
} else if (
typeof cap.config !== 'object' ||
cap.config === null ||
!Object.prototype.hasOwnProperty.call(cap.config, cap.activationKey)
) {
errors.push(
'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey +
'" is not declared in this capability\'s config slice — add it to the "config" object or use a key that is declared there',
);
}
}
return errors;
}
// ADR-857 phase 5e: Closed ConverterName enum — complete set used across 16 runtime descriptors,
// all exported by bin/install.js (commands/skills) and src/runtime-artifact-conversion.cts (agents).
// Any ArtifactKind with a non-null converter must use one of these.
const VALID_CONVERTER_NAMES = new Set([
// commands / skills converters (pre-existing)
'convertClaudeCommandToAntigravitySkill',
'convertClaudeCommandToAugmentSkill',
'convertClaudeCommandToClineSkill',
'convertClaudeCommandToClaudeSkill',
'convertClaudeCommandToCodebuddyCommand',
'convertClaudeCommandToCodebuddySkill',
'convertClaudeCommandToCodexSkill',
'convertClaudeCommandToCopilotSkill',
'convertClaudeCommandToCursorCommand',
'convertClaudeCommandToCursorSkill',
'convertClaudeCommandToKiloSkill',
'convertClaudeCommandToKimiSkill',
'convertClaudeCommandToOpencodeSkill',
'convertClaudeCommandToTraeSkill',
'convertClaudeCommandToWindsurfSkill',
'convertClaudeCommandToWindsurfWorkflow',
// agent converters (#1173 — descriptor-driven agent conversion wiring)
'convertClaudeAgentToCopilotAgent',
'convertClaudeAgentToAntigravityAgent',
'convertClaudeAgentToCursorAgent',
'convertClaudeAgentToWindsurfAgent',
'convertClaudeAgentToAugmentAgent',
'convertClaudeAgentToTraeAgent',
'convertClaudeAgentToCodebuddyAgent',
'convertClaudeAgentToClineAgent',
'convertClaudeAgentToCodexAgent',
]);
// C3: Validate role:runtime body
const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']);
const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']);
const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']);
const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']);
const VALID_HOOK_EVENTS = new Set(['claude', 'gemini', 'opencode-subset']);
const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey'];
const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']);
const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']);
const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']);
// GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant)
// Derived from the actual pairings in the 16 real runtime descriptors.
const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([
['settings-json', new Set(['settings-json', 'none'])],
['codex-toml', new Set(['codex-hooks-json'])],
['copilot-instructions', new Set(['copilot-inline'])],
['cline-rules', new Set(['cline-rules'])],
['cursor-hooks-json', new Set(['cursor-hooks-json'])],
['profile-marker-only', new Set(['none'])],
]);
// GATE B: extended hook event families → required hookEvents value
// Gemini agent-events require hookEvents='gemini'; Claude-family events require hookEvents='claude'.
const GEMINI_AGENT_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'BeforeModel']);
const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']);
/**
* Validate a runtime.configHome object per ADR-1016 Decision 1.
* Returns an array of error strings.
*
* @param {string} capId Capability id (for error messages)
* @param {*} ch The configHome value
* @returns {string[]}
*/
function validateConfigHome(capId, ch) {
const errors = [];
const ctx = 'capability "' + capId + '" runtime.configHome';
if (typeof ch !== 'object' || ch === null || Array.isArray(ch)) {
errors.push(ctx + ' must be an object (got: ' + (ch === null ? 'null' : typeof ch) + ')');
return errors;
}
// kind — must be in closed vocab; inline literal guard (CodeQL barrier)
if (ch.kind === '__proto__' || ch.kind === 'constructor' || ch.kind === 'prototype') {
errors.push(ctx + '.kind "' + ch.kind + '" is a reserved name');
} else if (!VALID_CONFIG_HOME_KINDS.has(ch.kind)) {
errors.push(
ctx + '.kind must be one of: ' + [...VALID_CONFIG_HOME_KINDS].join(', ') +
' (got: ' + JSON.stringify(ch.kind) + ')',
);
}
// name — required string
if (typeof ch.name !== 'string' || ch.name.length === 0) {
errors.push(ctx + '.name must be a non-empty string');
}
// parent — required when kind == dot-home-nested
if (ch.kind === 'dot-home-nested') {
if (typeof ch.parent !== 'string' || ch.parent.length === 0) {
errors.push(ctx + '.parent must be a non-empty string when kind is "dot-home-nested"');
}
}
// env — required; must be an array of strings (every runtime has ≥0 env overrides)
if (!Array.isArray(ch.env)) {
errors.push(ctx + '.env is required and must be an array of strings (got: ' + JSON.stringify(ch.env) + ')');
} else {
for (let i = 0; i < ch.env.length; i++) {
if (typeof ch.env[i] !== 'string') {
errors.push(ctx + '.env[' + i + '] must be a string');
}
}
}
// probe — optional; if present must be an array of strings
if (ch.probe !== undefined) {
if (!Array.isArray(ch.probe)) {
errors.push(ctx + '.probe must be an array of strings if present');
} else {
for (let i = 0; i < ch.probe.length; i++) {
if (typeof ch.probe[i] !== 'string') {
errors.push(ctx + '.probe[' + i + '] must be a string');
}
}
}
}
// probeExists — optional; if present must be a non-empty string (sub-path existence check for probe)
if (ch.probeExists !== undefined) {
if (typeof ch.probeExists !== 'string' || ch.probeExists.length === 0) {
errors.push(ctx + '.probeExists must be a non-empty string if present (got: ' + JSON.stringify(ch.probeExists) + ')');
}
}
// skillsHome — optional; if present must be a full valid configHome object (recursive validation)
if (ch.skillsHome !== undefined) {
// Recursive call: validate skillsHome as a nested configHome.
// Use a synthetic capId to surface the sub-path in error messages.
const skillsHomeErrors = validateConfigHome(capId + '.skillsHome', ch.skillsHome);
// Rewrite the inner ctx prefix so errors read as "...runtime.configHome.skillsHome..."
for (const e of skillsHomeErrors) {
errors.push(e.replace(
'capability "' + capId + '.skillsHome" runtime.configHome',
ctx + '.skillsHome',
));
}
}
return errors;
}
/**
* Validate a single ArtifactKind entry per ADR-1016 Decision 3.
* Returns an array of error strings.
*
* @param {string} capId Capability id (for error messages)
* @param {*} entry The ArtifactKind object
* @param {string} prefix Path prefix for error messages (e.g. "artifactLayout.global[0]")
* @returns {string[]}
*/
function validateArtifactKindEntry(capId, entry, prefix) {
const errors = [];
const ctx = 'capability "' + capId + '" runtime.' + prefix;
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
errors.push(ctx + ' must be an object');
return errors;
}
// kind — must be in closed vocab; inline literal guard (CodeQL barrier)
if (entry.kind === '__proto__' || entry.kind === 'constructor' || entry.kind === 'prototype') {
errors.push(ctx + '.kind "' + entry.kind + '" is a reserved name');
} else if (!VALID_ARTIFACT_KIND_NAMES.has(entry.kind)) {
errors.push(
ctx + '.kind must be one of: ' + [...VALID_ARTIFACT_KIND_NAMES].join(', ') +
' (got: ' + JSON.stringify(entry.kind) + ')',
);
}
// destSubpath — required non-empty string
if (typeof entry.destSubpath !== 'string' || entry.destSubpath.length === 0) {
errors.push(ctx + '.destSubpath must be a non-empty string');
}
// nesting — required; must be in closed vocab (ADR-857 §5d: now drives install)
if (entry.nesting === undefined || entry.nesting === null) {
errors.push(ctx + '.nesting is required and must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', '));
} else if (!VALID_ARTIFACT_NESTINGS.has(entry.nesting)) {
errors.push(
ctx + '.nesting must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', ') +
' (got: ' + JSON.stringify(entry.nesting) + ')',
);
}
// prefix — required; must be a string (may be empty string '')
if (entry.prefix === undefined || entry.prefix === null) {
errors.push(ctx + '.prefix is required (must be a string, may be empty)');
} else if (typeof entry.prefix !== 'string') {
errors.push(ctx + '.prefix must be a string (got: ' + typeof entry.prefix + ')');
}
// recursive — optional; if present must be a boolean
if (entry.recursive !== undefined) {
if (typeof entry.recursive !== 'boolean') {
errors.push(ctx + '.recursive must be a boolean if present (got: ' + typeof entry.recursive + ')');
}
}
// converter — required; must be a string or null (closed ConverterName enum — now enforced in phase 5e)
if (!Object.prototype.hasOwnProperty.call(entry, 'converter')) {
errors.push(ctx + '.converter is required (must be a string or null)');
} else if (entry.converter !== null && typeof entry.converter !== 'string') {
errors.push(ctx + '.converter must be a string or null (got: ' + typeof entry.converter + ')');
} else if (entry.converter !== null && typeof entry.converter === 'string' &&
!VALID_CONVERTER_NAMES.has(entry.converter)) {
// Closed ConverterName enum (ADR-857 phase 5e): reject unknown converter names
errors.push(ctx + '.converter "' + entry.converter + '" is not a known ConverterName');
}
return errors;
}
/**
* Validate runtime.artifactLayout per ADR-1016 Decision 3.
* Accepts the structured { global, local } shape.
* Returns an array of error strings.
*
* @param {string} capId Capability id (for error messages)
* @param {*} layout The artifactLayout value
* @returns {string[]}
*/
function validateArtifactLayout(capId, layout) {
const errors = [];
const ctx = 'capability "' + capId + '" runtime.artifactLayout';
if (typeof layout !== 'object' || layout === null || Array.isArray(layout)) {
errors.push(ctx + ' must be an object with "global" and "local" arrays');
return errors;
}
for (const scope of ['global', 'local']) {
const arr = layout[scope];
if (!Array.isArray(arr)) {
errors.push(ctx + '.' + scope + ' must be an array');
} else {
for (let i = 0; i < arr.length; i++) {
errors.push(...validateArtifactKindEntry(capId, arr[i], 'artifactLayout.' + scope + '[' + i + ']'));
}
}
}
return errors;
}
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;
// configHome — must be a structured object (ADR-1016 Decision 1)
errors.push(...validateConfigHome(cap.id || '(unknown)', r.configHome));
// configFormat — closed 5-enum (unchanged)
if (!VALID_CONFIG_FORMATS.has(r.configFormat)) {
errors.push('runtime.configFormat must be one of: ' + [...VALID_CONFIG_FORMATS].join(', ') + ' (got: ' + r.configFormat + ')');
}
// artifactLayout — structured { global, local } per ADR-1016 Decision 3
errors.push(...validateArtifactLayout(cap.id || '(unknown)', r.artifactLayout));
// commandStyle — closed 2-enum (ADR-1016 Decision 4); inline literal guard (CodeQL barrier)
if (r.commandStyle === '__proto__' || r.commandStyle === 'constructor' || r.commandStyle === 'prototype') {
errors.push('runtime.commandStyle "' + r.commandStyle + '" is a reserved name');
} else if (!VALID_COMMAND_STYLES.has(r.commandStyle)) {
errors.push(
'runtime.commandStyle must be one of: ' + [...VALID_COMMAND_STYLES].join(', ') +
' (got: ' + JSON.stringify(r.commandStyle) + ')',
);
}
// hooksSurface — closed 6-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier)
if (r.hooksSurface === '__proto__' || r.hooksSurface === 'constructor' || r.hooksSurface === 'prototype') {
errors.push('runtime.hooksSurface "' + r.hooksSurface + '" is a reserved name');
} else if (!VALID_HOOKS_SURFACES.has(r.hooksSurface)) {
errors.push(
'runtime.hooksSurface must be one of: ' + [...VALID_HOOKS_SURFACES].join(', ') +
' (got: ' + JSON.stringify(r.hooksSurface) + ')',
);
}
// hookEvents — optional; if present must be in closed 3-enum (ADR-1016 Decision 5)
if (r.hookEvents !== undefined) {
if (r.hookEvents === '__proto__' || r.hookEvents === 'constructor' || r.hookEvents === 'prototype') {
errors.push('runtime.hookEvents "' + r.hookEvents + '" is a reserved name');
} else if (!VALID_HOOK_EVENTS.has(r.hookEvents)) {
errors.push(
'runtime.hookEvents must be one of: ' + [...VALID_HOOK_EVENTS].join(', ') +
' (got: ' + JSON.stringify(r.hookEvents) + ')',
);
}
}
// sandboxTier — closed 2-enum (ADR-1016 Decision 6); inline literal guard (CodeQL barrier)
if (r.sandboxTier === '__proto__' || r.sandboxTier === 'constructor' || r.sandboxTier === 'prototype') {
errors.push('runtime.sandboxTier "' + r.sandboxTier + '" is a reserved name');
} else if (!VALID_SANDBOX_TIERS.has(r.sandboxTier)) {
errors.push(
'runtime.sandboxTier must be one of: ' + [...VALID_SANDBOX_TIERS].join(', ') +
' (got: ' + JSON.stringify(r.sandboxTier) + ')',
);
}
// supportTier — 1 or 2 (unchanged)
if (r.supportTier !== 1 && r.supportTier !== 2) {
errors.push('runtime.supportTier must be 1 or 2 (got: ' + r.supportTier + ')');
}
// installSurface — required string in closed enum
if (!VALID_INSTALL_SURFACES.has(r.installSurface)) {
errors.push(
'runtime.installSurface must be one of: ' + [...VALID_INSTALL_SURFACES].join(', ') +
' (got: ' + JSON.stringify(r.installSurface) + ')',
);
}
// writesSharedSettings — required boolean
if (typeof r.writesSharedSettings !== 'boolean') {
errors.push(
'runtime.writesSharedSettings must be a boolean (got: ' + JSON.stringify(r.writesSharedSettings) + ')',
);
}
// permissionWriter — required key; value must be null or a string in VALID_PERMISSION_WRITERS
if (!Object.prototype.hasOwnProperty.call(r, 'permissionWriter')) {
errors.push('runtime.permissionWriter is required (must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') + ')');
} else if (r.permissionWriter !== null && !VALID_PERMISSION_WRITERS.has(r.permissionWriter)) {
errors.push(
'runtime.permissionWriter must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') +
' (got: ' + JSON.stringify(r.permissionWriter) + ')',
);
}
// extendedHookEvents — required array; every element must be in closed enum
if (!Array.isArray(r.extendedHookEvents)) {
errors.push(
'runtime.extendedHookEvents must be an array (got: ' + JSON.stringify(r.extendedHookEvents) + ')',
);
} else {
for (let i = 0; i < r.extendedHookEvents.length; i++) {
const ev = r.extendedHookEvents[i];
if (typeof ev !== 'string' || !VALID_EXTENDED_HOOK_EVENTS.has(ev)) {
errors.push(
'runtime.extendedHookEvents[' + i + '] must be one of: ' + [...VALID_EXTENDED_HOOK_EVENTS].join(', ') +
' (got: ' + JSON.stringify(ev) + ')',
);
}
}
}
// GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX)
// Only check if both fields are valid strings (individual field validators above report type errors).
if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') {
const allowedHooksSurfaces = INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.get(r.installSurface);
if (allowedHooksSurfaces !== undefined && !allowedHooksSurfaces.has(r.hooksSurface)) {
errors.push(
'runtime.hooksSurface "' + r.hooksSurface + '" is not valid for installSurface "' + r.installSurface + '"' +
' — allowed: ' + [...allowedHooksSurfaces].join(', ') +
' (src: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES in scripts/gen-capability-registry.cjs)',
);
}
}
// GATE B: extendedHookEvents ↔ hookEvents consistency (DEFECT.GENERATIVE-FIX)
// If extendedHookEvents contains Gemini agent-events, hookEvents must be 'gemini'.
// If it contains Claude-family events, hookEvents must be 'claude'.
// Empty extendedHookEvents imposes no constraint.
if (Array.isArray(r.extendedHookEvents) && r.extendedHookEvents.length > 0) {
const hasGeminiEvents = r.extendedHookEvents.some((ev) => GEMINI_AGENT_EVENTS.has(ev));
const hasClaudeEvents = r.extendedHookEvents.some((ev) => CLAUDE_FAMILY_EVENTS.has(ev));
if (hasGeminiEvents && r.hookEvents !== 'gemini') {
errors.push(
'runtime.extendedHookEvents contains Gemini agent-events (' +
r.extendedHookEvents.filter((ev) => GEMINI_AGENT_EVENTS.has(ev)).join(', ') +
') but runtime.hookEvents is "' + r.hookEvents + '" — must be "gemini"',
);
}
if (hasClaudeEvents && r.hookEvents !== 'claude') {
errors.push(
'runtime.extendedHookEvents contains Claude-family events (' +
r.extendedHookEvents.filter((ev) => CLAUDE_FAMILY_EVENTS.has(ev)).join(', ') +
') but runtime.hookEvents is "' + r.hookEvents + '" — must be "claude"',
);
}
}
return errors;
}
// #1459 CONVERGENCE finding 1(b) — GENEROUS DoS backstop on a (possibly project-plantable) hook
// fragment file. A real fragment is a few KiB of markdown; 8 MiB is wildly more than any legitimate
// fragment. The bounded reader refuses a non-regular (FIFO/device/symlink-to-nonregular) or oversized
// fragment WITHOUT a raw blocking read, so a forged in-bundle FIFO/oversized fragment.path becomes an
// un-materializable fragment (a validation error / skip) instead of hanging or OOM-ing the loop.
const FRAGMENT_MAX_BYTES = 8 * 1024 * 1024;
function materializeHookFragments(cap, capDir) {
const errors = [];
const hookGroups = [
['steps', Array.isArray(cap.steps) ? cap.steps : []],
['contributions', Array.isArray(cap.contributions) ? cap.contributions : []],
];
// #1459 CONVERGENCE finding 1(b): the fragment body is read via the SHARED bounded fd reader (open →
// fstat → require regular file → size cap → read exactly size), NOT a raw fs.readFileSync(abs,'utf8')
// which BLOCKS forever on a forged in-bundle FIFO and reads an oversized fragment unbounded into memory.
// Required lazily so the committed plain-.cjs validator does not hard-depend on the built ledger artifact
// at module-load time (materialize is a runtime path, reached only after build:lib). A bounded-reader
// throw (non-regular/oversized/IO) → an un-materializable-fragment validation error, not a hang.
let readSmallRegularFile;
try {
({ readSmallRegularFile } = require('./capability-ledger.cjs'));
} catch {
// Defensive: if the bounded reader is unavailable, fall back to a fail-CLOSED stub so we never
// silently revert to an unbounded raw read. A null-returning stub turns every path fragment into an
// "could not be read" error rather than a hang (declarative-only fragments use `inline` and skip this).
readSmallRegularFile = () => null;
}
for (const [groupName, hooks] of hookGroups) {
for (let i = 0; i < hooks.length; i++) {
const hook = hooks[i];
if (!hook || typeof hook !== 'object' || Array.isArray(hook)) continue;
const fragment = hook.fragment;
if (!fragment || typeof fragment !== 'object' || Array.isArray(fragment)) continue;
if (typeof fragment.inline === 'string') continue;
if (typeof fragment.path !== 'string') continue;
const abs = path.resolve(capDir, fragment.path);
const capRoot = path.resolve(capDir);
if (abs !== capRoot && !abs.startsWith(capRoot + path.sep)) {
errors.push(
cap.id + '/' + groupName + '[' + i + '].fragment.path escapes capability directory: ' +
fragment.path,
);
continue;
}
try {
const body = readSmallRegularFile(abs, FRAGMENT_MAX_BYTES);
if (body === null) {
// null = genuinely missing (ENOENT) OR refused as non-regular/oversized via the stub fallback.
errors.push(
cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read (missing, non-regular ' +
'(FIFO/device), or exceeds the size cap): ' + fragment.path,
);
continue;
}
fragment.inline = body;
} catch (err) {
// Bounded-reader fail-closed throw (non-regular/oversized/IO) — an un-materializable fragment.
errors.push(
cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read: ' +
fragment.path + ' (' + err.message + ')',
);
}
}
}
return errors;
}
function validateFragment(fragment, prefix) {
const errors = [];
if (typeof fragment !== 'object' || fragment === null || Array.isArray(fragment)) {
errors.push(prefix + ' must be an object with path or inline key');
return errors;
}
const hasPath = Object.prototype.hasOwnProperty.call(fragment, 'path');
const hasInline = Object.prototype.hasOwnProperty.call(fragment, 'inline');
if (!hasPath && !hasInline) {
errors.push(prefix + ' must have a "path" or "inline" key');
}
if (hasInline) {
const inline = fragment.inline;
if (typeof inline !== 'string') {
errors.push(prefix + '.inline must be a string');
} else if (inline === '') {
errors.push(prefix + '.inline must be a non-empty string');
}
}
// S1: fragment.path traversal guard — must be a relative path with no ".." segments
if (hasPath) {
const p = fragment.path;
if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) {
errors.push(prefix + '.path must be a relative path with no ".." segments');
}
}
return errors;
}
/**
* Validate a single step entry.
*
* @param {object} step The step to validate.
* @param {string} prefix Path prefix for error messages (e.g. "steps[0]").
* @param {Set|null} declaredSkills Set of skill stems declared in this capability's skills array,
* or null if the skills array was not valid (skip membership check).
* @param {Set|null} declaredAgents Set of agent names declared in this capability's agents array,
* or null if the agents array was not valid (skip membership check).
* @returns {string[]}
*/
function validateStep(step, prefix, declaredSkills, declaredAgents) {
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, agent, or command key');
} else {
const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill');
const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent');
const hasCommand = Object.prototype.hasOwnProperty.call(step.ref, 'command');
const dispatchCount = [hasSkill, hasAgent, hasCommand].filter(Boolean).length;
if (dispatchCount === 0) {
errors.push(prefix + '.ref must have a "skill", "agent", or "command" key');
} else if (dispatchCount > 1) {
// ref must be exclusive: skill XOR agent XOR command
errors.push(prefix + '.ref must have exactly one of "skill", "agent", or "command", not multiple');
}
if (hasSkill && typeof step.ref.skill !== 'string') {
errors.push(prefix + '.ref.skill must be a string');
} else if (hasSkill && typeof step.ref.skill === 'string' && step.ref.skill.startsWith('gsd-')) {
// Double-prefix guard: ref.skill is an unprefixed stem (e.g. "ui-review").
// Workflow dispatch prepends "gsd-" at runtime → "gsd-ui-review".
// A stem that already starts with "gsd-" would produce "gsd-gsd-..." at dispatch.
errors.push(
prefix + '.ref.skill "' + step.ref.skill + '" must not start with "gsd-" ' +
'(it is an unprefixed stem; the workflow prepends "gsd-" at dispatch — ' +
'starting with "gsd-" would produce "gsd-' + step.ref.skill + '")',
);
} else if (hasSkill && typeof step.ref.skill === 'string' && declaredSkills !== null && !declaredSkills.has(step.ref.skill)) {
// Membership check: ref.skill must be declared in this capability's skills array.
// This catches typos and ensures every dispatched skill is owned by this capability.
errors.push(
prefix + '.ref.skill "' + step.ref.skill + '" is not declared in this capability\'s skills: [' +
[...declaredSkills].join(', ') + ']',
);
}
if (hasAgent && typeof step.ref.agent !== 'string') {
errors.push(prefix + '.ref.agent must be a string');
} else if (hasAgent && typeof step.ref.agent === 'string' && declaredAgents !== null && !declaredAgents.has(step.ref.agent)) {
// Membership check: ref.agent must be declared in this capability's agents array.
errors.push(
prefix + '.ref.agent "' + step.ref.agent + '" is not declared in this capability\'s agents: [' +
[...declaredAgents].join(', ') + ']',
);
}
if (hasCommand && typeof step.ref.command !== 'string') {
errors.push(prefix + '.ref.command 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 (step.fragment !== undefined) {
errors.push(...validateFragment(step.fragment, prefix + '.fragment'));
}
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 (!Array.isArray(contrib.produces)) {
errors.push(prefix + '.produces must be an array');
} else {
for (const p of contrib.produces) {
if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings');
}
}
if (!Array.isArray(contrib.consumes)) {
errors.push(prefix + '.consumes must be an array');
} else {
for (const c of contrib.consumes) {
if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings');
}
}
errors.push(...validateFragment(contrib.fragment, prefix + '.fragment'));
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 });
}
}
}
// Duplicate-producer invariant: two capability steps may not produce the same artifact
// at the same Loop Extension Point. Same-point dual production makes data-flow resolution
// ambiguous and is rejected at gen time (Decision #6).
for (const artifact of Object.keys(capHookProducers)) {
if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue;
const producers = capHookProducers[artifact];
// Group by pointIdx
const byPoint = Object.create(null);
for (const entry of producers) {
if (!byPoint[entry.pointIdx]) byPoint[entry.pointIdx] = [];
byPoint[entry.pointIdx].push(entry);
}
for (const pointIdxStr of Object.keys(byPoint)) {
const group = byPoint[pointIdxStr];
// Count distinct (capId, stepIdx) producer steps — a single step listing the same
// artifact twice in its produces array pushes duplicate entries but represents only
// ONE producer step and must not false-positive the cross-step gate.
const distinctProducers = new Set(group.map((e) => e.capId + ' ' + e.stepIdx));
if (distinctProducers.size >= 2) {
const pointIdx = Number(pointIdxStr);
const pointName = POINT_ORDER[pointIdx];
const capIds = [...new Set(group.map((e) => e.capId))].sort().join(', ');
throw new Error(
'duplicate-producer invariant violated: artifact "' + artifact + '" is produced by ' +
'two or more capability steps at the same Loop Extension Point "' + pointName + '" ' +
'(capabilities: ' + capIds + '). ' +
'Two capability steps producing the same artifact at the same Loop Extension Point ' +
'makes data-flow resolution ambiguous and is rejected at gen time.',
);
}
}
}
// 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
const familyOwner = new Map(); // command family → capId (ADR-959)
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);
}
}
// ADR-959: single family ownership across the whole registry
if (Array.isArray(cap.commands)) {
for (const cmd of cap.commands) {
if (typeof cmd.family !== 'string' || cmd.family.length === 0) continue; // already reported
if (cmd.family === '__proto__' || cmd.family === 'constructor' || cmd.family === 'prototype') continue;
if (familyOwner.has(cmd.family)) {
errors.push(
'command family "' + cmd.family + '" is owned by both "' + familyOwner.get(cmd.family) + '" and "' + capId + '"',
);
} else {
familyOwner.set(cmd.family, 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',
);
}
}
}
// runtimeCompat: explicit runtime ids must reference runtime capabilities.
// The wildcard "*" means descriptor-backed runtimes are supported by default.
const runtimeIds = new Set();
for (const [id, cap] of capMap) {
if (cap.role === 'runtime') runtimeIds.add(id);
}
for (const [capId, cap] of capMap) {
if (cap.role !== 'feature' || typeof cap.runtimeCompat !== 'object' || cap.runtimeCompat === null) continue;
for (const field of ['supported', 'unsupported']) {
const entries = Array.isArray(cap.runtimeCompat[field]) ? cap.runtimeCompat[field] : [];
for (const runtimeId of entries) {
if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue;
if (typeof runtimeId !== 'string' || runtimeId.length === 0) continue;
if (!runtimeIds.has(runtimeId)) {
errors.push(
'capability "' + capId + '" runtimeCompat.' + field +
' references unknown runtime "' + runtimeId + '"',
);
}
}
}
if (cap.runtimeCompat.notes && typeof cap.runtimeCompat.notes === 'object') {
for (const runtimeId of Object.keys(cap.runtimeCompat.notes)) {
if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue;
if (!runtimeIds.has(runtimeId)) {
errors.push(
'capability "' + capId + '" runtimeCompat.notes references unknown runtime "' + runtimeId + '"',
);
}
}
}
}
// 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 ─────────────────────────────────────────────────────
function topoSortHookEntries(entries, hookKey, hookKind) {
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][hookKey].produces || []);
for (let j = 0; j < n; j++) {
if (i === j) continue;
const consumesJ = entries[j][hookKey].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 ' + hookKind + ' at point "' +
(entries[0] && entries[0][hookKey] ? entries[0][hookKey].point : '?') +
'" among capabilities [' + sortedIds + ']: ' +
'a cycle in hook produces/consumes prevents deterministic ordering',
);
}
return result;
}
/**
* 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) {
return topoSortHookEntries(entries, 'step', 'steps');
}
function topoSortContributions(entries) {
return topoSortHookEntries(entries, 'contrib', 'contributions');
}
// ─── Gen-time wired guard ─────────────────────────────────────────────────────
/**
* Validate that every hook point declared by a capability has a corresponding
* `loop render-hooks <point>` call site in one of the host-loop workflow files.
*
* Only valid loop points (in VALID_LOOP_POINTS) are checked here. Invalid points
* are already caught by validateStep/validateContribution/validateGate — do not
* double-report.
*
* @param {object} cap Validated capability object.
* @param {Set<string>} wiredSet Set of points that have call sites in host workflows.
* @returns {string[]} Array of error strings; empty means all points are wired.
*/
function validateHooksWired(cap, wiredSet) {
const errors = [];
const capId = cap.id || '(unknown)';
function checkPoint(point, groupName, idx) {
// Only flag valid points that are unwired — invalid points are schema-validator's job.
if (!VALID_LOOP_POINTS.has(point)) return;
if (!wiredSet.has(point)) {
errors.push(
'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point +
'" is declared but not wired in any host-loop workflow ' +
'(no `loop render-hooks ' + point + '` call site). ' +
'Wire the call site in the host workflow ' +
'(see scripts/gen-loop-host-contract.cjs STEP_WORKFLOWS) or remove the hook.',
);
}
}
for (let i = 0; i < (cap.steps || []).length; i++) {
const hook = cap.steps[i];
if (hook.point !== undefined) checkPoint(hook.point, 'steps', i);
}
for (let i = 0; i < (cap.contributions || []).length; i++) {
const hook = cap.contributions[i];
if (hook.point !== undefined) checkPoint(hook.point, 'contributions', i);
}
for (let i = 0; i < (cap.gates || []).length; i++) {
const hook = cap.gates[i];
if (hook.point !== undefined) checkPoint(hook.point, 'gates', i);
}
return errors;
}
// ─── classifyCrossErrors ──────────────────────────────────────────────────────
/**
* 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)
* @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 };
}
// ─── ADR-857 phase 5e: configFormat ↔ installSurface parity gate ─────────────
// Map: installSurface → expected configFormat
// Derived from the pairing of capability.json descriptors (installSurface)
// and capability.json descriptors (configFormat). DEFECT.GENERATIVE-FIX: this map
// is the single parity contract between the two generated surfaces.
// NOTE: both values come from the descriptor bodies in capMap — no dependency on
// runtime-config-adapter-registry.cjs, which now requires capability-registry.cjs
// (the file this gen-script produces), and thus must not be required here.
const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([
['settings-json', 'settings-json'],
['codex-toml', 'toml'],
['copilot-instructions', 'markdown'],
['cline-rules', 'markdown-dir'],
['cursor-hooks-json', 'none'],
['profile-marker-only', 'none'],
]);
/**
* ADR-857 phase 5e: configFormat ↔ installSurface parity gate.
*
* For each runtime capability that has an installSurface in its descriptor,
* assert that its configFormat matches the expected value derived from its
* installSurface. Both values are read directly from the capMap descriptor
* bodies — no dependency on runtime-config-adapter-registry.cjs.
*
* HARD gate — throws on mismatch (DEFECT.GENERATIVE-FIX: this invariant is
* derived from two parallel generated surfaces and must fail loudly).
*
* @param {Map<string, object>} capMap Fully-validated capability map.
* @returns {void} Throws on mismatch; returns normally on success.
*/
function runConfigFormatParityGate(capMap) {
// Read installSurface directly from the descriptor bodies already loaded into
// capMap — eliminates the require cycle introduced when adapter-registry was
// changed to require capability-registry.cjs (ADR-857 phase 5g drive 2).
for (const [capId, cap] of capMap) {
if (cap.role !== 'runtime') continue;
const r = cap.runtime;
if (!r || typeof r.configFormat !== 'string') continue; // already validated above
// Only check runtimes that have an installSurface (i.e. are config-adapter runtimes)
if (typeof r.installSurface !== 'string') continue; // grok etc. excluded — no installSurface
const installSurface = r.installSurface;
const expectedConfigFormat = INSTALL_SURFACE_TO_CONFIG_FORMAT.get(installSurface);
if (expectedConfigFormat === undefined) {
// Unknown installSurface — the mapping needs to be updated
throw new Error(
'configFormat parity gate: runtime "' + capId + '" has installSurface "' + installSurface +
'" which is not in the INSTALL_SURFACE_TO_CONFIG_FORMAT mapping — ' +
'update the mapping in scripts/gen-capability-registry.cjs',
);
}
if (r.configFormat !== expectedConfigFormat) {
throw new Error(
'configFormat parity gate FAILED for runtime "' + capId + '":\n' +
' installSurface: ' + installSurface + '\n' +
' expected configFormat: ' + expectedConfigFormat + '\n' +
' actual configFormat: ' + r.configFormat + '\n' +
'The capability.json configFormat must match the value derived from installSurface ' +
'(src: scripts/gen-capability-registry.cjs INSTALL_SURFACE_TO_CONFIG_FORMAT)',
);
}
}
}
// ─── Exports ──────────────────────────────────────────────────────────────────
module.exports = {
// Constants
SCHEMA_VERSION,
POINT_ORDER,
HOST_ARTIFACT_EARLIEST_POINT_IDX,
VALID_LOOP_POINTS,
POINT_TO_CONTRACT,
VALID_CONFIG_SLICE_TYPES,
KEBAB_RE,
VALID_ROLES,
VALID_TIERS,
VALID_ON_ERROR,
RUNTIME_COMPAT_WILDCARD,
SEMVER_RE,
SEMVER_RANGE_RE,
SHA512_INTEGRITY_RE,
VALID_CONVERTER_NAMES,
VALID_CONFIG_FORMATS,
VALID_CONFIG_HOME_KINDS,
VALID_COMMAND_STYLES,
VALID_HOOKS_SURFACES,
VALID_HOOK_EVENTS,
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,
FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME,
VALID_INSTALL_SURFACES,
VALID_PERMISSION_WRITERS,
VALID_EXTENDED_HOOK_EVENTS,
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES,
GEMINI_AGENT_EVENTS,
CLAUDE_FAMILY_EVENTS,
TIER_RANK,
INSTALL_SURFACE_TO_CONFIG_FORMAT,
// Functions
isPlausibleRange,
validateVersionEnvelope,
validateCapability,
validateCommandEntry,
validateRuntimeCompat,
validateFeatureBody,
validateConfigHome,
validateArtifactKindEntry,
validateArtifactLayout,
validateRuntimeBody,
materializeHookFragments,
validateFragment,
validateStep,
validateContribution,
validateGate,
validateAgainstContract,
validateConsumesGlobal,
validateCrossCapability,
detectRequiresCycles,
computeRequiresClosure,
topoSortHookEntries,
topoSortSteps,
topoSortContributions,
validateHooksWired,
validateConfigSliceEntry,
classifyCrossErrors,
runConfigFormatParityGate,
};