Author capabilities/<runtime>/capability.json for all 16 runtimes (role:runtime),
populating the registry's runtimes index ({} -> 16). Each carries the 6 ADR-1016
axes (configHome structured, configFormat, artifactLayout structured, commandStyle,
hooksSurface + hookEvents, sandboxTier, supportTier), extracted from the live
modules (runtime-homes/runtime-slash/runtime-artifact-layout/runtime-config-adapter
+ CODEX_AGENT_SANDBOX). validateRuntimeBody tightened to enforce the closed
vocabularies + structured configHome/artifactLayout (rejects old string configHome;
env required; skillsHome recursively validated).
Registry-only: bin/install.js + src/*.cts untouched, zero behavior change. The 5b-5f
drive steps consume these one axis at a time (ADR-1016 §8).
Codex accuracy review caught + fixed: opencode/kilo register ZERO lifecycle hooks
(install.js skips the whole block) -> hooksSurface:none (configFormat stays
settings-json); kimi probe selects on <root>/skills existence -> probeExists:"skills".
ArtifactKind required-field strictness + ConverterName closing deferred to 5d/5e.
ADR-1016 (Proposed) amended to match (Decisions 1 + 5).
Closes #1035
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -452,8 +452,201 @@ function validateFeatureBody(cap) {
|
||||
|
||||
// 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'];
|
||||
|
||||
/**
|
||||
* 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 — optional; if present must be in closed vocab
|
||||
if (entry.nesting !== undefined) {
|
||||
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 — optional; if present must be a string
|
||||
if (entry.prefix !== undefined) {
|
||||
if (typeof entry.prefix !== 'string') {
|
||||
errors.push(ctx + '.prefix must be a string if present (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 — optional; if present must be a string or null (closed ConverterName enum in phase 5e)
|
||||
if (entry.converter !== undefined) {
|
||||
if (entry.converter !== null && typeof entry.converter !== 'string') {
|
||||
errors.push(ctx + '.converter must be a string or null if present (got: ' + typeof entry.converter + ')');
|
||||
}
|
||||
}
|
||||
|
||||
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 = [];
|
||||
|
||||
@@ -471,24 +664,61 @@ function validateRuntimeBody(cap) {
|
||||
}
|
||||
|
||||
const r = cap.runtime;
|
||||
if (typeof r.configHome !== 'string' || r.configHome.length === 0) {
|
||||
errors.push('runtime.configHome must be a non-empty string');
|
||||
}
|
||||
|
||||
// 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 + ')');
|
||||
}
|
||||
if (!Array.isArray(r.artifactLayout)) {
|
||||
errors.push('runtime.artifactLayout must be an array');
|
||||
|
||||
// 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) + ')',
|
||||
);
|
||||
}
|
||||
if (typeof r.commandStyle !== 'string' || r.commandStyle.length === 0) {
|
||||
errors.push('runtime.commandStyle must be a non-empty string');
|
||||
|
||||
// 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) + ')',
|
||||
);
|
||||
}
|
||||
if (typeof r.hooksSurface !== 'string' || r.hooksSurface.length === 0) {
|
||||
errors.push('runtime.hooksSurface must be a non-empty string');
|
||||
|
||||
// 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) + ')',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof r.sandboxTier !== 'string' || r.sandboxTier.length === 0) {
|
||||
errors.push('runtime.sandboxTier must be a non-empty string');
|
||||
|
||||
// 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 + ')');
|
||||
}
|
||||
@@ -1833,6 +2063,17 @@ module.exports = {
|
||||
runConsistencyGate,
|
||||
// ADR-959: command entry validation
|
||||
validateCommandEntry,
|
||||
// ADR-1016 phase 5a: runtime body validators + closed-vocab sets
|
||||
validateConfigHome,
|
||||
validateArtifactLayout,
|
||||
validateArtifactKindEntry,
|
||||
VALID_CONFIG_HOME_KINDS,
|
||||
VALID_COMMAND_STYLES,
|
||||
VALID_HOOKS_SURFACES,
|
||||
VALID_HOOK_EVENTS,
|
||||
VALID_SANDBOX_TIERS,
|
||||
VALID_ARTIFACT_KIND_NAMES,
|
||||
VALID_ARTIFACT_NESTINGS,
|
||||
// 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; },
|
||||
|
||||
Reference in New Issue
Block a user