Emit the 6 gsd-ns-* routers as the only top-level skill bundles and nest the ~61 concrete skills under <router>/skills/<name>/SKILL.md on runtimes with confirmed non-recursive skill loaders (claude global, cline, qwen, hermes, augment, trae, antigravity). Router bodies rewrite their routing tables from Skill-tool dispatch to a Read skills/<name>/SKILL.md pattern. Recursive/unconfirmed loaders (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) keep the flat layout. Completes the v1.40 namespace architecture (#2792) so the eager skill listing drops to ~6 entries. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -174,56 +174,65 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => {
|
||||
|
||||
// Use the real COMMANDS_DIR as the source via .gsd-source marker.
|
||||
// installRuntimeArtifacts('claude', configDir, 'global') writes to
|
||||
// configDir/skills/gsd-*/SKILL.md using the same converter as the shim did.
|
||||
// configDir/skills/ using the same converter as the shim did.
|
||||
// With the full profile, skills are nested: gsd-ns-<router>/skills/<stem>/SKILL.md
|
||||
const configDir = path.join(tmp, 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), COMMANDS_DIR + '\n');
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
|
||||
// Don't filter the directory listing by `startsWith('gsd-')` — that
|
||||
// would silently hide exactly the kind of drift this test exists to
|
||||
// catch (a `gsd:extract-learnings` colon variant or a bare
|
||||
// `extract-learnings` without the namespace prefix would never be
|
||||
// collected, and the loop below would never see them). Capture every
|
||||
// generated directory and assert the namespace invariants explicitly.
|
||||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
assert.ok(skillDirs.length > 0, 'expected generated skill directories under skillsDir');
|
||||
for (const dir of skillDirs) {
|
||||
assert.ok(
|
||||
dir.startsWith('gsd-'),
|
||||
`${dir}: generated skill directory must start with the canonical 'gsd-' namespace`,
|
||||
);
|
||||
assert.ok(
|
||||
!dir.includes(':'),
|
||||
`${dir}: generated skill directory must not contain the retired colon namespace separator`,
|
||||
);
|
||||
assert.ok(
|
||||
!dir.includes('_'),
|
||||
`${dir}: generated skill directory must use hyphens, not underscores`,
|
||||
);
|
||||
// Recursively collect all SKILL.md files under skills/ (handles both flat and
|
||||
// nested layouts). Don't filter any paths — that would silently hide exactly
|
||||
// the kind of drift this test exists to catch (a `gsd:extract-learnings`
|
||||
// colon variant or a bare `extract-learnings` without the namespace prefix
|
||||
// would never be collected, and the loop below would never see them).
|
||||
function collectSkillMds(dir) {
|
||||
const results = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectSkillMds(full));
|
||||
} else if (entry.name === 'SKILL.md') {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
assert.ok(skillDirs.includes('gsd-extract-learnings'), 'autocomplete surface must include gsd-extract-learnings');
|
||||
assert.ok(!skillDirs.includes('gsd-extract_learnings'), 'autocomplete surface must not include gsd-extract_learnings');
|
||||
const allSkillMdPaths = collectSkillMds(skillsDir);
|
||||
assert.ok(allSkillMdPaths.length > 0, 'expected generated SKILL.md files under skillsDir');
|
||||
|
||||
for (const skillDir of skillDirs) {
|
||||
const skillContent = fs.readFileSync(path.join(skillsDir, skillDir, 'SKILL.md'), 'utf-8');
|
||||
// Validate every SKILL.md's name: field (the consumer-facing name used in
|
||||
// autocomplete). We also check that the containing dir name doesn't use
|
||||
// banned characters at any level of nesting.
|
||||
const allNames = [];
|
||||
for (const skillMdPath of allSkillMdPaths) {
|
||||
const relPath = path.relative(skillsDir, skillMdPath);
|
||||
const skillContent = fs.readFileSync(skillMdPath, 'utf-8');
|
||||
// Scope the name: lookup to the YAML frontmatter block so a stray
|
||||
// `name:` line in the body cannot satisfy the assertion.
|
||||
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
||||
assert.ok(fmMatch, `${skillDir}: generated SKILL.md must include frontmatter`);
|
||||
assert.ok(fmMatch, `${relPath}: generated SKILL.md must include frontmatter`);
|
||||
const nameLine = fmMatch[1].split('\n').find((l) => /^name:\s*/.test(l));
|
||||
assert.ok(nameLine, `${skillDir}: generated SKILL.md is missing name: frontmatter`);
|
||||
assert.ok(nameLine, `${relPath}: generated SKILL.md is missing name: frontmatter`);
|
||||
const name = nameLine.replace(/^name:\s*/, '').trim();
|
||||
assert.ok(name.startsWith('gsd-'), `${skillDir}: autocomplete name must start with gsd-, got ${name}`);
|
||||
assert.ok(!name.includes(':'), `${skillDir}: autocomplete name must not contain colon, got ${name}`);
|
||||
assert.ok(!name.includes('_'), `${skillDir}: autocomplete name must not contain underscore, got ${name}`);
|
||||
assert.ok(name.startsWith('gsd-'), `${relPath}: autocomplete name must start with gsd-, got ${name}`);
|
||||
assert.ok(!name.includes(':'), `${relPath}: autocomplete name must not contain colon, got ${name}`);
|
||||
assert.ok(!name.includes('_'), `${relPath}: autocomplete name must not contain underscore, got ${name}`);
|
||||
allNames.push(name);
|
||||
|
||||
// Also validate each path segment (dir name) in the relative path doesn't
|
||||
// contain the banned characters — catches mislabeled directory names.
|
||||
const segments = relPath.split(path.sep).slice(0, -1); // exclude 'SKILL.md' filename
|
||||
for (const seg of segments) {
|
||||
assert.ok(!seg.includes(':'), `${relPath}: dir segment "${seg}" must not contain colon`);
|
||||
assert.ok(!seg.includes('_'), `${relPath}: dir segment "${seg}" must use hyphens, not underscores`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(allNames.includes('gsd-extract-learnings'), 'autocomplete surface must include gsd-extract-learnings');
|
||||
assert.ok(!allNames.includes('gsd-extract_learnings'), 'autocomplete surface must not include gsd-extract_learnings');
|
||||
});
|
||||
|
||||
test('transformContentToHyphen (from fix-slash-commands.cjs) rewrites colon to hyphen for known commands', () => {
|
||||
|
||||
@@ -41,6 +41,53 @@ const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||
|
||||
/**
|
||||
* Map from concrete skill stem → ns-* router stem.
|
||||
* Used for nesting runtimes (claude, cline, qwen, etc.) when the full profile
|
||||
* is installed: concrete skills live at <skillsRoot>/gsd-<router>/skills/<stem>/SKILL.md.
|
||||
*/
|
||||
const CHILD_ROUTER = {
|
||||
// ns-workflow
|
||||
'discuss-phase': 'ns-workflow', 'spec-phase': 'ns-workflow', 'plan-phase': 'ns-workflow',
|
||||
'execute-phase': 'ns-workflow', 'verify-work': 'ns-workflow', 'phase': 'ns-workflow',
|
||||
'progress': 'ns-workflow', 'ultraplan-phase': 'ns-workflow',
|
||||
'plan-review-convergence': 'ns-workflow', 'add-tests': 'ns-workflow',
|
||||
'ai-integration-phase': 'ns-workflow', 'autonomous': 'ns-workflow',
|
||||
'fast': 'ns-workflow', 'mvp-phase': 'ns-workflow', 'quick': 'ns-workflow',
|
||||
// ns-project
|
||||
'new-project': 'ns-project', 'new-milestone': 'ns-project', 'complete-milestone': 'ns-project',
|
||||
'audit-milestone': 'ns-project', 'milestone-summary': 'ns-project', 'import': 'ns-project',
|
||||
'ingest-docs': 'ns-project', 'profile-user': 'ns-project', 'review-backlog': 'ns-project',
|
||||
// ns-review
|
||||
'code-review': 'ns-review', 'audit-uat': 'ns-review', 'secure-phase': 'ns-review',
|
||||
'eval-review': 'ns-review', 'ui-review': 'ns-review', 'validate-phase': 'ns-review',
|
||||
'debug': 'ns-review', 'forensics': 'ns-review', 'audit-fix': 'ns-review',
|
||||
'review': 'ns-review', 'ui-phase': 'ns-review',
|
||||
// ns-context
|
||||
'map-codebase': 'ns-context', 'graphify': 'ns-context', 'docs-update': 'ns-context',
|
||||
'extract-learnings': 'ns-context',
|
||||
// ns-ideate
|
||||
'capture': 'ns-ideate', 'explore': 'ns-ideate', 'sketch': 'ns-ideate',
|
||||
'spike': 'ns-ideate',
|
||||
// ns-manage
|
||||
'config': 'ns-manage', 'workspace': 'ns-manage', 'workstreams': 'ns-manage',
|
||||
'thread': 'ns-manage', 'pause-work': 'ns-manage', 'resume-work': 'ns-manage',
|
||||
'update': 'ns-manage', 'ship': 'ns-manage', 'inbox': 'ns-manage',
|
||||
'pr-branch': 'ns-manage', 'undo': 'ns-manage', 'cleanup': 'ns-manage',
|
||||
'health': 'ns-manage', 'manager': 'ns-manage', 'settings': 'ns-manage',
|
||||
'stats': 'ns-manage', 'surface': 'ns-manage', 'help': 'ns-manage',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the nested SKILL.md path for a concrete skill stem on cline
|
||||
* (prefix='gsd-'): <skillsRoot>/gsd-<router>/skills/<stem>/SKILL.md
|
||||
*/
|
||||
function nestedClineSkillPath(skillsRoot, stem) {
|
||||
const router = CHILD_ROUTER[stem];
|
||||
if (!router) throw new Error(`No router mapping for stem: ${stem}`);
|
||||
return path.join(skillsRoot, 'gsd-' + router, 'skills', stem, 'SKILL.md');
|
||||
}
|
||||
|
||||
// ─── (a) Converter unit test ─────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_COMMAND = `---
|
||||
@@ -347,11 +394,11 @@ describe('install() global cline — coexistence: skills AND .clinerules', () =>
|
||||
`skills/ directory must exist under ${tmpGlobalDir} after global cline install`
|
||||
);
|
||||
|
||||
// gsd-help is present in every profile (core, standard, full)
|
||||
const helpSkillFile = path.join(skillsDir, 'gsd-help', 'SKILL.md');
|
||||
// full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md
|
||||
const helpSkillFile = nestedClineSkillPath(skillsDir, 'help');
|
||||
assert.ok(
|
||||
fs.existsSync(helpSkillFile),
|
||||
`skills/gsd-help/SKILL.md must exist under ${tmpGlobalDir} — skills emission broken for global cline`
|
||||
`${path.relative(tmpGlobalDir, helpSkillFile)} must exist under ${tmpGlobalDir} — skills emission broken for global cline`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -438,8 +485,9 @@ describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL);
|
||||
|
||||
const surfaceSkill = path.join(configDir, 'skills', 'gsd-surface', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(surfaceSkill), 'gsd-surface/SKILL.md must exist for full profile');
|
||||
// full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md
|
||||
const surfaceSkill = nestedClineSkillPath(path.join(configDir, 'skills'), 'surface');
|
||||
assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist for full profile`);
|
||||
|
||||
const content = fs.readFileSync(surfaceSkill, 'utf8');
|
||||
assert.ok(
|
||||
@@ -497,8 +545,9 @@ describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', ()
|
||||
|
||||
// gsd-surface SKILL.md references config paths; with a custom configDir
|
||||
// (not under $HOME), pathPrefix will be the absolute custom path.
|
||||
const surfaceSkill = path.join(configDir, 'skills', 'gsd-surface', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(surfaceSkill), 'gsd-surface/SKILL.md must exist');
|
||||
// full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md
|
||||
const surfaceSkill = nestedClineSkillPath(path.join(configDir, 'skills'), 'surface');
|
||||
assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist`);
|
||||
|
||||
const content = fs.readFileSync(surfaceSkill, 'utf8');
|
||||
// With a custom dir (path under /tmp, not ~/.cline), the output must NOT
|
||||
|
||||
@@ -208,6 +208,95 @@ describe('gsd-health --context flag is wired into command + workflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Namespace nesting completeness (#69) ──────────────────────────────
|
||||
// Guards that the install-layout nesting invariant (<=6 top-level entries)
|
||||
// is always satisfiable: every router's requires list points at real files,
|
||||
// every concrete skill is covered by at least one router, and each router's
|
||||
// body table stays in sync with its requires list.
|
||||
|
||||
const NS_FILES = NAMESPACE_SKILLS.map((ns) => ns.file);
|
||||
|
||||
/**
|
||||
* Parse the `requires:` flow-style array from a router file's raw content.
|
||||
* Matches `requires: [a, b, c]` anywhere (frontmatter or body — always in fm).
|
||||
*/
|
||||
function parseRouterRequires(content) {
|
||||
const m = content.match(/^requires:\s*\[([^\]]*)\]/m);
|
||||
if (!m) return [];
|
||||
return m[1].split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
describe('namespace nesting completeness (#69)', () => {
|
||||
// Build the concrete-skill set once (all *.md minus ns-*.md)
|
||||
const allFiles = fs.readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'));
|
||||
const concreteStemSet = new Set(
|
||||
allFiles
|
||||
.filter((f) => !f.startsWith('ns-'))
|
||||
.map((f) => f.replace(/\.md$/, '')),
|
||||
);
|
||||
|
||||
// Build per-router requires and the union over all routers
|
||||
const routerRequires = new Map(); // stem -> string[]
|
||||
for (const f of NS_FILES) {
|
||||
const stem = f.replace(/\.md$/, '');
|
||||
const content = fs.readFileSync(path.join(COMMANDS_DIR, f), 'utf-8');
|
||||
routerRequires.set(stem, parseRouterRequires(content));
|
||||
}
|
||||
const allRoutedStems = new Set([...routerRequires.values()].flat());
|
||||
|
||||
test('every router requires entry resolves to a real concrete skill file', () => {
|
||||
const bad = [];
|
||||
for (const [routerStem, children] of routerRequires) {
|
||||
for (const child of children) {
|
||||
if (!fs.existsSync(path.join(COMMANDS_DIR, `${child}.md`))) {
|
||||
bad.push(`${routerStem} → ${child}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
bad,
|
||||
[],
|
||||
`Router requires entries with no matching commands/gsd/<stem>.md: ${bad.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every concrete skill is routed by at least one namespace router', () => {
|
||||
const unrouted = [...concreteStemSet].filter((stem) => !allRoutedStems.has(stem));
|
||||
assert.deepStrictEqual(
|
||||
unrouted,
|
||||
[],
|
||||
`Concrete skills not routed by any ns-*.md (add to a router's requires:): ${unrouted.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("each router's routing-table rows reference only its own required sub-skills (plus flag variants)", () => {
|
||||
const bad = [];
|
||||
for (const [routerStem, children] of routerRequires) {
|
||||
const childSet = new Set(children);
|
||||
const content = fs.readFileSync(path.join(COMMANDS_DIR, `${routerStem}.md`), 'utf-8');
|
||||
const fm = parseFrontmatter(content);
|
||||
// Extract gsd-<stem> tokens from table data rows (last cell), strip flags
|
||||
for (const line of fm._body.split('\n')) {
|
||||
if (!line.startsWith('|') || /^\|[\s\-:|]+\|?\s*$/.test(line)) continue;
|
||||
const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
|
||||
if (cells.length < 2) continue;
|
||||
const lastCell = cells[cells.length - 1];
|
||||
for (const match of lastCell.matchAll(/\bgsd-([a-z][a-z0-9-]*)/g)) {
|
||||
const stem = match[1];
|
||||
if (!childSet.has(stem)) {
|
||||
bad.push(`${routerStem}: body table references gsd-${stem} but it's not in requires`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
bad,
|
||||
[],
|
||||
`Routing table / requires mismatch:\n${bad.join('\n')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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.
|
||||
|
||||
@@ -43,6 +43,52 @@ function makeTmpDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map from concrete skill stem → ns-* router stem for nesting runtimes.
|
||||
* Derived from the authoritative ns-*.md `requires:` lists.
|
||||
*/
|
||||
const CHILD_ROUTER = {
|
||||
// ns-workflow
|
||||
'discuss-phase': 'ns-workflow', 'spec-phase': 'ns-workflow', 'plan-phase': 'ns-workflow',
|
||||
'execute-phase': 'ns-workflow', 'verify-work': 'ns-workflow', 'phase': 'ns-workflow',
|
||||
'progress': 'ns-workflow', 'ultraplan-phase': 'ns-workflow',
|
||||
'plan-review-convergence': 'ns-workflow', 'add-tests': 'ns-workflow',
|
||||
'ai-integration-phase': 'ns-workflow', 'autonomous': 'ns-workflow',
|
||||
'fast': 'ns-workflow', 'mvp-phase': 'ns-workflow', 'quick': 'ns-workflow',
|
||||
// ns-project
|
||||
'new-project': 'ns-project', 'new-milestone': 'ns-project', 'complete-milestone': 'ns-project',
|
||||
'audit-milestone': 'ns-project', 'milestone-summary': 'ns-project', 'import': 'ns-project',
|
||||
'ingest-docs': 'ns-project', 'profile-user': 'ns-project', 'review-backlog': 'ns-project',
|
||||
// ns-review
|
||||
'code-review': 'ns-review', 'audit-uat': 'ns-review', 'secure-phase': 'ns-review',
|
||||
'eval-review': 'ns-review', 'ui-review': 'ns-review', 'validate-phase': 'ns-review',
|
||||
'debug': 'ns-review', 'forensics': 'ns-review', 'audit-fix': 'ns-review',
|
||||
'review': 'ns-review', 'ui-phase': 'ns-review',
|
||||
// ns-context
|
||||
'map-codebase': 'ns-context', 'graphify': 'ns-context', 'docs-update': 'ns-context',
|
||||
'extract-learnings': 'ns-context',
|
||||
// ns-ideate
|
||||
'capture': 'ns-ideate', 'explore': 'ns-ideate', 'sketch': 'ns-ideate',
|
||||
'spike': 'ns-ideate',
|
||||
// ns-manage
|
||||
'config': 'ns-manage', 'workspace': 'ns-manage', 'workstreams': 'ns-manage',
|
||||
'thread': 'ns-manage', 'pause-work': 'ns-manage', 'resume-work': 'ns-manage',
|
||||
'update': 'ns-manage', 'ship': 'ns-manage', 'inbox': 'ns-manage',
|
||||
'pr-branch': 'ns-manage', 'undo': 'ns-manage', 'cleanup': 'ns-manage',
|
||||
'health': 'ns-manage', 'manager': 'ns-manage', 'settings': 'ns-manage',
|
||||
'stats': 'ns-manage', 'surface': 'ns-manage', 'help': 'ns-manage',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the nested SKILL.md path for a concrete skill stem on Claude
|
||||
* (prefix='gsd-'): <skillsRoot>/gsd-<router>/skills/<stem>/SKILL.md
|
||||
*/
|
||||
function nestedClaudeSkillPath(skillsRoot, stem) {
|
||||
const router = CHILD_ROUTER[stem];
|
||||
if (!router) throw new Error(`No router mapping for stem: ${stem}`);
|
||||
return path.join(skillsRoot, 'gsd-' + router, 'skills', stem, 'SKILL.md');
|
||||
}
|
||||
|
||||
function readFrontmatter(mdPath) {
|
||||
const content = fs.readFileSync(mdPath, 'utf8');
|
||||
if (!content.startsWith('---')) return '';
|
||||
@@ -253,7 +299,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
|
||||
|
||||
test('gsd-autonomous SKILL.md has context: fork after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md');
|
||||
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'autonomous');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`gsd-autonomous SKILL.md must have context: fork\nActual:\n${fm}`);
|
||||
@@ -261,7 +307,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
|
||||
|
||||
test('gsd-autonomous SKILL.md has effort: xhigh after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md');
|
||||
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'autonomous');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-autonomous SKILL.md must have effort: xhigh\nActual:\n${fm}`);
|
||||
@@ -269,7 +315,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
|
||||
|
||||
test('gsd-execute-phase SKILL.md has context: fork after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md');
|
||||
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'execute-phase');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`gsd-execute-phase SKILL.md must have context: fork\nActual:\n${fm}`);
|
||||
@@ -277,7 +323,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
|
||||
|
||||
test('gsd-execute-phase SKILL.md has effort: xhigh after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md');
|
||||
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'execute-phase');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-execute-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`);
|
||||
@@ -285,7 +331,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
|
||||
|
||||
test('gsd-plan-phase SKILL.md has context: fork after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md');
|
||||
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'plan-phase');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`gsd-plan-phase SKILL.md must have context: fork\nActual:\n${fm}`);
|
||||
@@ -293,7 +339,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
|
||||
|
||||
test('gsd-plan-phase SKILL.md has effort: xhigh after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md');
|
||||
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'plan-phase');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-plan-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`);
|
||||
@@ -301,7 +347,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
|
||||
|
||||
test('gsd-progress SKILL.md has effort: low after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-progress', 'SKILL.md');
|
||||
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'progress');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`);
|
||||
@@ -309,7 +355,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
|
||||
|
||||
test('gsd-stats SKILL.md has effort: low after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-stats', 'SKILL.md');
|
||||
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'stats');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`);
|
||||
|
||||
@@ -421,9 +421,10 @@ describe('install: manifest records mode for both profiles', () => {
|
||||
const manifestPath = path.join(targetDir, MANIFEST_NAME);
|
||||
if (!fs.existsSync(manifestPath)) return { mode: '<no manifest>', skillCount: 0, agentCount: 0 };
|
||||
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const skillCount = new Set(
|
||||
Object.keys(m.files || {}).filter(k => k.startsWith('skills/')).map(k => k.split('/')[1]),
|
||||
).size;
|
||||
// Count SKILL.md files under skills/ (works for both flat and ns-nested layouts).
|
||||
const skillCount = Object.keys(m.files || {}).filter(
|
||||
k => k.startsWith('skills/') && k.endsWith('/SKILL.md'),
|
||||
).length;
|
||||
const agentCount = Object.keys(m.files || {}).filter(k => k.startsWith('agents/')).length;
|
||||
return { mode: m.mode, skillCount, agentCount };
|
||||
} finally {
|
||||
@@ -474,9 +475,10 @@ describe('install-minimal-backcompat: --minimal and --profile=core produce same
|
||||
const manifestPath = path.join(targetDir, MANIFEST_NAME);
|
||||
if (!fs.existsSync(manifestPath)) return { mode: null, skillCount: 0, profileMarker: null };
|
||||
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const skillCount = new Set(
|
||||
Object.keys(m.files || {}).filter(k => k.startsWith('skills/')).map(k => k.split('/')[1]),
|
||||
).size;
|
||||
// Count SKILL.md files under skills/ (works for both flat and ns-nested layouts).
|
||||
const skillCount = Object.keys(m.files || {}).filter(
|
||||
k => k.startsWith('skills/') && k.endsWith('/SKILL.md'),
|
||||
).length;
|
||||
const markerPath = path.join(targetDir, '.gsd-profile');
|
||||
const profileMarker = fs.existsSync(markerPath) ? fs.readFileSync(markerPath, 'utf8').trim() : null;
|
||||
return { mode: m.mode, skillCount, profileMarker };
|
||||
|
||||
296
tests/install-nested-layout.test.cjs
Normal file
296
tests/install-nested-layout.test.cjs
Normal file
@@ -0,0 +1,296 @@
|
||||
// #69: namespace nested-skill install layout — multi-runtime parity
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads installed .md files (product artefacts) from a real install run —
|
||||
// testing their on-disk layout tests the deployed contract.
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
const {
|
||||
installRuntimeArtifacts,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime parity decision matrix (#69)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NEST = [
|
||||
{ runtime: 'claude', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
|
||||
{ runtime: 'cline', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
|
||||
{ runtime: 'qwen', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
|
||||
{ runtime: 'hermes', scope: 'global', skillsSub: 'skills/gsd', prefix: '' },
|
||||
{ runtime: 'augment', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
|
||||
{ runtime: 'trae', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
|
||||
{ runtime: 'antigravity', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
|
||||
];
|
||||
|
||||
const FLAT = [
|
||||
{ runtime: 'cursor', scope: 'global', skillsSub: 'skills' },
|
||||
{ runtime: 'codex', scope: 'global', skillsSub: 'skills' },
|
||||
{ runtime: 'copilot', scope: 'global', skillsSub: 'skills' },
|
||||
{ runtime: 'windsurf', scope: 'global', skillsSub: 'skills' },
|
||||
{ runtime: 'codebuddy', scope: 'global', skillsSub: 'skills' },
|
||||
{ runtime: 'opencode', scope: 'global', skillsSub: 'skills' },
|
||||
{ runtime: 'kilo', scope: 'global', skillsSub: 'skills' },
|
||||
];
|
||||
|
||||
const ROUTER_STEMS = ['ns-context', 'ns-ideate', 'ns-manage', 'ns-project', 'ns-review', 'ns-workflow'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse the `requires:` flow-style array from a router file's raw content.
|
||||
* Matches `requires: [a, b, c]` (inline array form).
|
||||
*/
|
||||
function parseRouterRequires(content) {
|
||||
const m = content.match(/^requires:\s*\[([^\]]*)\]/m);
|
||||
if (!m) return [];
|
||||
return m[1].split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the requires list for a router stem from the source commands/gsd dir.
|
||||
*/
|
||||
function routerChildren(routerStem) {
|
||||
const srcFile = path.join(COMMANDS_GSD, `${routerStem}.md`);
|
||||
const content = fs.readFileSync(srcFile, 'utf-8');
|
||||
return parseRouterRequires(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh temp dir, run installRuntimeArtifacts into it, and return
|
||||
* the tmpDir path. Caller must cleanup in finally.
|
||||
*/
|
||||
function runInstall(runtime, scope, resolved) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-nest-test-${runtime}-`));
|
||||
installRuntimeArtifacts(runtime, tmpDir, scope, resolved);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
// Resolve the full profile once (shared by all installs)
|
||||
const MANIFEST = loadSkillsManifest(COMMANDS_GSD);
|
||||
const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NEST runtimes: should produce exactly 6 top-level router bundles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const { runtime, scope, skillsSub, prefix } of NEST) {
|
||||
describe(`${runtime} (nested layout)`, () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = runInstall(runtime, scope, RESOLVED_FULL);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (tmpDir) {
|
||||
try { cleanup(tmpDir); } catch { /* best-effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
test(`${runtime}: exactly 6 top-level router bundles, no concrete skill at top level`, () => {
|
||||
const skillsDir = path.join(tmpDir, skillsSub);
|
||||
assert.ok(fs.existsSync(skillsDir), `skillsDir must exist: ${skillsDir}`);
|
||||
|
||||
// Find router bundle dirs (top-level, named <prefix>ns-*)
|
||||
const topLevel = fs.readdirSync(skillsDir);
|
||||
const routerDirs = topLevel.filter((n) => n.startsWith(`${prefix}ns-`));
|
||||
assert.strictEqual(
|
||||
routerDirs.length,
|
||||
6,
|
||||
`Expected exactly 6 router dirs under ${skillsDir}, got ${routerDirs.length}: [${routerDirs.join(', ')}]`,
|
||||
);
|
||||
|
||||
// Each router dir must be a real directory with a SKILL.md
|
||||
for (const rd of routerDirs) {
|
||||
const routerPath = path.join(skillsDir, rd);
|
||||
assert.ok(
|
||||
fs.statSync(routerPath).isDirectory(),
|
||||
`${rd} must be a directory`,
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(routerPath, 'SKILL.md')),
|
||||
`${rd}/SKILL.md must exist`,
|
||||
);
|
||||
}
|
||||
|
||||
// Sample concrete skills must NOT appear at top level
|
||||
for (const concreteSample of ['plan-phase', 'code-review']) {
|
||||
const concreteName = `${prefix}${concreteSample}`;
|
||||
assert.ok(
|
||||
!topLevel.includes(concreteName),
|
||||
`Concrete skill ${concreteName} must NOT be at top level for ${runtime}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Total GSD-owned top-level entries must be EXACTLY 6 (only the routers).
|
||||
// For prefix='gsd-' runtimes: count dirs starting with 'gsd-'.
|
||||
// For hermes (prefix=''): count ALL dirs under skills/gsd (everything is GSD-owned).
|
||||
const gsdTopLevelCount = prefix !== ''
|
||||
? topLevel.filter((n) => n.startsWith(prefix)).length
|
||||
: topLevel.filter((n) => fs.statSync(path.join(skillsDir, n)).isDirectory()).length;
|
||||
assert.strictEqual(
|
||||
gsdTopLevelCount,
|
||||
6,
|
||||
`Expected exactly 6 total GSD-owned top-level skill dirs for ${runtime} (only routers), got ${gsdTopLevelCount}: [${topLevel.join(', ')}]`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${runtime}: every router has a skills/ subdir with its required children as nested SKILL.md`, () => {
|
||||
const skillsDir = path.join(tmpDir, skillsSub);
|
||||
|
||||
for (const routerStem of ROUTER_STEMS) {
|
||||
const routerDirName = `${prefix}${routerStem}`;
|
||||
const routerDir = path.join(skillsDir, routerDirName);
|
||||
assert.ok(
|
||||
fs.existsSync(routerDir),
|
||||
`Router dir must exist: ${routerDir}`,
|
||||
);
|
||||
|
||||
const childrenSubdir = path.join(routerDir, 'skills');
|
||||
assert.ok(
|
||||
fs.statSync(childrenSubdir).isDirectory(),
|
||||
`${routerDirName}/skills must be a directory`,
|
||||
);
|
||||
|
||||
const children = routerChildren(routerStem);
|
||||
assert.ok(children.length > 0, `Router ${routerStem} must have at least one child`);
|
||||
|
||||
for (const child of children) {
|
||||
const childSkillMd = path.join(childrenSubdir, child, 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(childSkillMd),
|
||||
`${routerDirName}/skills/${child}/SKILL.md must exist (child of ${routerStem})`,
|
||||
);
|
||||
assert.ok(
|
||||
fs.statSync(childSkillMd).isFile(),
|
||||
`${routerDirName}/skills/${child}/SKILL.md must be a file`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`${runtime}: every router body Read-reference resolves to a nested file`, () => {
|
||||
const skillsDir = path.join(tmpDir, skillsSub);
|
||||
|
||||
for (const routerStem of ROUTER_STEMS) {
|
||||
const routerDirName = `${prefix}${routerStem}`;
|
||||
const routerDir = path.join(skillsDir, routerDirName);
|
||||
const routerSkillMd = path.join(routerDir, 'SKILL.md');
|
||||
assert.ok(fs.existsSync(routerSkillMd), `${routerDirName}/SKILL.md must exist`);
|
||||
|
||||
const body = fs.readFileSync(routerSkillMd, 'utf-8');
|
||||
// Extract skills/<stem>/SKILL.md paths from Read-reference lines in the table
|
||||
const refs = [...body.matchAll(/skills\/([a-z0-9-]+)\/SKILL\.md/g)].map((m) => m[1]);
|
||||
|
||||
// There should be at least one reference in every nested router body
|
||||
assert.ok(
|
||||
refs.length > 0,
|
||||
`${routerDirName}/SKILL.md must contain at least one skills/<stem>/SKILL.md reference`,
|
||||
);
|
||||
|
||||
for (const stem of refs) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(routerDir, 'skills', stem, 'SKILL.md')),
|
||||
`${routerDirName}/SKILL.md references skills/${stem}/SKILL.md but it does not exist on disk`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// claude extra: total top-level gsd- count must equal exactly 6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('claude: total top-level gsd- entries == 6', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = runInstall('claude', 'global', RESOLVED_FULL);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (tmpDir) {
|
||||
try { cleanup(tmpDir); } catch { /* best-effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
test('claude: total top-level gsd- skill entries == 6', () => {
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist');
|
||||
|
||||
const topLevel = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.strictEqual(
|
||||
topLevel.length,
|
||||
6,
|
||||
`Expected exactly 6 gsd-* top-level entries under claude/skills, got ${topLevel.length}: [${topLevel.join(', ')}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FLAT runtimes: concrete skills stay top-level, no nesting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const { runtime, scope, skillsSub } of FLAT) {
|
||||
describe(`${runtime} (flat layout)`, () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = runInstall(runtime, scope, RESOLVED_FULL);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (tmpDir) {
|
||||
try { cleanup(tmpDir); } catch { /* best-effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
test(`${runtime}: stays flat — concrete skills remain top-level, no nesting`, () => {
|
||||
const skillsDir = path.join(tmpDir, skillsSub);
|
||||
assert.ok(fs.existsSync(skillsDir), `skillsDir must exist: ${skillsDir}`);
|
||||
|
||||
const topLevel = fs.readdirSync(skillsDir);
|
||||
const gsdEntries = topLevel.filter((n) => n.startsWith('gsd-'));
|
||||
|
||||
// For flat runtimes, there should be many more than 6 top-level gsd- entries
|
||||
assert.ok(
|
||||
gsdEntries.length >= 60,
|
||||
`Flat runtime ${runtime} must have >= 60 gsd-* top-level entries (concrete skills), got ${gsdEntries.length}`,
|
||||
);
|
||||
|
||||
// No router dir should contain a skills/ subdirectory (nesting must not have been applied)
|
||||
const routerDirsPresent = topLevel.filter((n) => n.startsWith('gsd-ns-'));
|
||||
for (const rd of routerDirsPresent) {
|
||||
const nestedSkillsDir = path.join(skillsDir, rd, 'skills');
|
||||
assert.ok(
|
||||
!fs.existsSync(nestedSkillsDir),
|
||||
`Flat runtime ${runtime}: router dir ${rd} must NOT have a skills/ subdirectory (nesting must not apply)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -53,6 +53,43 @@ const {
|
||||
walk,
|
||||
} = require('./helpers/install-shared.cjs');
|
||||
|
||||
/**
|
||||
* Map from concrete skill stem → ns-* router stem for nesting runtimes.
|
||||
* These runtimes nest concrete skills at <skillsRoot>/<prefix><router>/skills/<stem>/SKILL.md
|
||||
* (claude/cline/qwen/trae/augment/antigravity: prefix='gsd-'; hermes: prefix='').
|
||||
*/
|
||||
const CHILD_ROUTER = {
|
||||
// ns-workflow
|
||||
'discuss-phase': 'ns-workflow', 'spec-phase': 'ns-workflow', 'plan-phase': 'ns-workflow',
|
||||
'execute-phase': 'ns-workflow', 'verify-work': 'ns-workflow', 'phase': 'ns-workflow',
|
||||
'progress': 'ns-workflow', 'ultraplan-phase': 'ns-workflow',
|
||||
'plan-review-convergence': 'ns-workflow', 'add-tests': 'ns-workflow',
|
||||
'ai-integration-phase': 'ns-workflow', 'autonomous': 'ns-workflow',
|
||||
'fast': 'ns-workflow', 'mvp-phase': 'ns-workflow', 'quick': 'ns-workflow',
|
||||
// ns-project
|
||||
'new-project': 'ns-project', 'new-milestone': 'ns-project', 'complete-milestone': 'ns-project',
|
||||
'audit-milestone': 'ns-project', 'milestone-summary': 'ns-project', 'import': 'ns-project',
|
||||
'ingest-docs': 'ns-project', 'profile-user': 'ns-project', 'review-backlog': 'ns-project',
|
||||
// ns-review
|
||||
'code-review': 'ns-review', 'audit-uat': 'ns-review', 'secure-phase': 'ns-review',
|
||||
'eval-review': 'ns-review', 'ui-review': 'ns-review', 'validate-phase': 'ns-review',
|
||||
'debug': 'ns-review', 'forensics': 'ns-review', 'audit-fix': 'ns-review',
|
||||
'review': 'ns-review', 'ui-phase': 'ns-review',
|
||||
// ns-context
|
||||
'map-codebase': 'ns-context', 'graphify': 'ns-context', 'docs-update': 'ns-context',
|
||||
'extract-learnings': 'ns-context',
|
||||
// ns-ideate
|
||||
'capture': 'ns-ideate', 'explore': 'ns-ideate', 'sketch': 'ns-ideate',
|
||||
'spike': 'ns-ideate',
|
||||
// ns-manage
|
||||
'config': 'ns-manage', 'workspace': 'ns-manage', 'workstreams': 'ns-manage',
|
||||
'thread': 'ns-manage', 'pause-work': 'ns-manage', 'resume-work': 'ns-manage',
|
||||
'update': 'ns-manage', 'ship': 'ns-manage', 'inbox': 'ns-manage',
|
||||
'pr-branch': 'ns-manage', 'undo': 'ns-manage', 'cleanup': 'ns-manage',
|
||||
'health': 'ns-manage', 'manager': 'ns-manage', 'settings': 'ns-manage',
|
||||
'stats': 'ns-manage', 'surface': 'ns-manage', 'help': 'ns-manage',
|
||||
};
|
||||
|
||||
// ─── Section 1: getDirName / getGlobalConfigDir / getConfigDirFromHome ──────────
|
||||
|
||||
describe('getDirName — all runtimes', () => {
|
||||
@@ -286,7 +323,7 @@ describe('getConfigDirFromHome — spot-checks', () => {
|
||||
// Full E2E for runtimes that have distinct install paths (hermes nested layout,
|
||||
// qwen flat layout, trae flat layout). Others are covered by layout-loop tests.
|
||||
|
||||
describe('install/uninstall — hermes (nested skills/gsd/ layout)', () => {
|
||||
describe('install/uninstall — hermes (nested skills/gsd/<router>/skills/<stem>/ layout)', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
@@ -308,19 +345,28 @@ describe('install/uninstall — hermes (nested skills/gsd/ layout)', () => {
|
||||
assert.strictEqual(result.runtime, 'hermes');
|
||||
assert.strictEqual(result.configDir, fs.realpathSync(targetDir));
|
||||
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help', 'SKILL.md')));
|
||||
// hermes nests: skills/gsd/<router>/skills/<stem>/SKILL.md
|
||||
const hermesHelpPath = path.join(
|
||||
targetDir, 'skills', 'gsd', CHILD_ROUTER['help'], 'skills', 'help', 'SKILL.md'
|
||||
);
|
||||
assert.ok(fs.existsSync(hermesHelpPath),
|
||||
`help SKILL.md must exist at nested path: ${path.relative(targetDir, hermesHelpPath)}`);
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
|
||||
'DESCRIPTION.md at category root');
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
|
||||
|
||||
const manifest = writeManifest(targetDir, 'hermes');
|
||||
assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd/help/')),
|
||||
JSON.stringify(manifest.files));
|
||||
assert.ok(
|
||||
Object.keys(manifest.files).some(f =>
|
||||
f.startsWith('skills/gsd/' + CHILD_ROUTER['help'] + '/skills/help/')
|
||||
),
|
||||
JSON.stringify(manifest.files)
|
||||
);
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help')));
|
||||
assert.ok(!fs.existsSync(hermesHelpPath));
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd')));
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core')));
|
||||
});
|
||||
@@ -377,7 +423,7 @@ describe('install/uninstall — hermes (nested skills/gsd/ layout)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('install/uninstall — qwen (flat skills/gsd-* layout)', () => {
|
||||
describe('install/uninstall — qwen (nested skills/gsd-<router>/skills/<stem>/ layout)', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
@@ -399,20 +445,29 @@ describe('install/uninstall — qwen (flat skills/gsd-* layout)', () => {
|
||||
assert.strictEqual(result.runtime, 'qwen');
|
||||
assert.strictEqual(result.configDir, fs.realpathSync(targetDir));
|
||||
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
|
||||
// qwen nests: skills/gsd-<router>/skills/<stem>/SKILL.md
|
||||
const qwenHelpPath = path.join(
|
||||
targetDir, 'skills', 'gsd-' + CHILD_ROUTER['help'], 'skills', 'help', 'SKILL.md'
|
||||
);
|
||||
assert.ok(fs.existsSync(qwenHelpPath),
|
||||
`help SKILL.md must exist at nested path: ${path.relative(targetDir, qwenHelpPath)}`);
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
|
||||
|
||||
const manifest = writeManifest(targetDir, 'qwen');
|
||||
assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd-help/')));
|
||||
assert.ok(
|
||||
Object.keys(manifest.files).some(f =>
|
||||
f.startsWith('skills/gsd-' + CHILD_ROUTER['help'] + '/skills/help/')
|
||||
)
|
||||
);
|
||||
|
||||
uninstall(false, 'qwen');
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')));
|
||||
assert.ok(!fs.existsSync(qwenHelpPath));
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('install/uninstall — trae (flat skills/gsd-* layout)', () => {
|
||||
describe('install/uninstall — trae (nested skills/gsd-<router>/skills/<stem>/ layout)', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
@@ -440,15 +495,24 @@ describe('install/uninstall — trae (flat skills/gsd-* layout)', () => {
|
||||
configDir: fs.realpathSync(targetDir),
|
||||
});
|
||||
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
|
||||
// trae nests: skills/gsd-<router>/skills/<stem>/SKILL.md
|
||||
const traeHelpPath = path.join(
|
||||
targetDir, 'skills', 'gsd-' + CHILD_ROUTER['help'], 'skills', 'help', 'SKILL.md'
|
||||
);
|
||||
assert.ok(fs.existsSync(traeHelpPath),
|
||||
`help SKILL.md must exist at nested path: ${path.relative(targetDir, traeHelpPath)}`);
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
|
||||
|
||||
const manifest = writeManifest(targetDir, 'trae');
|
||||
assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd-help/')));
|
||||
assert.ok(
|
||||
Object.keys(manifest.files).some(f =>
|
||||
f.startsWith('skills/gsd-' + CHILD_ROUTER['help'] + '/skills/help/')
|
||||
)
|
||||
);
|
||||
|
||||
uninstall(false, 'trae');
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')));
|
||||
assert.ok(!fs.existsSync(traeHelpPath));
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core')));
|
||||
});
|
||||
});
|
||||
|
||||
80
tests/issue-69-surface-keeps-nested.test.cjs
Normal file
80
tests/issue-69-surface-keeps-nested.test.cjs
Normal file
@@ -0,0 +1,80 @@
|
||||
// #69 regression: applySurface must NOT re-flatten the nested skill layout
|
||||
//
|
||||
// Bug: stageSkillsForRuntimeAsSkills gated nesting on `resolvedProfile.skills === '*'`
|
||||
// (the sentinel). applySurface → resolveSurface materializes the full profile into a
|
||||
// concrete Set<string>, so the sentinel check was never true on the surface path.
|
||||
// Result: applySurface called kind.stage(resolved) → stageSkillsForRuntimeAsSkills with
|
||||
// a concrete Set → doNest = false → flat layout, overwriting the nested install.
|
||||
//
|
||||
// Fix (install-profiles.cts): gate nesting on full OR full-equivalent (all routerStems
|
||||
// present in the concrete Set) so that the surface path preserves nesting.
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
const { installRuntimeArtifacts } = require('../bin/install.js');
|
||||
const { applySurface } = require('../gsd-core/bin/lib/surface.cjs');
|
||||
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('issue-69: applySurface preserves nested skill layout (no re-flatten)', () => {
|
||||
test('claude global full: applySurface keeps 6 router dirs and nested gsd-ns-workflow/skills/plan-phase/SKILL.md', (t) => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-69-surface-'));
|
||||
t.after(() => { try { cleanup(tmpDir); } catch { /* best-effort */ } });
|
||||
|
||||
// Step 1: full install
|
||||
const manifest = loadSkillsManifest(COMMANDS_GSD);
|
||||
const resolved = resolveProfile({ modes: ['full'], manifest });
|
||||
installRuntimeArtifacts('claude', tmpDir, 'global', resolved);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
|
||||
// Sanity: install must produce nested layout
|
||||
const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.strictEqual(
|
||||
topLevelAfterInstall.length,
|
||||
6,
|
||||
`Install must produce exactly 6 gsd-* top-level dirs (routers). Got ${topLevelAfterInstall.length}: [${topLevelAfterInstall.join(', ')}]`,
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')),
|
||||
'After install: gsd-ns-workflow/skills/plan-phase/SKILL.md must exist',
|
||||
);
|
||||
|
||||
// Step 2: applySurface (full surface, no surface state file → resolves to full)
|
||||
const layout = resolveRuntimeArtifactLayout('claude', tmpDir, 'global');
|
||||
applySurface(tmpDir, layout, manifest);
|
||||
|
||||
// Step 3: assert nested layout is preserved after applySurface
|
||||
const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.strictEqual(
|
||||
topLevelAfterSurface.length,
|
||||
6,
|
||||
`After applySurface: expected exactly 6 gsd-* top-level dirs (routers only). Got ${topLevelAfterSurface.length}: [${topLevelAfterSurface.join(', ')}]. ` +
|
||||
'Re-flattening detected: applySurface must preserve nested layout (#69 regression).',
|
||||
);
|
||||
|
||||
// The nested SKILL.md must still exist (not re-flattened to top-level concrete dir)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')),
|
||||
'After applySurface: gsd-ns-workflow/skills/plan-phase/SKILL.md must still exist (nested layout preserved)',
|
||||
);
|
||||
|
||||
// The concrete skill must NOT have been promoted to a top-level flat dir
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')),
|
||||
'After applySurface: gsd-plan-phase/ must NOT exist at top level (#69 re-flatten regression guard)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -417,20 +417,40 @@ describe('stage — skills kind (claude global)', () => {
|
||||
assert.ok(entries.length >= 1, 'at least one skill dir should be staged');
|
||||
});
|
||||
|
||||
test('stage with skills="*" stages all commands/gsd/*.md as skills', () => {
|
||||
test('stage with skills="*" nests all commands/gsd/*.md under 6 routers (claude)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('claude', FAKE_STAGE_DIR, 'global');
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'should have a skills kind');
|
||||
|
||||
const stagedDir = skillsKind.stage(PROFILE_FULL);
|
||||
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
||||
const entries = fs.readdirSync(stagedDir);
|
||||
assert.ok(entries.length > 10, `full profile should have many skills, got ${entries.length}`);
|
||||
for (const entry of entries) {
|
||||
assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`);
|
||||
const skillMd = path.join(stagedDir, entry, 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`);
|
||||
|
||||
// Claude is a NESTING runtime: full profile produces exactly 6 gsd-ns-* router dirs.
|
||||
const topEntries = fs.readdirSync(stagedDir);
|
||||
assert.strictEqual(topEntries.length, 6, `full profile should have exactly 6 router dirs, got ${topEntries.length}`);
|
||||
for (const entry of topEntries) {
|
||||
assert.ok(entry.startsWith('gsd-ns-'), `top-level entry should be a gsd-ns-* router: ${entry}`);
|
||||
// Each router has its own SKILL.md.
|
||||
const routerSkillMd = path.join(stagedDir, entry, 'SKILL.md');
|
||||
assert.ok(fs.existsSync(routerSkillMd), `router SKILL.md must exist in ${entry}`);
|
||||
// Each router has a skills/ subdirectory with nested children.
|
||||
const skillsSubdir = path.join(stagedDir, entry, 'skills');
|
||||
assert.ok(fs.existsSync(skillsSubdir), `skills/ subdir must exist in ${entry}`);
|
||||
assert.ok(fs.statSync(skillsSubdir).isDirectory(), `${entry}/skills must be a directory`);
|
||||
}
|
||||
|
||||
// Total SKILL.md files across all routers + nested children must be large (proves no skill was dropped).
|
||||
function countSkillMdFiles(dir) {
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) count += countSkillMdFiles(fullPath);
|
||||
else if (entry.name === 'SKILL.md') count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
const totalSkillMd = countSkillMdFiles(stagedDir);
|
||||
assert.ok(totalSkillMd >= 60, `full profile should have >= 60 total SKILL.md files (routers + children), got ${totalSkillMd}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user