// allow-test-rule: source-text-is-the-product // Reads docs/*.md files whose deployed text IS what the user sees — asserting // that every slash-command token in docs resolves to a live registered command // tests the deployed contract. The commands/gsd/*.md reads in the helper are // the source-of-truth registry (product markdown). /** * Docs-parity live-registry test (#3049) * * Replaces three deny-list tests: * - bug-3010-reapply-patches-references.test.cjs * - bug-3029-3034-stale-command-routes.test.cjs * - bug-3042-3044-research-flag-and-stale-refs.test.cjs * * Polarity: instead of "these specific dead commands must be absent", we * assert "every slash-command token in docs must be a live registered command". * * This catches two failure modes the deny-list shape missed: * 1. A freshly-deleted command referenced in docs (no test-file edit needed) * 2. A live command renamed without updating docs (deny-list would pass silently) * * Surfaces scanned: * - docs/*.md (English) * - docs/{ja-JP,ko-KR,zh-CN,pt-BR}/*.md (localized) * * ALLOWED_HISTORICAL_MENTIONS: files that legitimately reference deleted * commands as part of deprecation documentation are excluded from the scan. * Preserved from the three legacy tests: * - gsd-core/workflows/help.md (deprecation-trail prose) * - CHANGELOG.md (historical release notes, must not be rewritten) */ 'use strict'; const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { getLiveCommandTokens } = require('./helpers/live-command-registry.cjs'); const ROOT = path.join(__dirname, '..'); const DOCS_DIR = path.join(ROOT, 'docs'); const LOCALES = ['ja-JP', 'ko-KR', 'zh-CN', 'pt-BR']; // Files that legitimately reference deleted commands as deprecation history. // Preserved from the three legacy tests — do not remove without understanding // why the exemption exists (see issue #3049 and legacy test comments). const ALLOWED_HISTORICAL_MENTIONS = new Set([ path.join(ROOT, 'gsd-core', 'workflows', 'help.md'), path.join(ROOT, 'CHANGELOG.md'), ]); // RELEASE-*.md files document past behavior for historical record. // They must not be rewritten, so they are exempt from the live-registry check. // Pattern: docs/RELEASE-*.md function isReleaseDoc(filePath) { return path.basename(filePath).startsWith('RELEASE-') && filePath.endsWith('.md'); } // Slugs that appear in docs as internal component names or documentation // syntax placeholders — they match the /gsd-* regex but are NOT user-typable // slash commands and never appear in the command registry. Adding a slug here // requires a code comment explaining why it is not a slash command. // // Do NOT add here: // - deleted slash commands (those should be scrubbed from docs) // - renamed commands (update the docs instead) const INTERNAL_COMPONENT_SLUGS = new Set([ // Documentation syntax placeholder — "command-name" is used in ARCHITECTURE.md, // COMMANDS.md, and USER-GUIDE.md to show the template form of a slash command // (e.g. "/gsd-command-name [args]"). It is not a registered command. 'command-name', 'command', // gsd-tools.cjs — the legacy Node CLI binary (bin/gsd-tools.cjs). // Docs reference it as a path component in shell examples, not as a slash command. // Example: node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" state validate 'tools', // Hook scripts — internal runtime hooks, not user-invocable slash commands. // hooks/gsd-statusline.js — session statusline hook // hooks/gsd-context-monitor.js — context-window monitor hook // hooks/gsd-update-banner.js — update-available banner hook // hooks/gsd-graphify-update.sh — knowledge-graph auto-update PostToolUse hook (#3347) // These appear in docs as file-path references (e.g. "gsd-statusline.js reads // the cache"), not as command invocations. 'statusline', 'context-monitor', 'update-banner', 'graphify-update', // gsd-update-check.json — background update-check CACHE FILE, not a slash command. // ARCHITECTURE.md references "~/.cache/gsd/gsd-update-check.json" as a path; // the regex captures "/gsd-update-check" from the path component. 'update-check', // Internal agent names referenced in ARCHITECTURE.md tables of agents. // These are spawned agents (gsd-planner, etc.), not user-typable slash commands. 'planner', // Malformed token from SDK init reference: "/gsd-init-" appears as a truncated // prefix in CLI-TOOLS.md describing the gsd-sdk init command family // (e.g., "gsd-sdk query init.phase-op 12"). The regex captures "/gsd-init-" // without a following slug — this is a documentation formatting artifact, not // a real command token. 'init-', // Compatibility guard for legacy doc links that may include // legacy org path segments in migrated historical URLs. // This is not a user-typable slash command. 'build', // ~/gsd-workspaces/ — filesystem directory path used by /gsd-workspace. // Docs reference "~/gsd-workspaces/" as the default workspace directory // in shell examples and option tables (e.g. "--path /target (default: ~/gsd-workspaces/)"). // The regex captures "/gsd-workspaces" from the path component. The LIVE slash // command is "/gsd-workspace" (singular) — not "/gsd-workspaces" (plural). 'workspaces', // Portuguese translation of "command" — pt-BR/ARCHITECTURE.md uses "/gsd-comando" // as the localized equivalent of the "/gsd-command-name" English placeholder // in an architecture flow diagram. Not a registered command. 'comando', // GitHub repository name: zh-CN/README.md references "github.com/rokicool/gsd-opencode" // as an external community project URL. The regex captures "/gsd-opencode" from // the URL path. Not a user-typable slash command in this product. 'opencode', // gsd-sdk — the @opengsd/gsd-sdk npm package and `gsd-sdk query` CLI binary. // Docs reference it as a package name (e.g. `@opengsd/gsd-sdk`) and CLI tool // (e.g. `gsd-sdk query init phase-op 12`). The regex captures "/gsd-sdk" from // the npm scope path separator in `@opengsd/gsd-sdk`. Not a user-typable slash command. 'sdk', // Smoke-test directory path — locale docs reference "/tmp/gsd-smoke-$(date +%s)" // as a temporary directory path in bash code-block examples. The regex captures // "/gsd-smoke-" from the filesystem path. Not a slash command. 'smoke-', // Template placeholders — zh-CN/references/ui-brand.md used "/gsd-alternative-1" // and "/gsd-alternative-2" as unfilled placeholders in a UI template example. // These were never registered commands. Fixed in the source doc; kept here as // a belt-and-suspenders guard against the pattern returning in other locale docs. 'alternative-1', 'alternative-2', // gsd-sync-skills — installed Claude skill directory name (also a workflow // under gsd-core/workflows/sync-skills.md), but NOT a registered // slash command (no commands/gsd/sync-skills.md). Docs reference it as a // filesystem path component, e.g. "~/.agents/skills/gsd-sync-skills/" in // docs/discussions/grok-build-support-2026-05.md. The regex captures // "/gsd-sync-skills" from the path. Invoked via Skill(skill="gsd-sync-skills"). 'sync-skills', // gsd-test-runner — GitHub repository name: "github.com/open-gsd/gsd-test-runner". // docs/contributing/bootstrap.md references it as a hyperlink target: // [gsd-test-runner](https://github.com/open-gsd/gsd-test-runner) // The regex captures "/gsd-test-runner" from the URL path component. This is // an external tool repo, not a user-typable slash command in this product. 'test-runner', // gsd-core — GitHub repository name: "open-gsd/gsd-core". // docs/adr/22-plan-drift-guard.md references it as an issue tracker link: // open-gsd/gsd-core#22 // The regex captures "/gsd-core" from the org/repo path separator. This is // the canonical repo name, not a user-typable slash command in this product. 'core', ]); /** * Strip HTML comments from content to avoid flagging commented-out examples * or prose that names a dead command for historical context (e.g. "previously * this was /gsd-old-name..."). */ function stripHtmlComments(content) { // regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization) let out = ''; let rest = content; let idx; while ((idx = rest.indexOf('', idx + 4); if (end === -1) { rest = ''; break; } rest = rest.slice(end + 3); } return out + rest; } /** * Extract the set of slash-command tokens from markdown content. * Three forms per command per runtime: * /gsd-slug — Claude / non-Gemini * /gsd:slug — Gemini * $gsd-slug — Codex * * Internal component slugs (INTERNAL_COMPONENT_SLUGS) are filtered out — * those are file-path references or documentation placeholders, not slash * command invocations. * * Returns: { slash: Set, colon: Set, dollar: Set } */ function extractCommandTokens(content) { const stripped = stripHtmlComments(content); function isInternal(token) { // Strip the /gsd- or /gsd: or $gsd- prefix to get the slug const slug = token.replace(/^(?:\/gsd[:-]|\$gsd-)/, ''); // Exact match OR prefix match for 'init-' (which ends with a dash) if (INTERNAL_COMPONENT_SLUGS.has(slug)) return true; for (const s of INTERNAL_COMPONENT_SLUGS) { if (s.endsWith('-') && slug.startsWith(s)) return true; } return false; } // Negative lookbehind: only match tokens NOT preceded by a letter, digit, // `/`, `_`, or `-`. This prevents matching the `/gsd-core` substring inside // the org/repo path `open-gsd/gsd-core` (and similar path-embedded segments) // while still matching real invocations preceded by BOL, space, backtick, or // `(`. Fixes false-positive class identified in #489. const allSlash = (stripped.match(/(? !isInternal(t))); const colon = new Set(allColon.filter(t => !isInternal(t))); const dollar = new Set(allDollar.filter(t => !isInternal(t))); return { slash, colon, dollar }; } /** * Walk a directory and return all .md files recursively. * Uses hand-rolled DFS for Node 20 compat (Node 22+ recursive readdirSync is * not available in all CI matrix entries). Surfaces permission-denied errors * as structured warnings (PRED.k302) rather than silently skipping. */ function listMdFiles(dir) { if (!fs.existsSync(dir)) return []; const files = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { try { files.push(...listMdFiles(fullPath)); } catch (err) { process.stderr.write('[docs-parity] WARNING: skipping unreadable directory ' + fullPath + ': ' + err.message + '\n'); } } else if (entry.isFile() && entry.name.endsWith('.md')) { files.push(fullPath); } } return files; } /** * Assert that every command token in a doc file resolves to the live registry. * Returns an array of diagnostic strings (empty = pass). */ function findUnknownTokens(filePath, liveTokens) { const content = fs.readFileSync(filePath, 'utf-8'); const { slash, colon, dollar } = extractCommandTokens(content); const unknowns = []; for (const token of slash) { if (!liveTokens.has(token)) unknowns.push(token); } for (const token of colon) { if (!liveTokens.has(token)) unknowns.push(token); } for (const token of dollar) { if (!liveTokens.has(token)) unknowns.push(token); } return unknowns; } // ─── Helper unit tests ──────────────────────────────────────────────────────── describe('getLiveCommandTokens() — helper contract', () => { test('returns a Set', () => { const result = getLiveCommandTokens(); assert.ok(result instanceof Set, 'getLiveCommandTokens() must return a Set'); }); test('returns a non-empty set (commands/gsd/ has registered commands)', () => { const result = getLiveCommandTokens(); assert.ok(result.size > 0, 'live registry must contain at least one token'); }); test('contains /gsd-help (from commands/gsd/help.md name: gsd:help)', () => { const result = getLiveCommandTokens(); assert.ok(result.has('/gsd-help'), 'registry must contain /gsd-help'); }); test('contains /gsd:help (Gemini form)', () => { const result = getLiveCommandTokens(); assert.ok(result.has('/gsd:help'), 'registry must contain /gsd:help'); }); test('contains $gsd-help (Codex form)', () => { const result = getLiveCommandTokens(); assert.ok(result.has('$gsd-help'), 'registry must contain $gsd-help'); }); test('contains /gsd-plan-phase (from commands/gsd/plan-phase.md)', () => { const result = getLiveCommandTokens(); assert.ok(result.has('/gsd-plan-phase'), 'registry must contain /gsd-plan-phase'); }); test('contains exactly 3 tokens per slug (slash, colon, dollar)', () => { const result = getLiveCommandTokens(); // Every /gsd-slug should have a matching /gsd:slug and $gsd-slug const slashTokens = [...result].filter(t => t.startsWith('/gsd-')); for (const slash of slashTokens) { const slug = slash.slice('/gsd-'.length); assert.ok( result.has(`/gsd:${slug}`), `registry must contain Gemini form /gsd:${slug} for slash form ${slash}` ); assert.ok( result.has(`$gsd-${slug}`), `registry must contain Codex form $gsd-${slug} for slash form ${slash}` ); } }); test('does NOT contain removed /gsd-reapply-patches', () => { const result = getLiveCommandTokens(); assert.ok(!result.has('/gsd-reapply-patches'), 'registry must NOT contain removed /gsd-reapply-patches'); }); test('does NOT contain removed /gsd-code-review-fix', () => { const result = getLiveCommandTokens(); assert.ok(!result.has('/gsd-code-review-fix'), 'registry must NOT contain removed /gsd-code-review-fix'); }); test('does NOT contain removed /gsd-status', () => { const result = getLiveCommandTokens(); assert.ok(!result.has('/gsd-status'), 'registry must NOT contain removed /gsd-status'); }); test('memoizes — returns the same Set reference on repeated calls', () => { const a = getLiveCommandTokens(); const b = getLiveCommandTokens(); assert.strictEqual(a, b, 'getLiveCommandTokens() must return the same Set instance (memoized)'); }); }); // ─── Fixture-based helper tests ─────────────────────────────────────────────── describe('getLiveCommandTokens() — fixture contract', () => { test('parses gsd:foo frontmatter and emits 3 canonical tokens', () => { // This test validates the parsing logic against a known-good fixture // by inspecting the live registry for commands/gsd/help.md (name: gsd:help). // Fixture file tests are done inline since the helper reads commands/gsd/ only. // The canonical token contract: // name: gsd:foo → /gsd-foo, /gsd:foo, $gsd-foo const registry = getLiveCommandTokens(); // We know help.md has name: gsd:help const slug = 'help'; assert.ok(registry.has(`/gsd-${slug}`), `must have /gsd-${slug}`); assert.ok(registry.has(`/gsd:${slug}`), `must have /gsd:${slug}`); assert.ok(registry.has(`$gsd-${slug}`), `must have $gsd-${slug}`); }); test('parses gsd-slug frontmatter (ns-* commands) and emits 3 tokens', () => { // ns-context.md has name: gsd-context (dash-style, no colon) const registry = getLiveCommandTokens(); assert.ok(registry.has('/gsd-context'), 'must have /gsd-context (from ns-context.md)'); assert.ok(registry.has('/gsd:context'), 'must have /gsd:context (Gemini form)'); assert.ok(registry.has('$gsd-context'), 'must have $gsd-context (Codex form)'); }); }); // ─── English docs parity check ─────────────────────────────────────────────── // Precomputed locale directory prefixes for efficient exclusion in the English scan. const LOCALE_DIRS = LOCALES.map(l => path.join(DOCS_DIR, l) + path.sep); /** * List all .md files under dir, excluding files under any of the known locale * subdirectories (which are covered by the per-locale describe blocks below). */ function listEnglishMdFiles(dir) { return listMdFiles(dir).filter( f => !LOCALE_DIRS.some(ld => f.startsWith(ld)) ); } describe('docs parity — English docs/*.md ⊆ liveRegistry', () => { test('docs/ directory exists and contains markdown files', () => { const files = listEnglishMdFiles(DOCS_DIR); assert.ok(files.length > 0, `expected markdown files under ${DOCS_DIR}`); }); test('every slash-command token in docs/*.md resolves to a live command', () => { const liveTokens = getLiveCommandTokens(); const docFiles = listEnglishMdFiles(DOCS_DIR); const allOffenders = []; for (const filePath of docFiles) { if (ALLOWED_HISTORICAL_MENTIONS.has(filePath)) continue; if (isReleaseDoc(filePath)) continue; const unknowns = findUnknownTokens(filePath, liveTokens); if (unknowns.length > 0) { allOffenders.push( `${path.relative(ROOT, filePath)}: unknown command token(s): [${unknowns.join(', ')}]` ); } } assert.deepStrictEqual( allOffenders, [], 'docs/*.md must only reference live registered commands:\n ' + allOffenders.join('\n ') ); }); }); // ─── Localized docs parity check ───────────────────────────────────────────── for (const locale of LOCALES) { const localeDir = path.join(DOCS_DIR, locale); describe(`docs parity — docs/${locale}/*.md ⊆ liveRegistry`, () => { test(`docs/${locale}/ exists and contains markdown files (or is empty/absent — skip gracefully)`, () => { if (!fs.existsSync(localeDir)) { // Some locales may not exist in every repo state — that is fine. return; } // If the dir exists, it should have at least one .md file. const files = listMdFiles(localeDir); // Warn but don't fail if locale dir is unexpectedly empty. // The parity test below will simply pass vacuously. assert.ok( files.length >= 0, `docs/${locale}/ exists but contains no markdown files` ); }); test(`every slash-command token in docs/${locale}/*.md resolves to a live command`, () => { if (!fs.existsSync(localeDir)) return; const liveTokens = getLiveCommandTokens(); const docFiles = listMdFiles(localeDir); const allOffenders = []; for (const filePath of docFiles) { if (ALLOWED_HISTORICAL_MENTIONS.has(filePath)) continue; if (isReleaseDoc(filePath)) continue; const unknowns = findUnknownTokens(filePath, liveTokens); if (unknowns.length > 0) { allOffenders.push( `${path.relative(ROOT, filePath)}: unknown command token(s): [${unknowns.join(', ')}]` ); } } assert.deepStrictEqual( allOffenders, [], `docs/${locale}/*.md must only reference live registered commands:\n ` + allOffenders.join('\n ') ); }); }); } // ─── Adversarial regression tests ──────────────────────────────────────────── describe('adversarial: polarity inversion catches drift deny-list misses', () => { test('renaming a live command without updating docs would fail this test (demonstrated via token absence)', () => { // If /gsd-progress were renamed to /gsd-status-new, the old /gsd-progress // token would not appear in the live registry, and any doc referencing // /gsd-progress would fail. The deny-list shape would have passed silently // (it only checks for specific known-bad tokens). // We can't simulate an actual rename in a live test, but we can assert // that the registry correctly contains the live name (progress, not status): const registry = getLiveCommandTokens(); assert.ok(registry.has('/gsd-progress'), '/gsd-progress must be live (not renamed to /gsd-status)'); assert.ok(!registry.has('/gsd-status'), '/gsd-status must be absent (was deleted, replaced by /gsd-progress)'); }); test('freshly-deleted command /gsd-check-todos is absent from registry', () => { const registry = getLiveCommandTokens(); assert.ok(!registry.has('/gsd-check-todos'), '/gsd-check-todos must not be in the live registry'); }); test('freshly-deleted command /gsd-new-workspace is absent from registry', () => { const registry = getLiveCommandTokens(); assert.ok(!registry.has('/gsd-new-workspace'), '/gsd-new-workspace must not be in the live registry'); }); test('freshly-deleted command /gsd-plan-milestone-gaps is absent from registry', () => { const registry = getLiveCommandTokens(); assert.ok(!registry.has('/gsd-plan-milestone-gaps'), '/gsd-plan-milestone-gaps must not be in the live registry'); }); test('freshly-deleted command /gsd-research-phase is absent from registry', () => { const registry = getLiveCommandTokens(); assert.ok(!registry.has('/gsd-research-phase'), '/gsd-research-phase must not be in the live registry'); }); }); // ─── Tokenizer regression tests (#489) ─────────────────────────────────────── describe('extractCommandTokens() — repo-path false-positive regression (#489)', () => { test('open-gsd/gsd-core#22 repo path does NOT produce a /gsd-core token', () => { // Before the lookbehind fix, /gsd-core inside `open-gsd/gsd-core#22` // would be matched by the slash regex — a false positive. const { slash, colon, dollar } = extractCommandTokens( 'see open-gsd/gsd-core#22 for details' ); const all = [...slash, ...colon, ...dollar]; assert.ok( !all.includes('/gsd-core'), 'repo path open-gsd/gsd-core#22 must not produce a /gsd-core token; got: ' + all.join(', ') ); assert.strictEqual(all.length, 0, 'expected zero tokens from a bare repo-path string; got: ' + all.join(', ')); }); test('space-preceded /gsd-totally-not-a-real-command is still extracted (real invocation)', () => { // A genuine (but unregistered) command reference after whitespace must be // captured so the live-registry check can flag it as unknown. const { slash } = extractCommandTokens( 'run /gsd-totally-not-a-real-command here' ); assert.ok( slash.has('/gsd-totally-not-a-real-command'), 'invocation after whitespace must be extracted; slash set: ' + [...slash].join(', ') ); }); test('backtick-wrapped `/gsd-plan` is still extracted (real invocation)', () => { // Backtick-wrapped commands (common in markdown) must still be captured. const { slash } = extractCommandTokens( 'use `/gsd-plan` to plan' ); assert.ok( slash.has('/gsd-plan'), 'backtick-wrapped invocation must be extracted; slash set: ' + [...slash].join(', ') ); }); }); // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2954-help-md-slash-command-stubs.test.cjs — consolidation epic #1969 (B3 #1972) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2954-help-md-slash-command-stubs (consolidation epic #1969 B3 #1972)", () => { // allow-test-rule: source-text-is-the-product (see #2954) // Workflow .md / agent .md / command .md / reference .md files — their text // IS what the runtime loads. Testing text content tests the deployed contract. // Per CONTRIBUTING.md exception matrix. 'use strict'; process.env.GSD_TEST_MODE = '1'; /** * Bug #2954: keep `help.md` and the live `commands/gsd/*` slash surface * in lockstep. Two regression tests: * * 1. help.md must not advertise any /gsd[-:] that has no shipped * slash command. (Caught the original #2954 regression: #2824 deleted * 31 stubs without updating help.md.) * * 2. Every shipped /gsd[-:] command must appear in help.md. (Caught * the inverse: a command lands without docs, so users never discover it.) * * The shipped slash name is parsed from frontmatter `name:` (which can be * either `gsd:foo` or `gsd-foo` — Claude Code surfaces both as `/gsd-foo`), * NOT from the filename, because some files (e.g. `ns-context.md`) ship a * different slash name (`gsd-context`) than their filename suggests. * * Also covers `do.md`, the dispatcher invoked at runtime by * `/gsd:progress --do`: any `/gsd[-:]` token in its routing table must * resolve to a live command, otherwise the dispatcher emits "Unknown command". */ 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'); // After #3039, the canonical command reference is the `--full` mode file. // `workflows/help.md` is now a small dispatcher; the bidirectional parity // invariant lives with the comprehensive reference body. const HELP_MD = path.join(ROOT, 'gsd-core', 'workflows', 'help', 'modes', 'full.md'); const DO_MD = path.join(ROOT, 'gsd-core', 'workflows', 'do.md'); function parseFrontmatter(content) { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return null; const fields = {}; for (const line of match[1].split(/\r?\n/)) { const fieldMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); if (!fieldMatch) continue; const value = fieldMatch[2].trim().replace(/^["']|["']$/g, ''); fields[fieldMatch[1]] = value; } return fields; } /** * Returns the set of slash-base-names actually shipped under commands/gsd/. * A "slash-base-name" is the part after `/gsd-` — e.g. for frontmatter * `name: gsd:foo` or `name: gsd-foo`, the slash-base-name is `foo`. */ function listShippedSlashBaseNames() { const names = new Set(); for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith('.md')) continue; const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8'); const fm = parseFrontmatter(content); if (!fm || !fm.name) continue; const fmName = fm.name; let base = null; if (fmName.startsWith('gsd:')) base = fmName.slice(4); else if (fmName.startsWith('gsd-')) base = fmName.slice(4); if (base && /^[a-z][a-z0-9-]*$/.test(base)) names.add(base); } return names; } function extractSlashReferences(contents) { const names = new Set(); // Negative lookbehind: must not be preceded by a letter (avoids matching npm scope // paths like @opengsd/gsd-core where `/gsd-` appears inside a package URL). // Negative lookahead (?![\w-]*\/): excludes filesystem path segments like // `/gsd-core/bin` where the captured name is followed by a `/`, which would // be a directory segment rather than a slash command name. const tokenRe = /(?>. Flags are recorded without their leading `--`. */ function listShippedFlagsByCommand() { const out = new Map(); for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith('.md')) continue; const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8'); const fm = parseFrontmatter(content); if (!fm || !fm.name || !fm['argument-hint']) continue; const fmName = fm.name; let base = null; if (fmName.startsWith('gsd:')) base = fmName.slice(4); else if (fmName.startsWith('gsd-')) base = fmName.slice(4); if (!base || !/^[a-z][a-z0-9-]*$/.test(base)) continue; const flags = new Set(); for (const m of fm['argument-hint'].matchAll(/--([a-z][a-z0-9-]*)/g)) { flags.add(m[1]); } if (flags.size) out.set(base, flags); } return out; } describe('Bug #2954: help.md ↔ commands/gsd/ bidirectional parity', () => { test('every /gsd[-:] referenced in help.md is a shipped command', () => { const helpContents = fs.readFileSync(HELP_MD, 'utf8'); const referenced = extractSlashReferences(helpContents); const shipped = listShippedSlashBaseNames(); const dangling = [...referenced].filter((n) => !shipped.has(n)).sort(); assert.deepEqual( dangling, [], `help.md advertises /gsd[-:] commands that are not shipped: ${dangling.join(', ')}`, ); }); test('every shipped /gsd[-:] command is documented in help.md', () => { const helpContents = fs.readFileSync(HELP_MD, 'utf8'); const referenced = extractSlashReferences(helpContents); const shipped = listShippedSlashBaseNames(); const undocumented = [...shipped].filter((n) => !referenced.has(n)).sort(); assert.deepEqual( undocumented, [], `commands shipped under commands/gsd/ with no /gsd[-:] reference in help.md: ${undocumented.join(', ')}`, ); }); test('every /gsd[-:] in do.md (live dispatcher) is a shipped command', () => { const doContents = fs.readFileSync(DO_MD, 'utf8'); const referenced = extractSlashReferences(doContents); const shipped = listShippedSlashBaseNames(); const dangling = [...referenced].filter((n) => !shipped.has(n)).sort(); assert.deepEqual( dangling, [], `do.md routing table references /gsd[-:] that is not shipped: ${dangling.join(', ')}`, ); }); test('every --flag in a command\'s argument-hint appears in help.md', () => { const helpContents = fs.readFileSync(HELP_MD, 'utf8'); const flagsByCommand = listShippedFlagsByCommand(); const gaps = []; for (const [command, flags] of flagsByCommand) { for (const flag of flags) { // Accept `/gsd- --` (precise) OR a bare `--` token // anywhere in help.md (good enough for shared flags like `--force` that // appear under multiple commands' descriptions). const preciseDash = `/gsd-${command} --${flag}`; const preciseColon = `/gsd:${command} --${flag}`; const flagToken = `--${flag}`; if ( !helpContents.includes(preciseDash) && !helpContents.includes(preciseColon) && !helpContents.includes(flagToken) ) { gaps.push(`/gsd:${command} --${flag}`); } } } assert.deepEqual( gaps.sort(), [], `commands ship --flag(s) in argument-hint that are absent from help.md: ${gaps.join(', ')}`, ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2950-stale-command-refs.test.cjs — consolidation epic #1969 (B4 #1973) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2950-stale-command-refs (consolidation epic #1969 B4 #1973)", () => { /** * Bug #2950: Stale deleted command references in workflow files * * Multiple workflow files referenced command names removed in #2790 * (gsd-add-phase, gsd-insert-phase, gsd-remove-phase, gsd-add-todo, * gsd-set-profile, gsd-settings-integrations, gsd-settings-advanced, * gsd-spike-wrap-up, gsd-sketch-wrap-up, gsd-code-review-fix). * * Fix: Update every occurrence to the new consolidated forms: * /gsd:phase (no flag | --insert | --remove) * /gsd:capture * /gsd:config (--profile | --integrations | --advanced) * /gsd:spike --wrap-up * /gsd:sketch --wrap-up * /gsd:code-review --fix */ 'use strict'; const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); function read(filename) { return fs.readFileSync(path.join(WORKFLOWS_DIR, filename), 'utf-8'); } // Deleted command names that must not appear anywhere in the fixed files. const DELETED_COMMANDS = [ '/gsd-add-phase', '/gsd-insert-phase', '/gsd-remove-phase', '/gsd-add-todo', '/gsd-set-profile', '/gsd-settings-integrations', '/gsd-settings-advanced', '/gsd-spike-wrap-up', '/gsd-sketch-wrap-up', '/gsd-code-review-fix', ]; // Per-file assertions: [file, deletedCmd, newForm] const FILE_ASSERTIONS = [ // help.md → moved to help/modes/full.md in #3039 tiered-help refactor ['help/modes/full.md', '/gsd-add-phase', '/gsd:phase "Add admin dashboard"'], ['help/modes/full.md', '/gsd-insert-phase', '/gsd:phase --insert 7 "Fix critical auth bug"'], ['help/modes/full.md', '/gsd-remove-phase', '/gsd:phase --remove 17'], ['help/modes/full.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], ['help/modes/full.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], ['help/modes/full.md', '/gsd-add-todo', '/gsd:capture'], ['help/modes/full.md', '/gsd-set-profile', '/gsd:config --profile budget'], // do.md ['do.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], ['do.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], ['do.md', '/gsd-add-phase', '/gsd:phase'], ['do.md', '/gsd-add-todo', '/gsd:capture'], // settings.md ['settings.md', '/gsd-code-review-fix', '/gsd:code-review --fix'], ['settings.md', '/gsd-settings-integrations', '/gsd:config --integrations'], ['settings.md', '/gsd-set-profile', '/gsd:config --profile'], ['settings.md', '/gsd-settings-advanced', '/gsd:config --advanced'], // discuss-phase.md ['discuss-phase.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], ['discuss-phase.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], // new-project.md ['new-project.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], ['new-project.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], // plan-phase.md ['plan-phase.md', '/gsd-insert-phase', '/gsd:phase --insert'], // spike.md ['spike.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], // sketch.md ['sketch.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], ]; describe('bug #2950: stale deleted-command references removed from workflow files', () => { // Build a map of file → content to avoid re-reading const files = [...new Set(FILE_ASSERTIONS.map(([f]) => f))]; const contentMap = {}; for (const f of files) { contentMap[f] = read(f); } // For each (file, deletedCmd) pair, assert the old name is absent for (const [file, deletedCmd] of FILE_ASSERTIONS) { test(`${file}: does not contain deleted command "${deletedCmd}"`, () => { const content = contentMap[file]; assert.ok( !content.includes(deletedCmd), `${file} still contains deleted command "${deletedCmd}" — update to new form` ); }); } // For each (file, deletedCmd, newForm) triple, assert the new form is present for (const [file, , newForm] of FILE_ASSERTIONS) { test(`${file}: contains new form "${newForm}"`, () => { const content = contentMap[file]; assert.ok( content.includes(newForm), `${file} is missing expected new form "${newForm}"` ); }); } // Blanket check: no affected workflow file contains any of the deleted command names // (catches any we might have missed in per-file assertions above) const affectedFiles = [ 'help.md', 'help/modes/full.md', 'help/modes/default.md', 'help/modes/brief.md', 'help/modes/topic.md', 'do.md', 'settings.md', 'discuss-phase.md', 'new-project.md', 'plan-phase.md', 'spike.md', 'sketch.md', ]; for (const file of affectedFiles) { const content = read(file); for (const deleted of DELETED_COMMANDS) { test(`${file}: blanket check — "${deleted}" not present`, () => { assert.ok( !content.includes(deleted), `${file} contains deleted command "${deleted}"` ); }); } } }); }); }