feat(#1056): phase 5e — close ConverterName enum + configFormat↔installSurface parity guard (#1057)

Two gen-time validation tightenings (validation-only; serialized registry content
unchanged; bin/install.js + adapter + descriptors untouched):

Part B: validateArtifactKindEntry now requires artifactLayout[].converter ∈
VALID_CONVERTER_NAMES (15 names, all exported by install.js) ∪ {null} — a typo'd
converter fails at gen time instead of silently → installExports[name]===undefined
at install time.

Part A: a HARD buildRegistry parity gate asserts each runtime descriptor's
configFormat agrees with the adapter registry's installSurface via a fixed mapping
(cursor-hooks-json/profile-marker-only→none, codex-toml→toml, copilot-instructions→
markdown, cline-rules→markdown-dir, settings-json→settings-json) — keeps configFormat
from drifting; prerequisite-validation for the deferred full drive (#1055).

The full config-writing drive (retire resolveRuntimeConfigIntent) is deferred to
#1055: configFormat is lossy vs installSurface (cursor vs profile-marker both → none;
opencode/kilo permissionWriter has no descriptor field) → needs schema extension.

Closes #1056

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:
Tom Boucher
2026-06-11 14:40:45 -04:00
committed by GitHub
parent 4698b3e349
commit 0cc37a94c2
2 changed files with 429 additions and 1 deletions

View File

@@ -450,6 +450,26 @@ function validateFeatureBody(cap) {
return errors;
}
// ADR-857 phase 5e: Closed ConverterName enum — complete set used across 16 runtime descriptors,
// all exported by bin/install.js. Any ArtifactKind with a non-null converter must use one of these.
const VALID_CONVERTER_NAMES = new Set([
'convertClaudeCommandToAntigravitySkill',
'convertClaudeCommandToAugmentSkill',
'convertClaudeCommandToClineSkill',
'convertClaudeCommandToClaudeSkill',
'convertClaudeCommandToCodebuddyCommand',
'convertClaudeCommandToCodebuddySkill',
'convertClaudeCommandToCodexSkill',
'convertClaudeCommandToCopilotSkill',
'convertClaudeCommandToCursorCommand',
'convertClaudeCommandToCursorSkill',
'convertClaudeCommandToKiloSkill',
'convertClaudeCommandToKimiSkill',
'convertClaudeCommandToOpencodeSkill',
'convertClaudeCommandToTraeSkill',
'convertClaudeCommandToWindsurfSkill',
]);
// 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']);
@@ -605,11 +625,15 @@ function validateArtifactKindEntry(capId, entry, prefix) {
}
}
// converter — required; must be a string or null (closed ConverterName enum in phase 5e)
// 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;
@@ -1525,6 +1549,122 @@ function runConsistencyGate(capabilityClusters, profileMembership, capMap) {
return warnings;
}
// ─── ADR-857 phase 5e: configFormat ↔ installSurface parity gate ─────────────
// Paths are declared at top level; the actual require() call is deferred (lazy) so importing
// this generator on an unbuilt worktree doesn't fail at module load. Mirrors the pattern
// used for install-profiles.cjs and clusters.cjs above.
const RUNTIME_CONFIG_ADAPTER_REGISTRY_PATH = path.join(
ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs',
);
let _runtimeConfigAdapterMod = null;
function getRuntimeConfigAdapterRegistry() {
if (!_runtimeConfigAdapterMod) {
_runtimeConfigAdapterMod = require(RUNTIME_CONFIG_ADAPTER_REGISTRY_PATH);
}
return _runtimeConfigAdapterMod;
}
// Map: installSurface → expected configFormat
// Derived from the pairing of runtime-config-adapter-registry.cjs (installSurface)
// and the capability.json descriptors (configFormat). DEFECT.GENERATIVE-FIX: this map
// is the single parity contract between the two generated surfaces.
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 also appears in INSTALL_SURFACES (i.e. is a
* known config-adapter runtime), assert that its capability.json configFormat
* matches the expected value derived from its installSurface.
*
* HARD gate — throws on mismatch (DEFECT.GENERATIVE-FIX: this invariant is
* derived from two parallel generated surfaces and must fail loudly).
* SOFT skip — if the runtime-config-adapter-registry.cjs module is not loadable
* (unbuilt worktree), emits a warning to stderr and returns without throwing.
*
* @param {Map<string, object>} capMap Fully-validated capability map.
* @returns {void} Throws on mismatch; returns normally on success or soft-skip.
*/
function runConfigFormatParityGate(capMap) {
let adapterMod;
try {
adapterMod = getRuntimeConfigAdapterRegistry();
} catch (_err) {
// Module not loadable (unbuilt worktree) — soft-skip with warning
process.stderr.write(
'⚠ configFormat parity gate SKIPPED: runtime-config-adapter-registry.cjs not loadable ' +
'(run `npm run build` first)\n',
);
return;
}
// Check that REGISTRY is present and is an object-like registry
// (the .cjs exports resolveRuntimeConfigIntent, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES —
// not REGISTRY directly; we need to reconstruct per-runtime installSurface from the adapter).
// We use ALLOWED_CONFIG_RUNTIMES to know which runtimes are in the adapter, then resolve each.
const { resolveRuntimeConfigIntent, ALLOWED_CONFIG_RUNTIMES: allowedRuntimes } = adapterMod;
if (typeof resolveRuntimeConfigIntent !== 'function' || !(allowedRuntimes instanceof Set)) {
process.stderr.write(
'⚠ configFormat parity gate SKIPPED: runtime-config-adapter-registry.cjs missing expected exports\n',
);
return;
}
// Only check runtimes present in BOTH the capability registry and INSTALL_SURFACES
for (const [capId, cap] of capMap) {
if (cap.role !== 'runtime') continue;
if (!allowedRuntimes.has(capId)) continue; // grok etc. excluded — not in adapter
const r = cap.runtime;
if (!r || typeof r.configFormat !== 'string') continue; // already validated above
let intent;
try {
intent = resolveRuntimeConfigIntent(capId);
} catch (_err) {
// Should not happen (we checked allowedRuntimes.has(capId)), but be defensive
process.stderr.write(
'⚠ configFormat parity gate: could not resolve installSurface for "' + capId + '" — skipping\n',
);
continue;
}
const installSurface = intent.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)',
);
}
}
}
// ─── Registry builder ─────────────────────────────────────────────────────────
/**
@@ -1748,6 +1888,10 @@ function buildRegistry(capMap) {
// without affecting the serialized file content (determinism gate stays clean).
const reconciliationWarnings = runConsistencyGate(capabilityClusters, profileMembership, capMap);
// ADR-857 phase 5e: configFormat ↔ installSurface parity gate.
// HARD gate — throws on mismatch; SOFT skip if adapter module not loadable.
runConfigFormatParityGate(capMap);
return {
version: SCHEMA_VERSION,
capabilities,
@@ -2074,6 +2218,11 @@ module.exports = {
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,
// ADR-857 phase 5e: closed ConverterName enum
VALID_CONVERTER_NAMES,
// ADR-857 phase 5e: configFormat ↔ installSurface parity gate
runConfigFormatParityGate,
INSTALL_SURFACE_TO_CONFIG_FORMAT,
// 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; },

View File

@@ -39,6 +39,11 @@ const {
PROFILE_RANK,
// ADR-959
validateCommandEntry,
// ADR-857 phase 5e
VALID_CONVERTER_NAMES,
validateArtifactKindEntry,
runConfigFormatParityGate,
INSTALL_SURFACE_TO_CONFIG_FORMAT,
} = require('../scripts/gen-capability-registry.cjs');
const ROOT = path.resolve(__dirname, '..');
@@ -3281,3 +3286,277 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
assert.strictEqual(VALID_ARTIFACT_NESTINGS.size, 2);
});
});
// ─── 25. ADR-857 phase 5e: closed ConverterName enum (Part B) ─────────────────
describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => {
test('VALID_CONVERTER_NAMES has exactly 15 entries', () => {
assert.ok(VALID_CONVERTER_NAMES instanceof Set, 'VALID_CONVERTER_NAMES must be a Set');
assert.strictEqual(VALID_CONVERTER_NAMES.size, 15, 'VALID_CONVERTER_NAMES must have exactly 15 entries, got: ' + VALID_CONVERTER_NAMES.size);
});
test('VALID_CONVERTER_NAMES contains all expected converter names', () => {
const expected = [
'convertClaudeCommandToAntigravitySkill',
'convertClaudeCommandToAugmentSkill',
'convertClaudeCommandToClineSkill',
'convertClaudeCommandToClaudeSkill',
'convertClaudeCommandToCodebuddyCommand',
'convertClaudeCommandToCodebuddySkill',
'convertClaudeCommandToCodexSkill',
'convertClaudeCommandToCopilotSkill',
'convertClaudeCommandToCursorCommand',
'convertClaudeCommandToCursorSkill',
'convertClaudeCommandToKiloSkill',
'convertClaudeCommandToKimiSkill',
'convertClaudeCommandToOpencodeSkill',
'convertClaudeCommandToTraeSkill',
'convertClaudeCommandToWindsurfSkill',
];
for (const name of expected) {
assert.ok(VALID_CONVERTER_NAMES.has(name), 'VALID_CONVERTER_NAMES must contain "' + name + '"');
}
});
});
describe('ADR-857 phase 5e: validateArtifactKindEntry — ConverterName enum (FAIL-FIRST regression)', () => {
// Helper to build a minimal valid ArtifactKind entry
function makeArtifactEntry(overrides) {
return {
kind: 'skills',
destSubpath: 'skills',
nesting: 'flat',
prefix: 'gsd-',
recursive: false,
converter: null,
...overrides,
};
}
// FAIL-FIRST: unknown converter name must be rejected
test('REJECTED: converter "convertClaudeCommandToUnknownRuntime" is not a known ConverterName', () => {
const entry = makeArtifactEntry({ converter: 'convertClaudeCommandToUnknownRuntime' });
const errors = validateArtifactKindEntry('test-cap', entry, 'artifactLayout.global[0]');
assert.ok(errors.length > 0, 'Expected rejection for unknown converter name, got: ' + JSON.stringify(errors));
assert.ok(
errors.some((e) => e.includes('convertClaudeCommandToUnknownRuntime') && e.includes('not a known ConverterName')),
'Error must name the bad converter and say "not a known ConverterName", got: ' + JSON.stringify(errors),
);
});
// Valid known name must be accepted
test('ACCEPTED: converter "convertClaudeCommandToKiloSkill" is a known ConverterName', () => {
const entry = makeArtifactEntry({ converter: 'convertClaudeCommandToKiloSkill' });
const errors = validateArtifactKindEntry('test-cap', entry, 'artifactLayout.global[0]');
const converterErrors = errors.filter((e) => e.includes('converter'));
assert.deepEqual(converterErrors, [], 'Known converter name must be accepted, got: ' + JSON.stringify(converterErrors));
});
// null converter is always accepted (means "no conversion")
test('ACCEPTED: converter: null is always accepted', () => {
const entry = makeArtifactEntry({ converter: null });
const errors = validateArtifactKindEntry('test-cap', entry, 'artifactLayout.global[0]');
const converterErrors = errors.filter((e) => e.includes('converter'));
assert.deepEqual(converterErrors, [], 'converter: null must always be accepted, got: ' + JSON.stringify(converterErrors));
});
// Parity: all 16 runtime descriptors must have converters in the valid set (or null)
test('all 16 real runtime descriptors have converters in VALID_CONVERTER_NAMES or null', () => {
const { capMap, errors } = loadAndValidate(new Set());
const hardErrors = errors.filter((e) => !e.includes('pending-migration'));
assert.deepEqual(hardErrors, [], 'Expected no hard errors from real capabilities, got: ' + JSON.stringify(hardErrors));
const runtimeIds = [
'claude', 'codex', 'antigravity', 'gemini', 'cursor', 'opencode',
'kilo', 'copilot', 'augment', 'trae', 'qwen', 'hermes',
'codebuddy', 'cline', 'kimi', 'windsurf',
];
for (const id of runtimeIds) {
const cap = capMap.get(id);
assert.ok(cap, 'capMap must contain "' + id + '"');
const r = cap.runtime;
const allEntries = [
...(r.artifactLayout && Array.isArray(r.artifactLayout.global) ? r.artifactLayout.global : []),
...(r.artifactLayout && Array.isArray(r.artifactLayout.local) ? r.artifactLayout.local : []),
];
for (let i = 0; i < allEntries.length; i++) {
const entry = allEntries[i];
if (entry.converter !== null) {
assert.ok(
VALID_CONVERTER_NAMES.has(entry.converter),
id + ' artifactLayout[' + i + '].converter "' + entry.converter +
'" is not in VALID_CONVERTER_NAMES',
);
}
}
}
});
// validateCapability end-to-end: unknown converter propagates through the full chain
test('validateCapability REJECTS a runtime cap with unknown converter in artifactLayout', () => {
const cap = {
id: 'test-rt',
role: 'runtime',
title: 'Test Runtime',
description: 'Test runtime with unknown converter.',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.test-rt', env: [] },
configFormat: 'settings-json',
artifactLayout: {
global: [{
kind: 'skills',
destSubpath: 'skills',
nesting: 'flat',
prefix: 'gsd-',
recursive: false,
converter: 'convertClaudeCommandToUnknownRuntime',
}],
local: [],
},
commandStyle: 'slash-hyphen',
hooksSurface: 'settings-json',
sandboxTier: 'none',
supportTier: 1,
},
};
const errors = validateCapability(cap, 'test-rt');
assert.ok(errors.length > 0, 'Expected validation errors for unknown converter, got: ' + JSON.stringify(errors));
assert.ok(
errors.some((e) => e.includes('convertClaudeCommandToUnknownRuntime') && e.includes('not a known ConverterName')),
'Error must mention the unknown converter name, got: ' + JSON.stringify(errors),
);
});
});
// ─── 26. ADR-857 phase 5e: configFormat ↔ installSurface parity gate (Part A) ─
describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () => {
// Helper: build a minimal runtime capMap for parity tests
function makeRuntimeCapMap(runtimeId, configFormat) {
const cap = {
id: runtimeId,
role: 'runtime',
title: 'Test ' + runtimeId,
description: 'Synthetic runtime for parity gate testing.',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.' + runtimeId, env: [] },
configFormat,
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'none',
sandboxTier: 'none',
supportTier: 1,
},
};
return new Map([[runtimeId, cap]]);
}
// FAIL-FIRST: parity mismatch must throw
test('THROWS: claude with wrong configFormat "toml" (installSurface=settings-json → expected settings-json)', () => {
// claude has installSurface=settings-json → expected configFormat=settings-json
// Giving it configFormat=toml must trigger the HARD gate
const capMap = makeRuntimeCapMap('claude', 'toml');
assert.throws(
() => runConfigFormatParityGate(capMap),
(err) => {
assert.ok(err instanceof Error, 'Must throw an Error');
assert.ok(
err.message.includes('claude') && err.message.includes('parity gate FAILED'),
'Error must name the runtime and say "parity gate FAILED", got: ' + err.message,
);
assert.ok(
err.message.includes('settings-json') && err.message.includes('toml'),
'Error must name both the expected and actual configFormat, got: ' + err.message,
);
return true;
},
);
});
test('THROWS: codex with wrong configFormat "settings-json" (installSurface=codex-toml → expected toml)', () => {
const capMap = makeRuntimeCapMap('codex', 'settings-json');
assert.throws(
() => runConfigFormatParityGate(capMap),
(err) => {
assert.ok(err instanceof Error);
assert.ok(err.message.includes('codex') && err.message.includes('parity gate FAILED'));
return true;
},
);
});
// All 16 real runtime descriptors must pass the parity gate (true-negative)
test('all 16 real runtime descriptors pass the configFormat parity gate (DOES NOT THROW)', () => {
const { capMap, errors } = loadAndValidate(new Set());
const hardErrors = errors.filter((e) => !e.includes('pending-migration'));
assert.deepEqual(hardErrors, [], 'No hard errors expected: ' + JSON.stringify(hardErrors));
// runConfigFormatParityGate must not throw for the real registry
assert.doesNotThrow(
() => runConfigFormatParityGate(capMap),
'runConfigFormatParityGate must not throw for the real 16 runtime descriptors',
);
});
// buildRegistry must not throw for the real registry (end-to-end integration)
test('buildRegistry with real 16 runtime descriptors does not throw (parity gate integrated)', () => {
const { capMap } = loadAndValidate(new Set());
assert.doesNotThrow(
() => buildRegistry(capMap),
'buildRegistry must not throw for the real registry (parity gate must pass)',
);
});
// INSTALL_SURFACE_TO_CONFIG_FORMAT export check
test('INSTALL_SURFACE_TO_CONFIG_FORMAT covers all 6 installSurface values with correct mappings', () => {
assert.ok(INSTALL_SURFACE_TO_CONFIG_FORMAT instanceof Map, 'Must be a Map');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.size, 6, 'Must cover 6 installSurface values');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('settings-json'), 'settings-json');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('codex-toml'), 'toml');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('copilot-instructions'), 'markdown');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cline-rules'), 'markdown-dir');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cursor-hooks-json'), 'none');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('profile-marker-only'), 'none');
});
// Feature capabilities (role:feature) are silently ignored by the gate
test('feature capabilities (role:feature) are ignored by the parity gate — does not throw', () => {
// Use the real UI cap (role:feature, has no installSurface) — gate must pass silently
const capMap = new Map([['ui', UI_CAP]]);
assert.doesNotThrow(
() => runConfigFormatParityGate(capMap),
'Feature capabilities must be ignored by the configFormat parity gate',
);
});
// Unknown runtimes (not in ALLOWED_CONFIG_RUNTIMES) are excluded from the gate
test('runtime capId not in adapter registry (e.g. "grok") is excluded from parity gate — does not throw', () => {
// 'grok' is not in the adapter registry → must be soft-skipped
const grokCap = {
id: 'grok',
role: 'runtime',
title: 'Grok',
description: 'Hypothetical grok runtime',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.grok', env: [] },
configFormat: 'settings-json', // any value — gate should not check this
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'none',
sandboxTier: 'none',
supportTier: 2,
},
};
const capMap = new Map([['grok', grokCap]]);
assert.doesNotThrow(
() => runConfigFormatParityGate(capMap),
'Unknown runtimes not in the adapter registry must be excluded from the parity gate',
);
});
});