diff --git a/.changeset/3757-consolidate-runtime-artifact-layout.md b/.changeset/3757-consolidate-runtime-artifact-layout.md new file mode 100644 index 000000000..0d6094658 --- /dev/null +++ b/.changeset/3757-consolidate-runtime-artifact-layout.md @@ -0,0 +1,8 @@ +--- +type: Fixed +pr: 3759 +--- + + + +Consolidates the Runtime Artifact Layout Module test cluster (ADR-3660) from 12 files spanning three module names (`surface-*`, `runtime-artifact-layout-*`, `install-profiles-*`) into 3 files, resolving the three-module-name split identified in ADR-3660. diff --git a/tests/install-profiles-manifest.test.cjs b/tests/install-profiles-manifest.test.cjs deleted file mode 100644 index b1b8bfd76..000000000 --- a/tests/install-profiles-manifest.test.cjs +++ /dev/null @@ -1,121 +0,0 @@ -'use strict'; -/** - * Tests for loadSkillsManifest — parses requires: frontmatter from commands/gsd/*.md - * and returns a Map. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const { - loadSkillsManifest, -} = require('../get-shit-done/bin/lib/install-profiles.cjs'); - -function createFixtureDir() { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-fixture-')); - return tmp; -} - -function writeSkill(dir, stem, frontmatter) { - const content = `---\n${frontmatter}\n---\n\n# body\n`; - fs.writeFileSync(path.join(dir, `${stem}.md`), content); -} - -describe('loadSkillsManifest', () => { - test('returns a Map', () => { - const dir = createFixtureDir(); - try { - const m = loadSkillsManifest(dir); - assert.ok(m instanceof Map, 'should return a Map'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('skill with no requires: frontmatter maps to empty array', () => { - const dir = createFixtureDir(); - try { - writeSkill(dir, 'help', 'name: gsd:help\ndescription: Help text'); - const m = loadSkillsManifest(dir); - assert.ok(m.has('help'), 'help should be in manifest'); - assert.deepStrictEqual(m.get('help'), []); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('skill with requires: single value maps to array of one', () => { - const dir = createFixtureDir(); - try { - writeSkill(dir, 'add-tests', 'name: gsd:add-tests\ndescription: Add tests\nrequires: [phase]'); - const m = loadSkillsManifest(dir); - assert.ok(m.has('add-tests')); - assert.deepStrictEqual(m.get('add-tests'), ['phase']); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('skill with requires: multiple values maps to full array', () => { - const dir = createFixtureDir(); - try { - writeSkill(dir, 'plan-phase', 'name: gsd:plan-phase\ndescription: Plan\nrequires: [discuss-phase, phase, review, update]'); - const m = loadSkillsManifest(dir); - assert.deepStrictEqual(m.get('plan-phase'), ['discuss-phase', 'phase', 'review', 'update']); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('ignores non-.md files in the dir', () => { - const dir = createFixtureDir(); - try { - writeSkill(dir, 'help', 'name: gsd:help\ndescription: Help'); - fs.writeFileSync(path.join(dir, 'README.txt'), 'not a skill'); - fs.writeFileSync(path.join(dir, 'notes.json'), '{}'); - const m = loadSkillsManifest(dir); - assert.ok(m.has('help')); - assert.ok(!m.has('README')); - assert.ok(!m.has('notes')); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('empty dir returns empty Map', () => { - const dir = createFixtureDir(); - try { - const m = loadSkillsManifest(dir); - assert.strictEqual(m.size, 0); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('skill with requires: empty array maps to empty array', () => { - const dir = createFixtureDir(); - try { - writeSkill(dir, 'explore', 'name: gsd:explore\ndescription: Explore\nrequires: []'); - const m = loadSkillsManifest(dir); - assert.deepStrictEqual(m.get('explore'), []); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('loads real commands/gsd/ directory without throwing', () => { - const realDir = path.join(__dirname, '..', 'commands', 'gsd'); - const m = loadSkillsManifest(realDir); - assert.ok(m.size >= 60, `expected >=60 skills, got ${m.size}`); - // discuss-phase requires [config, phase] - const depsDP = m.get('discuss-phase'); - assert.ok(Array.isArray(depsDP), 'discuss-phase should be in manifest'); - assert.ok(depsDP.includes('phase'), 'discuss-phase should require phase'); - assert.ok(depsDP.includes('config'), 'discuss-phase should require config'); - // help has no requires - assert.deepStrictEqual(m.get('help'), []); - }); -}); diff --git a/tests/install-profiles-marker.test.cjs b/tests/install-profiles-marker.test.cjs deleted file mode 100644 index 5abfd9d6c..000000000 --- a/tests/install-profiles-marker.test.cjs +++ /dev/null @@ -1,118 +0,0 @@ -'use strict'; -/** - * Tests for readActiveProfile / writeActiveProfile marker persistence. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const { - readActiveProfile, - writeActiveProfile, -} = require('../get-shit-done/bin/lib/install-profiles.cjs'); - -describe('readActiveProfile / writeActiveProfile', () => { - test('write then read round-trips the profile name', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-')); - try { - writeActiveProfile(dir, 'standard'); - assert.strictEqual(readActiveProfile(dir), 'standard'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('round-trips "core" profile', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-')); - try { - writeActiveProfile(dir, 'core'); - assert.strictEqual(readActiveProfile(dir), 'core'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('round-trips composed profiles "core,audit"', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-')); - try { - writeActiveProfile(dir, 'core,audit'); - assert.strictEqual(readActiveProfile(dir), 'core,audit'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('round-trips "full"', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-')); - try { - writeActiveProfile(dir, 'full'); - assert.strictEqual(readActiveProfile(dir), 'full'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('missing marker file returns null (not throws)', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-')); - try { - const result = readActiveProfile(dir); - assert.strictEqual(result, null); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('non-existent directory returns null (not throws)', () => { - const ghost = path.join(os.tmpdir(), 'gsd-marker-no-exist-' + Date.now()); - const result = readActiveProfile(ghost); - assert.strictEqual(result, null); - }); - - test('corrupt marker content (invalid chars) returns null', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-')); - try { - fs.writeFileSync(path.join(dir, '.gsd-profile'), 'profile with spaces and !!!\n'); - const result = readActiveProfile(dir); - assert.strictEqual(result, null); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('empty marker file returns null', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-')); - try { - fs.writeFileSync(path.join(dir, '.gsd-profile'), ''); - const result = readActiveProfile(dir); - assert.strictEqual(result, null); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('writeActiveProfile creates the directory if it does not exist', () => { - const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-base-')); - const nested = path.join(base, 'skills', '.claude'); - try { - writeActiveProfile(nested, 'standard'); - assert.ok(fs.existsSync(nested), 'directory should be created'); - assert.strictEqual(readActiveProfile(nested), 'standard'); - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } - }); - - test('overwrites a previously written profile', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-')); - try { - writeActiveProfile(dir, 'core'); - writeActiveProfile(dir, 'full'); - assert.strictEqual(readActiveProfile(dir), 'full'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/tests/install-profiles-resolve.test.cjs b/tests/install-profiles-resolve.test.cjs deleted file mode 100644 index 84a394709..000000000 --- a/tests/install-profiles-resolve.test.cjs +++ /dev/null @@ -1,196 +0,0 @@ -'use strict'; -/** - * Tests for resolveProfile — computes transitive closure over the requires: graph. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('path'); - -const { - PROFILES, - resolveProfile, - loadSkillsManifest, -} = require('../get-shit-done/bin/lib/install-profiles.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); - -describe('PROFILES map', () => { - test('PROFILES is frozen', () => { - assert.ok(Object.isFrozen(PROFILES)); - }); - - test('PROFILES has core, standard, full keys', () => { - assert.ok('core' in PROFILES, 'PROFILES.core missing'); - assert.ok('standard' in PROFILES, 'PROFILES.standard missing'); - assert.ok('full' in PROFILES, 'PROFILES.full missing'); - }); - - test('PROFILES.core contains the 7 main-loop skills (including phase)', () => { - const core = PROFILES.core; - assert.ok(Array.isArray(core), 'core should be an array'); - const sorted = [...core].sort(); - assert.deepStrictEqual(sorted, [ - 'discuss-phase', - 'execute-phase', - 'help', - 'new-project', - 'phase', - 'plan-phase', - 'update', - ]); - }); - - test('PROFILES.full is the sentinel "*"', () => { - assert.strictEqual(PROFILES.full, '*'); - }); - - test('PROFILES.standard contains at least the core skills', () => { - const core = new Set(PROFILES.core); - const standard = PROFILES.standard; - assert.ok(Array.isArray(standard), 'standard should be an array'); - for (const s of core) { - assert.ok(standard.includes(s), `standard should include core skill: ${s}`); - } - }); - - test('PROFILES.standard has at least 10 skills', () => { - assert.ok(PROFILES.standard.length >= 10, `standard should have >=10 skills, got ${PROFILES.standard.length}`); - }); -}); - -describe('resolveProfile', () => { - test('defaults to full when called with no args', () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = resolveProfile({ manifest }); - assert.strictEqual(result.name, 'full'); - assert.strictEqual(result.skills, '*'); - }); - - test('resolves core profile — returns 7+ skills', () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = resolveProfile({ modes: ['core'], manifest }); - assert.strictEqual(result.name, 'core'); - assert.ok(result.skills instanceof Set, 'skills should be a Set'); - // core has 7 base skills. - assert.ok(result.skills.size >= 7, `core closure should have >=7 skills, got ${result.skills.size}`); - // All base skills must be present - for (const s of PROFILES.core) { - assert.ok(result.skills.has(s), `core closure should include ${s}`); - } - assert.ok(result.skills.has('phase'), 'core closure must include phase'); - }); - - test('resolves standard profile — returns superset of core', () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const coreResult = resolveProfile({ modes: ['core'], manifest }); - const stdResult = resolveProfile({ modes: ['standard'], manifest }); - assert.strictEqual(stdResult.name, 'standard'); - assert.ok(stdResult.skills instanceof Set); - assert.ok(stdResult.skills.size >= coreResult.skills.size, 'standard should have >= skills than core'); - // All core closure skills must be in standard - for (const s of coreResult.skills) { - assert.ok(stdResult.skills.has(s), `standard must include core skill: ${s}`); - } - }); - - test('resolves full profile — returns sentinel', () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = resolveProfile({ modes: ['full'], manifest }); - assert.strictEqual(result.name, 'full'); - assert.strictEqual(result.skills, '*'); - }); - - test('composable profiles — core,standard union is same as standard', () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const stdResult = resolveProfile({ modes: ['standard'], manifest }); - const composed = resolveProfile({ modes: ['core', 'standard'], manifest }); - // name should reflect composed - assert.ok(composed.name.includes('core') && composed.name.includes('standard'), - `composed name should include both, got: ${composed.name}`); - // skills union should equal standard (since core ⊂ standard) - for (const s of stdResult.skills) { - assert.ok(composed.skills.has(s), `composed should include standard skill: ${s}`); - } - }); - - test('transitive closure: skill that requires phase pulls in phase', () => { - // Build a minimal manifest: only discuss-phase requiring phase - const manifest = new Map([ - ['discuss-phase', ['phase']], - ['phase', []], - ['help', []], - ]); - // Profile with only discuss-phase in base - const miniProfiles = { core: ['discuss-phase', 'help'], full: '*', standard: ['discuss-phase', 'help'] }; - const result = resolveProfile({ modes: ['core'], manifest, _profilesOverride: miniProfiles }); - assert.ok(result.skills.has('phase'), 'phase should be pulled in via closure from discuss-phase'); - assert.ok(result.skills.has('discuss-phase')); - assert.ok(result.skills.has('help')); - }); - - test('deep transitive closure works (A→B→C pulls in C)', () => { - const manifest = new Map([ - ['a', ['b']], - ['b', ['c']], - ['c', []], - ]); - const miniProfiles = { core: ['a'], full: '*', standard: ['a'] }; - const result = resolveProfile({ modes: ['core'], manifest, _profilesOverride: miniProfiles }); - assert.ok(result.skills.has('a')); - assert.ok(result.skills.has('b')); - assert.ok(result.skills.has('c')); - }); - - test('resolveProfile result has agents Set', () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = resolveProfile({ modes: ['core'], manifest }); - assert.ok(result.agents instanceof Set, 'result should have agents Set'); - }); - - test('resolveProfile standard — agents Set is non-empty (plan-phase pulls gsd-planner etc)', () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = resolveProfile({ modes: ['standard'], manifest }); - assert.ok(result.agents instanceof Set, 'agents should be a Set'); - assert.ok(result.agents.size > 0, `standard profile should have >0 agents, got ${result.agents.size}`); - // plan-phase is in standard and calls gsd-planner, gsd-plan-checker, gsd-phase-researcher - assert.ok(result.agents.has('gsd-planner'), 'standard should include gsd-planner (called by plan-phase)'); - assert.ok(result.agents.has('gsd-plan-checker'), 'standard should include gsd-plan-checker (called by plan-phase)'); - }); - - test('resolveProfile full — agents is empty Set (full staging uses srcDir directly)', () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = resolveProfile({ modes: ['full'], manifest }); - assert.strictEqual(result.skills, '*'); - // Full profile: agents Set is empty because stageAgentsForProfile uses srcDir directly - assert.ok(result.agents instanceof Set, 'agents should still be a Set for full'); - }); - - test('agents are derived from skill body text — synthetic manifest', () => { - // Build a synthetic manifest where plan-phase calls gsd-planner - const manifest = new Map([ - ['plan-phase', []], - ['phase', []], - ]); - // Override with calls_agents map - manifest.get('plan-phase'); // ensure it exists - // Use the real loadSkillsManifest with REAL dir to verify body parsing works - const realManifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const planPhaseAgents = realManifest.get('_calls_agents_plan-phase') || - // calls_agents may be stored as a separate key or on the manifest entry itself - []; - // This test validates that the real manifest has agent mappings for plan-phase - // by checking resolveProfile computes agents correctly - const result = resolveProfile({ modes: ['standard'], manifest: realManifest }); - assert.ok(result.agents.has('gsd-planner'), 'gsd-planner should be derived from plan-phase body'); - }); - - test('agents transitively closed — skill requiring plan-phase also gets its agents', () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - // quick requires plan-phase (via requires: field or direct) and also calls gsd-planner directly - // new-project requires plan-phase so inherits its agents - const result = resolveProfile({ modes: ['standard'], manifest }); - // Since plan-phase is in standard, and plan-phase calls gsd-planner, gsd-planner must be present - assert.ok(result.agents.has('gsd-planner')); - }); -}); diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs deleted file mode 100644 index 3cbe32e94..000000000 --- a/tests/install-profiles-stage.test.cjs +++ /dev/null @@ -1,271 +0,0 @@ -'use strict'; -/** - * Tests for stageSkillsForProfile and stageAgentsForProfile. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const { - stageSkillsForProfile, - stageAgentsForProfile, - stageSkillsForRuntimeAsSkills, - cleanupStagedSkills, - resolveProfile, - loadSkillsManifest, - STAGED_DIRS, -} = require('../get-shit-done/bin/lib/install-profiles.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); -const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents'); - -function createFixtureSkillsDir() { - const tmp = createTempDir('gsd-stage-profile-'); - for (const name of ['plan-phase', 'execute-phase', 'autonomous', 'progress', 'help', 'phase']) { - fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`); - } - return tmp; -} - -function createFixtureAgentsDir() { - const tmp = createTempDir('gsd-agents-profile-'); - for (const name of ['gsd-planner', 'gsd-executor', 'gsd-code-reviewer']) { - fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`); - } - return tmp; -} - -describe('stageSkillsForRuntimeAsSkills', () => { - test('is exported as a function', () => { - assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); - }); - - test('registers stagedDir in STAGED_DIRS after staging', (t) => { - const src = createTempDir('gsd-rta-src-'); - let stagedDir; - t.after(() => { - cleanup(src); - if (stagedDir) cleanupStagedSkills(); - }); - fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); - cleanupStagedSkills(); - const converter = (content, _skillName) => content; - stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); - assert.ok(STAGED_DIRS.has(stagedDir), 'stagedDir must be in STAGED_DIRS'); - }); - - test('non-existent srcCommandsDir returns srcCommandsDir unchanged', () => { - const ghost = path.join(require('os').tmpdir(), 'gsd-rta-no-exist-' + Date.now()); - const converter = (content, _skillName) => content; - const result = stageSkillsForRuntimeAsSkills(ghost, { skills: '*' }, converter, 'gsd-'); - assert.strictEqual(result, ghost); - }); - - test('empty prefix produces /SKILL.md without prefix segment', (t) => { - const src = createTempDir('gsd-rta-src-'); - let stagedDir; - t.after(() => { - cleanup(src); - if (stagedDir) cleanupStagedSkills(); - }); - fs.writeFileSync(path.join(src, 'phase.md'), '# phase\n'); - const converter = (content, _skillName) => content; - stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, ''); - const entries = fs.readdirSync(stagedDir); - assert.deepStrictEqual(entries, ['phase']); - const content = fs.readFileSync(path.join(stagedDir, 'phase', 'SKILL.md'), 'utf8'); - assert.strictEqual(content, '# phase\n'); - }); - - test('converter is called with (content, skillName) for each kept skill', (t) => { - const src = createTempDir('gsd-rta-src-'); - let stagedDir; - t.after(() => { - cleanup(src); - if (stagedDir) cleanupStagedSkills(); - }); - fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); - fs.writeFileSync(path.join(src, 'beta.md'), '# beta\n'); - const calls = []; - const converter = (content, skillName) => { - calls.push([content, skillName]); - return content; - }; - stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'x-'); - assert.strictEqual(calls.length, 2); - const callMap = Object.fromEntries(calls.map(([c, n]) => [n, c])); - assert.strictEqual(callMap['x-alpha'], '# alpha\n'); - assert.strictEqual(callMap['x-beta'], '# beta\n'); - }); - - test('skills Set filters: only matching stems land in stagedDir', (t) => { - const src = createTempDir('gsd-rta-src-'); - let stagedDir; - t.after(() => { - cleanup(src); - if (stagedDir) cleanupStagedSkills(); - }); - for (const name of ['alpha', 'beta', 'phase']) { - fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); - } - const converter = (content, _skillName) => content; - stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: new Set(['phase']) }, converter, 'gsd-'); - const entries = fs.readdirSync(stagedDir).sort(); - assert.deepStrictEqual(entries, ['gsd-phase']); - }); - - test('skills === "*" stages all md files as /SKILL.md', (t) => { - const src = createTempDir('gsd-rta-src-'); - let stagedDir; - t.after(() => { - cleanup(src); - if (stagedDir) cleanupStagedSkills(); - }); - for (const name of ['alpha', 'beta', 'gamma']) { - fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); - } - const converter = (content, _skillName) => content; - stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); - const entries = fs.readdirSync(stagedDir).sort(); - assert.deepStrictEqual(entries, ['gsd-alpha', 'gsd-beta', 'gsd-gamma']); - for (const name of ['alpha', 'beta', 'gamma']) { - const content = fs.readFileSync(path.join(stagedDir, `gsd-${name}`, 'SKILL.md'), 'utf8'); - assert.strictEqual(content, `# ${name}\n`); - } - }); -}); - -describe('stageSkillsForProfile', () => { - test('full profile (skills === "*") returns srcDir unchanged', (t) => { - const src = createFixtureSkillsDir(); - t.after(() => cleanup(src)); - const result = stageSkillsForProfile(src, { skills: '*', agents: new Set() }); - assert.strictEqual(result, src); - }); - - test('profile with Set copies only member files', (t) => { - const src = createFixtureSkillsDir(); - let staged; - t.after(() => { - cleanup(src); - if (staged) cleanupStagedSkills(); - }); - const skills = new Set(['plan-phase', 'help', 'phase']); - staged = stageSkillsForProfile(src, { skills, agents: new Set() }); - assert.notStrictEqual(staged, src); - const files = fs.readdirSync(staged).sort(); - assert.deepStrictEqual(files, ['help.md', 'phase.md', 'plan-phase.md']); - }); - - test('preserves file content byte-for-byte', (t) => { - const src = createFixtureSkillsDir(); - const content = '# plan-phase special content\n\nsome body\n'; - fs.writeFileSync(path.join(src, 'plan-phase.md'), content); - let staged; - t.after(() => { - cleanup(src); - if (staged) cleanupStagedSkills(); - }); - const skills = new Set(['plan-phase']); - staged = stageSkillsForProfile(src, { skills, agents: new Set() }); - const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8'); - assert.strictEqual(copied, content); - }); - - test('non-existent srcDir returns srcDir unchanged', () => { - const ghost = path.join(require('os').tmpdir(), 'gsd-no-exist-' + Date.now()); - const result = stageSkillsForProfile(ghost, { skills: new Set(['help']), agents: new Set() }); - assert.strictEqual(result, ghost); - }); - - test('empty skills Set produces empty staged dir', (t) => { - const src = createFixtureSkillsDir(); - let staged; - t.after(() => { - cleanup(src); - if (staged) cleanupStagedSkills(); - }); - staged = stageSkillsForProfile(src, { skills: new Set(), agents: new Set() }); - const files = fs.readdirSync(staged); - assert.deepStrictEqual(files, []); - }); -}); - -describe('stageAgentsForProfile', () => { - test('full profile (skills === "*") returns srcDir unchanged', (t) => { - const src = createFixtureAgentsDir(); - t.after(() => cleanup(src)); - const result = stageAgentsForProfile(src, { skills: '*', agents: new Set() }); - assert.strictEqual(result, src); - }); - - test('non-full profile with empty agents Set produces empty staged dir', (t) => { - const src = createFixtureAgentsDir(); - let staged; - t.after(() => { - cleanup(src); - if (staged) cleanupStagedSkills(); - }); - staged = stageAgentsForProfile(src, { skills: new Set(['help']), agents: new Set() }); - const files = fs.readdirSync(staged); - assert.deepStrictEqual(files, [], 'no agents for non-full profile by default'); - }); - - test('non-full profile with agents Set copies only member agent files', (t) => { - const src = createFixtureAgentsDir(); - let staged; - t.after(() => { - cleanup(src); - if (staged) cleanupStagedSkills(); - }); - const agents = new Set(['gsd-planner']); - staged = stageAgentsForProfile(src, { skills: new Set(['plan-phase']), agents }); - const files = fs.readdirSync(staged).sort(); - assert.deepStrictEqual(files, ['gsd-planner.md']); - }); - - test('non-existent srcAgentsDir returns srcAgentsDir unchanged', () => { - const ghost = path.join(require('os').tmpdir(), 'gsd-agents-no-exist-' + Date.now()); - const result = stageAgentsForProfile(ghost, { skills: new Set(), agents: new Set() }); - assert.strictEqual(result, ghost); - }); - - test('standard profile — stageAgentsForProfile copies exactly the agents in resolvedProfile.agents', (t) => { - // Uses the real agents dir and commands dir - if (!fs.existsSync(REAL_AGENTS_DIR) || !fs.existsSync(REAL_COMMANDS_DIR)) return; - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const resolved = resolveProfile({ modes: ['standard'], manifest }); - assert.ok(resolved.agents instanceof Set && resolved.agents.size > 0, - 'standard profile must have >0 agents (plan-phase calls gsd-planner etc)'); - let staged; - t.after(() => { - if (staged) cleanupStagedSkills(); - }); - staged = stageAgentsForProfile(REAL_AGENTS_DIR, resolved); - const stagedFiles = new Set( - fs.readdirSync(staged).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3)) - ); - // Every file staged must be in resolved.agents - for (const stem of stagedFiles) { - assert.ok(resolved.agents.has(stem), `staged agent ${stem} not in resolved.agents`); - } - // Every agent in resolved.agents that exists in the real dir must be staged - for (const agentStem of resolved.agents) { - const exists = fs.existsSync(path.join(REAL_AGENTS_DIR, `${agentStem}.md`)); - if (exists) { - assert.ok(stagedFiles.has(agentStem), `resolved agent ${agentStem} missing from staged dir`); - } - } - }); - - test('full profile staging returns real agents dir unchanged', () => { - if (!fs.existsSync(REAL_AGENTS_DIR)) return; - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const resolved = resolveProfile({ modes: ['full'], manifest }); - const result = stageAgentsForProfile(REAL_AGENTS_DIR, resolved); - assert.strictEqual(result, REAL_AGENTS_DIR); - }); -}); diff --git a/tests/runtime-artifact-layout-edge-cases.test.cjs b/tests/runtime-artifact-layout-edge-cases.test.cjs deleted file mode 100644 index c14b25c7e..000000000 --- a/tests/runtime-artifact-layout-edge-cases.test.cjs +++ /dev/null @@ -1,94 +0,0 @@ -'use strict'; -/** - * Edge-case tests for resolveRuntimeArtifactLayout. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); - -describe('hermes nested layout', () => { - test('hermes has destSubpath skills/gsd and empty prefix', () => { - const layout = resolveRuntimeArtifactLayout('hermes', '/tmp/x'); - assert.strictEqual(layout.kinds[0].destSubpath, 'skills/gsd'); - assert.strictEqual(layout.kinds[0].prefix, ''); - }); -}); - -describe('cline empty kinds', () => { - test('cline has no kinds', () => { - const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x'); - assert.strictEqual(layout.kinds.length, 0); - }); -}); - -describe('gemini commands layout', () => { - test('gemini has one commands kind', () => { - const layout = resolveRuntimeArtifactLayout('gemini', '/tmp/x'); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'commands'); - }); -}); - -describe('claude scope=local has commands and agents', () => { - test('claude local has both commands and agents kinds', () => { - const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'local'); - const kindNames = layout.kinds.map(k => k.kind); - assert.ok(kindNames.includes('commands'), 'should have commands kind'); - assert.ok(kindNames.includes('agents'), 'should have agents kind'); - }); -}); - -describe('claude scope=global has only skills', () => { - test('claude global has only skills kind', () => { - const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'global'); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - }); -}); - -describe('unknown runtime throws TypeError', () => { - test('grok throws TypeError containing "grok"', () => { - assert.throws( - () => resolveRuntimeArtifactLayout('grok', '/tmp/x'), - (err) => { - assert.ok(err instanceof TypeError); - assert.ok(err.message.includes('grok'), 'error message must contain the runtime name'); - return true; - } - ); - }); - - test('xyzunknown throws TypeError', () => { - assert.throws( - () => resolveRuntimeArtifactLayout('xyzunknown', '/tmp/x'), - TypeError - ); - }); -}); - -describe('invalid configDir throws TypeError', () => { - test('empty configDir throws TypeError', () => { - assert.throws( - () => resolveRuntimeArtifactLayout('claude', ''), - TypeError - ); - }); - - test('non-string configDir throws TypeError', () => { - assert.throws( - () => resolveRuntimeArtifactLayout('claude', null), - TypeError - ); - }); -}); - -describe('invalid scope throws TypeError', () => { - test('bad scope throws TypeError', () => { - assert.throws( - () => resolveRuntimeArtifactLayout('claude', '/x', 'invalid'), - TypeError - ); - }); -}); diff --git a/tests/runtime-artifact-layout-install-profiles.test.cjs b/tests/runtime-artifact-layout-install-profiles.test.cjs new file mode 100644 index 000000000..b547feea4 --- /dev/null +++ b/tests/runtime-artifact-layout-install-profiles.test.cjs @@ -0,0 +1,659 @@ +'use strict'; +/** + * Consolidated tests for the Runtime Artifact Layout Module — install-profiles parity (ADR-3660). + * + * Covers: + * - stageSkillsForProfile / stageAgentsForProfile / stageSkillsForRuntimeAsSkills + * - resolveProfile — transitive closure + PROFILES map + * - loadSkillsManifest — frontmatter parsing + * - readActiveProfile / writeActiveProfile — marker persistence + * + * Sources consolidated (4 files deleted): + * tests/install-profiles-stage.test.cjs + * tests/install-profiles-resolve.test.cjs + * tests/install-profiles-manifest.test.cjs + * tests/install-profiles-marker.test.cjs + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + stageSkillsForProfile, + stageAgentsForProfile, + stageSkillsForRuntimeAsSkills, + cleanupStagedSkills, + resolveProfile, + loadSkillsManifest, + readActiveProfile, + writeActiveProfile, + PROFILES, + STAGED_DIRS, +} = require('../get-shit-done/bin/lib/install-profiles.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents'); + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function tmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix || 'gsd-ip-')); +} + +function createFixtureSkillsDir() { + const tmp = createTempDir('gsd-stage-profile-'); + for (const name of ['plan-phase', 'execute-phase', 'autonomous', 'progress', 'help', 'phase']) { + fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`); + } + return tmp; +} + +function createFixtureAgentsDir() { + const tmp = createTempDir('gsd-agents-profile-'); + for (const name of ['gsd-planner', 'gsd-executor', 'gsd-code-reviewer']) { + fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`); + } + return tmp; +} + +function writeSkill(dir, stem, frontmatter) { + const content = `---\n${frontmatter}\n---\n\n# body\n`; + fs.writeFileSync(path.join(dir, `${stem}.md`), content); +} + +// ─── stageSkillsForRuntimeAsSkills ────────────────────────────────────────── + +describe('stageSkillsForRuntimeAsSkills', () => { + test('is exported as a function', () => { + assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); + }); + + test('registers stagedDir in STAGED_DIRS after staging', (t) => { + const src = createTempDir('gsd-rta-src-'); + let stagedDir; + t.after(() => { + cleanup(src); + if (stagedDir) cleanupStagedSkills(); + }); + fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); + cleanupStagedSkills(); + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); + assert.ok(STAGED_DIRS.has(stagedDir), 'stagedDir must be in STAGED_DIRS'); + }); + + test('non-existent srcCommandsDir returns srcCommandsDir unchanged', () => { + const ghost = path.join(os.tmpdir(), 'gsd-rta-no-exist-' + Date.now()); + const converter = (content, _skillName) => content; + const result = stageSkillsForRuntimeAsSkills(ghost, { skills: '*' }, converter, 'gsd-'); + assert.strictEqual(result, ghost); + }); + + test('empty prefix produces /SKILL.md without prefix segment', (t) => { + const src = createTempDir('gsd-rta-src-'); + let stagedDir; + t.after(() => { + cleanup(src); + if (stagedDir) cleanupStagedSkills(); + }); + fs.writeFileSync(path.join(src, 'phase.md'), '# phase\n'); + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, ''); + const entries = fs.readdirSync(stagedDir); + assert.deepStrictEqual(entries, ['phase']); + const content = fs.readFileSync(path.join(stagedDir, 'phase', 'SKILL.md'), 'utf8'); + assert.strictEqual(content, '# phase\n'); + }); + + test('converter is called with (content, skillName) for each kept skill', (t) => { + const src = createTempDir('gsd-rta-src-'); + let stagedDir; + t.after(() => { + cleanup(src); + if (stagedDir) cleanupStagedSkills(); + }); + fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); + fs.writeFileSync(path.join(src, 'beta.md'), '# beta\n'); + const calls = []; + const converter = (content, skillName) => { + calls.push([content, skillName]); + return content; + }; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'x-'); + assert.strictEqual(calls.length, 2); + const callMap = Object.fromEntries(calls.map(([c, n]) => [n, c])); + assert.strictEqual(callMap['x-alpha'], '# alpha\n'); + assert.strictEqual(callMap['x-beta'], '# beta\n'); + }); + + test('skills Set filters: only matching stems land in stagedDir', (t) => { + const src = createTempDir('gsd-rta-src-'); + let stagedDir; + t.after(() => { + cleanup(src); + if (stagedDir) cleanupStagedSkills(); + }); + for (const name of ['alpha', 'beta', 'phase']) { + fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); + } + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: new Set(['phase']) }, converter, 'gsd-'); + const entries = fs.readdirSync(stagedDir).sort(); + assert.deepStrictEqual(entries, ['gsd-phase']); + }); + + test('skills === "*" stages all md files as /SKILL.md', (t) => { + const src = createTempDir('gsd-rta-src-'); + let stagedDir; + t.after(() => { + cleanup(src); + if (stagedDir) cleanupStagedSkills(); + }); + for (const name of ['alpha', 'beta', 'gamma']) { + fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); + } + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); + const entries = fs.readdirSync(stagedDir).sort(); + assert.deepStrictEqual(entries, ['gsd-alpha', 'gsd-beta', 'gsd-gamma']); + for (const name of ['alpha', 'beta', 'gamma']) { + const content = fs.readFileSync(path.join(stagedDir, `gsd-${name}`, 'SKILL.md'), 'utf8'); + assert.strictEqual(content, `# ${name}\n`); + } + }); +}); + +// ─── stageSkillsForProfile ─────────────────────────────────────────────────── + +describe('stageSkillsForProfile', () => { + test('full profile (skills === "*") returns srcDir unchanged', (t) => { + const src = createFixtureSkillsDir(); + t.after(() => cleanup(src)); + const result = stageSkillsForProfile(src, { skills: '*', agents: new Set() }); + assert.strictEqual(result, src); + }); + + test('profile with Set copies only member files', (t) => { + const src = createFixtureSkillsDir(); + let staged; + t.after(() => { + cleanup(src); + if (staged) cleanupStagedSkills(); + }); + const skills = new Set(['plan-phase', 'help', 'phase']); + staged = stageSkillsForProfile(src, { skills, agents: new Set() }); + assert.notStrictEqual(staged, src); + const files = fs.readdirSync(staged).sort(); + assert.deepStrictEqual(files, ['help.md', 'phase.md', 'plan-phase.md']); + }); + + test('preserves file content byte-for-byte', (t) => { + const src = createFixtureSkillsDir(); + const content = '# plan-phase special content\n\nsome body\n'; + fs.writeFileSync(path.join(src, 'plan-phase.md'), content); + let staged; + t.after(() => { + cleanup(src); + if (staged) cleanupStagedSkills(); + }); + const skills = new Set(['plan-phase']); + staged = stageSkillsForProfile(src, { skills, agents: new Set() }); + const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8'); + assert.strictEqual(copied, content); + }); + + test('non-existent srcDir returns srcDir unchanged', () => { + const ghost = path.join(os.tmpdir(), 'gsd-no-exist-' + Date.now()); + const result = stageSkillsForProfile(ghost, { skills: new Set(['help']), agents: new Set() }); + assert.strictEqual(result, ghost); + }); + + test('empty skills Set produces empty staged dir', (t) => { + const src = createFixtureSkillsDir(); + let staged; + t.after(() => { + cleanup(src); + if (staged) cleanupStagedSkills(); + }); + staged = stageSkillsForProfile(src, { skills: new Set(), agents: new Set() }); + const files = fs.readdirSync(staged); + assert.deepStrictEqual(files, []); + }); +}); + +// ─── stageAgentsForProfile ─────────────────────────────────────────────────── + +describe('stageAgentsForProfile', () => { + test('full profile (skills === "*") returns srcDir unchanged', (t) => { + const src = createFixtureAgentsDir(); + t.after(() => cleanup(src)); + const result = stageAgentsForProfile(src, { skills: '*', agents: new Set() }); + assert.strictEqual(result, src); + }); + + test('non-full profile with empty agents Set produces empty staged dir', (t) => { + const src = createFixtureAgentsDir(); + let staged; + t.after(() => { + cleanup(src); + if (staged) cleanupStagedSkills(); + }); + staged = stageAgentsForProfile(src, { skills: new Set(['help']), agents: new Set() }); + const files = fs.readdirSync(staged); + assert.deepStrictEqual(files, [], 'no agents for non-full profile by default'); + }); + + test('non-full profile with agents Set copies only member agent files', (t) => { + const src = createFixtureAgentsDir(); + let staged; + t.after(() => { + cleanup(src); + if (staged) cleanupStagedSkills(); + }); + const agents = new Set(['gsd-planner']); + staged = stageAgentsForProfile(src, { skills: new Set(['plan-phase']), agents }); + const files = fs.readdirSync(staged).sort(); + assert.deepStrictEqual(files, ['gsd-planner.md']); + }); + + test('non-existent srcAgentsDir returns srcAgentsDir unchanged', () => { + const ghost = path.join(os.tmpdir(), 'gsd-agents-no-exist-' + Date.now()); + const result = stageAgentsForProfile(ghost, { skills: new Set(), agents: new Set() }); + assert.strictEqual(result, ghost); + }); + + test('standard profile — stageAgentsForProfile copies exactly the agents in resolvedProfile.agents', (t) => { + if (!fs.existsSync(REAL_AGENTS_DIR) || !fs.existsSync(REAL_COMMANDS_DIR)) return; + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const resolved = resolveProfile({ modes: ['standard'], manifest }); + assert.ok(resolved.agents instanceof Set && resolved.agents.size > 0, + 'standard profile must have >0 agents (plan-phase calls gsd-planner etc)'); + let staged; + t.after(() => { + if (staged) cleanupStagedSkills(); + }); + staged = stageAgentsForProfile(REAL_AGENTS_DIR, resolved); + const stagedFiles = new Set( + fs.readdirSync(staged).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3)) + ); + for (const stem of stagedFiles) { + assert.ok(resolved.agents.has(stem), `staged agent ${stem} not in resolved.agents`); + } + for (const agentStem of resolved.agents) { + const exists = fs.existsSync(path.join(REAL_AGENTS_DIR, `${agentStem}.md`)); + if (exists) { + assert.ok(stagedFiles.has(agentStem), `resolved agent ${agentStem} missing from staged dir`); + } + } + }); + + test('full profile staging returns real agents dir unchanged', () => { + if (!fs.existsSync(REAL_AGENTS_DIR)) return; + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const resolved = resolveProfile({ modes: ['full'], manifest }); + const result = stageAgentsForProfile(REAL_AGENTS_DIR, resolved); + assert.strictEqual(result, REAL_AGENTS_DIR); + }); +}); + +// ─── PROFILES map + resolveProfile ────────────────────────────────────────── + +describe('PROFILES map', () => { + test('PROFILES is frozen', () => { + assert.ok(Object.isFrozen(PROFILES)); + }); + + test('PROFILES has core, standard, full keys', () => { + assert.ok('core' in PROFILES, 'PROFILES.core missing'); + assert.ok('standard' in PROFILES, 'PROFILES.standard missing'); + assert.ok('full' in PROFILES, 'PROFILES.full missing'); + }); + + test('PROFILES.core contains the 7 main-loop skills (including phase)', () => { + const core = PROFILES.core; + assert.ok(Array.isArray(core), 'core should be an array'); + const sorted = [...core].sort(); + assert.deepStrictEqual(sorted, [ + 'discuss-phase', + 'execute-phase', + 'help', + 'new-project', + 'phase', + 'plan-phase', + 'update', + ]); + }); + + test('PROFILES.full is the sentinel "*"', () => { + assert.strictEqual(PROFILES.full, '*'); + }); + + test('PROFILES.standard contains at least the core skills', () => { + const core = new Set(PROFILES.core); + const standard = PROFILES.standard; + assert.ok(Array.isArray(standard), 'standard should be an array'); + for (const s of core) { + assert.ok(standard.includes(s), `standard should include core skill: ${s}`); + } + }); + + test('PROFILES.standard has at least 10 skills', () => { + assert.ok(PROFILES.standard.length >= 10, `standard should have >=10 skills, got ${PROFILES.standard.length}`); + }); +}); + +describe('resolveProfile', () => { + test('defaults to full when called with no modes arg', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = resolveProfile({ manifest }); + assert.strictEqual(result.name, 'full'); + assert.strictEqual(result.skills, '*'); + }); + + test('resolves core profile — returns 7+ skills, all base stems present', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = resolveProfile({ modes: ['core'], manifest }); + assert.strictEqual(result.name, 'core'); + assert.ok(result.skills instanceof Set, 'skills should be a Set'); + assert.ok(result.skills.size >= 7, `core closure should have >=7 skills, got ${result.skills.size}`); + for (const s of PROFILES.core) { + assert.ok(result.skills.has(s), `core closure should include ${s}`); + } + assert.ok(result.skills.has('phase'), 'core closure must include phase'); + }); + + test('resolves standard profile — superset of core', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const coreResult = resolveProfile({ modes: ['core'], manifest }); + const stdResult = resolveProfile({ modes: ['standard'], manifest }); + assert.strictEqual(stdResult.name, 'standard'); + assert.ok(stdResult.skills instanceof Set); + assert.ok(stdResult.skills.size >= coreResult.skills.size, 'standard should have >= skills than core'); + for (const s of coreResult.skills) { + assert.ok(stdResult.skills.has(s), `standard must include core skill: ${s}`); + } + }); + + test('resolves full profile — returns sentinel', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = resolveProfile({ modes: ['full'], manifest }); + assert.strictEqual(result.name, 'full'); + assert.strictEqual(result.skills, '*'); + }); + + test('composable profiles — core,standard union is same as standard', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const stdResult = resolveProfile({ modes: ['standard'], manifest }); + const composed = resolveProfile({ modes: ['core', 'standard'], manifest }); + assert.ok(composed.name.includes('core') && composed.name.includes('standard'), + `composed name should include both, got: ${composed.name}`); + for (const s of stdResult.skills) { + assert.ok(composed.skills.has(s), `composed should include standard skill: ${s}`); + } + }); + + test('transitive closure: skill that requires phase pulls in phase', () => { + const manifest = new Map([ + ['discuss-phase', ['phase']], + ['phase', []], + ['help', []], + ]); + const miniProfiles = { core: ['discuss-phase', 'help'], full: '*', standard: ['discuss-phase', 'help'] }; + const result = resolveProfile({ modes: ['core'], manifest, _profilesOverride: miniProfiles }); + assert.ok(result.skills.has('phase'), 'phase should be pulled in via closure from discuss-phase'); + assert.ok(result.skills.has('discuss-phase')); + assert.ok(result.skills.has('help')); + }); + + test('deep transitive closure works (A→B→C pulls in C)', () => { + const manifest = new Map([ + ['a', ['b']], + ['b', ['c']], + ['c', []], + ]); + const miniProfiles = { core: ['a'], full: '*', standard: ['a'] }; + const result = resolveProfile({ modes: ['core'], manifest, _profilesOverride: miniProfiles }); + assert.ok(result.skills.has('a')); + assert.ok(result.skills.has('b')); + assert.ok(result.skills.has('c')); + }); + + test('result has agents Set', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = resolveProfile({ modes: ['core'], manifest }); + assert.ok(result.agents instanceof Set, 'result should have agents Set'); + }); + + test('standard — agents Set is non-empty; gsd-planner and gsd-plan-checker present', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = resolveProfile({ modes: ['standard'], manifest }); + assert.ok(result.agents instanceof Set, 'agents should be a Set'); + assert.ok(result.agents.size > 0, `standard profile should have >0 agents, got ${result.agents.size}`); + assert.ok(result.agents.has('gsd-planner'), 'standard should include gsd-planner'); + assert.ok(result.agents.has('gsd-plan-checker'), 'standard should include gsd-plan-checker'); + }); + + test('full — agents is a Set (full staging uses srcDir directly)', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = resolveProfile({ modes: ['full'], manifest }); + assert.strictEqual(result.skills, '*'); + assert.ok(result.agents instanceof Set, 'agents should still be a Set for full'); + }); + + test('agents are derived from real manifest body text (gsd-planner from plan-phase)', () => { + const realManifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = resolveProfile({ modes: ['standard'], manifest: realManifest }); + assert.ok(result.agents.has('gsd-planner'), 'gsd-planner should be derived from plan-phase body'); + }); + + test('agents transitively closed — plan-phase in standard brings its agents', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = resolveProfile({ modes: ['standard'], manifest }); + assert.ok(result.agents.has('gsd-planner')); + }); +}); + +// ─── loadSkillsManifest ────────────────────────────────────────────────────── + +describe('loadSkillsManifest', () => { + test('returns a Map', () => { + const dir = tmpDir('gsd-manifest-fixture-'); + try { + const m = loadSkillsManifest(dir); + assert.ok(m instanceof Map, 'should return a Map'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('skill with no requires: frontmatter maps to empty array', () => { + const dir = tmpDir('gsd-manifest-fixture-'); + try { + writeSkill(dir, 'help', 'name: gsd:help\ndescription: Help text'); + const m = loadSkillsManifest(dir); + assert.ok(m.has('help'), 'help should be in manifest'); + assert.deepStrictEqual(m.get('help'), []); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('skill with requires: single value maps to array of one', () => { + const dir = tmpDir('gsd-manifest-fixture-'); + try { + writeSkill(dir, 'add-tests', 'name: gsd:add-tests\ndescription: Add tests\nrequires: [phase]'); + const m = loadSkillsManifest(dir); + assert.ok(m.has('add-tests')); + assert.deepStrictEqual(m.get('add-tests'), ['phase']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('skill with requires: multiple values maps to full array', () => { + const dir = tmpDir('gsd-manifest-fixture-'); + try { + writeSkill(dir, 'plan-phase', 'name: gsd:plan-phase\ndescription: Plan\nrequires: [discuss-phase, phase, review, update]'); + const m = loadSkillsManifest(dir); + assert.deepStrictEqual(m.get('plan-phase'), ['discuss-phase', 'phase', 'review', 'update']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('ignores non-.md files in the dir', () => { + const dir = tmpDir('gsd-manifest-fixture-'); + try { + writeSkill(dir, 'help', 'name: gsd:help\ndescription: Help'); + fs.writeFileSync(path.join(dir, 'README.txt'), 'not a skill'); + fs.writeFileSync(path.join(dir, 'notes.json'), '{}'); + const m = loadSkillsManifest(dir); + assert.ok(m.has('help')); + assert.ok(!m.has('README')); + assert.ok(!m.has('notes')); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('empty dir returns empty Map', () => { + const dir = tmpDir('gsd-manifest-fixture-'); + try { + const m = loadSkillsManifest(dir); + assert.strictEqual(m.size, 0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('skill with requires: empty array maps to empty array', () => { + const dir = tmpDir('gsd-manifest-fixture-'); + try { + writeSkill(dir, 'explore', 'name: gsd:explore\ndescription: Explore\nrequires: []'); + const m = loadSkillsManifest(dir); + assert.deepStrictEqual(m.get('explore'), []); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('loads real commands/gsd/ directory: >=60 skills, discuss-phase deps correct, help has no requires', () => { + const m = loadSkillsManifest(REAL_COMMANDS_DIR); + // Use prefix/set assertion, not a hardcoded count — avoids stale-count anti-pattern + assert.ok(m.size >= 60, `expected >=60 skills, got ${m.size}`); + const depsDP = m.get('discuss-phase'); + assert.ok(Array.isArray(depsDP), 'discuss-phase should be in manifest'); + assert.ok(depsDP.includes('phase'), 'discuss-phase should require phase'); + assert.ok(depsDP.includes('config'), 'discuss-phase should require config'); + assert.deepStrictEqual(m.get('help'), []); + }); +}); + +// ─── readActiveProfile / writeActiveProfile ────────────────────────────────── + +describe('readActiveProfile / writeActiveProfile', () => { + test('write then read round-trips the profile name', () => { + const dir = tmpDir('gsd-marker-'); + try { + writeActiveProfile(dir, 'standard'); + assert.strictEqual(readActiveProfile(dir), 'standard'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('round-trips "core" profile', () => { + const dir = tmpDir('gsd-marker-'); + try { + writeActiveProfile(dir, 'core'); + assert.strictEqual(readActiveProfile(dir), 'core'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('round-trips composed profiles "core,audit"', () => { + const dir = tmpDir('gsd-marker-'); + try { + writeActiveProfile(dir, 'core,audit'); + assert.strictEqual(readActiveProfile(dir), 'core,audit'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('round-trips "full"', () => { + const dir = tmpDir('gsd-marker-'); + try { + writeActiveProfile(dir, 'full'); + assert.strictEqual(readActiveProfile(dir), 'full'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('missing marker file returns null (not throws)', () => { + const dir = tmpDir('gsd-marker-'); + try { + const result = readActiveProfile(dir); + assert.strictEqual(result, null); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('non-existent directory returns null (not throws)', () => { + const ghost = path.join(os.tmpdir(), 'gsd-marker-no-exist-' + Date.now()); + const result = readActiveProfile(ghost); + assert.strictEqual(result, null); + }); + + test('corrupt marker content (invalid chars) returns null', () => { + const dir = tmpDir('gsd-marker-'); + try { + fs.writeFileSync(path.join(dir, '.gsd-profile'), 'profile with spaces and !!!\n'); + const result = readActiveProfile(dir); + assert.strictEqual(result, null); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('empty marker file returns null', () => { + const dir = tmpDir('gsd-marker-'); + try { + fs.writeFileSync(path.join(dir, '.gsd-profile'), ''); + const result = readActiveProfile(dir); + assert.strictEqual(result, null); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('writeActiveProfile creates the directory if it does not exist', () => { + const base = tmpDir('gsd-marker-base-'); + const nested = path.join(base, 'skills', '.claude'); + try { + writeActiveProfile(nested, 'standard'); + assert.ok(fs.existsSync(nested), 'directory should be created'); + assert.strictEqual(readActiveProfile(nested), 'standard'); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); + + test('overwrites a previously written profile', () => { + const dir = tmpDir('gsd-marker-'); + try { + writeActiveProfile(dir, 'core'); + writeActiveProfile(dir, 'full'); + assert.strictEqual(readActiveProfile(dir), 'full'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/runtime-artifact-layout-stage.test.cjs b/tests/runtime-artifact-layout-stage.test.cjs deleted file mode 100644 index b55cdb293..000000000 --- a/tests/runtime-artifact-layout-stage.test.cjs +++ /dev/null @@ -1,109 +0,0 @@ -'use strict'; -/** - * Tests for resolveRuntimeArtifactLayout kind.stage() invocations. - * Verifies each kind type produces the expected staged directory structure. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -// A small resolved profile selecting known real skills -const CORE_SKILLS = new Set(['help', 'phase', 'new-project']); -const CORE_AGENTS = new Set(['gsd-planner']); -const PROFILE_CORE = { skills: CORE_SKILLS, agents: CORE_AGENTS }; -const PROFILE_FULL = { skills: '*', agents: new Set() }; - -const FAKE_DIR = '/tmp/fake-config-dir-stage'; - -describe('commands kind — stage (gemini)', () => { - test('stage returns a directory containing only the selected skill .md files', () => { - const layout = resolveRuntimeArtifactLayout('gemini', FAKE_DIR); - const commandsKind = layout.kinds.find(k => k.kind === 'commands'); - assert.ok(commandsKind, 'should have a commands kind'); - - const stagedDir = commandsKind.stage(PROFILE_CORE); - // stagedDir is managed by stageSkillsForProfile — just verify, no manual cleanup needed - const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); - // All staged files must come from the selected skill set - for (const entry of entries) { - const stem = entry.slice(0, -3); - assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`); - } - // At least one file must be present (help.md exists in real commands/gsd) - assert.ok(entries.length >= 1, 'at least one skill file should be staged'); - }); -}); - -describe('agents kind — stage (claude local)', () => { - test('stage returns a directory containing only the selected agent .md files', () => { - const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'local'); - const agentsKind = layout.kinds.find(k => k.kind === 'agents'); - assert.ok(agentsKind, 'should have an agents kind'); - - const stagedDir = agentsKind.stage(PROFILE_CORE); - // stagedDir may be empty (agents Set from core profile may be empty set) - // But it must be a valid directory - assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); - assert.ok(fs.statSync(stagedDir).isDirectory(), 'stagedDir must be a directory'); - }); -}); - -describe('skills kind — stage (claude global)', () => { - test('stage returns a directory containing gsd-/SKILL.md entries', () => { - const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global'); - const skillsKind = layout.kinds.find(k => k.kind === 'skills'); - assert.ok(skillsKind, 'should have a skills kind'); - - const stagedDir = skillsKind.stage(PROFILE_CORE); - // managed by stageSkillsForRuntimeAsSkills — no manual cleanup needed - assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); - const entries = fs.readdirSync(stagedDir); - // Each entry should be a directory named gsd- - 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}`); - } - assert.ok(entries.length >= 1, 'at least one skill dir should be staged'); - }); -}); - -describe('skills kind — stage with full profile', () => { - test('stage with skills="*" stages all commands/gsd/*.md as skills', () => { - const layout = resolveRuntimeArtifactLayout('claude', FAKE_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); - // Full profile: should have many skills - 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}`); - } - }); -}); - -describe('opencode commands kind — stage', () => { - test('opencode stage returns directory with .md files for selected skills', () => { - const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR); - const commandsKind = layout.kinds.find(k => k.kind === 'commands'); - assert.ok(commandsKind, 'should have a commands kind'); - - const stagedDir = commandsKind.stage(PROFILE_CORE); - assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); - const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); - for (const entry of entries) { - const stem = entry.slice(0, -3); - assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`); - } - }); -}); diff --git a/tests/runtime-artifact-layout-surface.test.cjs b/tests/runtime-artifact-layout-surface.test.cjs new file mode 100644 index 000000000..51fbb101c --- /dev/null +++ b/tests/runtime-artifact-layout-surface.test.cjs @@ -0,0 +1,795 @@ +'use strict'; +/** + * Runtime Artifact Layout Module (ADR-3660) — surface seam. + * Consolidated from: surface-apply, surface-resolve, surface-state, + * surface-clusters, surface-list (5 files deleted). + * See also: runtime-artifact-layout.test.cjs, runtime-artifact-layout-install-profiles.test.cjs + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { writeSurface, readSurface, resolveSurface, listSurface, applySurface } = require('../get-shit-done/bin/lib/surface.cjs'); +const { loadSkillsManifest, writeActiveProfile, resolveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs'); +const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); +const { CLUSTERS, allClusteredSkills } = require('../get-shit-done/bin/lib/clusters.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function tmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix || 'gsd-ral-surf-')); +} + +function createFixtureRuntime() { + const base = createTempDir('gsd-surface-apply-'); + const runtimeConfigDir = base; + const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); + const agentsDir = path.join(runtimeConfigDir, 'agents'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.mkdirSync(agentsDir, { recursive: true }); + return { base, runtimeConfigDir, commandsDir, agentsDir }; +} + +function realManifest() { + return loadSkillsManifest(REAL_COMMANDS_DIR); +} + +function readFrontmatterDescription(markdown) { + const lines = markdown.split('\n'); + if (lines[0].trim() !== '---') return ''; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === '---') break; + const sep = line.indexOf(':'); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + if (key !== 'description') continue; + return line.slice(sep + 1).trim(); + } + return ''; +} + +// ─── applySurface ──────────────────────────────────────────────────────────── + +describe('applySurface', () => { + test('core profile: only core skills appear in commandsDir', (t) => { + const { base, runtimeConfigDir, commandsDir } = createFixtureRuntime(); + t.after(() => cleanup(base)); + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + const resolved = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); + for (const file of files) { + assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`); + } + const expectedCore = [...resolved.skills].map(stem => `${stem}.md`).sort(); + assert.deepStrictEqual( + [...files].sort(), + expectedCore, + 'commandsDir should contain exactly core commands' + ); + }); + + test('removes superseded files when profile shrinks', (t) => { + const { base, runtimeConfigDir, commandsDir } = createFixtureRuntime(); + t.after(() => cleanup(base)); + writeActiveProfile(runtimeConfigDir, 'standard'); + writeSurface(runtimeConfigDir, { + baseProfile: 'standard', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); + + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const resolvedCore = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); + + assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard'); + + const expectedCore = [...resolvedCore.skills].map(stem => `${stem}.md`).sort(); + assert.deepStrictEqual( + [...afterCore].sort(), + expectedCore, + 'afterCore should contain exactly core commands' + ); + + for (const file of afterCore) { + assert.ok( + fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), + `file in commandsDir not a real skill: ${file}` + ); + } + }); + + test('leaves non-gsd .md files alone in agentsDir', (t) => { + const { base, runtimeConfigDir, agentsDir } = createFixtureRuntime(); + t.after(() => cleanup(base)); + const foreignAgent = path.join(agentsDir, 'my-custom-agent.md'); + fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8'); + + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched'); + }); + + test('adds missing skill files from install source', (t) => { + const { base, runtimeConfigDir, commandsDir } = createFixtureRuntime(); + t.after(() => cleanup(base)); + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + assert.ok( + fs.existsSync(path.join(commandsDir, 'help.md')), + 'help.md should be copied from install source' + ); + assert.ok( + fs.existsSync(path.join(commandsDir, 'new-project.md')), + 'new-project.md should be copied from install source' + ); + }); + + test('_syncGsdDir skills kind: adds missing skill dirs, removes stale prefix-matched dirs, preserves foreign dirs', (t) => { + const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); + + const base = createTempDir('gsd-surface-skills-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); + + const stem1 = 'gsd-help'; + const stem2 = 'gsd-update'; + fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); + fs.mkdirSync(path.join(stagedDir, stem2), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem2, 'SKILL.md'), '# update\n', 'utf8'); + + const staleDir = path.join(destDir, 'gsd-old-skill'); + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '# old\n', 'utf8'); + + const foreignDir = path.join(destDir, 'my-custom-skill'); + fs.mkdirSync(foreignDir, { recursive: true }); + fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# custom\n', 'utf8'); + + const skillsKind = { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir }; + _syncGsdDir(stagedDir, destDir, skillsKind); + + assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'gsd-help/SKILL.md should be copied'); + assert.ok(fs.existsSync(path.join(destDir, stem2, 'SKILL.md')), 'gsd-update/SKILL.md should be copied'); + assert.ok(!fs.existsSync(staleDir), 'stale gsd-old-skill dir should be removed'); + assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved'); + }); + + test('applySurface recreates missing destination directories', (t) => { + const base = createTempDir('gsd-surface-missing-dest-'); + t.after(() => cleanup(base)); + const runtimeConfigDir = base; + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); + assert.ok(fs.existsSync(commandsDir), 'commands/gsd dir should be created even if initially absent'); + const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); + assert.ok(files.length > 0, 'commands/gsd should contain staged skill files'); + assert.ok(files.includes('help.md'), 'help.md should be present after applySurface on missing dest'); + }); + + test('Hermes profile shrink: stale GSD skill dirs are removed; user skills preserved', (t) => { + const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); + + const base = createTempDir('gsd-surface-hermes-shrink-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); + + fs.mkdirSync(path.join(stagedDir, 'gsd-executor'), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, 'gsd-executor', 'SKILL.md'), '# executor\n', 'utf8'); + + fs.mkdirSync(path.join(destDir, 'gsd-executor'), { recursive: true }); + fs.writeFileSync(path.join(destDir, 'gsd-executor', 'SKILL.md'), '# executor\n', 'utf8'); + fs.mkdirSync(path.join(destDir, 'gsd-planner'), { recursive: true }); + fs.writeFileSync(path.join(destDir, 'gsd-planner', 'SKILL.md'), '# planner\n', 'utf8'); + fs.mkdirSync(path.join(destDir, 'user-skill'), { recursive: true }); + fs.writeFileSync(path.join(destDir, 'user-skill', 'SKILL.md'), '# user\n', 'utf8'); + + const manifest = new Map([ + ['gsd-executor', []], + ['gsd-planner', []], + ]); + + const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; + _syncGsdDir(stagedDir, destDir, hermesKind, manifest); + + assert.ok( + fs.existsSync(path.join(destDir, 'gsd-executor', 'SKILL.md')), + 'gsd-executor should be kept (in staged set)' + ); + assert.ok( + !fs.existsSync(path.join(destDir, 'gsd-planner')), + 'gsd-planner should be removed (in manifest but not in staged set — stale GSD skill)' + ); + assert.ok( + fs.existsSync(path.join(destDir, 'user-skill', 'SKILL.md')), + 'user-skill should be preserved (not in manifest — user-owned)' + ); + }); + + test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', (t) => { + const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); + + const base = createTempDir('gsd-surface-hermes-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); + + const stem1 = 'help'; + fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); + + const userDir = path.join(destDir, 'user-custom-skill'); + fs.mkdirSync(userDir, { recursive: true }); + fs.writeFileSync(path.join(userDir, 'SKILL.md'), '# user custom\n', 'utf8'); + + const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; + _syncGsdDir(stagedDir, destDir, hermesKind); + + assert.ok(fs.existsSync(userDir), 'user-custom-skill dir must be preserved when kindPrefix is empty (Hermes)'); + assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'GSD help/SKILL.md must be copied'); + }); +}); + +// ─── resolveSurface ────────────────────────────────────────────────────────── + +describe('resolveSurface', () => { + test('no surface state + core base profile → identical to resolveProfile core', () => { + const dir = tmpDir('gsd-surface-resolve-'); + try { + writeActiveProfile(dir, 'core'); + const manifest = realManifest(); + const surfaceResolved = resolveSurface(dir, manifest, CLUSTERS); + const profileResolved = resolveProfile({ modes: ['core'], manifest }); + + assert.ok(surfaceResolved.skills instanceof Set); + assert.ok(profileResolved.skills instanceof Set); + assert.deepStrictEqual( + [...surfaceResolved.skills].sort(), + [...profileResolved.skills].sort(), + 'surface with no state should equal profile resolution' + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('standard base + disabledClusters:["utility"] removes utility skills', () => { + const dir = tmpDir('gsd-surface-resolve-'); + try { + writeActiveProfile(dir, 'standard'); + writeSurface(dir, { + baseProfile: 'standard', + disabledClusters: ['utility'], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = realManifest(); + const resolved = resolveSurface(dir, manifest, CLUSTERS); + + assert.ok(resolved.skills instanceof Set); + for (const stem of CLUSTERS.utility) { + const standardResolved = resolveProfile({ modes: ['standard'], manifest }); + if (standardResolved.skills.has(stem)) { + assert.ok( + !resolved.skills.has(stem), + `"${stem}" should be removed by disabling utility cluster` + ); + } + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('explicitAdds:["sketch"] adds sketch to a core install', () => { + const dir = tmpDir('gsd-surface-resolve-'); + try { + writeActiveProfile(dir, 'core'); + writeSurface(dir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: ['sketch'], + explicitRemoves: [], + }); + const manifest = realManifest(); + const resolved = resolveSurface(dir, manifest, CLUSTERS); + + assert.ok(resolved.skills instanceof Set); + assert.ok(resolved.skills.has('sketch'), 'sketch must be in resolved skills'); + + const sketchRequires = manifest.get('sketch') || []; + for (const dep of sketchRequires) { + assert.ok(resolved.skills.has(dep), `transitive dep "${dep}" of sketch must be present`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('explicitRemoves removes individual skill stems', () => { + const dir = tmpDir('gsd-surface-resolve-'); + try { + writeSurface(dir, { + baseProfile: 'standard', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: ['progress'], + }); + writeActiveProfile(dir, 'standard'); + const manifest = realManifest(); + const resolved = resolveSurface(dir, manifest, CLUSTERS); + + assert.ok(!resolved.skills.has('progress'), '"progress" must be removed by explicitRemoves'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('result is a Set with name property and agents Set', () => { + const dir = tmpDir('gsd-surface-resolve-'); + try { + writeActiveProfile(dir, 'core'); + const manifest = realManifest(); + const resolved = resolveSurface(dir, manifest, CLUSTERS); + + assert.ok(resolved.skills instanceof Set); + assert.ok(typeof resolved.name === 'string'); + assert.ok(resolved.agents instanceof Set); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('surface with baseProfile overrides .gsd-profile marker', () => { + const dir = tmpDir('gsd-surface-resolve-'); + try { + writeActiveProfile(dir, 'core'); + writeSurface(dir, { + baseProfile: 'standard', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = realManifest(); + const resolved = resolveSurface(dir, manifest, CLUSTERS); + const standardResolved = resolveProfile({ modes: ['standard'], manifest }); + + assert.deepStrictEqual( + [...resolved.skills].sort(), + [...standardResolved.skills].sort(), + 'surface baseProfile takes precedence over marker' + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('disabled cluster + explicitAdds can re-add specific skills from disabled cluster', () => { + const dir = tmpDir('gsd-surface-resolve-'); + try { + writeSurface(dir, { + baseProfile: 'standard', + disabledClusters: ['workspace_state'], + explicitAdds: ['capture'], + explicitRemoves: [], + }); + writeActiveProfile(dir, 'standard'); + const manifest = realManifest(); + const resolved = resolveSurface(dir, manifest, CLUSTERS); + + assert.ok(resolved.skills.has('capture'), '"capture" must be present via explicitAdds'); + const standardResolved = resolveProfile({ modes: ['standard'], manifest }); + for (const stem of CLUSTERS.workspace_state) { + if (stem === 'capture') continue; + if (standardResolved.skills.has(stem)) { + assert.ok( + !resolved.skills.has(stem), + `"${stem}" should be removed (workspace_state disabled, not explicitly re-added)` + ); + } + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// ─── readSurface / writeSurface ────────────────────────────────────────────── + +describe('readSurface / writeSurface', () => { + test('round-trips a complete surface state', () => { + const dir = tmpDir('gsd-surface-state-'); + try { + const state = { + baseProfile: 'standard', + disabledClusters: ['utility'], + explicitAdds: ['sketch'], + explicitRemoves: [], + }; + writeSurface(dir, state); + const read = readSurface(dir); + assert.deepStrictEqual(read, state); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('round-trips empty arrays', () => { + const dir = tmpDir('gsd-surface-state-'); + try { + const state = { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }; + writeSurface(dir, state); + assert.deepStrictEqual(readSurface(dir), state); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('round-trips composed base profile', () => { + const dir = tmpDir('gsd-surface-state-'); + try { + const state = { + baseProfile: 'core,audit', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: ['health'], + }; + writeSurface(dir, state); + assert.deepStrictEqual(readSurface(dir), state); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('missing file returns null', () => { + const dir = tmpDir('gsd-surface-state-'); + try { + const result = readSurface(dir); + assert.strictEqual(result, null); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('non-existent directory returns null', () => { + const ghost = path.join(os.tmpdir(), 'gsd-surface-no-exist-' + Date.now()); + const result = readSurface(ghost); + assert.strictEqual(result, null); + }); + + test('corrupt JSON returns null', () => { + const dir = tmpDir('gsd-surface-state-'); + try { + fs.writeFileSync(path.join(dir, '.gsd-surface.json'), '{not valid json', 'utf8'); + const result = readSurface(dir); + assert.strictEqual(result, null); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('JSON missing baseProfile field returns null', () => { + const dir = tmpDir('gsd-surface-state-'); + try { + fs.writeFileSync( + path.join(dir, '.gsd-surface.json'), + JSON.stringify({ disabledClusters: [], explicitAdds: [], explicitRemoves: [] }), + 'utf8' + ); + const result = readSurface(dir); + assert.strictEqual(result, null); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('JSON with non-array disabledClusters returns null', () => { + const dir = tmpDir('gsd-surface-state-'); + try { + fs.writeFileSync( + path.join(dir, '.gsd-surface.json'), + JSON.stringify({ baseProfile: 'standard', disabledClusters: 'utility', explicitAdds: [], explicitRemoves: [] }), + 'utf8' + ); + const result = readSurface(dir); + assert.strictEqual(result, null); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('atomic write: result file is never a partial tmp file', () => { + const dir = tmpDir('gsd-surface-state-'); + try { + const state = { baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }; + writeSurface(dir, state); + const files = fs.readdirSync(dir); + const tmpFiles = files.filter(f => f.includes('.tmp.')); + assert.deepStrictEqual(tmpFiles, [], 'no tmp files should remain after write'); + assert.ok(files.includes('.gsd-surface.json')); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('second write overwrites first', () => { + const dir = tmpDir('gsd-surface-state-'); + try { + writeSurface(dir, { baseProfile: 'core', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }); + writeSurface(dir, { baseProfile: 'standard', disabledClusters: ['utility'], explicitAdds: [], explicitRemoves: [] }); + const read = readSurface(dir); + assert.strictEqual(read.baseProfile, 'standard'); + assert.deepStrictEqual(read.disabledClusters, ['utility']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('writeSurface creates directory if it does not exist', () => { + const base = tmpDir('gsd-surface-state-'); + const nested = path.join(base, 'skills', 'subdir'); + try { + writeSurface(nested, { baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }); + assert.ok(fs.existsSync(nested)); + assert.ok(readSurface(nested) !== null); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); +}); + +// ─── CLUSTERS data structure ───────────────────────────────────────────────── + +describe('CLUSTERS data structure', () => { + test('no cluster is empty', () => { + for (const [name, members] of Object.entries(CLUSTERS)) { + assert.ok(members.length > 0, `cluster ${name} must not be empty`); + } + }); + + test('every cluster member is a real skill stem in commands/gsd/', () => { + const entries = fs.readdirSync(REAL_COMMANDS_DIR, { withFileTypes: true }); + const realStems = new Set( + entries + .filter(e => e.isFile() && e.name.endsWith('.md')) + .map(e => e.name.slice(0, -3)) + ); + const mismatches = []; + for (const [cluster, members] of Object.entries(CLUSTERS)) { + for (const stem of members) { + if (!realStems.has(stem)) { + mismatches.push(`${cluster}: "${stem}" not found in commands/gsd/`); + } + } + } + assert.deepStrictEqual(mismatches, [], `Cluster members missing from disk:\n${mismatches.join('\n')}`); + }); + + test('union of all clusters covers every skill in commands/gsd/', () => { + const entries = fs.readdirSync(REAL_COMMANDS_DIR, { withFileTypes: true }); + const realStems = new Set( + entries + .filter(e => e.isFile() && e.name.endsWith('.md')) + .map(e => e.name.slice(0, -3)) + ); + const clustered = allClusteredSkills(); + const uncategorized = []; + for (const stem of realStems) { + if (!clustered.has(stem)) uncategorized.push(stem); + } + assert.deepStrictEqual( + uncategorized, + [], + `Uncategorized skills (not in any cluster):\n${uncategorized.sort().join('\n')}` + ); + }); + + test('CLUSTERS is frozen (immutable)', () => { + assert.ok(Object.isFrozen(CLUSTERS), 'CLUSTERS must be frozen'); + for (const [name, members] of Object.entries(CLUSTERS)) { + assert.ok(Object.isFrozen(members), `CLUSTERS.${name} must be frozen`); + } + }); + + test('cluster names contain the expected set from research memo §3.2', () => { + const expectedClusterNames = new Set([ + 'core_loop', + 'audit_review', + 'milestone', + 'research_ideate', + 'workspace_state', + 'docs', + 'ui', + 'ai_eval', + 'ns_meta', + 'utility', + ]); + const actualClusterNames = new Set(Object.keys(CLUSTERS)); + for (const name of expectedClusterNames) { + assert.ok(actualClusterNames.has(name), `expected cluster "${name}" missing from CLUSTERS`); + } + }); + + test('allClusteredSkills returns a Set containing all cluster members', () => { + const result = allClusteredSkills(); + assert.ok(result instanceof Set, 'allClusteredSkills() must return a Set'); + for (const members of Object.values(CLUSTERS)) { + for (const stem of members) { + assert.ok(result.has(stem), `allClusteredSkills() missing "${stem}"`); + } + } + }); +}); + +// ─── listSurface ───────────────────────────────────────────────────────────── + +describe('listSurface', () => { + test('returns { enabled, disabled, tokenCost } structure', () => { + const dir = tmpDir('gsd-surface-list-'); + try { + fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); + writeActiveProfile(dir, 'core'); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = listSurface(dir, manifest, CLUSTERS); + + assert.ok(Array.isArray(result.enabled), 'enabled must be array'); + assert.ok(Array.isArray(result.disabled), 'disabled must be array'); + assert.ok(typeof result.tokenCost === 'number', 'tokenCost must be number'); + assert.ok(result.tokenCost >= 0, 'tokenCost must be non-negative'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('core profile: enabled has fewer skills than full; enabled + disabled = total stems', () => { + const dir = tmpDir('gsd-surface-list-'); + try { + fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); + writeActiveProfile(dir, 'core'); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const coreList = listSurface(dir, manifest, CLUSTERS); + + const totalStems = [...manifest.keys()].filter(k => !k.startsWith('_calls_agents_')).length; + assert.ok( + coreList.enabled.length < totalStems, + 'core should enable fewer skills than total' + ); + assert.ok(coreList.disabled.length > 0, 'core should have some disabled skills'); + assert.ok(coreList.enabled.length + coreList.disabled.length === totalStems, + 'enabled + disabled must equal total stems'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('disabling utility cluster reduces enabled count', () => { + const dir = tmpDir('gsd-surface-list-'); + try { + fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); + writeActiveProfile(dir, 'standard'); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + + const beforeList = listSurface(dir, manifest, CLUSTERS); + + writeSurface(dir, { + baseProfile: 'standard', + disabledClusters: ['utility'], + explicitAdds: [], + explicitRemoves: [], + }); + const afterList = listSurface(dir, manifest, CLUSTERS); + + assert.ok(afterList.enabled.length <= beforeList.enabled.length, + 'disabling utility cluster should not increase enabled count'); + assert.ok(afterList.tokenCost <= beforeList.tokenCost, + 'disabling a cluster should not increase token cost'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('tokenCost is sum of description char lengths ÷ 4 for enabled skills', () => { + const dir = tmpDir('gsd-surface-list-'); + try { + fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); + writeActiveProfile(dir, 'core'); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = listSurface(dir, manifest, CLUSTERS); + + let expected = 0; + for (const stem of result.enabled) { + const filePath = path.join(REAL_COMMANDS_DIR, `${stem}.md`); + if (!fs.existsSync(filePath)) continue; + const markdown = fs.readFileSync(filePath, 'utf8'); + const description = readFrontmatterDescription(markdown); + if (description) expected += Math.ceil(description.length / 4); + } + + assert.strictEqual(result.tokenCost, expected, 'tokenCost must equal sum of description lengths ÷ 4'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('enabled and disabled arrays are sorted', () => { + const dir = tmpDir('gsd-surface-list-'); + try { + fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); + writeActiveProfile(dir, 'standard'); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = listSurface(dir, manifest, CLUSTERS); + + assert.deepStrictEqual(result.enabled, [...result.enabled].sort()); + assert.deepStrictEqual(result.disabled, [...result.disabled].sort()); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/runtime-artifact-layout-resolve.test.cjs b/tests/runtime-artifact-layout.test.cjs similarity index 56% rename from tests/runtime-artifact-layout-resolve.test.cjs rename to tests/runtime-artifact-layout.test.cjs index d5eeccbf4..4b7fc3082 100644 --- a/tests/runtime-artifact-layout-resolve.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -1,15 +1,33 @@ 'use strict'; /** - * Tests for resolveRuntimeArtifactLayout — structural shape per runtime. + * Consolidated tests for the Runtime Artifact Layout Module (ADR-3660) — layout seam. + * + * Covers: + * - resolveRuntimeArtifactLayout — structural shape per runtime + * - resolveRuntimeArtifactLayout edge-cases (error paths, invalid input) + * - kind.stage() invocations per kind type + * + * Sources consolidated (3 files deleted): + * tests/runtime-artifact-layout-resolve.test.cjs + * tests/runtime-artifact-layout-edge-cases.test.cjs + * tests/runtime-artifact-layout-stage.test.cjs + * + * See also: + * runtime-artifact-layout-surface.test.cjs — surface seam + * runtime-artifact-layout-install-profiles.test.cjs — install-profiles seam */ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); const FAKE_DIR = '/tmp/fake-config-dir'; +// ─── resolveRuntimeArtifactLayout — structural shape ──────────────────────── + describe('resolveRuntimeArtifactLayout — claude local', () => { test('returns correct layout for claude scope=local', () => { const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'local'); @@ -217,3 +235,162 @@ describe('resolveRuntimeArtifactLayout — kilo', () => { assert.strictEqual(typeof layout.kinds[0].stage, 'function'); }); }); + +// ─── resolveRuntimeArtifactLayout — edge-cases ────────────────────────────── + +describe('resolveRuntimeArtifactLayout edge-cases', () => { + test('hermes has destSubpath skills/gsd and empty prefix', () => { + const layout = resolveRuntimeArtifactLayout('hermes', '/tmp/x'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills/gsd'); + assert.strictEqual(layout.kinds[0].prefix, ''); + }); + + test('cline has no kinds', () => { + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x'); + assert.strictEqual(layout.kinds.length, 0); + }); + + test('gemini has one commands kind', () => { + const layout = resolveRuntimeArtifactLayout('gemini', '/tmp/x'); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + }); + + test('claude local has both commands and agents kinds', () => { + const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'local'); + const kindNames = layout.kinds.map(k => k.kind); + assert.ok(kindNames.includes('commands'), 'should have commands kind'); + assert.ok(kindNames.includes('agents'), 'should have agents kind'); + }); + + test('claude global has only skills kind', () => { + const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'global'); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + }); + + test('unknown runtime grok throws TypeError containing runtime name', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('grok', '/tmp/x'), + (err) => { + assert.ok(err instanceof TypeError); + assert.ok(err.message.includes('grok'), 'error message must contain the runtime name'); + return true; + } + ); + }); + + test('unknown runtime xyzunknown throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('xyzunknown', '/tmp/x'), + TypeError + ); + }); + + test('empty configDir throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('claude', ''), + TypeError + ); + }); + + test('non-string configDir throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('claude', null), + TypeError + ); + }); + + test('bad scope throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('claude', '/x', 'invalid'), + TypeError + ); + }); +}); + +// ─── kind.stage() invocations ──────────────────────────────────────────────── + +const CORE_SKILLS = new Set(['help', 'phase', 'new-project']); +const CORE_AGENTS = new Set(['gsd-planner']); +const PROFILE_CORE = { skills: CORE_SKILLS, agents: CORE_AGENTS }; +const PROFILE_FULL = { skills: '*', agents: new Set() }; +const FAKE_STAGE_DIR = '/tmp/fake-config-dir-stage'; + +describe('stage — commands kind (gemini)', () => { + test('stage returns a directory containing only the selected skill .md files', () => { + const layout = resolveRuntimeArtifactLayout('gemini', FAKE_STAGE_DIR); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'should have a commands kind'); + + const stagedDir = commandsKind.stage(PROFILE_CORE); + const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); + for (const entry of entries) { + const stem = entry.slice(0, -3); + assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`); + } + assert.ok(entries.length >= 1, 'at least one skill file should be staged'); + }); +}); + +describe('stage — agents kind (claude local)', () => { + test('stage returns a valid directory for the agents kind', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_STAGE_DIR, 'local'); + const agentsKind = layout.kinds.find(k => k.kind === 'agents'); + assert.ok(agentsKind, 'should have an agents kind'); + + const stagedDir = agentsKind.stage(PROFILE_CORE); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + assert.ok(fs.statSync(stagedDir).isDirectory(), 'stagedDir must be a directory'); + }); +}); + +describe('stage — skills kind (claude global)', () => { + test('stage returns a directory containing gsd-/SKILL.md entries', () => { + 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_CORE); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const entries = fs.readdirSync(stagedDir); + 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}`); + } + assert.ok(entries.length >= 1, 'at least one skill dir should be staged'); + }); + + test('stage with skills="*" stages all commands/gsd/*.md as skills', () => { + 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}`); + } + }); +}); + +describe('stage — opencode commands kind', () => { + test('opencode stage returns directory with .md files for selected skills', () => { + const layout = resolveRuntimeArtifactLayout('opencode', FAKE_STAGE_DIR); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'should have a commands kind'); + + const stagedDir = commandsKind.stage(PROFILE_CORE); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); + for (const entry of entries) { + const stem = entry.slice(0, -3); + assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`); + } + }); +}); diff --git a/tests/surface-apply.test.cjs b/tests/surface-apply.test.cjs deleted file mode 100644 index eab1d488e..000000000 --- a/tests/surface-apply.test.cjs +++ /dev/null @@ -1,316 +0,0 @@ -'use strict'; -/** - * Tests for applySurface — file sync behavior. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const { writeSurface, applySurface } = require('../get-shit-done/bin/lib/surface.cjs'); -const { loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs'); -const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs'); -const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); -const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents'); - -/** - * Create a minimal fixture install dir structure for claude/local layout. - * runtimeConfigDir is the layout configDir. - * commandsDir = runtimeConfigDir/commands/gsd - * agentsDir = runtimeConfigDir/agents - */ -function createFixtureRuntime() { - const base = createTempDir('gsd-surface-apply-'); - const runtimeConfigDir = base; - const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); - const agentsDir = path.join(runtimeConfigDir, 'agents'); - fs.mkdirSync(commandsDir, { recursive: true }); - fs.mkdirSync(agentsDir, { recursive: true }); - return { base, runtimeConfigDir, commandsDir, agentsDir }; -} - -describe('applySurface', () => { - test('core profile: only core skills appear in commandsDir', (t) => { - const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - t.after(() => cleanup(base)); - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - const resolved = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - - const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); - // Every file should be a real stem we know about - for (const file of files) { - assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`); - } - // Core profile should materialize exactly the resolved core command set. - const expectedCore = [...resolved.skills].map(stem => `${stem}.md`).sort(); - assert.deepStrictEqual( - [...files].sort(), - expectedCore, - 'commandsDir should contain exactly core commands' - ); - }); - - test('removes superseded files when profile shrinks', (t) => { - const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - t.after(() => cleanup(base)); - // Start with standard: put some skill files in commandsDir - writeActiveProfile(runtimeConfigDir, 'standard'); - writeSurface(runtimeConfigDir, { - baseProfile: 'standard', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - - const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); - - // Now switch to core: skills not in core should be removed - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const resolvedCore = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - - const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); - - // core should be a subset of standard - assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard'); - - // Core profile should materialize exactly the resolved core command set. - const expectedCore = [...resolvedCore.skills].map(stem => `${stem}.md`).sort(); - assert.deepStrictEqual( - [...afterCore].sort(), - expectedCore, - 'afterCore should contain exactly core commands' - ); - - // All files should still map to known real skills. - for (const file of afterCore) { - assert.ok( - fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), - `file in commandsDir not a real skill: ${file}` - ); - } - }); - - test('leaves non-gsd .md files alone in agentsDir', (t) => { - const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - t.after(() => cleanup(base)); - // Place a non-gsd agent file in agentsDir - const foreignAgent = path.join(agentsDir, 'my-custom-agent.md'); - fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8'); - - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - - // Non-gsd file should still be there - assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched'); - }); - - test('adds missing skill files from install source', (t) => { - const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - t.after(() => cleanup(base)); - // commandsDir starts empty - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - - // Core skills should now be present - assert.ok( - fs.existsSync(path.join(commandsDir, 'help.md')), - 'help.md should be copied from install source' - ); - assert.ok( - fs.existsSync(path.join(commandsDir, 'new-project.md')), - 'new-project.md should be copied from install source' - ); - }); - - test('_syncGsdDir skills kind: adds missing skill dirs, removes stale prefix-matched dirs, preserves foreign dirs', (t) => { - const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); - const { stageSkillsForRuntimeAsSkills } = require('../get-shit-done/bin/lib/install-profiles.cjs'); - const { findInstallSourceRoot } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); - // Minimal converter that produces SKILL.md with given stem - function converter(stem, content) { - return [ - '---', - `name: ${stem}`, - '---', - content, - ].join('\n'); - } - - const base = createTempDir('gsd-surface-skills-'); - t.after(() => cleanup(base)); - const stagedDir = path.join(base, 'staged'); - const destDir = path.join(base, 'dest'); - fs.mkdirSync(destDir, { recursive: true }); - - // Build a staged dir manually: gsd-help/SKILL.md and gsd-update/SKILL.md - const stem1 = 'gsd-help'; - const stem2 = 'gsd-update'; - fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); - fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); - fs.mkdirSync(path.join(stagedDir, stem2), { recursive: true }); - fs.writeFileSync(path.join(stagedDir, stem2, 'SKILL.md'), '# update\n', 'utf8'); - - // In destDir: stale gsd- dir + foreign user dir - const staleDir = path.join(destDir, 'gsd-old-skill'); - fs.mkdirSync(staleDir, { recursive: true }); - fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '# old\n', 'utf8'); - - const foreignDir = path.join(destDir, 'my-custom-skill'); - fs.mkdirSync(foreignDir, { recursive: true }); - fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# custom\n', 'utf8'); - - const skillsKind = { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir }; - - _syncGsdDir(stagedDir, destDir, skillsKind); - - // staged dirs copied - assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'gsd-help/SKILL.md should be copied'); - assert.ok(fs.existsSync(path.join(destDir, stem2, 'SKILL.md')), 'gsd-update/SKILL.md should be copied'); - - // stale gsd- dir removed - assert.ok(!fs.existsSync(staleDir), 'stale gsd-old-skill dir should be removed'); - - // foreign dir preserved - assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved'); - }); - - test('applySurface recreates missing destination directories', (t) => { - // Fixture: layout configDir exists but the dest subdirectory for the kinds does NOT. - const base = createTempDir('gsd-surface-missing-dest-'); - t.after(() => cleanup(base)); - const runtimeConfigDir = base; - // Do NOT pre-create commands/gsd or agents — they are intentionally absent. - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - - // commands/gsd must have been created and populated - const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); - assert.ok(fs.existsSync(commandsDir), 'commands/gsd dir should be created even if initially absent'); - const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); - assert.ok(files.length > 0, 'commands/gsd should contain staged skill files'); - assert.ok(files.includes('help.md'), 'help.md should be present after applySurface on missing dest'); - }); - - test('Hermes profile shrink: stale GSD skill dirs are removed; user skills preserved', (t) => { - const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); - - const base = createTempDir('gsd-surface-hermes-shrink-'); - t.after(() => cleanup(base)); - const stagedDir = path.join(base, 'staged'); - const destDir = path.join(base, 'dest'); - fs.mkdirSync(destDir, { recursive: true }); - - // Staged: only gsd-executor (profile shrunk — gsd-planner no longer in profile) - fs.mkdirSync(path.join(stagedDir, 'gsd-executor'), { recursive: true }); - fs.writeFileSync(path.join(stagedDir, 'gsd-executor', 'SKILL.md'), '# executor\n', 'utf8'); - - // Dest already has: gsd-executor (keep), gsd-planner (stale GSD), user-skill (user-owned) - fs.mkdirSync(path.join(destDir, 'gsd-executor'), { recursive: true }); - fs.writeFileSync(path.join(destDir, 'gsd-executor', 'SKILL.md'), '# executor\n', 'utf8'); - fs.mkdirSync(path.join(destDir, 'gsd-planner'), { recursive: true }); - fs.writeFileSync(path.join(destDir, 'gsd-planner', 'SKILL.md'), '# planner\n', 'utf8'); - fs.mkdirSync(path.join(destDir, 'user-skill'), { recursive: true }); - fs.writeFileSync(path.join(destDir, 'user-skill', 'SKILL.md'), '# user\n', 'utf8'); - - // Manifest contains gsd-executor and gsd-planner as canonical GSD skills. - // user-skill is NOT in manifest (user-owned). - const manifest = new Map([ - ['gsd-executor', []], - ['gsd-planner', []], - ]); - - // Hermes kind: empty prefix, destSubpath = skills/gsd - const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; - - _syncGsdDir(stagedDir, destDir, hermesKind, manifest); - - assert.ok( - fs.existsSync(path.join(destDir, 'gsd-executor', 'SKILL.md')), - 'gsd-executor should be kept (in staged set)' - ); - assert.ok( - !fs.existsSync(path.join(destDir, 'gsd-planner')), - 'gsd-planner should be removed (in manifest but not in staged set — stale GSD skill)' - ); - assert.ok( - fs.existsSync(path.join(destDir, 'user-skill', 'SKILL.md')), - 'user-skill should be preserved (not in manifest — user-owned)' - ); - }); - - test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', (t) => { - const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); - - const base = createTempDir('gsd-surface-hermes-'); - t.after(() => cleanup(base)); - const stagedDir = path.join(base, 'staged'); - const destDir = path.join(base, 'dest'); - fs.mkdirSync(destDir, { recursive: true }); - - // Staged contains a GSD skill named 'help' (no prefix under hermes skills/gsd/) - const stem1 = 'help'; - fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); - fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); - - // Dest also has a user-owned custom skill dir (no gsd- prefix — Hermes namespace) - const userDir = path.join(destDir, 'user-custom-skill'); - fs.mkdirSync(userDir, { recursive: true }); - fs.writeFileSync(path.join(userDir, 'SKILL.md'), '# user custom\n', 'utf8'); - - // kindPrefix === '' simulates Hermes (destSubpath = skills/gsd, prefix = '') - const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; - - _syncGsdDir(stagedDir, destDir, hermesKind); - - // The user's custom skill dir must be preserved — it's not in staged but should not be removed - // (Fix 4: when kindPrefix === '', skip the startsWith guard and preserve ALL non-staged dirs) - assert.ok(fs.existsSync(userDir), 'user-custom-skill dir must be preserved when kindPrefix is empty (Hermes)'); - - // The staged skill must still be copied - assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'GSD help/SKILL.md must be copied'); - }); -}); diff --git a/tests/surface-clusters.test.cjs b/tests/surface-clusters.test.cjs deleted file mode 100644 index 6f928b70f..000000000 --- a/tests/surface-clusters.test.cjs +++ /dev/null @@ -1,94 +0,0 @@ -'use strict'; -/** - * Tests for CLUSTERS data structure integrity. - * Verifies every cluster member is a real skill stem and all skills are covered. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const { CLUSTERS, allClusteredSkills } = require('../get-shit-done/bin/lib/clusters.cjs'); - -const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); - -function realSkillStems() { - const entries = fs.readdirSync(COMMANDS_DIR, { withFileTypes: true }); - return new Set( - entries - .filter(e => e.isFile() && e.name.endsWith('.md')) - .map(e => e.name.slice(0, -3)) - ); -} - -describe('CLUSTERS data structure', () => { - test('no cluster is empty', () => { - for (const [name, members] of Object.entries(CLUSTERS)) { - assert.ok(members.length > 0, `cluster ${name} must not be empty`); - } - }); - - test('every cluster member is a real skill stem in commands/gsd/', () => { - const realStems = realSkillStems(); - const mismatches = []; - for (const [cluster, members] of Object.entries(CLUSTERS)) { - for (const stem of members) { - if (!realStems.has(stem)) { - mismatches.push(`${cluster}: "${stem}" not found in commands/gsd/`); - } - } - } - assert.deepStrictEqual(mismatches, [], `Cluster members missing from disk:\n${mismatches.join('\n')}`); - }); - - test('union of all clusters covers every skill in commands/gsd/', () => { - const realStems = realSkillStems(); - const clustered = allClusteredSkills(); - const uncategorized = []; - for (const stem of realStems) { - if (!clustered.has(stem)) uncategorized.push(stem); - } - assert.deepStrictEqual( - uncategorized, - [], - `Uncategorized skills (not in any cluster):\n${uncategorized.sort().join('\n')}` - ); - }); - - test('CLUSTERS is frozen (immutable)', () => { - assert.ok(Object.isFrozen(CLUSTERS), 'CLUSTERS must be frozen'); - for (const [name, members] of Object.entries(CLUSTERS)) { - assert.ok(Object.isFrozen(members), `CLUSTERS.${name} must be frozen`); - } - }); - - test('cluster names match expected set from research memo §3.2', () => { - const expectedClusterNames = new Set([ - 'core_loop', - 'audit_review', - 'milestone', - 'research_ideate', - 'workspace_state', - 'docs', - 'ui', - 'ai_eval', - 'ns_meta', - 'utility', - ]); - const actualClusterNames = new Set(Object.keys(CLUSTERS)); - for (const name of expectedClusterNames) { - assert.ok(actualClusterNames.has(name), `expected cluster "${name}" missing from CLUSTERS`); - } - }); - - test('allClusteredSkills returns a Set containing all cluster members', () => { - const result = allClusteredSkills(); - assert.ok(result instanceof Set, 'allClusteredSkills() must return a Set'); - for (const members of Object.values(CLUSTERS)) { - for (const stem of members) { - assert.ok(result.has(stem), `allClusteredSkills() missing "${stem}"`); - } - } - }); -}); diff --git a/tests/surface-list.test.cjs b/tests/surface-list.test.cjs deleted file mode 100644 index eb3bb11bb..000000000 --- a/tests/surface-list.test.cjs +++ /dev/null @@ -1,143 +0,0 @@ -'use strict'; -/** - * Tests for listSurface — enabled/disabled/tokenCost output. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const { writeSurface, listSurface } = require('../get-shit-done/bin/lib/surface.cjs'); -const { loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs'); -const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); - -function tmpDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-list-')); -} - -function readFrontmatterDescription(markdown) { - const lines = markdown.split('\n'); - if (lines[0].trim() !== '---') return ''; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - if (line.trim() === '---') break; - const sep = line.indexOf(':'); - if (sep === -1) continue; - const key = line.slice(0, sep).trim(); - if (key !== 'description') continue; - return line.slice(sep + 1).trim(); - } - return ''; -} - -describe('listSurface', () => { - test('returns { enabled, disabled, tokenCost } structure', () => { - const dir = tmpDir(); - try { - // Write source marker so listSurface can find descriptions - fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); - writeActiveProfile(dir, 'core'); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = listSurface(dir, manifest, CLUSTERS); - - assert.ok(Array.isArray(result.enabled), 'enabled must be array'); - assert.ok(Array.isArray(result.disabled), 'disabled must be array'); - assert.ok(typeof result.tokenCost === 'number', 'tokenCost must be number'); - assert.ok(result.tokenCost >= 0, 'tokenCost must be non-negative'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('core profile: enabled has fewer skills than full', () => { - const dir = tmpDir(); - try { - fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); - writeActiveProfile(dir, 'core'); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const coreList = listSurface(dir, manifest, CLUSTERS); - - // Core should have fewer enabled skills than total - const totalStems = [...manifest.keys()].filter(k => !k.startsWith('_calls_agents_')).length; - assert.ok( - coreList.enabled.length < totalStems, - 'core should enable fewer skills than total' - ); - assert.ok(coreList.disabled.length > 0, 'core should have some disabled skills'); - assert.ok(coreList.enabled.length + coreList.disabled.length === totalStems, - 'enabled + disabled must equal total stems'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('disabling utility cluster reduces enabled count', () => { - const dir = tmpDir(); - try { - fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); - writeActiveProfile(dir, 'standard'); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - - const beforeList = listSurface(dir, manifest, CLUSTERS); - - writeSurface(dir, { - baseProfile: 'standard', - disabledClusters: ['utility'], - explicitAdds: [], - explicitRemoves: [], - }); - const afterList = listSurface(dir, manifest, CLUSTERS); - - assert.ok(afterList.enabled.length <= beforeList.enabled.length, - 'disabling utility cluster should not increase enabled count'); - assert.ok(afterList.tokenCost <= beforeList.tokenCost, - 'disabling a cluster should not increase token cost'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('tokenCost is sum of description char lengths ÷ 4 for enabled skills', () => { - const dir = tmpDir(); - try { - fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); - writeActiveProfile(dir, 'core'); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = listSurface(dir, manifest, CLUSTERS); - - // Manually compute expected token cost for enabled skills - let expected = 0; - for (const stem of result.enabled) { - const filePath = path.join(REAL_COMMANDS_DIR, `${stem}.md`); - if (!fs.existsSync(filePath)) continue; - const markdown = fs.readFileSync(filePath, 'utf8'); - const description = readFrontmatterDescription(markdown); - if (description) expected += Math.ceil(description.length / 4); - } - - assert.strictEqual(result.tokenCost, expected, 'tokenCost must equal sum of description lengths ÷ 4'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('enabled and disabled arrays are sorted', () => { - const dir = tmpDir(); - try { - fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); - writeActiveProfile(dir, 'standard'); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = listSurface(dir, manifest, CLUSTERS); - - assert.deepStrictEqual(result.enabled, [...result.enabled].sort()); - assert.deepStrictEqual(result.disabled, [...result.disabled].sort()); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/tests/surface-resolve.test.cjs b/tests/surface-resolve.test.cjs deleted file mode 100644 index e8dc7aefd..000000000 --- a/tests/surface-resolve.test.cjs +++ /dev/null @@ -1,194 +0,0 @@ -'use strict'; -/** - * Tests for resolveSurface — profile + cluster + explicit combinations. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const { readSurface, writeSurface, resolveSurface } = require('../get-shit-done/bin/lib/surface.cjs'); -const { resolveProfile, loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs'); -const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); - -function tmpDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-resolve-')); -} - -function realManifest() { - return loadSkillsManifest(REAL_COMMANDS_DIR); -} - -describe('resolveSurface', () => { - test('no surface state + core base profile → identical to resolveProfile core', () => { - const dir = tmpDir(); - try { - writeActiveProfile(dir, 'core'); - const manifest = realManifest(); - const surfaceResolved = resolveSurface(dir, manifest, CLUSTERS); - const profileResolved = resolveProfile({ modes: ['core'], manifest }); - - // Both should have same skill sets - assert.ok(surfaceResolved.skills instanceof Set); - assert.ok(profileResolved.skills instanceof Set); - assert.deepStrictEqual( - [...surfaceResolved.skills].sort(), - [...profileResolved.skills].sort(), - 'surface with no state should equal profile resolution' - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('standard base + disabledClusters:["utility"] removes utility skills', () => { - const dir = tmpDir(); - try { - writeActiveProfile(dir, 'standard'); - writeSurface(dir, { - baseProfile: 'standard', - disabledClusters: ['utility'], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = realManifest(); - const resolved = resolveSurface(dir, manifest, CLUSTERS); - - assert.ok(resolved.skills instanceof Set); - // Utility cluster members should not be in the result - for (const stem of CLUSTERS.utility) { - // Only check stems that were actually in the standard profile - const standardResolved = resolveProfile({ modes: ['standard'], manifest }); - if (standardResolved.skills.has(stem)) { - assert.ok( - !resolved.skills.has(stem), - `"${stem}" should be removed by disabling utility cluster` - ); - } - } - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('explicitAdds:["sketch"] adds sketch to a core install', () => { - const dir = tmpDir(); - try { - writeActiveProfile(dir, 'core'); - writeSurface(dir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: ['sketch'], - explicitRemoves: [], - }); - const manifest = realManifest(); - const resolved = resolveSurface(dir, manifest, CLUSTERS); - - assert.ok(resolved.skills instanceof Set); - assert.ok(resolved.skills.has('sketch'), 'sketch must be in resolved skills'); - - // Transitive requires of sketch should also be present - const sketchRequires = manifest.get('sketch') || []; - for (const dep of sketchRequires) { - assert.ok(resolved.skills.has(dep), `transitive dep "${dep}" of sketch must be present`); - } - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('explicitRemoves removes individual skill stems', () => { - const dir = tmpDir(); - try { - writeSurface(dir, { - baseProfile: 'standard', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: ['progress'], - }); - writeActiveProfile(dir, 'standard'); - const manifest = realManifest(); - const resolved = resolveSurface(dir, manifest, CLUSTERS); - - // standard includes 'progress', so removing it should take it out - assert.ok(!resolved.skills.has('progress'), '"progress" must be removed by explicitRemoves'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('result is a Set with name property', () => { - const dir = tmpDir(); - try { - writeActiveProfile(dir, 'core'); - const manifest = realManifest(); - const resolved = resolveSurface(dir, manifest, CLUSTERS); - - assert.ok(resolved.skills instanceof Set); - assert.ok(typeof resolved.name === 'string'); - assert.ok(resolved.agents instanceof Set); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('surface with baseProfile overrides .gsd-profile marker', () => { - const dir = tmpDir(); - try { - writeActiveProfile(dir, 'core'); - writeSurface(dir, { - baseProfile: 'standard', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = realManifest(); - const resolved = resolveSurface(dir, manifest, CLUSTERS); - const standardResolved = resolveProfile({ modes: ['standard'], manifest }); - - // Should use standard profile from surface state, not core from marker - assert.deepStrictEqual( - [...resolved.skills].sort(), - [...standardResolved.skills].sort(), - 'surface baseProfile takes precedence over marker' - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('disabled cluster + explicitAdds can re-add specific skills from disabled cluster', () => { - const dir = tmpDir(); - try { - writeSurface(dir, { - baseProfile: 'standard', - disabledClusters: ['workspace_state'], - explicitAdds: ['capture'], - explicitRemoves: [], - }); - writeActiveProfile(dir, 'standard'); - const manifest = realManifest(); - const resolved = resolveSurface(dir, manifest, CLUSTERS); - - // workspace_state is disabled, but capture is explicitly re-added - assert.ok(resolved.skills.has('capture'), '"capture" must be present via explicitAdds'); - // Other workspace_state members that were in standard should be gone - const standardResolved = resolveProfile({ modes: ['standard'], manifest }); - for (const stem of CLUSTERS.workspace_state) { - if (stem === 'capture') continue; - if (standardResolved.skills.has(stem)) { - assert.ok( - !resolved.skills.has(stem), - `"${stem}" should be removed (workspace_state disabled, not explicitly re-added)` - ); - } - } - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/tests/surface-state.test.cjs b/tests/surface-state.test.cjs deleted file mode 100644 index 09dcd7e1b..000000000 --- a/tests/surface-state.test.cjs +++ /dev/null @@ -1,165 +0,0 @@ -'use strict'; -/** - * Tests for readSurface / writeSurface — state IO round-trips. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const { readSurface, writeSurface } = require('../get-shit-done/bin/lib/surface.cjs'); - -function tmpDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-state-')); -} - -describe('readSurface / writeSurface', () => { - test('round-trips a complete surface state', () => { - const dir = tmpDir(); - try { - const state = { - baseProfile: 'standard', - disabledClusters: ['utility'], - explicitAdds: ['sketch'], - explicitRemoves: [], - }; - writeSurface(dir, state); - const read = readSurface(dir); - assert.deepStrictEqual(read, state); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('round-trips empty arrays', () => { - const dir = tmpDir(); - try { - const state = { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }; - writeSurface(dir, state); - assert.deepStrictEqual(readSurface(dir), state); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('round-trips composed base profile', () => { - const dir = tmpDir(); - try { - const state = { - baseProfile: 'core,audit', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: ['health'], - }; - writeSurface(dir, state); - assert.deepStrictEqual(readSurface(dir), state); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('missing file returns null', () => { - const dir = tmpDir(); - try { - const result = readSurface(dir); - assert.strictEqual(result, null); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('non-existent directory returns null', () => { - const ghost = path.join(os.tmpdir(), 'gsd-surface-no-exist-' + Date.now()); - const result = readSurface(ghost); - assert.strictEqual(result, null); - }); - - test('corrupt JSON returns null', () => { - const dir = tmpDir(); - try { - fs.writeFileSync(path.join(dir, '.gsd-surface.json'), '{not valid json', 'utf8'); - const result = readSurface(dir); - assert.strictEqual(result, null); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('JSON missing baseProfile field returns null', () => { - const dir = tmpDir(); - try { - fs.writeFileSync( - path.join(dir, '.gsd-surface.json'), - JSON.stringify({ disabledClusters: [], explicitAdds: [], explicitRemoves: [] }), - 'utf8' - ); - const result = readSurface(dir); - assert.strictEqual(result, null); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('JSON with non-array disabledClusters returns null', () => { - const dir = tmpDir(); - try { - fs.writeFileSync( - path.join(dir, '.gsd-surface.json'), - JSON.stringify({ baseProfile: 'standard', disabledClusters: 'utility', explicitAdds: [], explicitRemoves: [] }), - 'utf8' - ); - const result = readSurface(dir); - assert.strictEqual(result, null); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('atomic write: result file is never a partial tmp file', () => { - const dir = tmpDir(); - try { - const state = { baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }; - writeSurface(dir, state); - // No .tmp.* files should remain - const files = fs.readdirSync(dir); - const tmpFiles = files.filter(f => f.includes('.tmp.')); - assert.deepStrictEqual(tmpFiles, [], 'no tmp files should remain after write'); - // The canonical file exists - assert.ok(files.includes('.gsd-surface.json')); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('second write overwrites first', () => { - const dir = tmpDir(); - try { - writeSurface(dir, { baseProfile: 'core', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }); - writeSurface(dir, { baseProfile: 'standard', disabledClusters: ['utility'], explicitAdds: [], explicitRemoves: [] }); - const read = readSurface(dir); - assert.strictEqual(read.baseProfile, 'standard'); - assert.deepStrictEqual(read.disabledClusters, ['utility']); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('writeSurface creates directory if it does not exist', () => { - const base = tmpDir(); - const nested = path.join(base, 'skills', 'subdir'); - try { - writeSurface(nested, { baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }); - assert.ok(fs.existsSync(nested)); - assert.ok(readSurface(nested) !== null); - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } - }); -});