Files
msd-core/tests/enh-2792-namespace-skills.test.cjs
Tom Boucher 3aaed8f5d7 test: replace deny-list parity tests with polarity-inverted live-registry (#3049) (#3284)
* test: reproduce Windows SDK not found after fresh npx install (#3211)

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

* test: red — docs-parity live-registry tests fail against stub helper (#3049)

Adds:
- tests/helpers/live-command-registry.cjs (stub: returns empty Set)
- tests/docs-parity-live-registry.test.cjs (new polarity-inverted test)
- tests/fixtures/live-command-registry/ (fixture .md files)

All parity and helper-contract tests fail because the stub returns an
empty registry. This is the intentional RED state before GREEN
implementation.

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

* feat(test-helpers): live-command-registry derives canonical tokens from commands/gsd/*.md (#3049)

Implements GREEN phase:
- tests/helpers/live-command-registry.cjs: walks commands/gsd/*.md, parses
  YAML frontmatter name: field, emits /gsd-slug, /gsd:slug, $gsd-slug per
  command. Memoized per process. Fails loud on malformed frontmatter (k302).
- tests/docs-parity-live-registry.test.cjs: updated with INTERNAL_COMPONENT_SLUGS
  exemption for path-component and placeholder tokens (gsd-build from GitHub
  org URLs, gsd-workspaces from ~/gsd-workspaces/ paths, gsd-tools from
  bin/gsd-tools.cjs paths, etc.)

Docs drift caught and fixed:
- ns-* rename: /gsd-ns-workflow→/gsd-workflow etc. in COMMANDS, FEATURES,
  INVENTORY, USER-GUIDE (6 commands across 4 English files)
- /gsd-scan → /gsd-map-codebase --fast (FEATURES, INVENTORY, USER-GUIDE)
- /gsd-note → /gsd-capture (FEATURES, issue-driven-orchestration, ja-JP, ko-KR)
- /gsd-do → /gsd-fast (FEATURES, ja-JP, ko-KR)
- /gsd-from-gsd2 → /gsd-import --from-gsd2 (CLI-TOOLS, FEATURES, INVENTORY)
- /gsd-verify-phase → /gsd-validate-phase (STATE-MD-LIFECYCLE)
- /gsd-settings-integrations → /gsd-settings or /gsd-config --integrations (CLI-TOOLS)
- /gsd-dev-preferences removed from profile-user artifact lists (AGENTS, COMMANDS,
  FEATURES in English, ja-JP, ko-KR)
- /gsd-select-framework removed from gsd-framework-selector spawner list
  (AGENTS, INVENTORY)

All 28 new tests pass.

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

* refactor(test): replace deny-list parity tests with polarity-inverted live-registry approach (#3049)

- Delete bug-3010-reapply-patches-references.test.cjs (hardcoded deny-list)
- Delete bug-3029-3034-stale-command-routes.test.cjs (hardcoded deny-list)
- Delete bug-3042-3044-research-flag-and-stale-refs.test.cjs (deny-list + frontmatter checks)
- Add tests/skill-frontmatter-contract.test.cjs (frontmatter structural checks extracted from deleted file)
- Update tests/commands-doc-parity.test.cjs to derive slug from name: frontmatter field
  instead of filename, so ns-* commands resolve to their actual deployed tokens

Closes #3049

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

* test: annotate commands-doc-parity with source-text-is-the-product exemption (#3049 lint fix)

The readFileSync on commands/gsd/*.md reads product markdown whose deployed
text IS what the user sees — content.startsWith('---') detects YAML frontmatter
in those files, not source-code structure. Add the allow-test-rule exemption
matching the same rationale used in docs-parity-live-registry.test.cjs.

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

* test: walk docs/** recursively to cover nested locale trees (CR finding 7)

Replaced the non-recursive listMdFiles() with a hand-rolled DFS walker
compatible with Node 20+. Surfaces unreadable-directory errors as stderr
warnings (PRED.k302) rather than silently skipping.

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

* test: annotate live-command-registry helper and commands-doc-parity with source-text exemptions (CR findings 6, 8)

Adds allow-test-rule comments to suppress lint-no-source-grep false
positives on YAML frontmatter structure checks in both files.

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

* test: anchor --research-phase assertions to arg-parsing section and verify combined refresh (CR findings 9, 10)

Finding 9: scopes --research-phase check to within 1200 chars of the flag
description section header, preventing false positives from prose mentions.

Finding 10: tightens the force-refresh assertion to require BOTH --research
and force/refresh semantics within the --research-phase description section,
verifying the combined-mode contract rather than standalone --research presence.

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

* test: fix execSync mock to accept opts parameter, forward to saved implementation (CR finding 5)

The mock at line 212 dropped the options parameter when delegating to
savedExecSync. Updated to (cmd, opts) signature and pass opts through.

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

* docs: correct routing entrypoint, --fast default, /gsd-review collision, verifying-stage mapping (CR findings 1-4)

Finding 1: Change Freeform Routing command from /gsd-fast to /gsd-progress --do.
/gsd-fast is the inline trivial-task executor, not the routing entrypoint.

Finding 2: Clarify that /gsd-map-codebase --fast REQ-SCAN-02 default (tech+arch)
runs as a single combined-focus agent, resolving the contradiction with REQ-SCAN-01.

Finding 3: Rename the namespace router /gsd-review to /gsd-quality across all docs,
command file, and help.md to eliminate the naming collision with the concrete
cross-AI peer-review command (review.md, name: gsd:review).

Finding 4: Replace /gsd-validate-phase with /gsd-verify-work in the STATE-MD-LIFECYCLE.md
verifying-stage table. /gsd-validate-phase is the retroactive Nyquist-validation
flow, not the normal phase-verification step.

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

* docs+test: fix locale doc drift surfaced by recursive walker (CR finding 7 follow-up)

The recursive listMdFiles() walker newly covered docs/**/*.md subdirs.
Stale command references in locale docs are now caught and fixed:

- docs/zh-CN/references/model-profiles.md: remove /gsd-set-profile (deleted command);
  config.json is the current mechanism
- docs/zh-CN/references/ui-brand.md: remove /gsd-alternative-1/2 template placeholders
- docs/{ja-JP,ko-KR,pt-BR}/superpowers/specs/2026-03-20-*: replace
  /gsd-new-workspace, /gsd-list-workspaces, /gsd-remove-workspace with
  /gsd-workspace --new / --list / --remove (consolidated in #2790)

Also adds smoke- and alternative-{1,2} to INTERNAL_COMPONENT_SLUGS (filesystem
path and template placeholder patterns, not slash commands) and introduces
listEnglishMdFiles() to scope the English parity check to docs/ excluding
locale subdirectories (which have their own per-locale describe blocks).

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

* docs: add bash language tag to fenced code blocks in ja-JP and ko-KR workspace specs (CR round 2)

Satisfies MD040 fenced-code-language requirement. These blocks contain
shell commands and were missing the language specifier.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 02:49:19 -04:00

256 lines
11 KiB
JavaScript

'use strict';
// allow-test-rule: source-text-is-the-product
// commands/gsd/*.md files ARE what the runtime loads — testing their
// frontmatter content tests the deployed system-prompt contract.
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const NAMESPACE_SKILLS = [
{ file: 'ns-workflow.md', name: 'gsd-workflow' },
{ file: 'ns-project.md', name: 'gsd-project' },
{ file: 'ns-review.md', name: 'gsd-quality' },
{ file: 'ns-context.md', name: 'gsd-context' },
{ file: 'ns-manage.md', name: 'gsd-manage' },
{ file: 'ns-ideate.md', name: 'gsd-ideate' },
];
// Route targets named in any namespace body. The cross-reference test below
// asserts that every one of these resolves to a surviving command file or to
// a known consolidated parent (which absorbs flag-form invocations of folded
// skills, e.g. `gsd-map-codebase --fast` for the former `gsd-scan`).
const FLAG_FORM_PARENTS = new Set([
'gsd-code-review', // --fix absorbs former gsd-code-review-fix
'gsd-map-codebase', // --fast absorbs scan, --query absorbs intel
]);
/**
* Parse the leading YAML frontmatter block of a markdown file into a
* shallow `{ key: value }` map plus the trailing body. Splits on `\r?\n`
* for CRLF tolerance and uses trimmed-line equality for the `---`
* delimiters so whitespace-padded delimiter lines are accepted.
*/
function parseFrontmatter(content) {
const lines = content.split(/\r?\n/);
let openIdx = -1;
let closeIdx = -1;
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].trim() === '---') {
if (openIdx === -1) openIdx = i;
else { closeIdx = i; break; }
}
}
assert.ok(openIdx !== -1 && closeIdx !== -1, 'frontmatter block must be delimited by --- on its own lines');
const fm = {};
for (const line of lines.slice(openIdx + 1, closeIdx)) {
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (!m) continue;
const [, key, raw] = m;
const value = raw.trim().replace(/^["']|["']$/g, '');
fm[key] = value;
}
fm._body = lines.slice(closeIdx + 1).join('\n');
return fm;
}
function readNamespaceFile(file) {
const filePath = path.join(COMMANDS_DIR, file);
assert.ok(fs.existsSync(filePath), `${file} must exist at ${filePath}`);
return { filePath, ...parseFrontmatter(fs.readFileSync(filePath, 'utf-8')) };
}
// ── Frontmatter contract ───────────────────────────────────────────────
describe('Namespace skill files exist with correct name', () => {
for (const { file, name } of NAMESPACE_SKILLS) {
test(`${file} — name field is hyphen-form ${name}`, () => {
const fm = readNamespaceFile(file);
assert.strictEqual(
fm.name,
name,
`name: in ${file} must be ${name} (hyphen form per #2858), got: ${fm.name}`,
);
});
}
});
describe('Namespace skill descriptions are keyword-tag format', () => {
for (const { file } of NAMESPACE_SKILLS) {
test(`${file} — description ≤ 60 chars`, () => {
const fm = readNamespaceFile(file);
assert.ok(
fm.description.length <= 60,
`${file} description must be ≤ 60 chars, got ${fm.description.length}: ${fm.description}`,
);
});
test(`${file} — description contains a pipe separator`, () => {
const fm = readNamespaceFile(file);
assert.ok(
fm.description.includes('|'),
`${file} description must contain | pipe separator, got: ${fm.description}`,
);
});
test(`${file} — description does not start with prose ("Use " / "This skill")`, () => {
const { description } = readNamespaceFile(file);
assert.ok(
!description.startsWith('Use ') && !description.startsWith('This skill'),
`${file} description must not start with "Use " or "This skill", got: ${description}`,
);
});
}
});
// ── allowed-tools must include Skill ──────────────────────────────────
describe('Namespace skills permit Skill execution', () => {
for (const { file } of NAMESPACE_SKILLS) {
test(`${file} — allowed-tools includes Skill`, () => {
const filePath = path.join(COMMANDS_DIR, file);
const raw = fs.readFileSync(filePath, 'utf-8');
const lines = raw.split(/\r?\n/);
const startIdx = lines.findIndex((l) => l.trim() === 'allowed-tools:');
assert.ok(startIdx !== -1, `${file} must declare an allowed-tools block`);
const tools = [];
for (let i = startIdx + 1; i < lines.length; i += 1) {
const m = lines[i].match(/^\s+-\s+(\S+)/);
if (!m) break;
tools.push(m[1]);
}
assert.ok(
tools.includes('Skill'),
`${file} body invokes the Skill tool but allowed-tools does not include Skill (got: ${tools.join(', ')})`,
);
});
}
});
// ── Body contains routing table ───────────────────────────────────────
describe('Namespace skill bodies carry a routing table', () => {
for (const { file } of NAMESPACE_SKILLS) {
test(`${file} — body contains "| User wants" table header`, () => {
const fm = readNamespaceFile(file);
const lines = fm._body.split('\n');
const hasHeader = lines.some((l) => l.includes('| User wants'));
assert.ok(hasHeader, `${file} body must contain a routing table starting with "| User wants"`);
});
test(`${file} — body has at least one Invoke target`, () => {
const fm = readNamespaceFile(file);
const hasInvoke = /\bgsd-[a-z-]+/i.test(fm._body);
assert.ok(hasInvoke, `${file} body must reference at least one gsd-* sub-skill`);
});
}
});
// ── Context guard contract on gsd-health ──────────────────────────────
// Asserts the `--context` surface promised by #2792 is wired through to
// both the command frontmatter and the workflow body. The classifier
// itself is covered by tests/context-utilization.test.cjs and the SDK
// CLI by tests/validate-context.test.cjs.
describe('gsd-health --context flag is wired into command + workflow', () => {
const HEALTH_CMD = path.join(COMMANDS_DIR, 'health.md');
const HEALTH_WORKFLOW = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'health.md');
test('commands/gsd/health.md argument-hint advertises --context', () => {
const raw = fs.readFileSync(HEALTH_CMD, 'utf-8');
const fm = parseFrontmatter(raw);
assert.ok(
fm['argument-hint'] && fm['argument-hint'].includes('--context'),
`health.md argument-hint must include --context, got: ${fm['argument-hint']}`,
);
});
test('commands/gsd/health.md body documents the three-state utilization table', () => {
const raw = fs.readFileSync(HEALTH_CMD, 'utf-8');
const body = parseFrontmatter(raw)._body.toLowerCase();
assert.ok(body.includes('healthy'), 'body must name the healthy state');
assert.ok(body.includes('warning'), 'body must name the warning state');
assert.ok(body.includes('critical'), 'body must name the critical state');
assert.ok(
body.includes('60%') && body.includes('70%'),
'body must reference the 60% and 70% threshold boundaries',
);
});
test('get-shit-done/workflows/health.md has a context_check step', () => {
const raw = fs.readFileSync(HEALTH_WORKFLOW, 'utf-8');
assert.match(
raw,
/<step name="context_check">/,
'workflow must define a <step name="context_check"> branch',
);
});
test('workflow context_check invokes gsd-sdk query validate.context', () => {
const raw = fs.readFileSync(HEALTH_WORKFLOW, 'utf-8');
// Extract just the context_check step's body so a stray reference
// elsewhere in the file can't satisfy this assertion.
const stepMatch = raw.match(/<step name="context_check">([\s\S]*?)<\/step>/);
assert.ok(stepMatch, 'context_check step must be a closed <step>...</step> block');
const stepBody = stepMatch[1];
assert.match(
stepBody,
/gsd-sdk\s+query\s+validate\.context/,
'context_check must call `gsd-sdk query validate.context`',
);
assert.match(stepBody, /--tokens-used/, 'context_check must pass --tokens-used');
assert.match(stepBody, /--context-window/, 'context_check must pass --context-window');
});
});
// ── Cross-reference: every routed sub-skill must exist ─────────────────
// This is the regression guard the original PR lacked. Without it,
// post-#2790 consolidations can quietly invalidate router targets again.
describe('Namespace router targets resolve to surviving skills', () => {
// Build the post-consolidation surviving set once.
const surviving = new Set();
for (const f of fs.readdirSync(COMMANDS_DIR)) {
if (!f.endsWith('.md')) continue;
const base = f.replace(/\.md$/, '');
if (base.startsWith('ns-')) continue; // namespace routers themselves
surviving.add(`gsd-${base}`);
// The PR #2858 rename canonicalized extract_learnings → extract-learnings.
// Until #2790 rebases onto current main, accept either source filename
// as resolving to the canonical hyphenated identifier.
if (base === 'extract_learnings') surviving.add('gsd-extract-learnings');
}
for (const { file } of NAMESPACE_SKILLS) {
test(`${file} — every routing target resolves`, () => {
const fm = readNamespaceFile(file);
// Extract every gsd-<name> token that appears in a table-row right column.
// Strip flag suffixes (`gsd-foo --bar` → `gsd-foo`) before resolving.
const targets = new Set();
for (const line of fm._body.split('\n')) {
// Only consider markdown table data rows: lines that start with `|`
// and have content between pipes. Skip header / separator rows.
if (!line.startsWith('|') || /^\|[\s\-:|]+\|?\s*$/.test(line)) continue;
const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
if (cells.length < 2) continue;
for (const m of cells[cells.length - 1].matchAll(/\bgsd-[a-z][a-z0-9-]*/g)) {
targets.add(m[0]);
}
}
assert.ok(targets.size > 0, `${file} routing table must reference at least one gsd-* target`);
const unresolved = [...targets].filter(
(t) => !surviving.has(t) && !FLAG_FORM_PARENTS.has(t),
);
assert.deepStrictEqual(
unresolved,
[],
`${file} routes to skills that don't exist in commands/gsd/: ${unresolved.join(', ')}`,
);
});
}
});