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:
Alex Alecu
2026-04-02 16:00:09 +03:00
95 changed files with 7754 additions and 384 deletions

View File

@@ -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`
);
}
});
});

View File

@@ -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');
});
});

View 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');
});
});

View File

@@ -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 ─────────────────────────────────────────────────────

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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',

View File

@@ -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'
);
});
});

View 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
View 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);
});
});

View File

@@ -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'
);
});
});

View File

@@ -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');
});
});
// ─────────────────────────────────────────────────────────────────────────────

View 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'
);
});
});

View File

@@ -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)'
);
});
});

View 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
View 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
View 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'
);
});
});

View 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'
);
});
});

View File

@@ -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/', () => {

View File

@@ -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 ────────────────────────────────────────────────────

View 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');
});
});