'use strict'; /** * Equivalence proof for ADR-857 phase 5d: descriptor-driven resolveRuntimeArtifactLayout. * * For every runtime in the 15-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 12 runtimes (cursor, 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 12, 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=[]), * the STEP-0 golden matches the descriptor exactly and is left unchanged. * * Unknown runtime case: * Missing runtime descriptors throw the same TypeError as the old table: * 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, resolveRuntimeArtifactLayoutFromRegistry, } = require( path.join(ROOT, 'msd-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'. // ADR-1235 step 1 (#1763): cursor, windsurf, augment, trae, codebuddy each gained // an `agents` kind (appended last). Goldens consciously updated post-cutover. const GOLDEN = { // ── claude ────────────────────────────────────────────────────────────────── // #2875 Part 2 (the agents-descriptor migration): claude/global gains an // `agents` kind. This is NOT new on-disk behavior — a `claude --global` // install always wrote `/agents/msd-*.md` via the now-deleted // inline agent-staging loop in bin/install.js (claude was never in // `_DESCRIPTOR_AGENTS_RUNTIMES`, and that loop ran unconditionally, // independent of `_isSkillsRuntime`/scope). The descriptor previously never // modeled that write; it now does, matching what was always materialized. 'claude/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], 'claude/local': [ { kind: 'commands', destSubpath: 'commands', prefix: 'msd-' }, // #1367: flat msd-.md { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], // ── cursor ─────────────────────────────────────────────────────────────────── // Old switch: BOTH scopes returned [skills, commands] (no scope branch). // 5b backfill: local == global. // ADR-1235 step 1 (#1763): agents kind added. // #2644: commands are deliberately retired so Cursor exposes a single skills surface. 'cursor/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], 'cursor/local': [ { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], // ── codex ──────────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. // #2875 Part 2: agents kind added (the inline loop wrote codex agents too — // codex was never in `_DESCRIPTOR_AGENTS_RUNTIMES`; the config.toml // [agents.msd-*] strip on a full→minimal downgrade is a SEPARATE, still // hand-rolled write this migration deliberately does not touch). 'codex/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], 'codex/local': [ { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], // ── antigravity ────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. // #1575: agents kind added (antigravity cutover — scope-aware converter). 'antigravity/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], 'antigravity/local': [ { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], // ── opencode ───────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. // #2329: destSubpath corrected from singular 'command' to plural 'commands' — // OpenCode discovers slash commands from commands/ (plural); the singular // command/ dir MSD previously wrote to is not scanned by OpenCode 1.17.13, // so none of the ~71 /msd-* commands ever appeared in the OpenCode TUI. // #2875 Part 2: agents kind added (the inline loop wrote opencode agents // too — opencode's agents materialization previously went through the // separate installOpencodeFamilySkills-adjacent inline loop, not through // this layout at all; installAgentsKindStandalone now covers it). 'opencode/global': [ { kind: 'commands', destSubpath: 'commands', prefix: 'msd-' }, { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], 'opencode/local': [ { kind: 'commands', destSubpath: 'commands', prefix: 'msd-' }, { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], // ── zcode ──────────────────────────────────────────────────────────────────── // Same layout in both scopes: skills + commands + agents. 'zcode/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'commands', destSubpath: 'commands', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], 'zcode/local': [ { kind: 'skills', destSubpath: 'skills', prefix: 'msd-' }, { kind: 'commands', destSubpath: 'commands', prefix: 'msd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'msd-' }, ], }; // ── Parametrized tests ──────────────────────────────────────────────────────── const RUNTIMES = [ 'claude', 'cursor', 'codex', 'antigravity', 'opencode', 'zcode', ]; 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 ─────────────────────────────────────────────────────────── // Missing descriptors reproduce the old loud-fail behaviour. describe('resolveRuntimeArtifactLayout — unknown runtime (descriptor-driven)', () => { test('throws TypeError for grok (no artifact layout descriptor)', () => { 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; }, ); }); }); describe('resolveRuntimeArtifactLayout — descriptor-only future runtime', () => { test('accepts a synthetic descriptor-backed runtime without a parallel allowlist update', () => { const registry = { runtimes: { futurecli: { runtime: { artifactLayout: { global: [ { kind: 'commands', destSubpath: 'commands', prefix: 'msd-', nesting: 'flat', recursive: false, converter: null, }, ], local: [], }, }, }, }, }; const layout = resolveRuntimeArtifactLayoutFromRegistry( registry, 'futurecli', FAKE_DIR, 'global', ); assert.strictEqual(layout.runtime, 'futurecli'); assert.strictEqual(layout.configDir, FAKE_DIR); assert.strictEqual(layout.scope, 'global'); assert.strictEqual(layout.kinds.length, 1); assert.strictEqual(layout.kinds[0].kind, 'commands'); assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); assert.strictEqual(layout.kinds[0].prefix, 'msd-'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); }); }); // ── 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); } }); }); // ── Non-vacuous check: verify at least one multi-kind runtime ───────────────── describe('resolveRuntimeArtifactLayout — multi-kind runtimes non-vacuous (descriptor-driven)', () => { test('opencode global returns 3 kinds (commands + skills + agents)', () => { const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR, 'global'); assert.strictEqual(layout.kinds.length, 3); assert.strictEqual(layout.kinds[0].kind, 'commands'); assert.strictEqual(layout.kinds[1].kind, 'skills'); assert.strictEqual(layout.kinds[2].kind, 'agents'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); assert.strictEqual(typeof layout.kinds[1].stage, 'function'); assert.strictEqual(typeof layout.kinds[2].stage, 'function'); }); });