feat(#1049): phase 5d — drive artifactLayout from the runtime descriptor (retire the 128-LOC switch) (#1053)
resolveRuntimeArtifactLayout now builds Layout from
registry.runtimes[id].runtime.artifactLayout[scope] — a loop dispatching each
ArtifactKind through the SAME 5 builders (commandsKind/agentsKind/skillsKind/
convertedCommandsKind/kimiAgentsKind, unchanged) by (kind, converter, nesting) —
replacing the hardcoded switch(runtime). Equivalence-preserving for all 16 runtimes
× {global, local} (Codex-verified, no divergence). -43 LOC; bin/install.js + the
converters + the install loop untouched. getInstallExports()[converterName]
resolution, configDir threading, scope default, unknown-runtime guard all preserved.
Driving the local scope surfaced a 5a gap: the old switch had no scope branch for 13
runtimes (cursor/gemini/codex/copilot/antigravity/windsurf/augment/trae/qwen/hermes/
codebuddy/opencode/kilo) → local == global for them, but 5a authored local:[].
Backfilled local=global for those 13 (descriptor-faithful; a fall-through shim would
wrongly give cline/kimi local=global). claude/cline/kimi scope-gating untouched.
validateArtifactKindEntry tightened: destSubpath/prefix/nesting/converter required
(ConverterName enum still open — 5e). New 39-case deep-equal golden equivalence test.
Closes #1049
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:
346
tests/runtime-artifact-layout-descriptor-drive.test.cjs
Normal file
346
tests/runtime-artifact-layout-descriptor-drive.test.cjs
Normal file
@@ -0,0 +1,346 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Equivalence proof for ADR-857 phase 5d: descriptor-driven resolveRuntimeArtifactLayout.
|
||||
*
|
||||
* For every runtime in the 16-entry capability registry × {global, local} scopes,
|
||||
* this test asserts that:
|
||||
* 1. kind.kind, kind.destSubpath, kind.prefix are byte-identical to the STEP-0
|
||||
* golden captured from the old switch() before any edits.
|
||||
* 2. typeof kind.stage === 'function' for every kind.
|
||||
* 3. layout.runtime === runtime, layout.configDir === configDir, layout.scope === scope.
|
||||
*
|
||||
* SCOPE-FALL-THROUGH NOTE:
|
||||
* The old switch() had no scope branches for 13 runtimes (cursor, gemini, codex,
|
||||
* copilot, antigravity, windsurf, augment, trae, qwen, hermes, codebuddy, opencode,
|
||||
* kilo), meaning scope='local' returned the same kinds as scope='global'. The 5a
|
||||
* descriptors incorrectly set local:[] for those runtimes, causing 31 local-install
|
||||
* test regressions. The 5b backfill sets local == global for these 13, restoring
|
||||
* the old switch's scope-agnostic behaviour.
|
||||
*
|
||||
* For runtimes that had explicit scope branches in the old switch
|
||||
* (claude: distinct local=commands+agents; cline: local=[]; kimi: local=[]),
|
||||
* the STEP-0 golden matches the descriptor exactly and is left unchanged.
|
||||
*
|
||||
* Unknown runtime case:
|
||||
* ALLOWED_RUNTIMES guard throws TypeError BEFORE the descriptor lookup,
|
||||
* so unknown runtimes still throw:
|
||||
* TypeError: Unknown runtime: 'grok' — add to runtime-artifact-layout.cjs table
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const { resolveRuntimeArtifactLayout } = require(
|
||||
path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs'),
|
||||
);
|
||||
|
||||
const FAKE_DIR = '/tmp/fake-config-dir-dd';
|
||||
|
||||
// ── STEP-0 golden (captured from switch BEFORE edits) ────────────────────────
|
||||
// Format: { kind, destSubpath, prefix } for each entry in kinds[].
|
||||
// 'function' means we assert typeof kind.stage === 'function'.
|
||||
|
||||
const GOLDEN = {
|
||||
// ── claude ──────────────────────────────────────────────────────────────────
|
||||
'claude/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'claude/local': [
|
||||
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── cursor ───────────────────────────────────────────────────────────────────
|
||||
// Old switch: BOTH scopes returned [skills, commands] (no scope branch).
|
||||
// 5b backfill: local == global.
|
||||
'cursor/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
|
||||
],
|
||||
'cursor/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── gemini ───────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'gemini/global': [
|
||||
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' },
|
||||
],
|
||||
'gemini/local': [
|
||||
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── codex ────────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'codex/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'codex/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── copilot ──────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'copilot/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'copilot/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── antigravity ──────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'antigravity/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'antigravity/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── windsurf ─────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'windsurf/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'windsurf/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── augment ──────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'augment/global': [
|
||||
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'augment/local': [
|
||||
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── trae ─────────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'trae/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'trae/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── qwen ─────────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'qwen/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'qwen/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── hermes ───────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'hermes/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills/gsd', prefix: 'gsd-' },
|
||||
],
|
||||
'hermes/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills/gsd', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── codebuddy ────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'codebuddy/global': [
|
||||
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'codebuddy/local': [
|
||||
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── cline ────────────────────────────────────────────────────────────────────
|
||||
// Old switch: scope='global' → [skills]; scope='local' → []. Matches descriptor.
|
||||
'cline/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'cline/local': [],
|
||||
|
||||
// ── kimi ─────────────────────────────────────────────────────────────────────
|
||||
// Old switch: scope='global' → [skills, kimi-agents]; scope='local' → []. Matches descriptor.
|
||||
'kimi/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'kimi-agents', destSubpath: 'agents', prefix: 'gsd' },
|
||||
],
|
||||
'kimi/local': [],
|
||||
|
||||
// ── opencode ─────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'opencode/global': [
|
||||
{ kind: 'commands', destSubpath: 'command', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'opencode/local': [
|
||||
{ kind: 'commands', destSubpath: 'command', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── kilo ─────────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
'kilo/global': [
|
||||
{ kind: 'commands', destSubpath: 'command', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'kilo/local': [
|
||||
{ kind: 'commands', destSubpath: 'command', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Parametrized tests ────────────────────────────────────────────────────────
|
||||
|
||||
const RUNTIMES = [
|
||||
'claude', 'cursor', 'gemini', 'codex', 'copilot',
|
||||
'antigravity', 'windsurf', 'augment', 'trae', 'qwen',
|
||||
'hermes', 'codebuddy', 'cline', 'kimi', 'opencode', 'kilo',
|
||||
];
|
||||
|
||||
for (const runtime of RUNTIMES) {
|
||||
for (const scope of ['global', 'local']) {
|
||||
const key = `${runtime}/${scope}`;
|
||||
const expected = GOLDEN[key];
|
||||
assert.ok(
|
||||
expected !== undefined,
|
||||
`GOLDEN missing entry for ${key} — update the golden table`,
|
||||
);
|
||||
|
||||
describe(`resolveRuntimeArtifactLayout — ${runtime} ${scope} (descriptor-driven)`, () => {
|
||||
test(`kinds array matches STEP-0 golden for ${runtime}/${scope}`, () => {
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, FAKE_DIR, /** @type {'local'|'global'} */ (scope));
|
||||
|
||||
// Structural fields
|
||||
assert.strictEqual(layout.runtime, runtime, 'layout.runtime');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR, 'layout.configDir');
|
||||
assert.strictEqual(layout.scope, scope, 'layout.scope');
|
||||
|
||||
// kinds length matches golden
|
||||
assert.strictEqual(
|
||||
layout.kinds.length,
|
||||
expected.length,
|
||||
`kinds.length for ${key}: expected ${expected.length}, got ${layout.kinds.length}`,
|
||||
);
|
||||
|
||||
// Per-kind field checks
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
const actual = layout.kinds[i];
|
||||
const exp = expected[i];
|
||||
|
||||
assert.strictEqual(
|
||||
actual.kind,
|
||||
exp.kind,
|
||||
`kinds[${i}].kind for ${key}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
actual.destSubpath,
|
||||
exp.destSubpath,
|
||||
`kinds[${i}].destSubpath for ${key}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
actual.prefix,
|
||||
exp.prefix,
|
||||
`kinds[${i}].prefix for ${key}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
typeof actual.stage,
|
||||
'function',
|
||||
`kinds[${i}].stage must be a function for ${key}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unknown runtime ───────────────────────────────────────────────────────────
|
||||
// ALLOWED_RUNTIMES guard fires BEFORE descriptor lookup — reproduces old behaviour.
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — unknown runtime (descriptor-driven)', () => {
|
||||
test('throws TypeError for grok (not in ALLOWED_RUNTIMES)', () => {
|
||||
assert.throws(
|
||||
() => resolveRuntimeArtifactLayout('grok', FAKE_DIR, 'global'),
|
||||
(err) => {
|
||||
assert.ok(err instanceof TypeError, 'must be TypeError');
|
||||
assert.ok(
|
||||
err.message.includes("Unknown runtime: 'grok'"),
|
||||
`message must include "Unknown runtime: 'grok'" — got: ${err.message}`,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('throws TypeError for an arbitrary unknown string', () => {
|
||||
assert.throws(
|
||||
() => resolveRuntimeArtifactLayout('notaruntime', FAKE_DIR, 'global'),
|
||||
(err) => {
|
||||
assert.ok(err instanceof TypeError);
|
||||
assert.ok(err.message.includes("Unknown runtime: 'notaruntime'"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scope default ─────────────────────────────────────────────────────────────
|
||||
// resolveRuntimeArtifactLayout(runtime, configDir) with no scope arg → 'global'.
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — scope defaults to global (descriptor-driven)', () => {
|
||||
test('omitting scope yields global layout for claude', () => {
|
||||
const withDefault = resolveRuntimeArtifactLayout('claude', FAKE_DIR);
|
||||
const withExplicit = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global');
|
||||
assert.strictEqual(withDefault.scope, 'global', 'default scope must be "global"');
|
||||
assert.strictEqual(withDefault.kinds.length, withExplicit.kinds.length);
|
||||
for (let i = 0; i < withDefault.kinds.length; i++) {
|
||||
assert.strictEqual(withDefault.kinds[i].kind, withExplicit.kinds[i].kind);
|
||||
assert.strictEqual(withDefault.kinds[i].destSubpath, withExplicit.kinds[i].destSubpath);
|
||||
assert.strictEqual(withDefault.kinds[i].prefix, withExplicit.kinds[i].prefix);
|
||||
}
|
||||
});
|
||||
|
||||
test('omitting scope yields global layout for kimi (2 kinds)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('kimi', FAKE_DIR);
|
||||
assert.strictEqual(layout.scope, 'global');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'kimi-agents');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-vacuous check: verify at least one multi-kind runtime ─────────────────
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — multi-kind runtimes non-vacuous (descriptor-driven)', () => {
|
||||
test('augment global returns 2 kinds (commands + skills)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR, 'global');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'skills');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
||||
});
|
||||
|
||||
test('kimi global returns skills then kimi-agents', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('kimi', FAKE_DIR, 'global');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'kimi-agents');
|
||||
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].prefix, 'gsd');
|
||||
});
|
||||
|
||||
test('codebuddy global returns commands then skills', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR, 'global');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'skills');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user