Files
msd-core/tests/helpers/live-command-registry.cjs
Tom Boucher 370033e907 fix(phase-dir): apply project_code prefix in plan-milestone-gaps, import, and add-backlog workflows (PRED.k015) (#3306)
* test(phase-dir): add red test for k015 prefix-drift in plan-milestone-gaps and import workflows (#3298)

Asserts that:
- plan-milestone-gaps.md step 8 does not use bare {NN}-{name} mkdir pattern
- plan-milestone-gaps.md step 8 uses phase.add or expected_phase_dir
- import.md plan_convert does not use bare {NN}-{slug} mkdir pattern
- import.md plan_convert uses expected_phase_dir from init.phase-op

These 5 tests are RED until the fix lands.

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

* fix(phase-dir): add projectCode prefix to phase-dir construction in plan-milestone-gaps and import workflows (#3298)

Both plan-milestone-gaps.md step 8 and import.md plan_convert step were
constructing phase directories using raw {NN}-{name}/{NN}-{slug} template
patterns, bypassing the project_code prefix from .planning/config.json.

Fix: both steps now call `gsd-sdk query init.phase-op <N>` and consume the
`expected_phase_dir` field (which includes the `<CODE>-<NN>-<slug>` prefix
when project_code is set), matching the pattern already established by
PR #3292 for /gsd-discuss-phase and /gsd-plan-phase.

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

* fix(phase-dir): apply project_code prefix to backlog phase dir in add-backlog workflow (k015 sibling, #3298)

Sibling k015 audit found a third drift site: add-backlog.md step 4 was
constructing the 999.x backlog phase directory using raw ${NEXT}-${SLUG}
without applying the project_code prefix from .planning/config.json.

Fix: read project_code via `gsd-sdk query config-get project_code --raw`
and prepend `${CODE}-` when set, matching the pattern used by phase.insert
(which already applies project_code to decimal phases in phase.cjs line 736).

Also extends bug-3298 regression test to cover this third site.

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

* chore(changeset): add changeset for #3298 phase-dir prefix drift fix

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

* chore(changeset): set pr: 3306 in changeset for #3298; resolve stash conflict in live-command-registry.cjs

The conflict was cosmetic (string concat → template literals) introduced by
accidental git stash during test verification. Taking the newer template-literal
form throughout.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:47:15 -04:00

133 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// allow-test-rule: source-text-is-the-product
// commands/gsd/*.md files ARE the deployed registry — reading their frontmatter
// validates the structural contract of the command surface, not application source.
'use strict';
/**
* live-command-registry.cjs
*
* Derives the canonical set of live slash-command tokens from the source-of-truth
* registry: commands/gsd/*.md (one file per registered command).
*
* Each command file has YAML frontmatter with a `name:` field:
* name: gsd:slug (colon-style — most commands)
* name: gsd-slug (dash-style — ns-* namespace commands)
*
* For each slug, three canonical token forms are emitted:
* /gsd-slug — Claude / non-Gemini runtimes
* /gsd:slug — Gemini runtime
* $gsd-slug — Codex runtime
*
* The result is memoized per process — a single fs walk is amortized across
* all test files that import this helper. The cache is intentionally not
* exposed for invalidation: test processes are short-lived and the registry
* does not change mid-run.
*
* Per CONTEXT.md k003: all readFileSync calls happen inside getLiveCommandTokens()
* (i.e., inside a function call, not at module top-level) so that import-time
* ENOENT errors are caught and reported with context rather than aborting the
* test runner before any test registers.
*/
const fs = require('node:fs');
const path = require('node:path');
const COMMANDS_DIR = path.join(__dirname, '..', '..', 'commands', 'gsd');
// Module-level memoization — set on first call, reused thereafter.
let _cache = null;
/**
* Parse the YAML frontmatter `name:` field from a command file's content.
* Returns the slug (e.g. "help", "plan-phase", "context") or null if the
* field is absent or the file has no frontmatter.
*
* The frontmatter is bounded by the first `---` line and the next `---` line.
* We parse only the `name:` field — the full YAML spec is not needed and
* introducing a YAML parser dependency would be disproportionate.
*
* Supported name forms:
* name: gsd:slug → slug = "slug"
* name: gsd-slug → slug = "slug"
* name: "gsd:slug" → slug = "slug" (quoted)
* name: "gsd-slug" → slug = "slug" (quoted)
*/
function parseSlug(content, filePath) {
// Frontmatter must start with '---' on the very first line.
if (!content.startsWith('---')) {
throw new Error(
`[live-command-registry] ${filePath}: missing YAML frontmatter — file must start with '---'`
);
}
// Find the closing '---' delimiter.
const closingIdx = content.indexOf('\n---', 3);
if (closingIdx < 0) {
throw new Error(
`[live-command-registry] ${filePath}: unclosed YAML frontmatter — no closing '---' found`
);
}
const frontmatter = content.slice(0, closingIdx);
// Match `name:` line, allowing optional quotes around the value.
// The value must be one of: gsd:<slug> or gsd-<slug>
// where slug = [a-z0-9][a-z0-9-]*
const nameMatch = frontmatter.match(/^name:\s*"?(gsd[:‑-])([a-z0-9][a-z0-9-]*)"?\s*$/m);
if (!nameMatch) {
throw new Error(
`[live-command-registry] ${filePath}: could not extract slug from frontmatter ` +
`(expected "name: gsd:<slug>" or "name: gsd-<slug>")`
);
}
return nameMatch[2]; // the slug after "gsd:" or "gsd-"
}
/**
* Returns the Set<string> of all canonical slash-command tokens derived from
* commands/gsd/*.md. Memoized — safe to call repeatedly without extra fs I/O.
*
* Throws on the first malformed file (fail-loud per CONTEXT.md k302) so
* registry drift is caught immediately rather than silently producing an
* incomplete allow-list.
*/
function getLiveCommandTokens() {
if (_cache !== null) return _cache;
if (!fs.existsSync(COMMANDS_DIR)) {
throw new Error(
`[live-command-registry] commands directory not found: ${COMMANDS_DIR}`
);
}
const entries = fs.readdirSync(COMMANDS_DIR)
.filter(f => f.endsWith('.md'))
.sort(); // deterministic order for reproducible error messages
const tokens = new Set();
for (const fileName of entries) {
const filePath = path.join(COMMANDS_DIR, fileName);
let content;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch (err) {
throw new Error(
`[live-command-registry] failed to read ${filePath}: ${err.message}`
);
}
const slug = parseSlug(content, filePath);
// Emit all three canonical token forms per slug.
tokens.add(`/gsd-${slug}`); // Claude / non-Gemini
tokens.add(`/gsd:${slug}`); // Gemini
tokens.add(`$gsd-${slug}`); // Codex
}
_cache = tokens;
return _cache;
}
module.exports = { getLiveCommandTokens };