Files
msd-core/tests/docs-parity-live-registry.test.cjs
Tom Boucher 463cffd894 chore(#604): rename get-shit-done/ runtime directory to gsd-core/ (#615)
* chore(#604): rename get-shit-done/ runtime directory to gsd-core/

Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the
on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary
(`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers
are unaffected.

Mechanical (bulk, ~90% of the diff):
- `git mv get-shit-done gsd-core`
- Swept path/identifier references across the repo via
  `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead
  preserves the five legitimate slug variants that are NOT the directory:
  get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names).
- Build/manifest wiring: package.json (bin, files, coverage globs),
  tsconfig.build.json (outDir), ~86 .gitignore build-output entries,
  stryker.config.mjs, scan-ignore files, install.js path strings.
- Frozen (not rewritten): CHANGELOG.md history; translated docs
  (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/).

New logic (review here):
- src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper
  ADR-0008 installer migration. On upgrade it walks the legacy
  `~/.claude/get-shit-done/` tree, classifies each file via the prior install
  manifest, and emits remove-managed / backup-and-remove for managed files
  while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked
  root and symlinked entries; bounds-checks every path under configDir). The
  framework rolls back on install failure. Emptied dirs may remain (framework
  has no recursive dir-removal primitive) — documented.
- scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare
  `get-shit-done` directory token (split token to avoid self-match; case-
  insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists
  CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines).
  Wired into the lint-tests CI job.
- Restored scripts/lint-package-identity-drift.cjs detection regexes (the
  mechanical sweep had wrongly rewritten the old-name patterns it exists to
  detect) and marked them as intentional legacy references.
- TDD tests for the migration and the guard; do.md slash-command guard regex
  tightened so a `/gsd-core/bin` path segment is not mistaken for a command;
  changeset + docs/installer-migrations.md row added.

Breaking: the installed runtime path moves `~/.claude/get-shit-done/` ->
`~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed
files (preserving user files) on upgrade. Users with custom hooks/configs
hardcoding the old path must update them.

Closes #604

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

* fix(#604): unsweep pending changesets + allowlist injection-example docs

CI fixes for the rename PR:
- Do not sweep pending .changeset/*.md (ephemeral release-note fragments,
  like CHANGELOG); reverted those body edits so 5 pre-existing malformed
  fragments (missing type/pr) no longer enter the PR diff and trip docs-lint.
  Allowlisted .changeset/ in the legacy-name guard accordingly.
- Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in
  prompt-injection-scan.sh: they contain intentional injection examples /
  security-model prose; the path-reference rewrites are kept.

CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR;
none in the new migration/guard) and are out of scope for the rename.

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

* fix(#604): resolve CodeQL alerts surfaced on this PR

The rename diff touched files carrying pre-existing CodeQL findings; per the
no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving
them off. All behavior-preserving:

- scripts/ci-test-scope.cjs: build the config-path match from string
  .includes() instead of a RegExp over an arg-derived value (js/regex-injection).
- src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName
  so the table-cell escape is complete (js/incomplete-sanitization).
- tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment
  strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization).
- tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace,
  keep the meaningful POSIX-class conversion (js/identity-replacement).

Verified: build:lib green; the touched test files + ci-test-scope + profile-output
suites pass; lint:legacy-name clean.

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

* fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization)

The prior commit's fixes for two alerts were ineffective:
- ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file`
  reaching static regex `.test(file)` calls (not the config rule). Removed ALL
  regex over file/t — startsWith/includes/=== string checks + an isWindowsHint
  helper — so there is no regex sink for the tainted value.
- js/incomplete-multi-character-sanitization (3 test files): a single
  `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint
  loop (replace until stable) plus a final bare-opener strip.

Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass;
lint:legacy-name clean.

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

* fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL

CodeQL flags the regex PATTERNS syntactically (regex-injection on the
--files arg split; incomplete-multi-character-sanitization on the <!--...-->
replace), so loop fixes do not satisfy it. Made these paths regex-free:
- ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/).
- 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)).
Behavior preserved; ci-test-scope + the 3 suites pass; guard clean.

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

* fix(#604): unblock security base64 scan on the large rename diff

The security job hit its 10m timeout: base64-scan.sh choked on the binary
test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/
non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings),
and the ~800-file rename diff is slow to scan regardless.

- scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they
  can't carry base64-obfuscated *text* and feeding NUL bytes through the
  per-line scanner is pathologically slow. collect_files already filtered
  binary *extensions*; this catches binary *content* in text extensions.
- .github/workflows/security-scan.yml: raise the security job timeout 10m->30m
  to accommodate very large diffs (the scan itself is unchanged).

Verified locally: scan skips the fixture, 0 "ignored null byte" warnings,
0 findings, exit 0.

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

* fix(#604): sweep get-shit-done refs introduced by merging next

The branch was updated with next (#614/#384/#618 etc.), which reference the
get-shit-done/ dir (still named that on next). Swept the stale references in
the merged files to gsd-core so the rename stays consistent and lint:legacy-name
passes:
- commands/gsd/discuss-phase.md (runtime-launcher shim paths)
- src/core.cts (getAgentsDir layout comments)
- tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib)

Verified: guard 0 violations; build green.

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

* fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant

The #614 runtime-launcher shim added to discuss-phase.md references
`${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y
refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it
mis-read the directory path as a dangling `/gsd-core` command ref (same class as
the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path
segments are not treated as slash-command references.

Verified locally on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22 image) full suite: 0 failures
- bug-3683 + bug-2954 pass.

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

* fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI)

CI intermittently failed state.test's gsd-tools subprocess with
"findProjectRoot is not a function" (flip-flopping across legs; not reproducible
on mac full suite, gsd-test linux full suite, test:unit, or state.test x8).
findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs);
binding it via destructure at module-load can be undefined under a load-ordering
edge. Resolve it lazily at call time via a small wrapper so the lookup happens
after core.cjs is fully initialized.

Verified green on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22) full suite: 0 failures
- state.test.cjs: 106/106; gsd-tools loads cleanly.

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

* fix(#604): allowlist verification-patterns.md placeholder examples in secret scan

The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling
it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var
examples (illustrative Stripe test-key / database-URL / API-key placeholders) —
not real credentials. Added it to .secretscanignore with the strict annotation,
mirroring the existing gsd-core/workflows/plan-phase.md exception.

Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next
exits 0 with 0 findings.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:35:29 -04:00

549 lines
24 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
let tokenCount = 0;
for (const token of result) {
if (token.startsWith('/gsd-')) tokenCount++;
}
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(', ')
);
});
});