From 734c56ccfe4988dee4065a6b5b147025c3555264 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 11 Jun 2026 12:37:25 -0400 Subject: [PATCH] =?UTF-8?q?feat(#1046):=20phase=205c=20=E2=80=94=20drive?= =?UTF-8?q?=20commandStyle=20from=20the=20runtime=20descriptor=20(runtime-?= =?UTF-8?q?slash=20codex-check=20=E2=86=92=20lookup)=20(#1048)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatGsdSlash now reads commandStyle from registry.runtimes[id].runtime.commandStyle (lazy require of the committed capability-registry.cjs) instead of the hardcoded if (rt === 'codex'). Equivalence-preserving (Codex-verified): codex (shell-var) → $gsd- + lowercased token; all 15 others (slash-hyphen) + unknown → /gsd- + case-preserved token. canonicalizeRuntimeName + input normalization + claude default preserved; no circular load (mirrors 5b runtime-homes pattern). Added a registry-parity test: 16 parametrized sub-tests derive the expected prefix + lowercasing from each runtime's commandStyle, proving the prefix is a pure function of the registry (catches future hardcode-vs-registry divergence). Closes #1046 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- src/runtime-slash.cts | 13 ++- .../bug-3584-runtime-slash-formatter.test.cjs | 80 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/runtime-slash.cts b/src/runtime-slash.cts index 56a12d245..4b2ad04a3 100644 --- a/src/runtime-slash.cts +++ b/src/runtime-slash.cts @@ -55,8 +55,17 @@ export function formatGsdSlash(commandName: unknown, runtime: unknown): unknown const runtimeText = (typeof runtime === 'string' && runtime ? runtime : 'claude').toLowerCase(); const rt = canonicalizeRuntimeName(runtimeText) || runtimeText; - if (rt === 'codex') { - // Codex skills are invoked as $gsd- (shell-var syntax). The command + + // Descriptor-driven: look up commandStyle from the capability registry. + // Mirrors the lazy-require pattern from runtime-homes.cts §getGlobalConfigDir. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { runtimes } = require('./capability-registry.cjs') as { + runtimes: Record; + }; + const style = runtimes[rt]?.runtime?.commandStyle; + + if (style === 'shell-var') { + // shell-var runtimes (currently: codex) use $gsd- syntax. The command // token is lowercased because shell-var identifiers are conventionally // lowercase; matches the convertCodexSlash() projection in bin/install.js. return `$gsd-${token.toLowerCase()}${tail}`; diff --git a/tests/bug-3584-runtime-slash-formatter.test.cjs b/tests/bug-3584-runtime-slash-formatter.test.cjs index dbbf64b56..231b0045f 100644 --- a/tests/bug-3584-runtime-slash-formatter.test.cjs +++ b/tests/bug-3584-runtime-slash-formatter.test.cjs @@ -195,6 +195,86 @@ describe('formatGsdSlash — runtime-aware slash command formatter', () => { }); }); +describe('formatGsdSlash — descriptor-driven commandStyle (ADR-857 phase 5c)', () => { + // These three assertions are the non-vacuous equivalence anchor for the + // descriptor-driven branch: the formatter reads commandStyle from the + // capability registry instead of hardcoding `if (rt === 'codex')`. + + test('codex (commandStyle=shell-var) → $gsd- with lowercased token', () => { + // The registry carries commandStyle=shell-var for codex. The formatter + // must look it up and emit the shell-var form, lowercasing only the token. + assert.strictEqual(formatGsdSlash('Foo', 'codex'), '$gsd-foo'); + }); + + test('slash-hyphen runtime (claude) → /gsd- with case-preserved token', () => { + // claude descriptor has commandStyle=slash-hyphen. Token case is preserved. + assert.strictEqual(formatGsdSlash('Foo', 'claude'), '/gsd-Foo'); + }); + + test('slash-hyphen runtime (cursor) → /gsd- with case-preserved token', () => { + // cursor descriptor has commandStyle=slash-hyphen. + assert.strictEqual(formatGsdSlash('Foo', 'cursor'), '/gsd-Foo'); + }); + + test('unknown runtime (no registry entry) → /gsd- default (slash-hyphen fallback)', () => { + // An unknown runtime has no descriptor entry → runtimes[rt] is undefined → + // style is undefined → not 'shell-var' → falls through to /gsd- default. + assert.strictEqual(formatGsdSlash('foo', 'doesnotexist'), '/gsd-foo'); + }); +}); + +describe('formatGsdSlash — registry-parity: prefix and casing are a pure function of commandStyle', () => { + // Non-vacuous proof that the REGISTRY drives the decision, not a hardcoded + // `rt === 'codex'` check. For every runtime id in capability-registry.cjs we + // derive the expected prefix and lowercasing linkage directly from the + // registry's commandStyle field and assert that formatGsdSlash matches. + // + // This fails if: + // (a) anyone reverts the formatter to a hardcode that diverges from the + // registry (e.g. a future runtime gets commandStyle=shell-var but the + // formatter still only special-cases 'codex'); or + // (b) a runtime's commandStyle is changed in the registry without + // formatGsdSlash following suit. + const { runtimes } = require( + path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'), + ); + + const TOKEN_MIXED = 'SomeCmd'; // mixed-case to distinguish lowercasing behaviour + + for (const [id, descriptor] of Object.entries(runtimes)) { + const style = descriptor.runtime.commandStyle; + + test(`${id}: commandStyle=${style} → formatGsdSlash prefix and casing match registry`, () => { + const result = formatGsdSlash(TOKEN_MIXED, id); + + if (style === 'shell-var') { + // shell-var runtimes must emit $gsd- + assert.ok( + result.startsWith('$gsd-'), + `[${id}] expected $gsd- prefix (commandStyle=shell-var), got: ${result}`, + ); + assert.strictEqual( + result, + `$gsd-${TOKEN_MIXED.toLowerCase()}`, + `[${id}] shell-var must lowercase the token`, + ); + } else { + // slash-hyphen (or any other non-shell-var value) must emit /gsd- + // with case preserved (no lowercasing) + assert.ok( + result.startsWith('/gsd-'), + `[${id}] expected /gsd- prefix (commandStyle=${style}), got: ${result}`, + ); + assert.strictEqual( + result, + `/gsd-${TOKEN_MIXED}`, + `[${id}] slash-hyphen must preserve token case`, + ); + } + }); + } +}); + describe('resolveRuntime — env > config > default', () => { test('process.env.GSD_RUNTIME wins over everything', () => { const saved = process.env.GSD_RUNTIME;