Files
msd-core/tests/slash-command-namespace.test.cjs
Tom Boucher 8f2ebbe9bf feat(#1928): remove sunset Gemini CLI runtime, redirect to Antigravity (#1996)
* feat(#1928): remove sunset gemini cli runtime, redirect to antigravity

Google sunset Gemini CLI on 2026-06-18; Antigravity CLI is its official successor (already a first-class GSD runtime). Remove the gemini runtime from the enum (16->15), aliases, labels, config-home fragment, install path, converters (convertClaudeToGemini{Markdown,Toml,Agent}, convertSlashCommandsToGeminiMentions), capability descriptor, gemini-extension.json, RULESET.GEMINI.*, and the interactive menu (renumbered, no gap).

--gemini now prints an explicit deprecation notice citing the 2026-06-18 sunset and redirects to --antigravity (no silent alias, per the issue's Hyrum's-Law rejection). Antigravity is preserved throughout: its GEMINI.md contextFileName, .gemini/antigravity config home, the shared convertGeminiToolName/claudeToGeminiTools tool vocabulary, and the 'gemini' hookEvents dialect it declares. GEMINI.md retargeted as Antigravity's context file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#1928): backfill changeset PR number (#1996)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#1928): drop Gemini CLI from issue templates (review nit)

Removes the sunset Gemini CLI runtime from the two GitHub issue-template
runtime lists that the removal PR missed, per @davesienkowski's review nit:
- feature_request.yml: 'Applicable runtimes' checkbox (a user could otherwise
  request a feature for a runtime GSD no longer supports)
- bug_report.yml: 'Runtime' dropdown + the stale ~/.gemini/settings.json
  retrieval-help line

Leaves the post-removal templates fully consistent with the Antigravity redirect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:32:51 -04:00

1098 lines
50 KiB
JavaScript

'use strict';
// Slash / colon command-namespace invariant tests.
//
// Consolidated home for the namespace-leak regression suites (epic #1969, batch
// B6 #1975). Each block below is folded verbatim from its origin issue-named
// file and carries its origin issue number for provenance. These tests share no
// production module — they assert the cross-surface invariant that GSD command,
// agent, and workflow bodies use the hyphen form (`gsd-<cmd>`) and never leak the
// deprecated colon/slash namespace after install/conversion.
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2543-gsd-slash-namespace.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2543-gsd-slash-namespace (consolidation epic #1969 B6 #1975)", () => {
'use strict';
// allow-test-rule: structural-regression-guard (see #2543)
/**
* Slash-command namespace invariant (#3443) — SCOPED ACTIVE VARIANT.
*
* History:
* #3443 re-establishes `/gsd:<cmd>` as canonical in Claude-facing source text.
* The source repo is authored for Claude command registration under
* `.claude/commands/gsd/` (namespaced slash commands), while non-Claude runtimes
* perform install-time conversion (for example `/gsd:<cmd>` -> `/gsd-<cmd>`).
*
* Two-tier model (current — see CONTEXT.md § "Slash-command form: directory-level matrix"):
* • Claude-facing SOURCE TEXT (commands/, agents/, workflows/, references/,
* templates/, hooks/, .clinerules): uses `/gsd:<cmd>` (colon).
* THIS test enforces the colon invariant over those directories.
* • Runtime-emitter contexts (runtime-slash.cjs, phase-lifecycle-policy.ts,
* *.generated.cjs, bug-3584 test file): use `/gsd-<cmd>` (hyphen) per
* bug-3584's contract. Those files are EXCLUDED from this scan.
*
* Scoped invariant enforced here:
* No `/gsd-<cmd>` pattern in Claude-facing source files, EXCLUDING the
* runtime-emitter contexts listed in RUNTIME_EMITTER_EXCLUDES below.
*
* Canonical reference for the runtime-emitter (hyphen-form) contract:
* tests/bug-3584-runtime-slash-emitters.test.cjs
*
* DO NOT expand RUNTIME_EMITTER_EXCLUDES without also updating the bug-3584
* test and CONTEXT.md § "Slash-command form: directory-level matrix".
*
* See also: PR #154 first-pass incident (agent applied outdated invariant,
* broke bug-3584 contract); PR #164 Codex adversarial review (surfaced the
* need to re-activate this test with explicit exclusions).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
// Runtime-emitter contexts: these files intentionally emit `/gsd-<cmd>` (hyphen)
// as part of the bug-3584 runtime contract. They must NOT be scanned by this
// invariant — doing so caused PR #154 first-pass to revert correct hyphen form
// to colon form, breaking bug-3584-runtime-slash-emitters.test.cjs.
//
// Expand this list only if a new runtime-emitter module is introduced AND the
// bug-3584 test is updated to cover it.
const SEARCH_DIRS = [
// NOTE: gsd-core/bin/lib is intentionally EXCLUDED from SEARCH_DIRS.
// runtime-slash.cjs and *.generated.cjs live there and use the hyphen form
// per bug-3584's runtime-emitter contract. The full bin/lib tree is
// runtime-emitter territory — scanning it would cause false positives.
path.join(ROOT, 'gsd-core', 'workflows'),
path.join(ROOT, 'gsd-core', 'references'),
path.join(ROOT, 'gsd-core', 'templates'),
COMMANDS_DIR,
path.join(ROOT, 'agents'),
path.join(ROOT, 'hooks'),
];
const TOP_LEVEL_FILES = [
path.join(ROOT, '.clinerules'),
];
// Re-use SKIP_DIRS from the production script so the test's directory walker
// stays in lockstep with the fixer's. EXTENSIONS legitimately diverges (the
// guard scans only `.md`/`.cjs`/`.js` per the no-source-grep standard, while
// the fixer also rewrites `.ts`/`.tsx`), so it is not shared.
const { SKIP_DIRS } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const EXTENSIONS = new Set(['.md', '.cjs', '.js']);
function collectFiles(dir, results = []) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
collectFiles(full, results);
}
else if (EXTENSIONS.has(path.extname(e.name))) results.push(full);
}
return results;
}
const cmdNames = fs.readdirSync(COMMANDS_DIR)
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''))
.sort((a, b) => b.length - a.length);
const retiredPattern = new RegExp(`/gsd-(${cmdNames.join('|')})(?=[^a-zA-Z0-9_-]|$)`);
const allFiles = SEARCH_DIRS.flatMap(d => collectFiles(d));
const topLevelFiles = TOP_LEVEL_FILES.filter((file) => fs.existsSync(file));
const allUserFacingFiles = allFiles.concat(topLevelFiles);
describe('slash-command namespace invariant (#3443)', () => {
test('commands/gsd/ directory contains known command files', () => {
assert.ok(cmdNames.length > 0, 'commands/gsd/ must contain .md files');
assert.ok(cmdNames.includes('plan-phase'), 'plan-phase must be a known command');
assert.ok(cmdNames.includes('execute-phase'), 'execute-phase must be a known command');
});
// SCOPED ACTIVE INVARIANT (2026-05-23 re-activation after Codex adversarial review of PR #164).
//
// Scan is scoped to Claude-facing source directories only (SEARCH_DIRS above).
// gsd-core/bin/lib/ is excluded entirely — runtime-slash.cjs and
// *.generated.cjs there use hyphen form per bug-3584's runtime-emitter contract.
//
// If this test fails: check CONTEXT.md § "Slash-command form: directory-level matrix"
// before deciding whether to update the file or add to RUNTIME_EMITTER_EXCLUDES.
test('no /gsd-<cmd> retired syntax in Claude-facing source files (scoped — excludes runtime-emitter contexts)', () => {
const violations = [];
for (const file of allUserFacingFiles) {
const src = fs.readFileSync(file, 'utf-8');
const lines = src.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (retiredPattern.test(lines[i])) {
violations.push(`${path.relative(ROOT, file)}:${i + 1}: ${lines[i].trim().slice(0, 80)}`);
}
}
}
assert.strictEqual(
violations.length,
0,
`Found ${violations.length} retired /gsd-<cmd> reference(s) — use /gsd:<cmd> instead:\n${violations.slice(0, 10).join('\n')}`,
);
});
test('command filenames use canonical hyphenated command slugs', () => {
const underscoreFiles = fs.readdirSync(COMMANDS_DIR)
.filter((f) => f.endsWith('.md') && f.includes('_'));
assert.deepStrictEqual(
underscoreFiles,
[],
'command filenames feed generated skill/autocomplete names and must not contain underscores',
);
});
describe('fix-slash-commands transformer behavior', () => {
const { transformContent } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
// Use the live command names so the transformer matches the same surface
// the production CLI rewrites.
const liveCmdNames = cmdNames;
test('rewrites /gsd-<cmd> to /gsd:<cmd>', () => {
const out = transformContent('See /gsd-plan-phase for details.', liveCmdNames);
assert.ok(out.includes('/gsd:plan-phase'), `expected /gsd:plan-phase, got: ${out}`);
assert.ok(!out.includes('/gsd-plan-phase'), `dash form must not survive, got: ${out}`);
});
test('rewrites multiple occurrences in one pass', () => {
const out = transformContent('Run /gsd-plan-phase then /gsd-execute-phase.', liveCmdNames);
assert.ok(out.includes('/gsd:plan-phase'));
assert.ok(out.includes('/gsd:execute-phase'));
assert.ok(!out.match(/\/gsd-[a-z]/), `no dash form may remain, got: ${out}`);
});
test('does not rewrite canonical colon form (idempotent)', () => {
const input = '/gsd:plan-phase is the canonical name.';
assert.strictEqual(transformContent(input, liveCmdNames), input,
'transformer must be a no-op when input is already canonical');
});
test('does not rewrite gsd-sdk or gsd-tools (not slash commands)', () => {
const input = 'Run /gsd-sdk query and /gsd-tools init.';
assert.strictEqual(transformContent(input, liveCmdNames), input,
'transformer must leave non-command identifiers alone');
});
test('respects word boundary — does not rewrite /gsd-plan-phase-extra', () => {
const out = transformContent('/gsd-plan-phase-extra', liveCmdNames);
assert.strictEqual(out, '/gsd-plan-phase-extra',
'word-boundary lookahead must prevent partial matches');
});
});
test('transformer leaves non-command identifiers untouched', () => {
const { transformContent } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const sample = 'Use /gsd-sdk query and node bin/gsd-tools.cjs';
assert.strictEqual(
transformContent(sample, cmdNames),
sample,
'gsd-sdk and gsd-tools are not slash commands and must remain untouched'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3677-agent-colon-namespace-leak.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3677-agent-colon-namespace-leak (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #3677)
// Tests A1/A2/B inspect agent / installed `.md` bodies whose deployed text IS
// the runtime contract. Tests C exercises the install.js exported pure helper
// `shouldNormalizeHyphenNamespaceInAgentBody` directly — purely behavioral.
/**
* Regression for #3677 — installed agent bodies leak `/gsd:<cmd>` colon refs
* for Claude / Qwen / Hermes (unroutable since #2808).
*
* Root cause: `bin/install.js` agent install loop (around line 8350-8447)
* reads each agent .md, runs runtime-specific transforms via
* `convertClaudeAgentToXAgent()`, then writes the result. For:
* - Self-converting runtimes (Copilot/Codex/Cursor/Windsurf/Augment/Trae/
* Codebuddy/Cline/Antigravity/Opencode/Kilo): their converters handle
* namespace themselves.
* - Gemini: intentionally uses colon namespace.
* - Claude-default / Qwen / Hermes: register hyphen-form `name:` (#2808)
* but copy bodies verbatim (Qwen/Hermes do branding-only swaps; Claude
* does no namespace work). The retired `/gsd:<cmd>` colon refs leak.
*
* Sibling fixes #3583 (SKILL.md, via #3629) and #3584 (runtime emissions, via
* #3606) covered the other two surfaces. This is the agent-body surface.
*
* Fix surface:
* 1. `bin/install.js` exports a pure predicate
* `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` plus a helper
* `normalizeAgentBodyForRuntime(content, runtime, cmdNames)` that
* conditionally applies `transformContentToHyphen` from
* scripts/fix-slash-commands.cjs.
* 2. The agent install loop calls the helper after all runtime-specific
* conversions but before writeFileSync.
* 3. This regression test guards both the predicate and the integration.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
// Single `..` traversal matches the existing tests/helpers.cjs convention
// (TOOLS_PATH at tests/helpers.cjs:21). Avoids `..` chains per CLAUDE.md and
// works in the docker mirror at /work/tests (which has no `.git` to anchor on).
const REPO_ROOT = path.resolve(__dirname, '..');
const install = require(path.join(REPO_ROOT, 'bin', 'install.js'));
const { transformContentToHyphen } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
// Snapshot of all runtime IDs in the layout table at the time of this fix.
// Keep these two sets covering: any runtime listed in
// runtime-artifact-layout.cjs MUST appear in exactly one bucket.
const HYPHEN_NAME_AGENT_RUNTIMES = ['claude', 'qwen', 'hermes'];
const SELF_CONVERTING_OR_COLON_RUNTIMES = [
'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment',
'trae', 'codebuddy', 'cline',
'opencode', 'kilo',
];
describe('bug #3677 — agent body colon-namespace leak (Claude / Qwen / Hermes)', () => {
describe('A — install.js exports the pure predicate + helper', () => {
test('A1: shouldNormalizeHyphenNamespaceInAgentBody is an exported function', () => {
assert.strictEqual(
typeof install.shouldNormalizeHyphenNamespaceInAgentBody,
'function',
'bin/install.js must export shouldNormalizeHyphenNamespaceInAgentBody as the runtime predicate (regression seam for #3677)',
);
});
test('A2: normalizeAgentBodyForRuntime is an exported function', () => {
assert.strictEqual(
typeof install.normalizeAgentBodyForRuntime,
'function',
'bin/install.js must export normalizeAgentBodyForRuntime as the wired helper called by the agent install loop',
);
});
});
describe('B — predicate returns true for hyphen-`name:` runtimes and false otherwise', () => {
const { shouldNormalizeHyphenNamespaceInAgentBody } = install;
for (const runtime of HYPHEN_NAME_AGENT_RUNTIMES) {
test(`B+ '${runtime}': normalize hyphen namespace (true)`, () => {
assert.strictEqual(
shouldNormalizeHyphenNamespaceInAgentBody(runtime),
true,
`${runtime} registers hyphen-form 'name:' (#2808) and copies agent bodies verbatim — must normalize`,
);
});
}
for (const runtime of SELF_CONVERTING_OR_COLON_RUNTIMES) {
test(`B- '${runtime}': skip normalization (false)`, () => {
assert.strictEqual(
shouldNormalizeHyphenNamespaceInAgentBody(runtime),
false,
`${runtime} either self-converts via convertClaudeAgentToXAgent or intentionally uses colon — must NOT re-rewrite`,
);
});
}
test('B?: unknown runtime defaults to false (conservative)', () => {
assert.strictEqual(
shouldNormalizeHyphenNamespaceInAgentBody('bogus-runtime-id'),
false,
'unknown runtimes must not be normalized — better to leak than to mangle',
);
});
});
describe('C — normalizeAgentBodyForRuntime applies transformContentToHyphen iff predicate is true', () => {
const { normalizeAgentBodyForRuntime } = install;
// Sample agent body with colon refs that #2808 retired.
const inputBody = [
'# Agent prose',
'',
'Run `/gsd:execute-phase 1 --tdd` to execute the phase.',
'Then `/gsd:verify-work 1` to verify.',
'Reference unchanged: `gsd-sdk query commit` (this is a CLI binary, not a slash command).',
].join('\n');
// Only known commands from commands/gsd/*.md should be rewritten; gsd-sdk
// (a binary) must stay untouched.
const cmdNames = ['execute-phase', 'verify-work', 'plan-phase'];
test('C1: claude — rewrites both colon refs to hyphen', () => {
const out = normalizeAgentBodyForRuntime(inputBody, 'claude', cmdNames);
assert.ok(out.includes('/gsd-execute-phase'), 'execute-phase must be rewritten to hyphen form');
assert.ok(out.includes('/gsd-verify-work'), 'verify-work must be rewritten to hyphen form');
assert.ok(!out.includes('/gsd:execute-phase'), 'colon form for execute-phase must be gone');
assert.ok(!out.includes('/gsd:verify-work'), 'colon form for verify-work must be gone');
assert.ok(out.includes('gsd-sdk query commit'), 'gsd-sdk (CLI binary) must not be touched');
});
test('C2: qwen — same transform applies', () => {
const out = normalizeAgentBodyForRuntime(inputBody, 'qwen', cmdNames);
assert.ok(out.includes('/gsd-execute-phase'));
assert.ok(!out.includes('/gsd:execute-phase'));
});
test('C3: hermes — same transform applies', () => {
const out = normalizeAgentBodyForRuntime(inputBody, 'hermes', cmdNames);
assert.ok(out.includes('/gsd-execute-phase'));
assert.ok(!out.includes('/gsd:execute-phase'));
});
test('C5: self-converting runtime (copilot) — body returned unchanged at this layer', () => {
const out = normalizeAgentBodyForRuntime(inputBody, 'copilot', cmdNames);
// Copilot has its own convertClaudeAgentToCopilotAgent that handles
// namespace — the normalize layer is a no-op for it.
assert.strictEqual(out, inputBody);
});
});
describe('D — sanity check: the underlying transform actually works against real cmd names', () => {
test('D1: transformContentToHyphen rewrites /gsd:<cmd> to /gsd-<cmd> for known cmds only', () => {
const out = transformContentToHyphen(
'A /gsd:execute-phase B /gsd:unknown-cmd C /gsd-sdk D',
['execute-phase'],
);
assert.ok(out.includes('/gsd-execute-phase'), 'known cmd rewritten');
assert.ok(out.includes('/gsd:unknown-cmd'), 'unknown cmd preserved (longest-first matcher only rewrites registered names)');
assert.ok(out.includes('/gsd-sdk'), 'gsd-sdk (binary, not slash command) preserved');
});
});
// ---------------------------------------------------------------------------
// E — Behavioral coverage ported from PR #3681 (johnzilla / John Turner).
//
// #3681 proposed the same allow-list fix independently and was closed by its
// author in favor of this PR. Its test file contributed two coverage angles
// worth keeping: real-source efficacy against every `agents/gsd-*.md` (the
// shape of bug that pure-function tests miss) and idempotence-via-fixpoint
// (guards against double-rewrite on reinstall). Credit: johnzilla.
// ---------------------------------------------------------------------------
describe('E — real-source efficacy + idempotence (ported from #3681, credit: johnzilla)', () => {
const fs = require('node:fs');
const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
const cmdNames = readCmdNames();
// Roster regex matches any registered command in `gsd:<cmd>` form with a
// negative lookbehind (so `mygsd:foo` is ignored) and a non-word lookahead
// (so `plan-phase-extra` is not a false match for `plan-phase`).
const roster = () => new RegExp(
`(?<![a-zA-Z0-9_-])gsd:(${[...cmdNames].sort((a, b) => b.length - a.length).join('|')})(?=[^a-zA-Z0-9_-]|$)`,
);
test('E0: command roster is populated and contains the symptom commands', () => {
assert.ok(cmdNames.length > 0, 'command roster must be populated');
assert.ok(cmdNames.includes('execute-phase'));
assert.ok(cmdNames.includes('plan-phase'));
});
test('E1: every agents/gsd-*.md transforms clean — no roster colon refs survive', () => {
const agentsDir = path.join(REPO_ROOT, 'agents');
const offenders = [];
// Not the shared listAgentFiles() helper: this needs full `.md` filenames
// (not stripped basenames) to readFileSync + transform each agent body.
for (const f of fs.readdirSync(agentsDir)) {
if (!f.startsWith('gsd-') || !f.endsWith('.md')) continue;
const src = fs.readFileSync(path.join(agentsDir, f), 'utf-8');
const out = transformContentToHyphen(src, cmdNames);
if (roster().test(out)) offenders.push(f);
}
assert.deepEqual(
offenders,
[],
`agents still carry roster colon refs after transform: ${offenders.join(', ')}`,
);
});
test('E2: idempotent — transform of already-hyphenated input is a no-op', () => {
const input = 'use /gsd-plan-phase next, then /gsd-execute-phase';
assert.strictEqual(
transformContentToHyphen(input, cmdNames),
input,
'reinstalls re-run the transform; double application must not mangle the body',
);
});
test('E3: word boundary — /gsd:plan-phase-extra is not a roster match', () => {
assert.strictEqual(
transformContentToHyphen('/gsd:plan-phase-extra', cmdNames),
'/gsd:plan-phase-extra',
);
});
test('E4: rewrites bare `gsd:<cmd>` shorthand (no leading slash)', () => {
const out = transformContentToHyphen(
'Spawned by the gsd:execute-phase orchestrator.',
cmdNames,
);
assert.strictEqual(out, 'Spawned by the gsd-execute-phase orchestrator.');
});
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3683-command-colon-namespace-leak.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3683-command-colon-namespace-leak (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #3683)
// Command `.md` files — their staged text IS the runtime contract loaded by
// Claude Code. Asserting that staged bodies lack `/gsd:<cmd>` colon refs is
// a behavioral test of the install transform, not source-grep theater.
/**
* Regression for #3683 — installed command bodies leak `/gsd:<cmd>` colon refs
* for Claude Code local installs.
*
* Root cause: `bin/install.js` command install path (`copyWithPathReplacement`,
* around line 8296 in the `else` branch) copies each command `.md` body without
* applying the hyphen-namespace normalizer that the agent install loop gained in
* PR #3677. Static prose in `commands/gsd/*.md` (e.g. plan-phase.md referencing
* `/gsd:execute-phase`) therefore reaches the model verbatim, causing the model
* to echo the retired colon form at workflow boundaries.
*
* Fix surface:
* Call `normalizeAgentBodyForRuntime` (or an equivalent helper) in the command
* staging path after all other rewrites but before writeFileSync, mirroring
* the agent install loop fix from #3677.
*
* This test guards the behavioral integration: run a real local claude install
* into a temp dir, then assert that no staged command body contains a
* `/gsd:<known-cmd>` colon ref.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
const install = require(INSTALL_PATH);
const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Run `node install.js --claude --local --no-sdk` in tmpDir.
* GSD_TEST_MODE must be cleared so the install() main block executes.
*/
function runClaudeLocalInstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
/**
* Build the roster regex that matches `/gsd:<known-cmd>` or `gsd:<known-cmd>`
* (with appropriate word boundaries). Mirrors the pattern used in bug-3677.
*/
function buildRosterRegex(cmdNames) {
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
return new RegExp(
`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`,
);
}
// ---------------------------------------------------------------------------
// Suite A — export surface: normalizeAgentBodyForRuntime must be exported
// (same seam used for command bodies)
// ---------------------------------------------------------------------------
describe('bug #3683 — command body colon-namespace leak (Claude local install)', () => {
describe('A — install.js exports the normalizer seam', () => {
test('A1: normalizeAgentBodyForRuntime is exported (reused for command bodies)', () => {
assert.strictEqual(
typeof install.normalizeAgentBodyForRuntime,
'function',
'bin/install.js must export normalizeAgentBodyForRuntime — the seam used for both agent and command body normalization',
);
});
test('A2: shouldNormalizeHyphenNamespaceInAgentBody is exported and true for claude', () => {
assert.strictEqual(
typeof install.shouldNormalizeHyphenNamespaceInAgentBody,
'function',
);
assert.strictEqual(
install.shouldNormalizeHyphenNamespaceInAgentBody('claude'),
true,
'claude must normalize hyphen namespace — it is in the allow-list from #2808',
);
});
});
// ---------------------------------------------------------------------------
// B — pure-function coverage: normalizer rewrites command body colon refs
// ---------------------------------------------------------------------------
describe('B — normalizeAgentBodyForRuntime rewrites colon refs in command-body prose', () => {
const { normalizeAgentBodyForRuntime } = install;
const cmdNames = readCmdNames();
test('B0: command roster is populated and includes symptom commands', () => {
assert.ok(cmdNames.length > 0, 'readCmdNames() must return a non-empty list');
assert.ok(cmdNames.includes('execute-phase'), 'roster must include execute-phase');
assert.ok(cmdNames.includes('plan-phase'), 'roster must include plan-phase');
});
test('B1: claude — rewrites /gsd:<cmd> colon refs in command-body prose to hyphen form', () => {
const input = [
'## After planning',
'',
'Run `/gsd:execute-phase 1 --tdd` to begin execution.',
'Then use `/gsd:verify-work 1` when done.',
].join('\n');
const out = normalizeAgentBodyForRuntime(input, 'claude', cmdNames);
assert.ok(out.includes('/gsd-execute-phase'), 'execute-phase must be rewritten to hyphen form');
assert.ok(out.includes('/gsd-verify-work'), 'verify-work must be rewritten to hyphen form');
assert.ok(!out.includes('/gsd:execute-phase'), 'colon form for execute-phase must be absent');
assert.ok(!out.includes('/gsd:verify-work'), 'colon form for verify-work must be absent');
});
test('B2: gemini — colon refs preserved (Gemini intentionally uses colon namespace)', () => {
const input = 'Run `/gsd:execute-phase 1` to begin.';
const out = normalizeAgentBodyForRuntime(input, 'gemini', cmdNames);
assert.ok(out.includes('/gsd:execute-phase'), 'Gemini must keep colon form');
assert.ok(!out.includes('/gsd-execute-phase'), 'Gemini must not have hyphen form injected');
});
});
// ---------------------------------------------------------------------------
// E — Integration: real local claude install produces clean command bodies
// ---------------------------------------------------------------------------
// E — integration: flat gsd-*.md layout + clean bodies (#1367 fix)
//
// Prior to #1367: commands wrote to commands/gsd/<cmd>.md (bare names in a
// subdir), causing Claude Code to namespace them as /gsd:<cmd> (colon form).
// After #1367: commands write flat gsd-<cmd>.md at commands/ level so Claude
// Code registers them as /gsd-<cmd> (hyphen form, matching all framework refs).
// ---------------------------------------------------------------------------
describe('E — integration: staged gsd-*.md flat commands contain no colon-namespace refs', () => {
let tmpDir;
const cmdNames = readCmdNames();
const rosterRegex = buildRosterRegex(cmdNames);
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-'));
runClaudeLocalInstall(tmpDir);
});
after(() => {
cleanup(tmpDir);
});
test('E0: staged commands/ directory has flat gsd-*.md files after install (#1367)', () => {
// After #1367 fix: commands land at .claude/commands/gsd-<cmd>.md (flat,
// hyphen-prefixed). The old .claude/commands/gsd/<cmd>.md subdirectory
// layout must NOT be created.
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(
fs.existsSync(commandsDir),
`commands/ must be created by local claude install at ${commandsDir}`,
);
const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(
flatFiles.length > 0,
`commands/ must contain flat gsd-*.md files (e.g. gsd-help.md). ` +
`Found none — install may still be using the old commands/gsd/<cmd>.md subdirectory layout.`,
);
// The old subdirectory must NOT exist (it caused /gsd:<cmd> colon namespace)
const oldSubdir = path.join(commandsDir, 'gsd');
assert.ok(
!fs.existsSync(oldSubdir),
`commands/gsd/ subdir must NOT exist after install (it causes /gsd:<cmd> colon namespace in Claude Code). ` +
`#1367 fix: use flat gsd-<cmd>.md at commands/ level instead.`,
);
});
test('E1: no staged command body contains /gsd:<known-cmd> colon refs', () => {
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful');
const offenders = [];
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
if (!entry.name.startsWith('gsd-')) continue;
const fullPath = path.join(commandsDir, entry.name);
const content = fs.readFileSync(fullPath, 'utf-8');
if (rosterRegex.test(content)) {
offenders.push(path.relative(tmpDir, fullPath));
}
}
assert.deepEqual(
offenders,
[],
`Staged command bodies still contain roster colon refs (e.g. /gsd:execute-phase). ` +
`Install must normalize these to /gsd-<cmd> for claude runtime. Offenders: ${offenders.join(', ')}`,
);
});
test('E2: idempotent — re-running install does not double-mangle already-hyphenated refs', () => {
// Run install a second time; if the normalizer double-applies it would
// produce garbled output like /gsd--execute-phase. Verify the commands
// still pass the same cleanliness check after a second install.
runClaudeLocalInstall(tmpDir);
const commandsDir = path.join(tmpDir, '.claude', 'commands');
const doubleRewriteRegex = /\/gsd--[a-z]/;
const garbled = [];
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
if (!entry.name.startsWith('gsd-')) continue;
const content = fs.readFileSync(path.join(commandsDir, entry.name), 'utf-8');
if (doubleRewriteRegex.test(content)) {
garbled.push(entry.name);
}
}
assert.deepEqual(
garbled,
[],
`Re-install produced double-hyphen artifacts (/gsd--cmd) — normalizer is not idempotent. Garbled files: ${garbled.join(', ')}`,
);
});
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3683-workflow-colon-namespace-leak.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3683-workflow-colon-namespace-leak (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #3683)
// Workflow and reference `.md` files are deployed verbatim as part of the
// gsd-core skill payload — their staged text IS the runtime contract
// loaded by Claude Code. Asserting that staged bodies lack `/gsd:<cmd>`
// colon refs is a behavioral test of the install transform, not
// source-grep theater.
/**
* Regression for #3683 — installed workflow/reference bodies leak `/gsd:<cmd>`
* colon refs for Claude Code local installs.
*
* Root cause: `copyWithPathReplacement` in `bin/install.js` guarded the
* `normalizeAgentBodyForRuntime` call behind `if (isCommand)`, so the
* `gsd-core/` directory (workflows, references — all `isCommand=false`)
* was copied without applying the hyphen-namespace normalizer. Static prose
* in `gsd-core/workflows/*.md` and `gsd-core/references/*.md`
* (e.g. discuss-phase.md referencing `/gsd:plan-phase`) therefore reached
* the model verbatim, causing it to echo the retired colon form.
*
* Fix surface:
* Remove the `if (isCommand)` guard so `normalizeAgentBodyForRuntime` is
* called unconditionally in `copyWithPathReplacement`. The function
* self-gates on `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` and
* is a no-op for colon-canonical runtimes (Gemini, Codex, etc.).
*
* User repro path: `/gsd-discuss-phase` output ends with `/gsd:nextcommand`
* because discuss-phase.md (7 colon refs) is not normalized at install time.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
require(INSTALL_PATH);
const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Run `node install.js --claude --local --no-sdk` in tmpDir.
* GSD_TEST_MODE must be cleared so the install() main block executes.
*/
function runClaudeLocalInstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
/**
* Build the roster regex that matches `gsd:<known-cmd>` references.
* Mirrors the pattern used by the Cycle 1 command test.
*/
function buildRosterRegex(cmdNames) {
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
return new RegExp(
`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`,
);
}
/**
* Walk a directory recursively and collect .md files whose body matches regex.
*/
function collectOffenders(dir, regex) {
const offenders = [];
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const fullPath = path.join(d, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8');
if (regex.test(content)) {
offenders.push(fullPath);
}
}
}
};
walk(dir);
return offenders;
}
// ---------------------------------------------------------------------------
// Suite — integration: staged gsd-core/workflows/ and references/ must
// have no colon-namespace refs for claude.
// ---------------------------------------------------------------------------
describe('bug #3683 — workflow/reference colon-namespace leak (Claude local install)', () => {
// Shared Claude local install used by W and R suites.
// Consolidating to a single install halves disk I/O for this file and
// reduces concurrent load on CI runners — preventing timing interference
// with concurrently-running tests (e.g. the TOCTOU barrier tests in
// locking-bugs-1909-1916-1925-1927.test.cjs).
let claudeTmpDir;
const cmdNames = readCmdNames();
const rosterRegex = buildRosterRegex(cmdNames);
// Shared claude local install — used by W (workflow/reference clean-slate) and
// R (routing-block positive assertion) sub-suites.
before(() => {
claudeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-claude-'));
runClaudeLocalInstall(claudeTmpDir);
});
after(() => {
cleanup(claudeTmpDir);
});
// -------------------------------------------------------------------------
// W — real local claude install: workflow + reference bodies are clean
// -------------------------------------------------------------------------
describe('W — integration: staged workflows and references contain no colon-namespace refs', () => {
test('W0: staged gsd-core/workflows/ directory exists after install', () => {
const workflowsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'workflows');
assert.ok(
fs.existsSync(workflowsDir),
`gsd-core/workflows/ must be created by local claude install at ${workflowsDir}`,
);
});
test('W1: staged gsd-core/references/ directory exists after install', () => {
const refsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'references');
assert.ok(
fs.existsSync(refsDir),
`gsd-core/references/ must be created by local claude install at ${refsDir}`,
);
});
test('W2: focused repro — staged discuss-phase.md has zero /gsd: colon refs', () => {
// User-reported repro: /gsd-discuss-phase output ends with /gsd:nextcommand
// because discuss-phase.md ships 7 colon refs that were not normalized.
const stagedFile = path.join(
claudeTmpDir, '.claude', 'gsd-core', 'workflows', 'discuss-phase.md',
);
assert.ok(
fs.existsSync(stagedFile),
`discuss-phase.md must exist in staged gsd-core/workflows/`,
);
const content = fs.readFileSync(stagedFile, 'utf-8');
const colonMatches = content.match(/gsd:[a-z][a-z0-9-]*/g) || [];
// Filter to known-command refs only
const knownColonRefs = colonMatches.filter(m => {
const cmd = m.slice(4); // strip 'gsd:'
return cmdNames.includes(cmd);
});
assert.deepEqual(
knownColonRefs,
[],
`discuss-phase.md still contains colon-namespace refs that install must normalize: ${knownColonRefs.join(', ')}`,
);
});
test('W3: no staged workflow body contains /gsd:<known-cmd> colon refs', () => {
const workflowsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'workflows');
assert.ok(fs.existsSync(workflowsDir), 'workflows/ must exist for this check to be meaningful');
const offenders = collectOffenders(workflowsDir, rosterRegex);
const relOffenders = offenders.map(f => path.relative(claudeTmpDir, f));
assert.deepEqual(
relOffenders,
[],
`Staged workflow bodies still contain roster colon refs (e.g. /gsd:plan-phase). ` +
`Install must normalize these to /gsd-<cmd> for claude runtime. Offenders: ${relOffenders.join(', ')}`,
);
});
test('W4: no staged reference body contains /gsd:<known-cmd> colon refs', () => {
const refsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'references');
assert.ok(fs.existsSync(refsDir), 'references/ must exist for this check to be meaningful');
const offenders = collectOffenders(refsDir, rosterRegex);
const relOffenders = offenders.map(f => path.relative(claudeTmpDir, f));
assert.deepEqual(
relOffenders,
[],
`Staged reference bodies still contain roster colon refs. ` +
`Install must normalize these to /gsd-<cmd> for claude runtime. Offenders: ${relOffenders.join(', ')}`,
);
});
});
// -------------------------------------------------------------------------
// R — #3646 routing-block positive assertion: ▶-prefixed lines use hyphen
//
// User repro: workflow output ends with "▶ /gsd:validate-phase {N}" (colon
// form) which does not resolve in Claude Code — the installed skill is
// /gsd-validate-phase (hyphen). Workflows emit routing blocks verbatim, so
// the colon form reaches the model and is echoed to the user unchanged.
//
// This suite checks the POSITIVE invariant: lines starting with ▶ that
// reference a GSD slash command must use /gsd-<cmd> (hyphen) in the staged
// output. This is a stricter assertion than W3 (which only checks absence
// of colon globally) because it confirms the routing-position strings were
// NOT omitted — they must be present AND use the correct form.
//
// Source files with known ▶-prefixed routing-block colon refs (#3646):
// - gsd-core/workflows/validate-phase.md:151 ▶ Next: /gsd:audit-milestone
// - gsd-core/workflows/validate-phase.md:158 ▶ Retry: /gsd:validate-phase
// - gsd-core/workflows/secure-phase.md:140 ▶ Fix mitigations: /gsd:secure-phase
// - gsd-core/workflows/secure-phase.md:158 ▶ /gsd:validate-phase
// - gsd-core/workflows/secure-phase.md:159 ▶ /gsd:verify-work
// -------------------------------------------------------------------------
describe('R — #3646 routing-block: ▶-prefixed lines use hyphen form in staged claude install', () => {
// Uses the shared claudeTmpDir from the parent describe block — no separate install needed.
/**
* Collect all lines starting with the ▶ routing marker from a file.
* Returns an array of { lineNo, text } objects.
*/
function collectRoutingLines(filePath) {
if (!fs.existsSync(filePath)) return [];
return fs.readFileSync(filePath, 'utf-8')
.split(/\r?\n/)
.map((text, i) => ({ lineNo: i + 1, text }))
.filter(({ text }) => text.startsWith('▶'));
}
test('R1: staged validate-phase.md routing block uses /gsd-<cmd> hyphen form', () => {
const stagedFile = path.join(
claudeTmpDir, '.claude', 'gsd-core', 'workflows', 'validate-phase.md',
);
assert.ok(
fs.existsSync(stagedFile),
`validate-phase.md must exist in staged gsd-core/workflows/`,
);
const routingLines = collectRoutingLines(stagedFile);
// Exactly two known routing lines (▶ Next / ▶ Retry).
const gsdRoutingLines = routingLines.filter(({ text }) => /\/gsd[-:]/.test(text));
assert.strictEqual(
gsdRoutingLines.length,
2,
`validate-phase.md must have exactly 2 ▶-routing lines referencing a /gsd- command — ` +
`found ${gsdRoutingLines.length}: ${JSON.stringify(gsdRoutingLines)}`,
);
// Positive: every routing line that references gsd must use the hyphen form.
for (const { lineNo, text } of gsdRoutingLines) {
assert.ok(
/\/gsd-[a-z]/.test(text),
`validate-phase.md line ${lineNo}: ▶-routing line must use /gsd-<cmd> hyphen form, got: ${JSON.stringify(text)}`,
);
// Negative: must not contain the colon form.
assert.ok(
!/\/gsd:[a-z]/.test(text),
`validate-phase.md line ${lineNo}: ▶-routing line must not contain /gsd:<cmd> colon form, got: ${JSON.stringify(text)}`,
);
// Token-level: extract real command tokens (/gsd-<cmd> starting with a
// lowercase letter) and assert none contain an embedded colon.
// Skips documentation placeholder tokens like /gsd-[command].
const rawTokens = text.match(/\/gsd[^\s]*/g) || [];
for (const token of rawTokens) {
assert.ok(
!token.includes(':'),
`validate-phase.md line ${lineNo}: /gsd token "${token}" must not contain a colon — embedded colon detected (e.g. /gsd-validate:phase), got: ${JSON.stringify(text)}`,
);
}
}
});
test('R2: staged secure-phase.md routing block uses /gsd-<cmd> hyphen form', () => {
const stagedFile = path.join(
claudeTmpDir, '.claude', 'gsd-core', 'workflows', 'secure-phase.md',
);
assert.ok(
fs.existsSync(stagedFile),
`secure-phase.md must exist in staged gsd-core/workflows/`,
);
const routingLines = collectRoutingLines(stagedFile);
// Exactly three known routing lines (fix-mitigations, validate-phase, verify-work).
const gsdRoutingLines = routingLines.filter(({ text }) => /\/gsd[-:]/.test(text));
assert.strictEqual(
gsdRoutingLines.length,
3,
`secure-phase.md must have exactly 3 ▶-routing lines referencing a /gsd- command — ` +
`found ${gsdRoutingLines.length}: ${JSON.stringify(gsdRoutingLines)}`,
);
for (const { lineNo, text } of gsdRoutingLines) {
assert.ok(
/\/gsd-[a-z]/.test(text),
`secure-phase.md line ${lineNo}: ▶-routing line must use /gsd-<cmd> hyphen form, got: ${JSON.stringify(text)}`,
);
assert.ok(
!/\/gsd:[a-z]/.test(text),
`secure-phase.md line ${lineNo}: ▶-routing line must not contain /gsd:<cmd> colon form, got: ${JSON.stringify(text)}`,
);
// Token-level: extract all /gsd... tokens and assert none contain an
// embedded colon (catches /gsd-validate:phase etc).
// Skips documentation placeholder tokens like /gsd-[command].
const rawTokens = text.match(/\/gsd[^\s]*/g) || [];
for (const token of rawTokens) {
assert.ok(
!token.includes(':'),
`secure-phase.md line ${lineNo}: /gsd token "${token}" must not contain a colon — embedded colon detected (e.g. /gsd-validate:phase), got: ${JSON.stringify(text)}`,
);
}
}
});
test('R3: all staged workflow routing blocks use hyphen form (cross-file sweep)', () => {
// R3 unique value vs W3:
// W3 catches overt /gsd:<cmd> at file level (any line).
// R3 adds:
// (a) ▶-line-scoped assertion (catches drift specifically in routing-block context)
// (b) embedded-colon token check (e.g. /gsd-validate:phase partial-conversion artifacts)
// not detectable by W3's file-level regex
// Sweeps both workflows/ and references/ so routing blocks in reference files
// are covered alongside workflow files.
const gsdDir = path.join(claudeTmpDir, '.claude', 'gsd-core');
const workflowsDir = path.join(gsdDir, 'workflows');
assert.ok(fs.existsSync(workflowsDir), 'workflows/ must exist for R3 to be meaningful');
const colonOffenders = [];
const embeddedColonOffenders = [];
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const fullPath = path.join(d, entry.name);
if (entry.isDirectory()) { walk(fullPath); continue; }
if (!entry.name.endsWith('.md')) continue;
const lines = fs.readFileSync(fullPath, 'utf-8').split(/\r?\n/);
const rel = path.relative(claudeTmpDir, fullPath);
lines.forEach((text, i) => {
if (!text.startsWith('▶')) return;
// Negative: must not contain overt /gsd:<cmd> colon form.
if (/\/gsd:[a-z]/.test(text)) {
colonOffenders.push(`${rel}:${i + 1}: ${text.trim()}`);
}
// Token-level: check each /gsd... token for an embedded colon.
// Catches cases like /gsd-validate:phase where normalizer half-converted.
// Documentation placeholder tokens like /gsd-[command] are skipped
// because their tokens will not contain a colon.
const tokens = text.match(/\/gsd[^\s]*/g) || [];
for (const token of tokens) {
if (token.includes(':')) {
embeddedColonOffenders.push(`${rel}:${i + 1}: token "${token}" in "${text.trim()}"`);
}
}
});
}
};
// Walk both workflows/ and references/ — routing blocks can appear in either.
walk(workflowsDir);
const refsDir = path.join(gsdDir, 'references');
if (fs.existsSync(refsDir)) walk(refsDir);
assert.deepEqual(
colonOffenders,
[],
`Staged workflows contain ▶-routing lines with /gsd:<cmd> colon form — ` +
`these must resolve to /gsd-<cmd> for Claude Code skills-based install. ` +
`Offenders:\n ${colonOffenders.join('\n ')}`,
);
assert.deepEqual(
embeddedColonOffenders,
[],
`Staged workflows contain ▶-routing lines with /gsd tokens that have an embedded ` +
`colon (e.g. /gsd-validate:phase) — normalizer may have partially converted a token. ` +
`Offenders:\n ${embeddedColonOffenders.join('\n ')}`,
);
});
});
});
});
}