enh(#142): centralize runtime alias canonicalization seam (#143)

* fix(#142): canonicalize runtime aliases across cjs and sdk

* fix(#142): satisfy hand-sync and inventory parity gates

* test(#1974): remove record-session lock contention in context monitor spec

* test(config): retry transient config-ensure-section failures

* docs(context): capture PR #143 CI reliability findings

* fix(#142): bump CLI Modules inventory headline to 76 (runtime-name-policy + runtime-slash)

docs/INVENTORY.md had the two new .cjs rows listed but the headline
count stayed at 75; fs count is 76. inventory-counts.test.cjs caught
the drift.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-23 18:39:02 -04:00
committed by GitHub
parent 33ffc647e2
commit 63396dbb16
17 changed files with 420 additions and 47 deletions

View File

@@ -403,10 +403,59 @@ describe('detectRuntime', () => {
expect(detectRuntime()).toBe('codex');
});
it('normalizes Codex env aliases to canonical codex runtime', () => {
process.env.GSD_RUNTIME = 'codex-app';
expect(detectRuntime()).toBe('codex');
process.env.GSD_RUNTIME = 'codex_cli';
expect(detectRuntime()).toBe('codex');
});
it('normalizes common runtime env aliases to canonical runtime IDs', () => {
const cases: Array<[string, Runtime]> = [
['claude-code', 'claude'],
['gemini-cli', 'gemini'],
['opencode-cli', 'opencode'],
['qwen-code', 'qwen'],
['hermes-agent', 'hermes'],
['github-copilot', 'copilot'],
['cursor-nightly', 'cursor'],
['windsurf-next', 'windsurf'],
['augment-code', 'augment'],
['codebuddy-cli', 'codebuddy'],
['cline-cli', 'cline'],
];
for (const [alias, runtime] of cases) {
process.env.GSD_RUNTIME = alias;
expect(detectRuntime()).toBe(runtime);
}
});
it('falls back to config.runtime when GSD_RUNTIME unset', () => {
expect(detectRuntime({ runtime: 'gemini' })).toBe('gemini');
});
it('normalizes Codex config aliases to canonical codex runtime', () => {
expect(detectRuntime({ runtime: 'codex-cli' })).toBe('codex');
expect(detectRuntime({ runtime: 'codex app' })).toBe('codex');
});
it('normalizes common runtime config aliases to canonical runtime IDs', () => {
const cases: Array<[string, Runtime]> = [
['claude-cli', 'claude'],
['gemini-code', 'gemini'],
['open code', 'opencode'],
['hermes_cli', 'hermes'],
['antigravity-agent', 'antigravity'],
['trae-cli', 'trae'],
['kilo-cli', 'kilo'],
];
for (const [alias, runtime] of cases) {
expect(detectRuntime({ runtime: alias })).toBe(runtime);
}
});
it('GSD_RUNTIME wins over config.runtime', () => {
process.env.GSD_RUNTIME = 'codex';
expect(detectRuntime({ runtime: 'gemini' })).toBe('codex');

View File

@@ -24,6 +24,7 @@ import { homedir } from 'node:os';
import { GSDError, ErrorClassification } from '../errors.js';
export { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js';
import { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js';
import { canonicalizeRuntimeName } from '../runtime-name-policy.js';
import { workspacePlanningPaths, resolveWorkspaceContext, type PlanningPaths } from './workspace.js';
export { stateExtractField } from './state-document.js';
import { relPlanningPath, validateWorkstreamName } from '../workstream-utils.js';
@@ -93,14 +94,12 @@ export function getRuntimeConfigDir(runtime: Runtime): string {
* stale env values don't hard-block workflows.
*/
export function detectRuntime(config?: { runtime?: unknown }): Runtime {
const envValue = process.env.GSD_RUNTIME;
if (envValue && (SUPPORTED_RUNTIMES as readonly string[]).includes(envValue)) {
return envValue as Runtime;
}
const configValue = config?.runtime;
if (typeof configValue === 'string' && (SUPPORTED_RUNTIMES as readonly string[]).includes(configValue)) {
return configValue as Runtime;
}
const envRuntime = canonicalizeRuntimeName(process.env.GSD_RUNTIME);
if (envRuntime) return envRuntime;
const configRuntime = canonicalizeRuntimeName(config?.runtime);
if (configRuntime) return configRuntime;
return 'claude';
}

View File

@@ -40,6 +40,13 @@ describe('assertRuntimeSupportsAutoMode', () => {
expect(() => assertRuntimeSupportsAutoMode({ runtime: 'claude' })).toThrow(/codex/);
});
it('throws for GSD_RUNTIME codex alias values', () => {
process.env.GSD_RUNTIME = 'codex-app';
expect(() => assertRuntimeSupportsAutoMode({ runtime: 'claude' })).toThrow(/codex/);
process.env.GSD_RUNTIME = 'codex_cli';
expect(() => assertRuntimeSupportsAutoMode({ runtime: 'claude' })).toThrow(/codex/);
});
it('error message references issue #2832 and slash-command workaround', () => {
let caught: Error | undefined;
try {
@@ -81,4 +88,16 @@ describe('assertRuntimeSupportsAutoMode', () => {
expect(caught!.message).toMatch(/config\.runtime="codex"/);
expect(caught!.message).not.toMatch(/GSD_RUNTIME=unsupported-env/);
});
it('attributes source to GSD_RUNTIME when env uses a supported codex alias', () => {
process.env.GSD_RUNTIME = 'codex-cli';
let caught: Error | undefined;
try {
assertRuntimeSupportsAutoMode({ runtime: 'claude' });
} catch (err) {
caught = err as Error;
}
expect(caught).toBeDefined();
expect(caught!.message).toMatch(/GSD_RUNTIME=codex-cli/);
});
});

View File

@@ -13,6 +13,7 @@
* for non-Claude runtimes.
*/
import { detectRuntime, SUPPORTED_RUNTIMES, type Runtime } from './query/helpers.js';
import { canonicalizeRuntimeName } from './runtime-name-policy.js';
/**
* Throw a clear error when the active runtime is not Claude.
@@ -33,8 +34,9 @@ export function assertRuntimeSupportsAutoMode(config?: Record<string, unknown> |
// a `GSD_RUNTIME` value that isn't in SUPPORTED_RUNTIMES falls through to
// the config tier, so reporting it as the source would be misleading.
const env = process.env.GSD_RUNTIME;
const envCanonical = canonicalizeRuntimeName(env);
const envIsSupported =
typeof env === 'string' && (SUPPORTED_RUNTIMES as readonly string[]).includes(env);
typeof envCanonical === 'string' && (SUPPORTED_RUNTIMES as readonly string[]).includes(envCanonical);
const source = envIsSupported
? `GSD_RUNTIME=${env}`
: `config.runtime="${String(cfg.runtime ?? '')}"`;

View File

@@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { SUPPORTED_RUNTIMES, type Runtime } from './model-catalog.js';
interface RuntimeAliasManifest {
[canonicalRuntime: string]: string[];
}
const MANIFEST_PATH = new URL('../shared/runtime-aliases.manifest.json', import.meta.url);
const manifest: RuntimeAliasManifest = JSON.parse(readFileSync(fileURLToPath(MANIFEST_PATH), 'utf-8'));
function normalizeRuntimeToken(value: string): string {
return value.trim().toLowerCase().replace(/[_\s]+/g, '-');
}
const aliasToCanonical = new Map<string, Runtime>();
for (const runtime of SUPPORTED_RUNTIMES as Runtime[]) {
aliasToCanonical.set(normalizeRuntimeToken(runtime), runtime);
}
for (const [canonical, aliases] of Object.entries(manifest)) {
if (!(SUPPORTED_RUNTIMES as readonly string[]).includes(canonical)) continue;
for (const alias of aliases) {
if (typeof alias !== 'string') continue;
aliasToCanonical.set(normalizeRuntimeToken(alias), canonical as Runtime);
}
}
export function canonicalizeRuntimeName(value: unknown): Runtime | null {
if (typeof value !== 'string') return null;
return aliasToCanonical.get(normalizeRuntimeToken(value)) ?? null;
}