merge: sync Kilo runtime branch with main
Bring the latest main branch updates into feat/kilo-runtime-support while preserving KILO_CONFIG resolution, Kilo agent permission conversion, and relative .claude path rewrites.
This commit is contained in:
@@ -386,55 +386,23 @@ describe('DISCUSS: discussion log generation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Worktree Permission Mode (#1334) ───────────────────────────────────────
|
||||
// ─── Cross-runtime agent compatibility (#1522) ──────────────────────────────
|
||||
|
||||
describe('PERM: worktree agents have permissionMode: acceptEdits', () => {
|
||||
// Agents spawned with isolation="worktree" need permissionMode: acceptEdits
|
||||
// to avoid per-directory edit permission prompts in the worktree path.
|
||||
// See: anthropics/claude-code#29110, anthropics/claude-code#28041
|
||||
const WORKTREE_AGENTS = ['gsd-executor', 'gsd-debugger'];
|
||||
describe('COMPAT: agents must not use runtime-specific frontmatter keys', () => {
|
||||
// permissionMode is Claude Code-specific and breaks Gemini CLI agent loading.
|
||||
// It also has no effect on subagent Write permissions in Claude Code (blocked
|
||||
// at runtime level regardless). See #1522, #1387.
|
||||
const AGENTS_WITH_WRITE = ['gsd-executor', 'gsd-debugger'];
|
||||
|
||||
for (const agent of WORKTREE_AGENTS) {
|
||||
test(`${agent} has permissionMode: acceptEdits`, () => {
|
||||
for (const agent of AGENTS_WITH_WRITE) {
|
||||
test(`${agent} does not have permissionMode (breaks Gemini CLI)`, () => {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, agent + '.md'), 'utf-8');
|
||||
const frontmatter = content.split('---')[1] || '';
|
||||
assert.ok(
|
||||
frontmatter.includes('permissionMode: acceptEdits'),
|
||||
`${agent} must have permissionMode: acceptEdits — worktree agents need this to avoid ` +
|
||||
`per-directory edit permission prompts (see #1334)`
|
||||
!frontmatter.includes('permissionMode'),
|
||||
`${agent} must not have permissionMode — it breaks Gemini CLI agent loading (#1522) ` +
|
||||
`and has no effect in Claude Code (#1387)`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('worktree-spawned agents are covered', () => {
|
||||
// Verify that agents referenced with isolation="worktree" in workflows
|
||||
// are included in the WORKTREE_AGENTS list above
|
||||
const dirs = [WORKFLOWS_DIR, COMMANDS_DIR];
|
||||
const worktreeAgentTypes = new Set();
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(path.join(dir, file), 'utf-8');
|
||||
// Find patterns like: subagent_type="gsd-executor" ... isolation="worktree"
|
||||
// These can span multiple lines in Task() calls
|
||||
const taskBlocks = content.match(/Task\([^)]*isolation="worktree"[^)]*\)/gs) || [];
|
||||
for (const block of taskBlocks) {
|
||||
const typeMatch = block.match(/subagent_type="([^"]+)"/);
|
||||
if (typeMatch) {
|
||||
worktreeAgentTypes.add(typeMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentType of worktreeAgentTypes) {
|
||||
assert.ok(
|
||||
WORKTREE_AGENTS.includes(agentType),
|
||||
`${agentType} is spawned with isolation="worktree" but not in WORKTREE_AGENTS list — ` +
|
||||
`add permissionMode: acceptEdits to its frontmatter and update this test`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('generate-claude-md', () => {
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.action, 'created');
|
||||
assert.strictEqual(output.sections_total, 5);
|
||||
assert.strictEqual(output.sections_total, 6);
|
||||
assert.ok(output.sections_generated.includes('workflow'));
|
||||
|
||||
const claudePath = path.join(tmpDir, 'CLAUDE.md');
|
||||
@@ -80,3 +80,170 @@ describe('new-project workflow includes CLAUDE.md generation', () => {
|
||||
assert.ok(commandsContent.includes('`CLAUDE.md`'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate-claude-md skills section', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
'# Test Project\n\n## What This Is\n\nA test project.\n'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('includes skills fallback when no skills directories exist', () => {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(output.sections_fallback.includes('skills'));
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('<!-- GSD:skills-start'));
|
||||
assert.ok(content.includes('<!-- GSD:skills-end -->'));
|
||||
assert.ok(content.includes('No project skills found. Add skills to any of'));
|
||||
});
|
||||
|
||||
test('discovers skills from .claude/skills/ directory', () => {
|
||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'api-payments');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: api-payments\ndescription: Payment gateway integration.\n---\n\n# API Payments\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(output.sections_generated.includes('skills'));
|
||||
assert.ok(!output.sections_fallback.includes('skills'));
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('api-payments'));
|
||||
assert.ok(content.includes('Payment gateway integration'));
|
||||
assert.ok(content.includes('## Project Skills'));
|
||||
});
|
||||
|
||||
test('discovers skills from .agents/skills/ directory', () => {
|
||||
const skillDir = path.join(tmpDir, '.agents', 'skills', 'data-sync');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: data-sync\ndescription: ERP synchronization flows.\n---\n\n# Data Sync\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('data-sync'));
|
||||
assert.ok(content.includes('ERP synchronization flows'));
|
||||
});
|
||||
|
||||
test('skips gsd- prefixed skill directories', () => {
|
||||
const gsdSkillDir = path.join(tmpDir, '.claude', 'skills', 'gsd-plan-phase');
|
||||
const userSkillDir = path.join(tmpDir, '.claude', 'skills', 'my-feature');
|
||||
fs.mkdirSync(gsdSkillDir, { recursive: true });
|
||||
fs.mkdirSync(userSkillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(gsdSkillDir, 'SKILL.md'),
|
||||
'---\nname: gsd-plan-phase\ndescription: GSD internal skill.\n---\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(userSkillDir, 'SKILL.md'),
|
||||
'---\nname: my-feature\ndescription: Custom project skill.\n---\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(!content.includes('gsd-plan-phase'));
|
||||
assert.ok(content.includes('my-feature'));
|
||||
assert.ok(content.includes('Custom project skill'));
|
||||
});
|
||||
|
||||
test('handles multi-line description in frontmatter', () => {
|
||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'complex-skill');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: complex-skill\ndescription: First line of description.\n Continued on second line.\n And a third line.\n---\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('First line of description'));
|
||||
assert.ok(content.includes('Continued on second line'));
|
||||
assert.ok(content.includes('And a third line'));
|
||||
});
|
||||
|
||||
test('deduplicates skills found in multiple directories', () => {
|
||||
// Same skill in both .claude/skills/ and .agents/skills/
|
||||
const dir1 = path.join(tmpDir, '.claude', 'skills', 'shared-skill');
|
||||
const dir2 = path.join(tmpDir, '.agents', 'skills', 'shared-skill');
|
||||
fs.mkdirSync(dir1, { recursive: true });
|
||||
fs.mkdirSync(dir2, { recursive: true });
|
||||
const skillContent = '---\nname: shared-skill\ndescription: Appears twice.\n---\n';
|
||||
fs.writeFileSync(path.join(dir1, 'SKILL.md'), skillContent);
|
||||
fs.writeFileSync(path.join(dir2, 'SKILL.md'), skillContent);
|
||||
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const matches = content.match(/shared-skill/g);
|
||||
// Should appear exactly twice: once in name column, once in path column (single row)
|
||||
assert.strictEqual(matches.length, 2);
|
||||
});
|
||||
|
||||
test('updates existing skills section on regeneration', () => {
|
||||
// First generation — no skills
|
||||
runGsdTools('generate-claude-md', tmpDir);
|
||||
let content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('No project skills found'));
|
||||
|
||||
// Add a skill and regenerate
|
||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'new-skill');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: new-skill\ndescription: Just added.\n---\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(!content.includes('No project skills found'));
|
||||
assert.ok(content.includes('new-skill'));
|
||||
assert.ok(content.includes('Just added'));
|
||||
});
|
||||
|
||||
test('skills section appears between architecture and workflow', () => {
|
||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'ordering-test');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: ordering-test\ndescription: Verify section order.\n---\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const archIdx = content.indexOf('## Architecture');
|
||||
const skillsIdx = content.indexOf('## Project Skills');
|
||||
const workflowIdx = content.indexOf('## GSD Workflow Enforcement');
|
||||
assert.ok(archIdx < skillsIdx, 'Skills section should come after Architecture');
|
||||
assert.ok(skillsIdx < workflowIdx, 'Skills section should come before Workflow Enforcement');
|
||||
});
|
||||
});
|
||||
|
||||
378
tests/claude-skills-migration.test.cjs
Normal file
378
tests/claude-skills-migration.test.cjs
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* GSD Tools Tests - Claude Skills Migration (#1504)
|
||||
*
|
||||
* Tests for migrating Claude Code from commands/gsd/ to skills/gsd-xxx/SKILL.md
|
||||
* format for compatibility with Claude Code 2.1.88+.
|
||||
*
|
||||
* Uses node:test and node:assert (NOT Jest).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
copyCommandsAsClaudeSkills,
|
||||
writeManifest,
|
||||
install,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// ─── convertClaudeCommandToClaudeSkill ──────────────────────────────────────
|
||||
|
||||
describe('convertClaudeCommandToClaudeSkill', () => {
|
||||
test('preserves allowed-tools multiline YAML list', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:next',
|
||||
'description: Advance to the next step',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
' - Grep',
|
||||
'---',
|
||||
'',
|
||||
'Body content here.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next');
|
||||
assert.ok(result.includes('allowed-tools:'), 'allowed-tools field is present');
|
||||
assert.ok(result.includes('Read'), 'Read tool preserved');
|
||||
assert.ok(result.includes('Bash'), 'Bash tool preserved');
|
||||
assert.ok(result.includes('Grep'), 'Grep tool preserved');
|
||||
});
|
||||
|
||||
test('preserves argument-hint', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:debug',
|
||||
'description: Debug issues',
|
||||
'argument-hint: "[issue description]"',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
'---',
|
||||
'',
|
||||
'Debug body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-debug');
|
||||
assert.ok(result.includes('argument-hint:'), 'argument-hint field is present');
|
||||
// The value should be preserved (possibly yaml-quoted)
|
||||
assert.ok(
|
||||
result.includes('[issue description]'),
|
||||
'argument-hint value preserved'
|
||||
);
|
||||
});
|
||||
|
||||
test('converts name format from gsd:xxx to skill naming', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:next',
|
||||
'description: Advance workflow',
|
||||
'---',
|
||||
'',
|
||||
'Body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next');
|
||||
assert.ok(result.includes('name: gsd-next'), 'name uses skill naming convention');
|
||||
assert.ok(!result.includes('name: gsd:next'), 'old name format removed');
|
||||
});
|
||||
|
||||
test('preserves body content unchanged', () => {
|
||||
const body = '\n<objective>\nDo the thing.\n</objective>\n\n<process>\nStep 1.\nStep 2.\n</process>\n';
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test',
|
||||
'description: Test command',
|
||||
'---',
|
||||
body,
|
||||
].join('');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-test');
|
||||
assert.ok(result.includes('<objective>'), 'objective tag preserved');
|
||||
assert.ok(result.includes('Do the thing.'), 'body text preserved');
|
||||
assert.ok(result.includes('<process>'), 'process tag preserved');
|
||||
assert.ok(result.includes('Step 1.'), 'step text preserved');
|
||||
});
|
||||
|
||||
test('preserves agent field', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:plan-phase',
|
||||
'description: Plan a phase',
|
||||
'agent: true',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
'---',
|
||||
'',
|
||||
'Plan body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-plan-phase');
|
||||
assert.ok(result.includes('agent:'), 'agent field is present');
|
||||
});
|
||||
|
||||
test('handles content with no frontmatter', () => {
|
||||
const input = 'Just some plain markdown content.';
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-plain');
|
||||
assert.strictEqual(result, input, 'content returned unchanged');
|
||||
});
|
||||
|
||||
test('preserves allowed-tools as multiline YAML list (not flattened)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:debug',
|
||||
'description: Debug',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
' - Task',
|
||||
' - AskUserQuestion',
|
||||
'---',
|
||||
'',
|
||||
'Body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-debug');
|
||||
// Claude Code native format keeps YAML multiline list
|
||||
assert.ok(result.includes(' - Read'), 'Read in multiline list');
|
||||
assert.ok(result.includes(' - Bash'), 'Bash in multiline list');
|
||||
assert.ok(result.includes(' - Task'), 'Task in multiline list');
|
||||
assert.ok(result.includes(' - AskUserQuestion'), 'AskUserQuestion in multiline list');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── copyCommandsAsClaudeSkills ─────────────────────────────────────────────
|
||||
|
||||
describe('copyCommandsAsClaudeSkills', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-claude-skills-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('creates correct directory structure skills/gsd-xxx/SKILL.md', () => {
|
||||
// Create source commands
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'next.md'),
|
||||
'---\nname: gsd:next\ndescription: Advance\nallowed-tools:\n - Read\n---\n\nBody.'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'health.md'),
|
||||
'---\nname: gsd:health\ndescription: Check health\n---\n\nHealth body.'
|
||||
);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
|
||||
// Verify directory structure
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
||||
'skills/gsd-next/SKILL.md exists'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-health', 'SKILL.md')),
|
||||
'skills/gsd-health/SKILL.md exists'
|
||||
);
|
||||
});
|
||||
|
||||
test('cleans up old skills before installing new ones', () => {
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'next.md'),
|
||||
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
|
||||
);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
// Create a stale skill that should be removed
|
||||
const staleDir = path.join(skillsDir, 'gsd-old-command');
|
||||
fs.mkdirSync(staleDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
|
||||
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
|
||||
// Stale skill removed
|
||||
assert.ok(
|
||||
!fs.existsSync(staleDir),
|
||||
'stale skill directory removed'
|
||||
);
|
||||
// New skill created
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
||||
'new skill created'
|
||||
);
|
||||
});
|
||||
|
||||
test('does not remove non-GSD skills', () => {
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'next.md'),
|
||||
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
|
||||
);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
// Create a non-GSD skill
|
||||
const otherDir = path.join(skillsDir, 'my-custom-skill');
|
||||
fs.mkdirSync(otherDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(otherDir, 'SKILL.md'), 'custom content');
|
||||
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
|
||||
// Non-GSD skill preserved
|
||||
assert.ok(
|
||||
fs.existsSync(otherDir),
|
||||
'non-GSD skill preserved'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles recursive subdirectories', () => {
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
const subDir = path.join(srcDir, 'wired');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(subDir, 'ready.md'),
|
||||
'---\nname: gsd-wired:ready\ndescription: Show ready tasks\n---\n\nBody.'
|
||||
);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-wired-ready', 'SKILL.md')),
|
||||
'nested command creates gsd-wired-ready/SKILL.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('no-ops when source directory does not exist', () => {
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
// Should not throw
|
||||
copyCommandsAsClaudeSkills(
|
||||
path.join(tmpDir, 'nonexistent'),
|
||||
skillsDir,
|
||||
'gsd',
|
||||
'$HOME/.claude/',
|
||||
'claude',
|
||||
true
|
||||
);
|
||||
assert.ok(!fs.existsSync(skillsDir), 'skills dir not created when src missing');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Legacy cleanup during install ──────────────────────────────────────────
|
||||
|
||||
describe('Legacy commands/gsd/ cleanup', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-legacy-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('install removes legacy commands/gsd/ directory when present', () => {
|
||||
// Create a mock legacy commands/gsd/ directory
|
||||
const legacyDir = path.join(tmpDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'next.md'), 'legacy content');
|
||||
|
||||
// Create source commands for the installer to read
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'next.md'),
|
||||
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
|
||||
);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
// Install skills
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
|
||||
// Simulate the legacy cleanup that install() does after copyCommandsAsClaudeSkills
|
||||
if (fs.existsSync(legacyDir)) {
|
||||
fs.rmSync(legacyDir, { recursive: true });
|
||||
}
|
||||
|
||||
assert.ok(!fs.existsSync(legacyDir), 'legacy commands/gsd/ removed');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
||||
'new skill installed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── writeManifest tracks skills/ for Claude ────────────────────────────────
|
||||
|
||||
describe('writeManifest tracks skills/ for Claude', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('manifest includes skills/gsd-xxx/SKILL.md entries for Claude runtime', () => {
|
||||
// Create skills directory structure (as install would)
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
const skillDir = path.join(skillsDir, 'gsd-next');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'skill content');
|
||||
|
||||
// Create get-shit-done directory (required by writeManifest)
|
||||
const gsdDir = path.join(tmpDir, 'get-shit-done');
|
||||
fs.mkdirSync(gsdDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(gsdDir, 'test.md'), 'test');
|
||||
|
||||
writeManifest(tmpDir, 'claude');
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8')
|
||||
);
|
||||
|
||||
// Should have skills/ entries
|
||||
const skillEntries = Object.keys(manifest.files).filter(k =>
|
||||
k.startsWith('skills/')
|
||||
);
|
||||
assert.ok(skillEntries.length > 0, 'manifest has skills/ entries');
|
||||
assert.ok(
|
||||
skillEntries.some(k => k === 'skills/gsd-next/SKILL.md'),
|
||||
'manifest has skills/gsd-next/SKILL.md'
|
||||
);
|
||||
|
||||
// Should NOT have commands/gsd/ entries
|
||||
const cmdEntries = Object.keys(manifest.files).filter(k =>
|
||||
k.startsWith('commands/gsd/')
|
||||
);
|
||||
assert.strictEqual(cmdEntries.length, 0, 'manifest has no commands/gsd/ entries');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Exports exist ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Claude skills migration exports', () => {
|
||||
test('convertClaudeCommandToClaudeSkill is exported', () => {
|
||||
assert.strictEqual(typeof convertClaudeCommandToClaudeSkill, 'function');
|
||||
});
|
||||
|
||||
test('copyCommandsAsClaudeSkills is exported', () => {
|
||||
assert.strictEqual(typeof copyCommandsAsClaudeSkills, 'function');
|
||||
});
|
||||
});
|
||||
@@ -169,6 +169,21 @@ Run /gsd:execute-phase to proceed.`;
|
||||
const result = convertClaudeAgentToCodexAgent(input);
|
||||
assert.strictEqual(result, input, 'returns input unchanged');
|
||||
});
|
||||
|
||||
test('replaces .claude paths with .codex paths (#1430)', () => {
|
||||
const input = `---
|
||||
name: gsd-debugger
|
||||
description: Debugs issues
|
||||
tools: Read, Bash
|
||||
---
|
||||
|
||||
INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state load)
|
||||
node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs: resolve"`;
|
||||
|
||||
const result = convertClaudeAgentToCodexAgent(input);
|
||||
assert.ok(result.includes('$HOME/.codex/get-shit-done/bin/gsd-tools.cjs'), 'replaces $HOME/.claude/ with $HOME/.codex/');
|
||||
assert.ok(!result.includes('$HOME/.claude/'), 'no .claude paths remain');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── generateCodexAgentToml ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1250,6 +1250,41 @@ describe('commit command', () => {
|
||||
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
||||
assert.strictEqual(branch, 'gsd/phase-01-setup', 'should be on phase branch');
|
||||
});
|
||||
|
||||
test('decimal phase numbers are captured correctly in branching strategy', () => {
|
||||
// Configure phase branching strategy
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
commit_docs: true,
|
||||
branching_strategy: 'phase',
|
||||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||||
})
|
||||
);
|
||||
// Create ROADMAP.md with a decimal phase
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '45.14-golden-capture'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap\n\n## Phase 45.14: Golden Capture\nGoal: Capture golden standard\n'
|
||||
);
|
||||
|
||||
// Create a context file for phase 45.14
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '45.14-golden-capture', '45.14-CONTEXT.md'), '# Context\n');
|
||||
|
||||
const result = runGsdTools(
|
||||
'commit "docs(45.14): add context" --files .planning/phases/45.14-golden-capture/45.14-CONTEXT.md',
|
||||
tmpDir
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.committed, true, 'should have committed');
|
||||
|
||||
// Verify we're on the correct branch (45.14, not 14)
|
||||
const { execFileSync } = require('child_process');
|
||||
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
||||
assert.strictEqual(branch, 'gsd/phase-45.14-golden-capture', 'should be on decimal phase branch, not integer-only');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1409,11 +1444,12 @@ describe('stats command', () => {
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.mkdirSync(p2, { recursive: true });
|
||||
|
||||
// Phase 1: 2 plans, 2 summaries (complete)
|
||||
// Phase 1: 2 plans, 2 summaries, passing verification (complete)
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
|
||||
|
||||
// Phase 2: 1 plan, 0 summaries (planned)
|
||||
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
|
||||
@@ -1485,8 +1521,10 @@ describe('stats command', () => {
|
||||
fs.mkdirSync(p2, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '14-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '14-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
||||
fs.writeFileSync(path.join(p2, '15-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p2, '15-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p2, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
@@ -1569,6 +1607,7 @@ describe('stats command', () => {
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
||||
|
||||
const result = runGsdTools('stats table', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
@@ -1580,4 +1619,78 @@ describe('stats command', () => {
|
||||
assert.ok(parsed.rendered.includes('| 1 |'), 'should include phase row');
|
||||
assert.ok(parsed.rendered.includes('1/1 phases'), 'should report phase progress');
|
||||
});
|
||||
|
||||
test('phase with summaries but no verification is Executed, not Complete', () => {
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
||||
assert.strictEqual(phase.status, 'Executed', 'should be Executed without verification');
|
||||
assert.strictEqual(stats.phases_completed, 0, 'unverified phase should not count as completed');
|
||||
});
|
||||
|
||||
test('phase with passing verification is Complete', () => {
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
||||
assert.strictEqual(phase.status, 'Complete', 'should be Complete with passing verification');
|
||||
assert.strictEqual(stats.phases_completed, 1);
|
||||
});
|
||||
|
||||
test('phase with gaps_found verification is Executed', () => {
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Verification');
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
||||
assert.strictEqual(phase.status, 'Executed', 'gaps_found should show as Executed');
|
||||
});
|
||||
|
||||
test('phase with human_needed verification shows Needs Review', () => {
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: human_needed\n---\n# Verification');
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
||||
assert.strictEqual(phase.status, 'Needs Review', 'human_needed should show as Needs Review');
|
||||
});
|
||||
|
||||
test('progress command also uses verification-aware status', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap v1.0 MVP\n`
|
||||
);
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('progress json', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,6 +232,16 @@ describe('config-set command', () => {
|
||||
assert.strictEqual(config.workflow.text_mode, true);
|
||||
});
|
||||
|
||||
test('sets workflow.use_worktrees to disable worktree isolation', () => {
|
||||
writeConfig(tmpDir, {});
|
||||
|
||||
const result = runGsdTools('config-set workflow.use_worktrees false', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const config = readConfig(tmpDir);
|
||||
assert.strictEqual(config.workflow.use_worktrees, false);
|
||||
});
|
||||
|
||||
test('errors when no key path provided', () => {
|
||||
const result = runGsdTools('config-set', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
@@ -765,3 +775,58 @@ describe('config-set workflow.skip_discuss', () => {
|
||||
assert.strictEqual(output, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── config-set/config-get workflow.use_worktrees ────────────────────────────
|
||||
|
||||
describe('config-set/config-get workflow.use_worktrees', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('config-get workflow.use_worktrees returns false after setting to false', () => {
|
||||
runGsdTools('config-set workflow.use_worktrees false', tmpDir);
|
||||
const result = runGsdTools('config-get workflow.use_worktrees', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output, false);
|
||||
});
|
||||
|
||||
test('config-get workflow.use_worktrees errors when not set (default config)', () => {
|
||||
// config-ensure-section does NOT include use_worktrees in hardcoded defaults,
|
||||
// so config-get should error with "Key not found". This is the expected behavior
|
||||
// that workflows rely on: the shell fallback `|| echo "true"` provides the default.
|
||||
const result = runGsdTools('config-get workflow.use_worktrees', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(
|
||||
result.error.includes('Key not found'),
|
||||
`Expected "Key not found" in error: ${result.error}`
|
||||
);
|
||||
});
|
||||
|
||||
test('config-get workflow.use_worktrees returns true after setting to true', () => {
|
||||
runGsdTools('config-set workflow.use_worktrees true', tmpDir);
|
||||
const result = runGsdTools('config-get workflow.use_worktrees', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output, true);
|
||||
});
|
||||
|
||||
test('use_worktrees can be toggled back and forth', () => {
|
||||
runGsdTools('config-set workflow.use_worktrees false', tmpDir);
|
||||
runGsdTools('config-set workflow.use_worktrees true', tmpDir);
|
||||
const result = runGsdTools('config-get workflow.use_worktrees', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1174,6 +1174,8 @@ describe('E2E: Copilot full install verification', () => {
|
||||
'gsd-assumptions-analyzer.agent.md',
|
||||
'gsd-codebase-mapper.agent.md',
|
||||
'gsd-debugger.agent.md',
|
||||
'gsd-doc-verifier.agent.md',
|
||||
'gsd-doc-writer.agent.md',
|
||||
'gsd-executor.agent.md',
|
||||
'gsd-integration-checker.agent.md',
|
||||
'gsd-nyquist-auditor.agent.md',
|
||||
@@ -1183,6 +1185,7 @@ describe('E2E: Copilot full install verification', () => {
|
||||
'gsd-project-researcher.agent.md',
|
||||
'gsd-research-synthesizer.agent.md',
|
||||
'gsd-roadmapper.agent.md',
|
||||
'gsd-security-auditor.agent.md',
|
||||
'gsd-ui-auditor.agent.md',
|
||||
'gsd-ui-checker.agent.md',
|
||||
'gsd-ui-researcher.agent.md',
|
||||
|
||||
@@ -366,6 +366,17 @@ describe('generateSlugInternal', () => {
|
||||
test('returns null for empty string', () => {
|
||||
assert.strictEqual(generateSlugInternal(''), null);
|
||||
});
|
||||
|
||||
test('strips newlines and control characters', () => {
|
||||
assert.strictEqual(generateSlugInternal('hello\nworld'), 'hello-world');
|
||||
assert.strictEqual(generateSlugInternal('tab\there'), 'tab-here');
|
||||
});
|
||||
|
||||
test('truncates to 60 characters', () => {
|
||||
const long = 'a'.repeat(100);
|
||||
const result = generateSlugInternal(long);
|
||||
assert.ok(result.length <= 60, `slug should be <=60 chars, got ${result.length}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizePhaseName / comparePhaseNum ──────────────────────────────────────
|
||||
@@ -996,19 +1007,54 @@ describe('stale hook filter', () => {
|
||||
// ─── stale hook path regression (#1249) ──────────────────────────────────────
|
||||
|
||||
describe('stale hook path', () => {
|
||||
test('gsd-check-update.js checks get-shit-done/hooks/ not configDir/hooks/', () => {
|
||||
test('gsd-check-update.js checks configDir/hooks/ where hooks are actually installed (#1421)', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'hooks', 'gsd-check-update.js'), 'utf-8'
|
||||
);
|
||||
// Hooks are installed at configDir/hooks/ (e.g. ~/.claude/hooks/),
|
||||
// not configDir/get-shit-done/hooks/ which doesn't exist (#1421)
|
||||
assert.ok(
|
||||
content.includes("path.join(configDir, 'get-shit-done', 'hooks')"),
|
||||
'stale hook check must look in configDir/get-shit-done/hooks/, not configDir/hooks/'
|
||||
content.includes("path.join(configDir, 'hooks')"),
|
||||
'stale hook check must look in configDir/hooks/ where hooks are actually installed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── shared cache directory regression (#1421) ─────────────────────────────────
|
||||
|
||||
describe('shared cache directory (#1421)', () => {
|
||||
test('gsd-check-update.js writes cache to shared ~/.cache/gsd/ directory', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'hooks', 'gsd-check-update.js'), 'utf-8'
|
||||
);
|
||||
// Cache must use a tool-agnostic path so statusline can find it
|
||||
// regardless of which runtime (Claude, Gemini, OpenCode) ran the check
|
||||
assert.ok(
|
||||
!content.includes("path.join(configDir, 'hooks')") ||
|
||||
content.indexOf("path.join(configDir, 'get-shit-done', 'hooks')") <
|
||||
content.indexOf("path.join(configDir, 'hooks')") + 100, // allow the old pattern only if corrected version exists first
|
||||
'should not use the wrong hooks path'
|
||||
content.includes("path.join(homeDir, '.cache', 'gsd')"),
|
||||
'check-update must write cache to ~/.cache/gsd/ (shared, tool-agnostic)'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-statusline.js checks shared cache first, falls back to legacy (#1421)', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'hooks', 'gsd-statusline.js'), 'utf-8'
|
||||
);
|
||||
// Statusline must check the shared cache path first
|
||||
assert.ok(
|
||||
content.includes("path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json')"),
|
||||
'statusline must check shared cache at ~/.cache/gsd/gsd-update-check.json'
|
||||
);
|
||||
// Must fall back to legacy runtime-specific cache for backward compat
|
||||
assert.ok(
|
||||
content.includes("path.join(claudeDir, 'cache', 'gsd-update-check.json')"),
|
||||
'statusline must fall back to legacy cache at claudeDir/cache/gsd-update-check.json'
|
||||
);
|
||||
// Shared cache must be checked before legacy (existsSync order matters)
|
||||
const sharedIdx = content.indexOf('sharedCacheFile');
|
||||
const legacyIdx = content.indexOf('legacyCacheFile');
|
||||
assert.ok(
|
||||
sharedIdx < legacyIdx,
|
||||
'shared cache must be defined and checked before legacy cache'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
72
tests/discuss-checkpoint.test.cjs
Normal file
72
tests/discuss-checkpoint.test.cjs
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* GSD Tools Tests - discuss-phase incremental checkpoint saves
|
||||
*
|
||||
* Validates that the discuss-phase workflow includes incremental
|
||||
* checkpoint logic to prevent answer loss on session interruption.
|
||||
*
|
||||
* Closes: #1485
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('discuss-phase incremental checkpoint saves (#1485)', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'discuss-phase.md');
|
||||
|
||||
test('workflow writes checkpoint file after each area completes', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('DISCUSS-CHECKPOINT.json'),
|
||||
'workflow should reference checkpoint JSON file'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('Incremental checkpoint') || content.includes('incremental checkpoint'),
|
||||
'workflow should describe incremental checkpoint saves'
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint includes decisions, areas completed, and areas remaining', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
assert.ok(content.includes('areas_completed'), 'checkpoint should track completed areas');
|
||||
assert.ok(content.includes('areas_remaining'), 'checkpoint should track remaining areas');
|
||||
assert.ok(content.includes('"decisions"'), 'checkpoint should include decisions object');
|
||||
});
|
||||
|
||||
test('check_existing step detects checkpoint for session resume', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
// The check_existing step should look for checkpoint files
|
||||
assert.ok(
|
||||
content.includes('DISCUSS-CHECKPOINT.json') && content.includes('Resume'),
|
||||
'check_existing should detect checkpoint and offer resume'
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint is cleaned up after successful CONTEXT.md write', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('rm -f') && content.includes('DISCUSS-CHECKPOINT'),
|
||||
'checkpoint file should be deleted after successful write_context'
|
||||
);
|
||||
});
|
||||
|
||||
test('success criteria include checkpoint requirements', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
const criteriaMatch = content.match(/<success_criteria>([\s\S]*?)<\/success_criteria>/);
|
||||
const criteria = criteriaMatch ? criteriaMatch[1] : '';
|
||||
assert.ok(criteria.includes('checkpoint') || criteria.includes('Checkpoint'),
|
||||
'success criteria should mention checkpoints');
|
||||
assert.ok(criteria.includes('resume') || criteria.includes('Resume'),
|
||||
'success criteria should mention session resume capability');
|
||||
});
|
||||
|
||||
test('auto mode also writes checkpoints', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
// The checkpoint section should mention auto mode
|
||||
assert.ok(
|
||||
content.includes('auto-resolves') || content.includes('--auto'),
|
||||
'checkpoint logic should apply to both interactive and auto modes'
|
||||
);
|
||||
});
|
||||
});
|
||||
272
tests/docs-update.test.cjs
Normal file
272
tests/docs-update.test.cjs
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* GSD Tools Tests - docs-update
|
||||
*
|
||||
* Integration tests for the docs-init gsd-tools subcommand.
|
||||
* Covers: JSON output shape, project type detection, existing doc scanning,
|
||||
* GSD marker detection, and doc tooling detection.
|
||||
*
|
||||
* Requirements: VERF-03
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── JSON output shape ────────────────────────────────────────────────────────
|
||||
|
||||
describe('docs-init command', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('returns expected JSON shape', () => {
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
// Top-level scalar fields
|
||||
assert.strictEqual(typeof data.doc_writer_model, 'string');
|
||||
assert.strictEqual(typeof data.commit_docs, 'boolean');
|
||||
assert.strictEqual(typeof data.planning_exists, 'boolean');
|
||||
assert.strictEqual(typeof data.project_root, 'string');
|
||||
assert.strictEqual(typeof data.agents_installed, 'boolean');
|
||||
|
||||
// Array fields
|
||||
assert.ok(Array.isArray(data.existing_docs), 'existing_docs should be an array');
|
||||
assert.ok(Array.isArray(data.monorepo_workspaces), 'monorepo_workspaces should be an array');
|
||||
assert.ok(Array.isArray(data.missing_agents), 'missing_agents should be an array');
|
||||
|
||||
// project_type object with 7 boolean fields
|
||||
assert.ok(data.project_type && typeof data.project_type === 'object', 'project_type should be an object');
|
||||
assert.strictEqual(typeof data.project_type.has_package_json, 'boolean');
|
||||
assert.strictEqual(typeof data.project_type.has_api_routes, 'boolean');
|
||||
assert.strictEqual(typeof data.project_type.has_cli_bin, 'boolean');
|
||||
assert.strictEqual(typeof data.project_type.is_open_source, 'boolean');
|
||||
assert.strictEqual(typeof data.project_type.has_deploy_config, 'boolean');
|
||||
assert.strictEqual(typeof data.project_type.is_monorepo, 'boolean');
|
||||
assert.strictEqual(typeof data.project_type.has_tests, 'boolean');
|
||||
|
||||
// doc_tooling object with 4 boolean fields
|
||||
assert.ok(data.doc_tooling && typeof data.doc_tooling === 'object', 'doc_tooling should be an object');
|
||||
assert.strictEqual(typeof data.doc_tooling.docusaurus, 'boolean');
|
||||
assert.strictEqual(typeof data.doc_tooling.vitepress, 'boolean');
|
||||
assert.strictEqual(typeof data.doc_tooling.mkdocs, 'boolean');
|
||||
assert.strictEqual(typeof data.doc_tooling.storybook, 'boolean');
|
||||
|
||||
// planning_exists is true since createTempProject creates .planning/
|
||||
assert.strictEqual(data.planning_exists, true);
|
||||
});
|
||||
|
||||
test('bare project returns all false signals', () => {
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
// All project_type fields should be false for a bare project
|
||||
assert.strictEqual(data.project_type.has_package_json, false);
|
||||
assert.strictEqual(data.project_type.has_api_routes, false);
|
||||
assert.strictEqual(data.project_type.has_cli_bin, false);
|
||||
assert.strictEqual(data.project_type.is_open_source, false);
|
||||
assert.strictEqual(data.project_type.has_deploy_config, false);
|
||||
assert.strictEqual(data.project_type.is_monorepo, false);
|
||||
assert.strictEqual(data.project_type.has_tests, false);
|
||||
|
||||
// No docs, no workspaces, no doc tooling
|
||||
assert.deepEqual(data.existing_docs, []);
|
||||
assert.deepEqual(data.monorepo_workspaces, []);
|
||||
assert.strictEqual(data.doc_tooling.docusaurus, false);
|
||||
assert.strictEqual(data.doc_tooling.vitepress, false);
|
||||
assert.strictEqual(data.doc_tooling.mkdocs, false);
|
||||
assert.strictEqual(data.doc_tooling.storybook, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── project type detection ───────────────────────────────────────────────────
|
||||
|
||||
describe('project type detection', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('detects CLI tool from package.json bin field', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'package.json'),
|
||||
JSON.stringify({ name: 'my-cli', bin: { mycli: 'bin/cli.js' } }),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.project_type.has_cli_bin, true);
|
||||
assert.strictEqual(data.project_type.has_package_json, true);
|
||||
});
|
||||
|
||||
test('detects open source from LICENSE file', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'LICENSE'), 'MIT License', 'utf-8');
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.project_type.is_open_source, true);
|
||||
});
|
||||
|
||||
test('detects monorepo from package.json workspaces', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'package.json'),
|
||||
JSON.stringify({ name: 'mono', workspaces: ['packages/*'] }),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.project_type.is_monorepo, true);
|
||||
assert.ok(data.monorepo_workspaces.includes('packages/*'), 'monorepo_workspaces should contain packages/*');
|
||||
});
|
||||
|
||||
test('detects tests from tests directory', () => {
|
||||
fs.mkdirSync(path.join(tmpDir, 'tests'), { recursive: true });
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.project_type.has_tests, true);
|
||||
});
|
||||
|
||||
test('detects deploy config from Dockerfile', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'Dockerfile'), 'FROM node:20', 'utf-8');
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.project_type.has_deploy_config, true);
|
||||
});
|
||||
|
||||
test('detects API routes from src/app/api directory', () => {
|
||||
fs.mkdirSync(path.join(tmpDir, 'src', 'app', 'api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.project_type.has_api_routes, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── existing doc scanning ────────────────────────────────────────────────────
|
||||
|
||||
describe('existing doc scanning', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('scans .md files in project root', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# README\n', 'utf-8');
|
||||
fs.writeFileSync(path.join(tmpDir, 'ARCHITECTURE.md'), '# Architecture\n', 'utf-8');
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
assert.ok(data.existing_docs.length >= 2, 'existing_docs should contain at least 2 entries');
|
||||
|
||||
const paths = data.existing_docs.map(d => d.path);
|
||||
assert.ok(paths.includes('README.md'), 'existing_docs should contain README.md');
|
||||
assert.ok(paths.includes('ARCHITECTURE.md'), 'existing_docs should contain ARCHITECTURE.md');
|
||||
});
|
||||
|
||||
test('detects GSD marker in existing docs', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'README.md'),
|
||||
'<!-- generated-by: gsd-doc-writer -->\n# README\n',
|
||||
'utf-8'
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, 'NOTES.md'), '# Notes\n', 'utf-8');
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
|
||||
const readmeEntry = data.existing_docs.find(d => d.path === 'README.md');
|
||||
assert.ok(readmeEntry, 'README.md should appear in existing_docs');
|
||||
assert.strictEqual(readmeEntry.has_gsd_marker, true, 'README.md should have GSD marker');
|
||||
|
||||
const notesEntry = data.existing_docs.find(d => d.path === 'NOTES.md');
|
||||
assert.ok(notesEntry, 'NOTES.md should appear in existing_docs');
|
||||
assert.strictEqual(notesEntry.has_gsd_marker, false, 'NOTES.md should not have GSD marker');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── doc tooling detection ────────────────────────────────────────────────────
|
||||
|
||||
describe('doc tooling detection', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('detects Docusaurus config', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'docusaurus.config.js'), 'module.exports = {};', 'utf-8');
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.doc_tooling.docusaurus, true);
|
||||
});
|
||||
|
||||
test('detects VitePress config', () => {
|
||||
fs.mkdirSync(path.join(tmpDir, '.vitepress'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.vitepress', 'config.ts'), 'export default {};', 'utf-8');
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.doc_tooling.vitepress, true);
|
||||
});
|
||||
|
||||
test('detects MkDocs config', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'mkdocs.yml'), 'site_name: test', 'utf-8');
|
||||
|
||||
const result = runGsdTools(['docs-init'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.doc_tooling.mkdocs, true);
|
||||
});
|
||||
});
|
||||
@@ -105,4 +105,80 @@ describe('execute-phase docs: user-facing wave flag', () => {
|
||||
'help.md should include wave-filter usage'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow supports use_worktrees config toggle', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('USE_WORKTREES'),
|
||||
'workflow should reference USE_WORKTREES variable'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('config-get workflow.use_worktrees'),
|
||||
'workflow should read use_worktrees from config'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('Sequential mode'),
|
||||
'workflow should document sequential mode when worktrees disabled'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('use_worktrees config: cross-workflow structural coverage', () => {
|
||||
const QUICK_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'quick.md');
|
||||
const DIAGNOSE_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'diagnose-issues.md');
|
||||
const EXECUTE_PLAN_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'execute-plan.md');
|
||||
const PLANNING_CONFIG_PATH = path.join(__dirname, '..', 'get-shit-done', 'references', 'planning-config.md');
|
||||
const CONFIG_CJS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'config.cjs');
|
||||
|
||||
test('quick workflow reads USE_WORKTREES from config', () => {
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('config-get workflow.use_worktrees'),
|
||||
'quick.md should read use_worktrees from config'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('USE_WORKTREES'),
|
||||
'quick.md should reference USE_WORKTREES variable'
|
||||
);
|
||||
});
|
||||
|
||||
test('diagnose-issues workflow reads USE_WORKTREES from config', () => {
|
||||
const content = fs.readFileSync(DIAGNOSE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('config-get workflow.use_worktrees'),
|
||||
'diagnose-issues.md should read use_worktrees from config'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('USE_WORKTREES'),
|
||||
'diagnose-issues.md should reference USE_WORKTREES variable'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-plan workflow references use_worktrees config', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('workflow.use_worktrees'),
|
||||
'execute-plan.md should reference workflow.use_worktrees'
|
||||
);
|
||||
});
|
||||
|
||||
test('planning-config reference documents use_worktrees', () => {
|
||||
const content = fs.readFileSync(PLANNING_CONFIG_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('workflow.use_worktrees'),
|
||||
'planning-config.md should document workflow.use_worktrees'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('worktree'),
|
||||
'planning-config.md should describe worktree behavior'
|
||||
);
|
||||
});
|
||||
|
||||
test('config.cjs includes workflow.use_worktrees in VALID_CONFIG_KEYS', () => {
|
||||
const content = fs.readFileSync(CONFIG_CJS_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes("'workflow.use_worktrees'"),
|
||||
'config.cjs VALID_CONFIG_KEYS should include workflow.use_worktrees'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -657,6 +657,92 @@ describe('phase add command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// phase add with project_code prefix
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
describe('phase add with project_code', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('prefixes phase directory with project_code', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ project_code: 'CK' })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_number, 2, 'should be phase 2');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-user-dashboard')),
|
||||
'directory should have CK- prefix'
|
||||
);
|
||||
});
|
||||
|
||||
test('no prefix when project_code is null', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ project_code: null })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-user-dashboard')),
|
||||
'directory should have no prefix'
|
||||
);
|
||||
});
|
||||
|
||||
test('find-phase resolves prefixed directories', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
|
||||
|
||||
const result = runGsdTools('find-phase 01', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.found, true, 'should find prefixed phase');
|
||||
assert.strictEqual(output.phase_number, '01', 'should extract numeric phase number');
|
||||
});
|
||||
|
||||
test('phases list sorts prefixed directories correctly', () => {
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-api'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-03-ui'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phases list', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.deepStrictEqual(
|
||||
output.directories,
|
||||
['CK-01-foundation', 'CK-02-api', 'CK-03-ui'],
|
||||
'prefixed phases should sort numerically'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// phase insert command
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1533,6 +1619,130 @@ describe('phase complete command', () => {
|
||||
// Verify compound format preserved
|
||||
assert.ok(state.match(/Phase:.*of\s+1/), 'should preserve "of N" in compound Phase format');
|
||||
});
|
||||
|
||||
test('updates Plans Complete column in 4-column progress table', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
- [ ] Phase 1: Foundation
|
||||
- [ ] Phase 2: API
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Setup
|
||||
**Plans:** 1 plans
|
||||
|
||||
### Phase 2: API
|
||||
**Goal:** Build API
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. Foundation | 0/1 | Not started | - |
|
||||
| 2. API | 0/1 | Not started | - |
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
||||
);
|
||||
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
|
||||
assert.ok(rowMatch, 'table row should exist');
|
||||
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
|
||||
assert.strictEqual(cells.length, 4, 'should have 4 columns');
|
||||
assert.strictEqual(cells[1], '1/1', 'Plans Complete column should be updated to 1/1');
|
||||
assert.ok(cells[2].includes('Complete'), 'Status column should be Complete');
|
||||
});
|
||||
|
||||
test('updates Plans Complete column in 5-column progress table', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
- [ ] Phase 1: Foundation
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Setup
|
||||
**Plans:** 1 plans
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||
|-------|-----------|----------------|--------|-----------|
|
||||
| 1. Foundation | v1.0 | 0/1 | Planned | |
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
||||
);
|
||||
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
|
||||
assert.ok(rowMatch, 'table row should exist');
|
||||
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
|
||||
assert.strictEqual(cells.length, 5, 'should have 5 columns');
|
||||
assert.strictEqual(cells[2], '1/1', 'Plans Complete column should be updated to 1/1');
|
||||
assert.ok(cells[3].includes('Complete'), 'Status column should be Complete');
|
||||
});
|
||||
|
||||
test('marks plan-level checkboxes on phase complete', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
- [ ] Phase 1: Foundation
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Setup
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 01-01-PLAN.md \u2014 Schema migration
|
||||
- [ ] 01-02-PLAN.md \u2014 Auth setup
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
||||
);
|
||||
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 checkbox should be checked');
|
||||
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 checkbox should be checked');
|
||||
assert.ok(!roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should not remain unchecked');
|
||||
assert.ok(!roadmap.includes('[ ] 01-02-PLAN.md'), 'plan 01-02 should not remain unchecked');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
53
tests/quick-commit-boundary.test.cjs
Normal file
53
tests/quick-commit-boundary.test.cjs
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* GSD Quick Workflow — Commit Boundary Tests (#1503)
|
||||
*
|
||||
* Validates that the quick workflow correctly separates executor
|
||||
* responsibilities (code commits) from orchestrator responsibilities
|
||||
* (docs artifact commit), preventing PLAN.md from being left untracked
|
||||
* when the executor runs without worktree isolation.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'get-shit-done', 'workflows');
|
||||
|
||||
describe('quick workflow commit boundary (#1503)', () => {
|
||||
const quickPath = path.join(WORKFLOWS_DIR, 'quick.md');
|
||||
let content;
|
||||
|
||||
test('quick.md exists', () => {
|
||||
assert.ok(fs.existsSync(quickPath), 'workflows/quick.md should exist');
|
||||
content = fs.readFileSync(quickPath, 'utf-8');
|
||||
});
|
||||
|
||||
test('executor constraints prohibit committing docs artifacts', () => {
|
||||
assert.ok(
|
||||
content.includes('Do NOT commit docs artifacts'),
|
||||
'executor constraints should prohibit committing SUMMARY.md, STATE.md, PLAN.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 8 explicitly stages artifacts with git add before commit', () => {
|
||||
assert.ok(
|
||||
content.includes('git add ${file_list}'),
|
||||
'Step 8 should explicitly git add the file list before gsd-tools commit'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 8 includes PLAN.md in file list', () => {
|
||||
assert.ok(
|
||||
content.includes('${QUICK_DIR}/${quick_id}-PLAN.md'),
|
||||
'Step 8 file list must include PLAN.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 8 runs unconditionally', () => {
|
||||
assert.ok(
|
||||
content.includes('MUST always run'),
|
||||
'Step 8 should state it must always run regardless of executor commits'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -279,19 +279,19 @@ describe('quick workflow: banner variants for flag combinations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('has banner for research + full mode', () => {
|
||||
test('has banner for research + validate mode', () => {
|
||||
content = fs.readFileSync(path.join(WORKFLOWS_DIR, 'quick.md'), 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('RESEARCH + FULL)'),
|
||||
'should have banner for --research --full'
|
||||
content.includes('RESEARCH + VALIDATE)'),
|
||||
'should have banner for --research --validate'
|
||||
);
|
||||
});
|
||||
|
||||
test('has banner for all three flags', () => {
|
||||
test('has banner for full mode (all phases)', () => {
|
||||
content = fs.readFileSync(path.join(WORKFLOWS_DIR, 'quick.md'), 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('DISCUSS + RESEARCH + FULL)'),
|
||||
'should have banner for --discuss --research --full'
|
||||
content.includes('QUICK TASK (FULL)'),
|
||||
'should have banner for --full (all phases enabled)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
276
tests/reapply-patches.test.cjs
Normal file
276
tests/reapply-patches.test.cjs
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* GSD Tools Tests - reapply-patches backup logic
|
||||
*
|
||||
* Validates that saveLocalPatches() in the installer correctly detects
|
||||
* user-modified files and saves pristine hashes for three-way merge.
|
||||
*
|
||||
* Closes: #1469
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-patch-test-'));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate what the installer does: create a manifest, modify a file,
|
||||
* then run the saveLocalPatches detection logic.
|
||||
*/
|
||||
function simulateManifestAndPatch(configDir, files) {
|
||||
// Create the GSD files
|
||||
for (const [relPath, content] of Object.entries(files.original)) {
|
||||
const fullPath = path.join(configDir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
|
||||
// Create manifest with hashes of original files
|
||||
const manifest = {
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
files: {}
|
||||
};
|
||||
for (const [relPath, content] of Object.entries(files.original)) {
|
||||
manifest.files[relPath] = sha256(content);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'gsd-file-manifest.json'),
|
||||
JSON.stringify(manifest, null, 2)
|
||||
);
|
||||
|
||||
// Now modify files to simulate user edits
|
||||
for (const [relPath, content] of Object.entries(files.modified || {})) {
|
||||
fs.writeFileSync(path.join(configDir, relPath), content);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// ─── inline saveLocalPatches (mirrors install.js logic) ──────────────────────
|
||||
|
||||
function fileHash(filePath) {
|
||||
const content = fs.readFileSync(filePath);
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function saveLocalPatches(configDir) {
|
||||
const PATCHES_DIR_NAME = 'gsd-local-patches';
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
const manifestPath = path.join(configDir, MANIFEST_NAME);
|
||||
if (!fs.existsSync(manifestPath)) return [];
|
||||
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; }
|
||||
|
||||
const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
|
||||
const modified = [];
|
||||
|
||||
for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
|
||||
const fullPath = path.join(configDir, relPath);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
const currentHash = fileHash(fullPath);
|
||||
if (currentHash !== originalHash) {
|
||||
const backupPath = path.join(patchesDir, relPath);
|
||||
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
||||
fs.copyFileSync(fullPath, backupPath);
|
||||
modified.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (modified.length > 0) {
|
||||
const meta = {
|
||||
backed_up_at: new Date().toISOString(),
|
||||
from_version: manifest.version,
|
||||
from_manifest_timestamp: manifest.timestamp,
|
||||
files: modified,
|
||||
pristine_hashes: {}
|
||||
};
|
||||
for (const relPath of modified) {
|
||||
meta.pristine_hashes[relPath] = manifest.files[relPath];
|
||||
}
|
||||
fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('saveLocalPatches — patch backup and pristine hash tracking (#1469)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('detects modified files and backs them up', () => {
|
||||
simulateManifestAndPatch(tmpDir, {
|
||||
original: {
|
||||
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\nOriginal content\n',
|
||||
'get-shit-done/workflows/plan-phase.md': '# Plan Phase\nOriginal content\n',
|
||||
},
|
||||
modified: {
|
||||
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\nOriginal content\n\n## My Custom Step\nDo something special\n',
|
||||
},
|
||||
});
|
||||
|
||||
const result = saveLocalPatches(tmpDir);
|
||||
|
||||
assert.strictEqual(result.length, 1, 'should detect exactly one modified file');
|
||||
assert.ok(result.includes('get-shit-done/workflows/execute-phase.md'));
|
||||
|
||||
// Verify backup exists
|
||||
const backupPath = path.join(tmpDir, 'gsd-local-patches', 'get-shit-done/workflows/execute-phase.md');
|
||||
assert.ok(fs.existsSync(backupPath), 'backup file should exist');
|
||||
|
||||
const backupContent = fs.readFileSync(backupPath, 'utf8');
|
||||
assert.ok(backupContent.includes('My Custom Step'), 'backup should contain user modification');
|
||||
});
|
||||
|
||||
test('backup-meta.json includes pristine_hashes for three-way merge', () => {
|
||||
const originalContent = '# Execute Phase\nOriginal content\n';
|
||||
simulateManifestAndPatch(tmpDir, {
|
||||
original: {
|
||||
'get-shit-done/workflows/execute-phase.md': originalContent,
|
||||
},
|
||||
modified: {
|
||||
'get-shit-done/workflows/execute-phase.md': originalContent + '\n## Custom\n',
|
||||
},
|
||||
});
|
||||
|
||||
saveLocalPatches(tmpDir);
|
||||
|
||||
const metaPath = path.join(tmpDir, 'gsd-local-patches', 'backup-meta.json');
|
||||
assert.ok(fs.existsSync(metaPath), 'backup-meta.json should exist');
|
||||
|
||||
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
||||
|
||||
// Verify pristine_hashes field exists and contains correct hash
|
||||
assert.ok(meta.pristine_hashes, 'meta should have pristine_hashes field');
|
||||
const expectedHash = sha256(originalContent);
|
||||
assert.strictEqual(
|
||||
meta.pristine_hashes['get-shit-done/workflows/execute-phase.md'],
|
||||
expectedHash,
|
||||
'pristine hash should match SHA-256 of original file content'
|
||||
);
|
||||
});
|
||||
|
||||
test('backup-meta.json includes from_version and from_manifest_timestamp', () => {
|
||||
simulateManifestAndPatch(tmpDir, {
|
||||
original: { 'get-shit-done/workflows/test.md': 'original' },
|
||||
modified: { 'get-shit-done/workflows/test.md': 'modified' },
|
||||
});
|
||||
|
||||
saveLocalPatches(tmpDir);
|
||||
|
||||
const meta = JSON.parse(fs.readFileSync(
|
||||
path.join(tmpDir, 'gsd-local-patches', 'backup-meta.json'), 'utf8'
|
||||
));
|
||||
|
||||
assert.strictEqual(meta.from_version, '1.0.0');
|
||||
assert.ok(meta.from_manifest_timestamp, 'should have from_manifest_timestamp');
|
||||
assert.ok(meta.backed_up_at, 'should have backed_up_at timestamp');
|
||||
});
|
||||
|
||||
test('unmodified files are not backed up', () => {
|
||||
simulateManifestAndPatch(tmpDir, {
|
||||
original: {
|
||||
'get-shit-done/workflows/a.md': 'content A',
|
||||
'get-shit-done/workflows/b.md': 'content B',
|
||||
},
|
||||
// No modifications
|
||||
});
|
||||
|
||||
const result = saveLocalPatches(tmpDir);
|
||||
assert.strictEqual(result.length, 0, 'no files should be detected as modified');
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, 'gsd-local-patches')), 'patches dir should not be created');
|
||||
});
|
||||
|
||||
test('multiple modified files all get pristine hashes', () => {
|
||||
simulateManifestAndPatch(tmpDir, {
|
||||
original: {
|
||||
'get-shit-done/workflows/a.md': 'original A',
|
||||
'get-shit-done/workflows/b.md': 'original B',
|
||||
'get-shit-done/workflows/c.md': 'original C',
|
||||
},
|
||||
modified: {
|
||||
'get-shit-done/workflows/a.md': 'modified A',
|
||||
'get-shit-done/workflows/b.md': 'modified B',
|
||||
},
|
||||
});
|
||||
|
||||
const result = saveLocalPatches(tmpDir);
|
||||
assert.strictEqual(result.length, 2);
|
||||
|
||||
const meta = JSON.parse(fs.readFileSync(
|
||||
path.join(tmpDir, 'gsd-local-patches', 'backup-meta.json'), 'utf8'
|
||||
));
|
||||
|
||||
assert.strictEqual(Object.keys(meta.pristine_hashes).length, 2);
|
||||
assert.strictEqual(meta.pristine_hashes['get-shit-done/workflows/a.md'], sha256('original A'));
|
||||
assert.strictEqual(meta.pristine_hashes['get-shit-done/workflows/b.md'], sha256('original B'));
|
||||
// c.md should NOT have a pristine hash (it wasn't modified)
|
||||
assert.strictEqual(meta.pristine_hashes['get-shit-done/workflows/c.md'], undefined);
|
||||
});
|
||||
|
||||
test('returns empty array when no manifest exists', () => {
|
||||
const result = saveLocalPatches(tmpDir);
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
test('returns empty array when manifest is malformed', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'not json');
|
||||
const result = saveLocalPatches(tmpDir);
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reapply-patches workflow contract (#1469)', () => {
|
||||
test('workflow file contains critical invariant about never skipping backed-up files', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'commands', 'gsd', 'reapply-patches.md');
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
// The workflow must explicitly state that "no custom content" is never valid
|
||||
assert.ok(
|
||||
content.includes('NEVER conclude "no custom content"') ||
|
||||
content.includes('never a valid conclusion'),
|
||||
'workflow must contain the critical invariant about never skipping backed-up files'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow file describes three-way merge strategy', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'commands', 'gsd', 'reapply-patches.md');
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
assert.ok(content.includes('three-way') || content.includes('Three-way'),
|
||||
'workflow must describe three-way merge strategy');
|
||||
assert.ok(content.includes('pristine'),
|
||||
'workflow must reference pristine baseline for comparison');
|
||||
});
|
||||
|
||||
test('workflow file describes git-aware detection path', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'commands', 'gsd', 'reapply-patches.md');
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
assert.ok(content.includes('git log') || content.includes('git -C'),
|
||||
'workflow must describe git-based detection of user changes');
|
||||
});
|
||||
});
|
||||
358
tests/schema-drift.test.cjs
Normal file
358
tests/schema-drift.test.cjs
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* GSD Tools Tests - Schema Drift Detection
|
||||
*
|
||||
* Tests for schema-relevant file detection (plan-phase injection)
|
||||
* and post-execution schema drift gate (execute-phase verification).
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createTempProject, createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
// ─── Unit: detectSchemaFiles ─────────────────────────────────────────────────
|
||||
|
||||
const { detectSchemaFiles, detectSchemaOrm, checkSchemaDrift } = require(
|
||||
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'schema-detect.cjs')
|
||||
);
|
||||
|
||||
describe('detectSchemaFiles', () => {
|
||||
test('detects Payload CMS collection files', () => {
|
||||
const files = ['src/collections/Posts.ts', 'src/collections/Users.ts', 'src/lib/utils.ts'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected, 'should detect schema files');
|
||||
assert.deepStrictEqual(result.matches, [
|
||||
'src/collections/Posts.ts',
|
||||
'src/collections/Users.ts',
|
||||
]);
|
||||
assert.ok(result.orms.includes('payload'), 'should identify Payload CMS');
|
||||
});
|
||||
|
||||
test('detects Payload CMS globals files', () => {
|
||||
const files = ['src/globals/Settings.ts'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected);
|
||||
assert.ok(result.orms.includes('payload'));
|
||||
});
|
||||
|
||||
test('detects Prisma schema file', () => {
|
||||
const files = ['prisma/schema.prisma', 'src/index.ts'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected);
|
||||
assert.deepStrictEqual(result.matches, ['prisma/schema.prisma']);
|
||||
assert.ok(result.orms.includes('prisma'));
|
||||
});
|
||||
|
||||
test('detects Prisma multi-file schema', () => {
|
||||
const files = ['prisma/schema/user.prisma', 'prisma/schema/post.prisma'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected);
|
||||
assert.strictEqual(result.matches.length, 2);
|
||||
assert.ok(result.orms.includes('prisma'));
|
||||
});
|
||||
|
||||
test('detects Drizzle schema files', () => {
|
||||
const files = ['drizzle/schema.ts', 'src/routes/api.ts'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected);
|
||||
assert.ok(result.orms.includes('drizzle'));
|
||||
});
|
||||
|
||||
test('detects Drizzle schema in src/db/', () => {
|
||||
const files = ['src/db/schema.ts'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected);
|
||||
assert.ok(result.orms.includes('drizzle'));
|
||||
});
|
||||
|
||||
test('detects Drizzle multi-file schemas', () => {
|
||||
const files = ['drizzle/users.ts', 'drizzle/posts.ts'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected);
|
||||
assert.ok(result.orms.includes('drizzle'));
|
||||
});
|
||||
|
||||
test('detects Supabase migration files', () => {
|
||||
const files = ['supabase/migrations/20240101_add_users.sql'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected);
|
||||
assert.ok(result.orms.includes('supabase'));
|
||||
});
|
||||
|
||||
test('detects TypeORM entity files', () => {
|
||||
const files = ['src/entities/User.ts', 'src/entities/Post.ts'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected);
|
||||
assert.ok(result.orms.includes('typeorm'));
|
||||
});
|
||||
|
||||
test('detects TypeORM migration files', () => {
|
||||
const files = ['src/migrations/1234567890-CreateUsers.ts'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected);
|
||||
assert.ok(result.orms.includes('typeorm'));
|
||||
});
|
||||
|
||||
test('returns not detected for non-schema files', () => {
|
||||
const files = ['src/index.ts', 'src/utils/helpers.ts', 'package.json', 'README.md'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.strictEqual(result.detected, false);
|
||||
assert.strictEqual(result.matches.length, 0);
|
||||
assert.strictEqual(result.orms.length, 0);
|
||||
});
|
||||
|
||||
test('returns empty for empty file list', () => {
|
||||
const result = detectSchemaFiles([]);
|
||||
assert.strictEqual(result.detected, false);
|
||||
assert.strictEqual(result.matches.length, 0);
|
||||
});
|
||||
|
||||
test('detects multiple ORMs in same file list', () => {
|
||||
const files = ['prisma/schema.prisma', 'src/collections/Posts.ts'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected);
|
||||
assert.ok(result.orms.includes('prisma'));
|
||||
assert.ok(result.orms.includes('payload'));
|
||||
});
|
||||
|
||||
test('handles Windows-style paths', () => {
|
||||
const files = ['src\\collections\\Posts.ts', 'src\\globals\\Settings.ts'];
|
||||
const result = detectSchemaFiles(files);
|
||||
assert.ok(result.detected, 'should detect schema files with backslash paths');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unit: detectSchemaOrm ───────────────────────────────────────────────────
|
||||
|
||||
describe('detectSchemaOrm', () => {
|
||||
test('returns push command for Payload CMS', () => {
|
||||
const info = detectSchemaOrm('payload');
|
||||
assert.ok(info.pushCommand);
|
||||
assert.ok(info.pushCommand.includes('payload'));
|
||||
assert.ok(info.envHint, 'should include env hint for non-TTY');
|
||||
});
|
||||
|
||||
test('returns push command for Prisma', () => {
|
||||
const info = detectSchemaOrm('prisma');
|
||||
assert.ok(info.pushCommand.includes('prisma'));
|
||||
});
|
||||
|
||||
test('returns push command for Drizzle', () => {
|
||||
const info = detectSchemaOrm('drizzle');
|
||||
assert.ok(info.pushCommand.includes('drizzle'));
|
||||
});
|
||||
|
||||
test('returns push command for Supabase', () => {
|
||||
const info = detectSchemaOrm('supabase');
|
||||
assert.ok(info.pushCommand.includes('supabase'));
|
||||
});
|
||||
|
||||
test('returns push command for TypeORM', () => {
|
||||
const info = detectSchemaOrm('typeorm');
|
||||
assert.ok(info.pushCommand.includes('typeorm'));
|
||||
});
|
||||
|
||||
test('returns null for unknown ORM', () => {
|
||||
const info = detectSchemaOrm('unknown-orm');
|
||||
assert.strictEqual(info, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unit: checkSchemaDrift ──────────────────────────────────────────────────
|
||||
|
||||
describe('checkSchemaDrift', () => {
|
||||
test('returns no drift when no schema files changed', () => {
|
||||
const changedFiles = ['src/index.ts', 'package.json'];
|
||||
const executionLog = '';
|
||||
const result = checkSchemaDrift(changedFiles, executionLog);
|
||||
assert.strictEqual(result.driftDetected, false);
|
||||
assert.strictEqual(result.blocking, false);
|
||||
});
|
||||
|
||||
test('detects drift when schema files changed but no push executed', () => {
|
||||
const changedFiles = ['src/collections/Posts.ts', 'src/index.ts'];
|
||||
const executionLog = 'npm run build\nnpm run test';
|
||||
const result = checkSchemaDrift(changedFiles, executionLog);
|
||||
assert.strictEqual(result.driftDetected, true);
|
||||
assert.strictEqual(result.blocking, true);
|
||||
assert.ok(result.schemaFiles.length > 0);
|
||||
assert.ok(result.orms.includes('payload'));
|
||||
assert.ok(result.message.length > 0);
|
||||
});
|
||||
|
||||
test('no drift when schema files changed AND push was executed (payload)', () => {
|
||||
const changedFiles = ['src/collections/Posts.ts'];
|
||||
const executionLog = 'npx payload migrate\nnpm run build';
|
||||
const result = checkSchemaDrift(changedFiles, executionLog);
|
||||
assert.strictEqual(result.driftDetected, false);
|
||||
assert.strictEqual(result.blocking, false);
|
||||
});
|
||||
|
||||
test('no drift when schema files changed AND push was executed (prisma)', () => {
|
||||
const changedFiles = ['prisma/schema.prisma'];
|
||||
const executionLog = 'npx prisma db push\nnpm run build';
|
||||
const result = checkSchemaDrift(changedFiles, executionLog);
|
||||
assert.strictEqual(result.driftDetected, false);
|
||||
assert.strictEqual(result.blocking, false);
|
||||
});
|
||||
|
||||
test('no drift when schema files changed AND push was executed (drizzle)', () => {
|
||||
const changedFiles = ['drizzle/schema.ts'];
|
||||
const executionLog = 'npx drizzle-kit push\nnpm run test';
|
||||
const result = checkSchemaDrift(changedFiles, executionLog);
|
||||
assert.strictEqual(result.driftDetected, false);
|
||||
assert.strictEqual(result.blocking, false);
|
||||
});
|
||||
|
||||
test('no drift when schema files changed AND push was executed (supabase)', () => {
|
||||
const changedFiles = ['supabase/migrations/001_init.sql'];
|
||||
const executionLog = 'supabase db push\nnpm run test';
|
||||
const result = checkSchemaDrift(changedFiles, executionLog);
|
||||
assert.strictEqual(result.driftDetected, false);
|
||||
assert.strictEqual(result.blocking, false);
|
||||
});
|
||||
|
||||
test('no drift when schema files changed AND push was executed (typeorm)', () => {
|
||||
const changedFiles = ['src/entities/User.ts'];
|
||||
const executionLog = 'npx typeorm migration:run\nnpm run test';
|
||||
const result = checkSchemaDrift(changedFiles, executionLog);
|
||||
assert.strictEqual(result.driftDetected, false);
|
||||
assert.strictEqual(result.blocking, false);
|
||||
});
|
||||
|
||||
test('respects GSD_SKIP_SCHEMA_CHECK override', () => {
|
||||
const changedFiles = ['src/collections/Posts.ts'];
|
||||
const executionLog = 'npm run build';
|
||||
const result = checkSchemaDrift(changedFiles, executionLog, { skipCheck: true });
|
||||
assert.strictEqual(result.driftDetected, true);
|
||||
assert.strictEqual(result.blocking, false, 'should not block when skip override is set');
|
||||
assert.ok(result.skipped, 'should indicate the check was skipped');
|
||||
});
|
||||
|
||||
test('detects drift with multiple ORMs and partial push', () => {
|
||||
const changedFiles = ['prisma/schema.prisma', 'src/collections/Posts.ts'];
|
||||
const executionLog = 'npx prisma db push';
|
||||
const result = checkSchemaDrift(changedFiles, executionLog);
|
||||
// Prisma was pushed but Payload was not
|
||||
assert.strictEqual(result.driftDetected, true);
|
||||
assert.strictEqual(result.blocking, true);
|
||||
assert.ok(result.unpushedOrms.includes('payload'));
|
||||
assert.ok(!result.unpushedOrms.includes('prisma'));
|
||||
});
|
||||
|
||||
test('includes actionable message with push commands', () => {
|
||||
const changedFiles = ['prisma/schema.prisma'];
|
||||
const executionLog = '';
|
||||
const result = checkSchemaDrift(changedFiles, executionLog);
|
||||
assert.ok(result.message.includes('prisma'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CLI: verify schema-drift ────────────────────────────────────────────────
|
||||
|
||||
describe('verify schema-drift CLI command', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempGitProject('gsd-schema-drift-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('passes when no schema files in phase diff', () => {
|
||||
// Create a phase dir with a plan that modifies non-schema files
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), [
|
||||
'---',
|
||||
'files_modified: [src/index.ts, src/utils.ts]',
|
||||
'---',
|
||||
'',
|
||||
'Plan content',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools(['verify', 'schema-drift', '01-setup'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.drift_detected, false);
|
||||
assert.strictEqual(output.blocking, false);
|
||||
});
|
||||
|
||||
test('detects drift when schema files in plan but no push evidence', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), [
|
||||
'---',
|
||||
'files_modified: [src/collections/Posts.ts, src/index.ts]',
|
||||
'---',
|
||||
'',
|
||||
'Plan content',
|
||||
].join('\n'));
|
||||
// No SUMMARY.md with push evidence
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), [
|
||||
'# Summary',
|
||||
'',
|
||||
'## Accomplishments',
|
||||
'- Added Post collection',
|
||||
'',
|
||||
'## Commands Run',
|
||||
'- npm run build',
|
||||
'- npm run test',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools(['verify', 'schema-drift', '01-setup'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.drift_detected, true);
|
||||
assert.strictEqual(output.blocking, true);
|
||||
});
|
||||
|
||||
test('passes when schema files in plan AND push evidence in summary', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), [
|
||||
'---',
|
||||
'files_modified: [src/collections/Posts.ts]',
|
||||
'---',
|
||||
'',
|
||||
'Plan content',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), [
|
||||
'# Summary',
|
||||
'',
|
||||
'## Accomplishments',
|
||||
'- Added Post collection',
|
||||
'',
|
||||
'## Commands Run',
|
||||
'- npx payload migrate',
|
||||
'- npm run build',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools(['verify', 'schema-drift', '01-setup'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.drift_detected, false);
|
||||
assert.strictEqual(output.blocking, false);
|
||||
});
|
||||
|
||||
test('respects skip flag', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), [
|
||||
'---',
|
||||
'files_modified: [src/collections/Posts.ts]',
|
||||
'---',
|
||||
'',
|
||||
'Plan content',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
||||
|
||||
const result = runGsdTools(['verify', 'schema-drift', '01-setup', '--skip'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.blocking, false);
|
||||
});
|
||||
});
|
||||
440
tests/secure-phase.test.cjs
Normal file
440
tests/secure-phase.test.cjs
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* GSD Secure-Phase Tests
|
||||
*
|
||||
* Validates the security-first enforcement layer:
|
||||
* - gsd-security-auditor agent frontmatter and structure
|
||||
* - secure-phase command file
|
||||
* - secure-phase workflow file
|
||||
* - SECURITY.md template
|
||||
* - config.json security defaults
|
||||
* - VALIDATION.md security columns
|
||||
* - Threat-model-anchored behaviour (structural)
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const AGENTS_DIR = path.join(REPO_ROOT, 'agents');
|
||||
const COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd');
|
||||
const WORKFLOWS_DIR = path.join(REPO_ROOT, 'get-shit-done', 'workflows');
|
||||
const TEMPLATES_DIR = path.join(REPO_ROOT, 'get-shit-done', 'templates');
|
||||
|
||||
// ─── 1. Agent frontmatter — gsd-security-auditor.md ─────────────────────────
|
||||
|
||||
describe('SECURE: gsd-security-auditor agent', () => {
|
||||
const agentPath = path.join(AGENTS_DIR, 'gsd-security-auditor.md');
|
||||
|
||||
test('agent file exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(agentPath),
|
||||
'gsd-security-auditor.md must exist in agents/'
|
||||
);
|
||||
});
|
||||
|
||||
test('has valid frontmatter with name, description, tools, color', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
const frontmatter = content.split('---')[1] || '';
|
||||
assert.ok(frontmatter.includes('name:'), 'missing name:');
|
||||
assert.ok(frontmatter.includes('description:'), 'missing description:');
|
||||
assert.ok(frontmatter.includes('tools:'), 'missing tools:');
|
||||
assert.ok(frontmatter.includes('color:'), 'missing color:');
|
||||
});
|
||||
|
||||
test('name is gsd-security-auditor', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
const frontmatter = content.split('---')[1] || '';
|
||||
assert.ok(
|
||||
frontmatter.includes('name: gsd-security-auditor'),
|
||||
'name must be gsd-security-auditor'
|
||||
);
|
||||
});
|
||||
|
||||
test('tools include Read, Write, Bash, Glob, Grep', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
const requiredTools = ['Read', 'Write', 'Bash', 'Glob', 'Grep'];
|
||||
for (const tool of requiredTools) {
|
||||
assert.ok(
|
||||
content.includes(`- ${tool}`),
|
||||
`tools must include ${tool}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('has <role> section', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
assert.ok(content.includes('<role>'), 'must have <role> section');
|
||||
assert.ok(content.includes('</role>'), 'must close <role> section');
|
||||
});
|
||||
|
||||
test('has <execution_flow> section', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
assert.ok(content.includes('<execution_flow>'), 'must have <execution_flow> section');
|
||||
assert.ok(content.includes('</execution_flow>'), 'must close <execution_flow> section');
|
||||
});
|
||||
|
||||
test('has <structured_returns> with SECURED, OPEN_THREATS, ESCALATE', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
assert.ok(content.includes('<structured_returns>'), 'must have <structured_returns> section');
|
||||
assert.ok(content.includes('## SECURED'), 'must have SECURED return type');
|
||||
assert.ok(content.includes('## OPEN_THREATS'), 'must have OPEN_THREATS return type');
|
||||
assert.ok(content.includes('## ESCALATE'), 'must have ESCALATE return type');
|
||||
});
|
||||
|
||||
test('has <success_criteria> section', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
assert.ok(content.includes('<success_criteria>'), 'must have <success_criteria> section');
|
||||
assert.ok(content.includes('</success_criteria>'), 'must close <success_criteria> section');
|
||||
});
|
||||
|
||||
test('has READ-ONLY rule — does NOT modify implementation files', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('READ-ONLY'),
|
||||
'must contain READ-ONLY rule for implementation files'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. Command file — secure-phase.md ──────────────────────────────────────
|
||||
|
||||
describe('SECURE: secure-phase command file', () => {
|
||||
const cmdPath = path.join(COMMANDS_DIR, 'secure-phase.md');
|
||||
|
||||
test('command file exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(cmdPath),
|
||||
'secure-phase.md must exist in commands/gsd/'
|
||||
);
|
||||
});
|
||||
|
||||
test('has valid frontmatter with name gsd:secure-phase', () => {
|
||||
const content = fs.readFileSync(cmdPath, 'utf-8');
|
||||
const frontmatter = content.split('---')[1] || '';
|
||||
assert.ok(
|
||||
frontmatter.includes('name: gsd:secure-phase'),
|
||||
'name must be gsd:secure-phase'
|
||||
);
|
||||
});
|
||||
|
||||
test('has allowed-tools list', () => {
|
||||
const content = fs.readFileSync(cmdPath, 'utf-8');
|
||||
const frontmatter = content.split('---')[1] || '';
|
||||
assert.ok(
|
||||
frontmatter.includes('allowed-tools:'),
|
||||
'must have allowed-tools in frontmatter'
|
||||
);
|
||||
});
|
||||
|
||||
test('contains reference to secure-phase.md workflow', () => {
|
||||
const content = fs.readFileSync(cmdPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('secure-phase.md'),
|
||||
'must reference secure-phase.md workflow'
|
||||
);
|
||||
});
|
||||
|
||||
test('has <objective> section mentioning states A, B, C', () => {
|
||||
const content = fs.readFileSync(cmdPath, 'utf-8');
|
||||
assert.ok(content.includes('<objective>'), 'must have <objective> section');
|
||||
assert.ok(content.includes('(A)'), 'must mention state A');
|
||||
assert.ok(content.includes('(B)'), 'must mention state B');
|
||||
assert.ok(content.includes('(C)'), 'must mention state C');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. Workflow file — secure-phase.md ─────────────────────────────────────
|
||||
|
||||
describe('SECURE: secure-phase workflow file', () => {
|
||||
const wfPath = path.join(WORKFLOWS_DIR, 'secure-phase.md');
|
||||
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(wfPath),
|
||||
'secure-phase.md must exist in get-shit-done/workflows/'
|
||||
);
|
||||
});
|
||||
|
||||
test('contains gsd-security-auditor reference', () => {
|
||||
const content = fs.readFileSync(wfPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('gsd-security-auditor'),
|
||||
'must reference gsd-security-auditor agent'
|
||||
);
|
||||
});
|
||||
|
||||
test('contains threats_open enforcement logic', () => {
|
||||
const content = fs.readFileSync(wfPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('threats_open'),
|
||||
'must contain threats_open enforcement logic'
|
||||
);
|
||||
});
|
||||
|
||||
test('contains security_enforcement config check', () => {
|
||||
const content = fs.readFileSync(wfPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('security_enforcement'),
|
||||
'must check security_enforcement config setting'
|
||||
);
|
||||
});
|
||||
|
||||
test('contains SECURITY.md template reference', () => {
|
||||
const content = fs.readFileSync(wfPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('SECURITY.md'),
|
||||
'must reference SECURITY.md template'
|
||||
);
|
||||
});
|
||||
|
||||
test('has success_criteria section', () => {
|
||||
const content = fs.readFileSync(wfPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('<success_criteria>'),
|
||||
'must have <success_criteria> section'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('</success_criteria>'),
|
||||
'must close <success_criteria> section'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. SECURITY.md template ────────────────────────────────────────────────
|
||||
|
||||
describe('SECURE: SECURITY.md template', () => {
|
||||
const tplPath = path.join(TEMPLATES_DIR, 'SECURITY.md');
|
||||
|
||||
test('template exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(tplPath),
|
||||
'SECURITY.md must exist in get-shit-done/templates/'
|
||||
);
|
||||
});
|
||||
|
||||
test('has YAML frontmatter with required fields', () => {
|
||||
const content = fs.readFileSync(tplPath, 'utf-8');
|
||||
const frontmatter = content.split('---')[1] || '';
|
||||
const requiredFields = ['phase', 'slug', 'status', 'threats_open', 'asvs_level', 'created'];
|
||||
for (const field of requiredFields) {
|
||||
assert.ok(
|
||||
frontmatter.includes(`${field}:`),
|
||||
`frontmatter must have ${field}: field`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('has ## Trust Boundaries section', () => {
|
||||
const content = fs.readFileSync(tplPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('## Trust Boundaries'),
|
||||
'must have ## Trust Boundaries section'
|
||||
);
|
||||
});
|
||||
|
||||
test('has ## Threat Register table with required columns', () => {
|
||||
const content = fs.readFileSync(tplPath, 'utf-8');
|
||||
assert.ok(content.includes('## Threat Register'), 'must have ## Threat Register section');
|
||||
const requiredColumns = ['Threat ID', 'Category', 'Component', 'Disposition', 'Mitigation', 'Status'];
|
||||
for (const col of requiredColumns) {
|
||||
assert.ok(
|
||||
content.includes(col),
|
||||
`Threat Register table must have ${col} column`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('has ## Accepted Risks Log section', () => {
|
||||
const content = fs.readFileSync(tplPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('## Accepted Risks Log'),
|
||||
'must have ## Accepted Risks Log section'
|
||||
);
|
||||
});
|
||||
|
||||
test('has ## Security Audit Trail section', () => {
|
||||
const content = fs.readFileSync(tplPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('## Security Audit Trail'),
|
||||
'must have ## Security Audit Trail section'
|
||||
);
|
||||
});
|
||||
|
||||
test('has sign-off checklist', () => {
|
||||
const content = fs.readFileSync(tplPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('## Sign-Off'),
|
||||
'must have ## Sign-Off section'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('- [ ]'),
|
||||
'sign-off must have checklist items'
|
||||
);
|
||||
});
|
||||
|
||||
test('threats_open field is present (terminal condition field)', () => {
|
||||
const content = fs.readFileSync(tplPath, 'utf-8');
|
||||
const frontmatter = content.split('---')[1] || '';
|
||||
assert.ok(
|
||||
frontmatter.includes('threats_open:'),
|
||||
'threats_open must be present in frontmatter as terminal condition field'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. Config defaults ─────────────────────────────────────────────────────
|
||||
|
||||
describe('SECURE: config.json security defaults', () => {
|
||||
const configPath = path.join(TEMPLATES_DIR, 'config.json');
|
||||
|
||||
test('config template exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(configPath),
|
||||
'config.json must exist in get-shit-done/templates/'
|
||||
);
|
||||
});
|
||||
|
||||
test('has workflow.security_enforcement set to true', () => {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
config.workflow.security_enforcement,
|
||||
true,
|
||||
'security_enforcement must default to true'
|
||||
);
|
||||
});
|
||||
|
||||
test('has workflow.security_asvs_level set to 1', () => {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
config.workflow.security_asvs_level,
|
||||
1,
|
||||
'security_asvs_level must default to 1'
|
||||
);
|
||||
});
|
||||
|
||||
test('has workflow.security_block_on set to "high"', () => {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
config.workflow.security_block_on,
|
||||
'high',
|
||||
'security_block_on must default to "high"'
|
||||
);
|
||||
});
|
||||
|
||||
test('security_enforcement appears after nyquist_validation (opt-out pattern parity)', () => {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const nyquistPos = raw.indexOf('nyquist_validation');
|
||||
const securityPos = raw.indexOf('security_enforcement');
|
||||
assert.ok(nyquistPos > -1, 'nyquist_validation must exist in config');
|
||||
assert.ok(securityPos > -1, 'security_enforcement must exist in config');
|
||||
assert.ok(
|
||||
securityPos > nyquistPos,
|
||||
'security_enforcement must appear after nyquist_validation for opt-out pattern parity'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. VALIDATION.md template security columns ────────────────────────────
|
||||
|
||||
describe('SECURE: VALIDATION.md security columns', () => {
|
||||
const valPath = path.join(TEMPLATES_DIR, 'VALIDATION.md');
|
||||
|
||||
test('VALIDATION.md template exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(valPath),
|
||||
'VALIDATION.md must exist in get-shit-done/templates/'
|
||||
);
|
||||
});
|
||||
|
||||
test('contains Threat Ref column header', () => {
|
||||
const content = fs.readFileSync(valPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('Threat Ref'),
|
||||
'must have Threat Ref column in Per-Task Verification Map'
|
||||
);
|
||||
});
|
||||
|
||||
test('contains Secure Behavior column header', () => {
|
||||
const content = fs.readFileSync(valPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('Secure Behavior'),
|
||||
'must have Secure Behavior column in Per-Task Verification Map'
|
||||
);
|
||||
});
|
||||
|
||||
test('both columns appear in the Per-Task Verification Map table', () => {
|
||||
const content = fs.readFileSync(valPath, 'utf-8');
|
||||
// Find the table header row containing both columns
|
||||
const lines = content.split('\n');
|
||||
const headerLine = lines.find(
|
||||
line => line.includes('Threat Ref') && line.includes('Secure Behavior')
|
||||
);
|
||||
assert.ok(
|
||||
headerLine,
|
||||
'Threat Ref and Secure Behavior must appear in the same table header row'
|
||||
);
|
||||
// Verify this is in the Per-Task Verification Map section
|
||||
const mapIdx = content.indexOf('## Per-Task Verification Map');
|
||||
const threatRefIdx = content.indexOf('Threat Ref');
|
||||
assert.ok(mapIdx > -1, 'must have Per-Task Verification Map section');
|
||||
assert.ok(
|
||||
threatRefIdx > mapIdx,
|
||||
'Threat Ref column must appear after Per-Task Verification Map heading'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 7. Threat-model-anchored behaviour (structural) ────────────────────────
|
||||
|
||||
describe('SECURE: threat-model-anchored behaviour', () => {
|
||||
const agentPath = path.join(AGENTS_DIR, 'gsd-security-auditor.md');
|
||||
const wfPath = path.join(WORKFLOWS_DIR, 'secure-phase.md');
|
||||
|
||||
test('agent does NOT contain "scan for vulnerabilities" (verifies, not scans)', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
assert.ok(
|
||||
!content.toLowerCase().includes('scan for vulnerabilities'),
|
||||
'agent must NOT scan for vulnerabilities — it verifies threat mitigations'
|
||||
);
|
||||
});
|
||||
|
||||
test('agent does NOT contain "find vulnerabilities" (verifies, not scans)', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
assert.ok(
|
||||
!content.toLowerCase().includes('find vulnerabilities'),
|
||||
'agent must NOT find vulnerabilities — it verifies threat mitigations'
|
||||
);
|
||||
});
|
||||
|
||||
test('agent contains mitigate, accept, transfer disposition types', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
assert.ok(content.includes('mitigate'), 'must contain mitigate disposition');
|
||||
assert.ok(content.includes('accept'), 'must contain accept disposition');
|
||||
assert.ok(content.includes('transfer'), 'must contain transfer disposition');
|
||||
});
|
||||
|
||||
test('agent contains OPEN and CLOSED status values', () => {
|
||||
const content = fs.readFileSync(agentPath, 'utf-8');
|
||||
assert.ok(content.includes('OPEN'), 'must contain OPEN status');
|
||||
assert.ok(content.includes('CLOSED'), 'must contain CLOSED status');
|
||||
});
|
||||
|
||||
test('workflow contains enforcing gate (threats_open + block pattern)', () => {
|
||||
const content = fs.readFileSync(wfPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('threats_open'),
|
||||
'workflow must reference threats_open for enforcement'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('BLOCKED') || content.includes('blocked'),
|
||||
'workflow must contain a blocking pattern when threats are open'
|
||||
);
|
||||
// Verify it does NOT emit next-phase routing when blocked
|
||||
assert.ok(
|
||||
content.includes('Do NOT emit next-phase routing'),
|
||||
'workflow must explicitly prevent next-phase routing when blocked'
|
||||
);
|
||||
});
|
||||
});
|
||||
186
tests/settings-jsonc.test.cjs
Normal file
186
tests/settings-jsonc.test.cjs
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* GSD Tools Tests - settings.json JSONC (JSON with comments) support
|
||||
*
|
||||
* Validates that the installer's readSettings() correctly handles
|
||||
* settings.json files containing comments (line and block) without
|
||||
* silently overwriting them with empty objects.
|
||||
*
|
||||
* Closes: #1461
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ─── inline stripJsonComments (mirrors install.js logic) ─────────────────────
|
||||
|
||||
function stripJsonComments(text) {
|
||||
let result = '';
|
||||
let i = 0;
|
||||
let inString = false;
|
||||
let stringChar = '';
|
||||
while (i < text.length) {
|
||||
if (inString) {
|
||||
if (text[i] === '\\') {
|
||||
result += text[i] + (text[i + 1] || '');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === stringChar) {
|
||||
inString = false;
|
||||
}
|
||||
result += text[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === '"' || text[i] === "'") {
|
||||
inString = true;
|
||||
stringChar = text[i];
|
||||
result += text[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === '/' && text[i + 1] === '/') {
|
||||
while (i < text.length && text[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === '/' && text[i + 1] === '*') {
|
||||
i += 2;
|
||||
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
result += text[i];
|
||||
i++;
|
||||
}
|
||||
return result.replace(/,\s*([}\]])/g, '$1');
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('stripJsonComments (#1461)', () => {
|
||||
|
||||
test('strips line comments', () => {
|
||||
const input = `{
|
||||
// This is a comment
|
||||
"key": "value"
|
||||
}`;
|
||||
const result = JSON.parse(stripJsonComments(input));
|
||||
assert.deepStrictEqual(result, { key: 'value' });
|
||||
});
|
||||
|
||||
test('strips block comments', () => {
|
||||
const input = `{
|
||||
/* Block comment */
|
||||
"key": "value"
|
||||
}`;
|
||||
const result = JSON.parse(stripJsonComments(input));
|
||||
assert.deepStrictEqual(result, { key: 'value' });
|
||||
});
|
||||
|
||||
test('strips multi-line block comments', () => {
|
||||
const input = `{
|
||||
/*
|
||||
* Multi-line
|
||||
* block comment
|
||||
*/
|
||||
"key": "value"
|
||||
}`;
|
||||
const result = JSON.parse(stripJsonComments(input));
|
||||
assert.deepStrictEqual(result, { key: 'value' });
|
||||
});
|
||||
|
||||
test('preserves comments inside string values', () => {
|
||||
const input = `{
|
||||
"url": "https://example.com/path",
|
||||
"description": "Use // for line comments"
|
||||
}`;
|
||||
const result = JSON.parse(stripJsonComments(input));
|
||||
assert.strictEqual(result.url, 'https://example.com/path');
|
||||
assert.strictEqual(result.description, 'Use // for line comments');
|
||||
});
|
||||
|
||||
test('handles trailing commas', () => {
|
||||
const input = `{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
}`;
|
||||
const result = JSON.parse(stripJsonComments(input));
|
||||
assert.deepStrictEqual(result, { a: 1, b: 2 });
|
||||
});
|
||||
|
||||
test('handles inline comments after values', () => {
|
||||
const input = `{
|
||||
"timeout": 5000, // milliseconds
|
||||
"retries": 3 // max attempts
|
||||
}`;
|
||||
const result = JSON.parse(stripJsonComments(input));
|
||||
assert.strictEqual(result.timeout, 5000);
|
||||
assert.strictEqual(result.retries, 3);
|
||||
});
|
||||
|
||||
test('handles standard JSON (no comments) unchanged', () => {
|
||||
const input = '{"key": "value", "num": 42}';
|
||||
const result = JSON.parse(stripJsonComments(input));
|
||||
assert.deepStrictEqual(result, { key: 'value', num: 42 });
|
||||
});
|
||||
|
||||
test('handles empty object', () => {
|
||||
const result = JSON.parse(stripJsonComments('{}'));
|
||||
assert.deepStrictEqual(result, {});
|
||||
});
|
||||
|
||||
test('handles real-world settings.json with comments', () => {
|
||||
const input = `{
|
||||
// My configuration
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "", /* match all */
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ~/.claude/hooks/gsd-statusline.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"statusLine": {
|
||||
"command": "node ~/.claude/hooks/gsd-statusline.js",
|
||||
"refreshInterval": 10
|
||||
}
|
||||
}`;
|
||||
const result = JSON.parse(stripJsonComments(input));
|
||||
assert.ok(result.hooks, 'should have hooks');
|
||||
assert.ok(result.statusLine, 'should have statusLine');
|
||||
assert.strictEqual(result.statusLine.refreshInterval, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readSettings null return on malformed files (#1461)', () => {
|
||||
test('install.js contains JSONC stripping in readSettings', () => {
|
||||
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const content = fs.readFileSync(installPath, 'utf8');
|
||||
assert.ok(content.includes('stripJsonComments'),
|
||||
'install.js should use stripJsonComments in readSettings');
|
||||
});
|
||||
|
||||
test('readSettings returns null on truly malformed files (not empty object)', () => {
|
||||
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const content = fs.readFileSync(installPath, 'utf8');
|
||||
assert.ok(content.includes('return null'),
|
||||
'readSettings should return null on parse failure, not empty object');
|
||||
});
|
||||
|
||||
test('callers guard against null readSettings return', () => {
|
||||
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const content = fs.readFileSync(installPath, 'utf8');
|
||||
// Should have null guards at the settings configuration call sites
|
||||
assert.ok(
|
||||
content.includes('=== null') || content.includes('rawSettings === null'),
|
||||
'callers should check for null return from readSettings'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -105,10 +105,11 @@ describe('convertClaudeToWindsurfMarkdown', () => {
|
||||
assert.ok(!result.includes('Claude Code'), 'original brand removed');
|
||||
});
|
||||
|
||||
test('replaces CLAUDE.md with .windsurf/rules/', () => {
|
||||
test('replaces CLAUDE.md with .windsurf/rules (no trailing slash)', () => {
|
||||
const input = 'See `CLAUDE.md` for configuration. Also check ./CLAUDE.md file.';
|
||||
const result = convertClaudeToWindsurfMarkdown(input);
|
||||
assert.ok(result.includes('.windsurf/rules/'), 'CLAUDE.md replaced');
|
||||
assert.ok(result.includes('.windsurf/rules'), 'CLAUDE.md replaced');
|
||||
assert.ok(!result.includes('.windsurf/rules/'), 'no trailing slash (Node v25 compat)');
|
||||
});
|
||||
|
||||
test('replaces .claude/skills/ with .windsurf/skills/', () => {
|
||||
|
||||
@@ -268,6 +268,24 @@ describe('workstream set/get', () => {
|
||||
assert.ok(result.success);
|
||||
assert.strictEqual(result.output, 'ws-a');
|
||||
});
|
||||
|
||||
test('errors when set called with no name (#1527)', () => {
|
||||
const result = runGsdTools(['workstream', 'set', '--raw'], tmpDir);
|
||||
assert.ok(!result.success, 'should fail when no name provided');
|
||||
assert.ok(result.error.includes('name required'), 'error should mention name required');
|
||||
});
|
||||
|
||||
test('--clear explicitly unsets active workstream', () => {
|
||||
// First set one
|
||||
runGsdTools(['workstream', 'set', 'ws-b', '--raw'], tmpDir);
|
||||
// Then clear
|
||||
const result = runGsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir);
|
||||
assert.ok(result.success);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.active, null);
|
||||
assert.strictEqual(data.cleared, true);
|
||||
assert.strictEqual(data.previous, 'ws-b');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Collision Detection ────────────────────────────────────────────────────
|
||||
|
||||
70
tests/worktree-cleanup.test.cjs
Normal file
70
tests/worktree-cleanup.test.cjs
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* GSD Tools Tests - worktree cleanup after executor completes
|
||||
*
|
||||
* Validates that execute-phase.md and quick.md include post-execution
|
||||
* worktree cleanup logic (merge branch, remove worktree, delete branch).
|
||||
*
|
||||
* Closes: #1496
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('worktree cleanup after executor completes (#1496)', () => {
|
||||
const executePhasePath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'execute-phase.md');
|
||||
const quickPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'quick.md');
|
||||
|
||||
test('execute-phase.md includes worktree cleanup step', () => {
|
||||
const content = fs.readFileSync(executePhasePath, 'utf8');
|
||||
assert.ok(content.includes('Worktree cleanup'),
|
||||
'execute-phase should have a worktree cleanup step');
|
||||
assert.ok(content.includes('git worktree remove'),
|
||||
'cleanup should remove worktrees');
|
||||
assert.ok(content.includes('git branch -D'),
|
||||
'cleanup should delete temporary branches');
|
||||
});
|
||||
|
||||
test('execute-phase.md merges worktree branch before removing', () => {
|
||||
const content = fs.readFileSync(executePhasePath, 'utf8');
|
||||
assert.ok(content.includes('git merge'),
|
||||
'cleanup should merge worktree branch into current branch');
|
||||
});
|
||||
|
||||
test('execute-phase.md handles merge conflicts gracefully', () => {
|
||||
const content = fs.readFileSync(executePhasePath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('Merge conflict') || content.includes('merge conflict'),
|
||||
'cleanup should handle merge conflicts gracefully'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md skips cleanup when use_worktrees is false', () => {
|
||||
const content = fs.readFileSync(executePhasePath, 'utf8');
|
||||
assert.ok(content.includes('use_worktrees'),
|
||||
'cleanup should respect workflow.use_worktrees config');
|
||||
});
|
||||
|
||||
test('quick.md includes worktree cleanup after executor returns', () => {
|
||||
const content = fs.readFileSync(quickPath, 'utf8');
|
||||
assert.ok(content.includes('Worktree cleanup') || content.includes('worktree cleanup'),
|
||||
'quick should have worktree cleanup');
|
||||
assert.ok(content.includes('git worktree remove'),
|
||||
'quick cleanup should remove worktrees');
|
||||
assert.ok(content.includes('git branch -D'),
|
||||
'quick cleanup should delete temporary branches');
|
||||
});
|
||||
|
||||
test('quick.md merges worktree branch before removing', () => {
|
||||
const content = fs.readFileSync(quickPath, 'utf8');
|
||||
assert.ok(content.includes('git merge'),
|
||||
'quick cleanup should merge worktree branch');
|
||||
});
|
||||
|
||||
test('cleanup uses git worktree list to discover orphans', () => {
|
||||
const content = fs.readFileSync(executePhasePath, 'utf8');
|
||||
assert.ok(content.includes('git worktree list'),
|
||||
'cleanup should discover worktrees via git worktree list');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user