Fold 48 issue-named workflow-markdown regression files into the canonical test that owns each workflow aspect (execute-phase-*, plan-phase-*, quick-*, discuss-*, worktree-cleanup, secure-phase, verify, update, settings, etc.), across 32 existing suites. Verbatim block-scoped describe wrappers; 281 subtests conserved 1:1. No new test files. Host-env pre-check: only gsd-settings-advanced spawns CLI and it sets no GSD_WORKSTREAM/GSD_PROJECT value — no leak risk. Regenerates regression-name allowlist (222->181), ratchets file-count allowlist (verify 11->10), makes 30 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes 30 stale ids). lint:ci green. Part of epic #1969. Closes #1973. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
891 lines
37 KiB
JavaScript
891 lines
37 KiB
JavaScript
// 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/<name>" as the default workspace directory
|
|
// in shell examples and option tables (e.g. "--path /target (default: ~/gsd-workspaces/<name>)").
|
|
// 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('<!--')) !== -1) {
|
|
out += rest.slice(0, idx);
|
|
const end = 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<string>, colon: Set<string>, dollar: Set<string> }
|
|
*/
|
|
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(/(?<![A-Za-z0-9/_-])\/gsd-[a-z0-9][a-z0-9-]*/g) || []);
|
|
const allColon = (stripped.match(/(?<![A-Za-z0-9/_-])\/gsd:[a-z0-9][a-z0-9-]*/g) || []);
|
|
const allDollar = (stripped.match(/(?<![A-Za-z0-9/_-])\$gsd-[a-z0-9][a-z0-9-]*/g) || []);
|
|
|
|
const slash = new Set(allSlash.filter(t => !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[-:]<name> that has no shipped
|
|
* slash command. (Caught the original #2954 regression: #2824 deleted
|
|
* 31 stubs without updating help.md.)
|
|
*
|
|
* 2. Every shipped /gsd[-:]<name> 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[-:]<name>` 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 = /(?<![a-z])\/gsd[:-]([a-z][a-z0-9-]*)(?![\w-]*\/)/g;
|
|
let match;
|
|
while ((match = tokenRe.exec(contents)) !== null) {
|
|
names.add(match[1]);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* For every shipped command with an `argument-hint:` frontmatter entry,
|
|
* collect the `--flag` tokens it advertises. Returns a Map<slashBaseName,
|
|
* Set<flagName>>. 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[-:]<name> 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[-:]<name> commands that are not shipped: ${dangling.join(', ')}`,
|
|
);
|
|
});
|
|
|
|
test('every shipped /gsd[-:]<name> 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[-:]<name> reference in help.md: ${undocumented.join(', ')}`,
|
|
);
|
|
});
|
|
|
|
test('every /gsd[-:]<name> 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[-:]<name> 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-<command> --<flag>` (precise) OR a bare `--<flag>` 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}"`
|
|
);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|