merge: resolve conflicts with main (plan_bounce + code_review_command)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-11 10:32:20 -04:00
25 changed files with 1870 additions and 144 deletions

View File

@@ -0,0 +1,201 @@
/**
* Tests for configurable claude_md_path setting (#2010)
*/
const { describe, test, 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');
describe('claude_md_path config key', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('claude_md_path is in VALID_CONFIG_KEYS', () => {
const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config.cjs');
assert.ok(VALID_CONFIG_KEYS.has('claude_md_path'));
});
test('config template includes claude_md_path', () => {
const templatePath = path.join(__dirname, '..', 'get-shit-done', 'templates', 'config.json');
const template = JSON.parse(fs.readFileSync(templatePath, 'utf-8'));
assert.strictEqual(template.claude_md_path, './CLAUDE.md');
});
test('config-get claude_md_path returns default value when not set', () => {
// Create a config.json without claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ mode: 'interactive' }), 'utf-8');
const result = runGsdTools('config-get claude_md_path --default ./CLAUDE.md', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Expected success but got error: ${result.error}`);
assert.strictEqual(JSON.parse(result.output), './CLAUDE.md');
});
test('config-set claude_md_path works', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ mode: 'interactive' }), 'utf-8');
const setResult = runGsdTools('config-set claude_md_path .claude/CLAUDE.md', tmpDir, { HOME: tmpDir });
assert.ok(setResult.success, `Expected success but got error: ${setResult.error}`);
const getResult = runGsdTools('config-get claude_md_path', tmpDir, { HOME: tmpDir });
assert.ok(getResult.success, `Expected success but got error: ${getResult.error}`);
assert.strictEqual(JSON.parse(getResult.output), '.claude/CLAUDE.md');
});
test('buildNewProjectConfig includes claude_md_path default', () => {
// Use config-new-project which calls buildNewProjectConfig
const result = runGsdTools('config-new-project', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.strictEqual(config.claude_md_path, './CLAUDE.md');
});
});
describe('cmdGenerateClaudeProfile reads claude_md_path from config', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('uses claude_md_path from config when no --output or --global', () => {
// Set up config with custom claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
const customPath = '.claude/CLAUDE.md';
fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: customPath }), 'utf-8');
// Create the target directory
fs.mkdirSync(path.join(tmpDir, '.claude'), { recursive: true });
// Create a minimal analysis file
const analysisPath = path.join(tmpDir, '.planning', 'analysis.json');
const analysis = {
dimensions: {
communication_style: { rating: 'terse-direct', confidence: 'HIGH' },
},
data_source: 'test',
};
fs.writeFileSync(analysisPath, JSON.stringify(analysis), 'utf-8');
const result = runGsdTools(
['generate-claude-profile', '--analysis', analysisPath],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
const expectedPath = path.join(realTmpDir, customPath);
assert.strictEqual(parsed.claude_md_path, expectedPath);
assert.ok(fs.existsSync(expectedPath), `Expected file at ${expectedPath}`);
});
test('--output flag overrides claude_md_path from config', () => {
// Set up config with custom claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: '.claude/CLAUDE.md' }), 'utf-8');
// Create analysis file
const analysisPath = path.join(tmpDir, '.planning', 'analysis.json');
const analysis = {
dimensions: {
communication_style: { rating: 'terse-direct', confidence: 'HIGH' },
},
data_source: 'test',
};
fs.writeFileSync(analysisPath, JSON.stringify(analysis), 'utf-8');
const outputFile = 'custom-output.md';
const result = runGsdTools(
['generate-claude-profile', '--analysis', analysisPath, '--output', outputFile],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, outputFile));
});
});
describe('cmdGenerateClaudeMd reads claude_md_path from config', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Create minimal project files so generate-claude-md has something to read
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
['# Test Project', '', 'A test project.'].join('\n'),
'utf-8'
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('uses claude_md_path from config when no --output', () => {
// Set up config with custom claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
const customPath = '.claude/CLAUDE.md';
fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: customPath }), 'utf-8');
// Create the target directory
fs.mkdirSync(path.join(tmpDir, '.claude'), { recursive: true });
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
const expectedPath = path.join(realTmpDir, customPath);
assert.strictEqual(parsed.claude_md_path, expectedPath);
assert.ok(fs.existsSync(expectedPath), `Expected file at ${expectedPath}`);
});
test('--output flag overrides claude_md_path from config', () => {
// Set up config with custom claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: '.claude/CLAUDE.md' }), 'utf-8');
const outputFile = 'my-custom.md';
const result = runGsdTools(['generate-claude-md', '--output', outputFile], tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, outputFile));
});
test('defaults to ./CLAUDE.md when config has no claude_md_path', () => {
// Set up config without claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ mode: 'interactive' }), 'utf-8');
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, 'CLAUDE.md'));
});
});

View File

@@ -1629,9 +1629,11 @@ describe('findProjectRoot', () => {
// ─── reapStaleTempFiles ─────────────────────────────────────────────────────
describe('reapStaleTempFiles', () => {
const gsdTmpDir = path.join(os.tmpdir(), 'gsd');
test('removes stale gsd-*.json files older than maxAgeMs', () => {
const tmpDir = os.tmpdir();
const stalePath = path.join(tmpDir, `gsd-reap-test-${Date.now()}.json`);
fs.mkdirSync(gsdTmpDir, { recursive: true });
const stalePath = path.join(gsdTmpDir, `gsd-reap-test-${Date.now()}.json`);
fs.writeFileSync(stalePath, '{}');
// Set mtime to 10 minutes ago
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
@@ -1643,8 +1645,8 @@ describe('reapStaleTempFiles', () => {
});
test('preserves fresh gsd-*.json files', () => {
const tmpDir = os.tmpdir();
const freshPath = path.join(tmpDir, `gsd-reap-fresh-${Date.now()}.json`);
fs.mkdirSync(gsdTmpDir, { recursive: true });
const freshPath = path.join(gsdTmpDir, `gsd-reap-fresh-${Date.now()}.json`);
fs.writeFileSync(freshPath, '{}');
reapStaleTempFiles('gsd-reap-fresh-', { maxAgeMs: 5 * 60 * 1000 });
@@ -1655,8 +1657,8 @@ describe('reapStaleTempFiles', () => {
});
test('removes stale temp directories when present', () => {
const tmpDir = os.tmpdir();
const staleDir = fs.mkdtempSync(path.join(tmpDir, 'gsd-reap-dir-'));
fs.mkdirSync(gsdTmpDir, { recursive: true });
const staleDir = fs.mkdtempSync(path.join(gsdTmpDir, 'gsd-reap-dir-'));
fs.writeFileSync(path.join(staleDir, 'data.jsonl'), 'test');
// Set mtime to 10 minutes ago
const oldTime = new Date(Date.now() - 10 * 60 * 1000);

View File

@@ -1,11 +1,11 @@
/**
* GSD Tools Tests - /gsd-next safety gates and consecutive-call guard
* GSD Tools Tests - /gsd-next safety gates and prior-phase completeness scan
*
* Validates that the next workflow includes three hard-stop safety gates
* (checkpoint, error state, verification), a consecutive-call budget guard,
* and a --force bypass flag.
* (checkpoint, error state, verification), a prior-phase completeness scan
* replacing the old consecutive-call counter, and a --force bypass flag.
*
* Closes: #1732
* Closes: #1732, #2089
*/
const { test, describe } = require('node:test');
@@ -13,7 +13,7 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
describe('/gsd-next safety gates (#1732)', () => {
describe('/gsd-next safety gates (#1732, #2089)', () => {
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'next.md');
const commandPath = path.join(__dirname, '..', 'commands', 'gsd', 'next.md');
@@ -79,19 +79,72 @@ describe('/gsd-next safety gates (#1732)', () => {
);
});
test('consecutive-call budget guard', () => {
test('prior-phase completeness scan replaces consecutive-call counter', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('.next-call-count'),
'workflow should reference .next-call-count counter file'
content.includes('Prior-phase completeness scan'),
'workflow should have a prior-phase completeness scan section'
);
assert.ok(
content.includes('6'),
'consecutive guard should trigger at count >= 6'
!content.includes('.next-call-count'),
'workflow must not reference the old .next-call-count counter file'
);
assert.ok(
content.includes('consecutively'),
'guard should mention consecutive calls'
!content.includes('consecutively'),
'workflow must not reference consecutive call counting'
);
});
test('completeness scan checks plans without summaries', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('Plans without summaries') || content.includes('no SUMMARY.md'),
'completeness scan should detect plans that ran without producing summaries'
);
});
test('completeness scan checks verification failures in prior phases', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('Verification failures not overridden') ||
content.includes('VERIFICATION.md with `FAIL`'),
'completeness scan should detect unoverridden FAIL items in prior phase VERIFICATION.md'
);
});
test('completeness scan checks CONTEXT.md without plans', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('CONTEXT.md without plans') ||
content.includes('CONTEXT.md but no PLAN.md'),
'completeness scan should detect phases with discussion but no planning'
);
});
test('completeness scan offers Continue, Stop, and Force options', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(content.includes('[C]'), 'completeness scan should offer [C] Continue option');
assert.ok(content.includes('[S]'), 'completeness scan should offer [S] Stop option');
assert.ok(content.includes('[F]'), 'completeness scan should offer [F] Force option');
});
test('deferral path creates backlog entry using 999.x scheme', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('999.'),
'deferral should use the 999.x backlog numbering scheme'
);
assert.ok(
content.includes('Backlog') || content.includes('BACKLOG'),
'deferral should write to the Backlog section of ROADMAP.md'
);
});
test('clean prior phases route silently with no interruption', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('silently') || content.includes('no interruption'),
'workflow should route without interruption when prior phases are clean'
);
});
@@ -107,7 +160,7 @@ describe('/gsd-next safety gates (#1732)', () => {
);
});
test('command definition documents --force flag', () => {
test('command definition documents --force flag and completeness scan', () => {
const content = fs.readFileSync(commandPath, 'utf8');
assert.ok(
content.includes('--force'),
@@ -117,6 +170,10 @@ describe('/gsd-next safety gates (#1732)', () => {
content.includes('bypass safety gates'),
'command definition should explain that --force bypasses safety gates'
);
assert.ok(
content.includes('completeness'),
'command definition should document the prior-phase completeness scan'
);
});
test('gates exit on first hit', () => {

View File

@@ -0,0 +1,177 @@
/**
* Phase Researcher Application-Aware Tests (#1988)
*
* Validates that gsd-phase-researcher maps capabilities to architectural
* tiers before diving into framework-specific research. Also validates
* that gsd-planner and gsd-plan-checker consume the Architectural
* Responsibility Map downstream.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
const TEMPLATES_DIR = path.join(__dirname, '..', 'get-shit-done', 'templates');
// ─── Phase Researcher: Architectural Responsibility Mapping ─────────────────
describe('phase-researcher: Architectural Responsibility Mapping', () => {
const researcherPath = path.join(AGENTS_DIR, 'gsd-phase-researcher.md');
const content = fs.readFileSync(researcherPath, 'utf-8');
test('contains Architectural Responsibility Mapping step', () => {
assert.ok(
content.includes('Architectural Responsibility Map'),
'gsd-phase-researcher.md must contain "Architectural Responsibility Map"'
);
});
test('Architectural Responsibility Mapping step comes after Step 1 and before Step 2', () => {
const step1Pos = content.indexOf('## Step 1:');
// Look for the step heading specifically (not the output format section)
const stepARMPos = content.indexOf('## Step 1.5:');
const step2Pos = content.indexOf('## Step 2:');
assert.ok(step1Pos !== -1, 'Step 1 must exist');
assert.ok(stepARMPos !== -1, 'Step 1.5 Architectural Responsibility Mapping step must exist');
assert.ok(step2Pos !== -1, 'Step 2 must exist');
assert.ok(
stepARMPos > step1Pos,
'Step 1.5 (Architectural Responsibility Mapping) must come after Step 1'
);
assert.ok(
stepARMPos < step2Pos,
'Step 1.5 (Architectural Responsibility Mapping) must come before Step 2'
);
});
test('step is a pure reasoning step with no tool calls', () => {
// Extract the ARM section content (between the ARM heading and the next ## Step heading)
const armHeadingMatch = content.match(/## Step 1\.5[^\n]*Architectural Responsibility Map/);
assert.ok(armHeadingMatch, 'Must have a Step 1.5 heading for Architectural Responsibility Mapping');
const armStart = content.indexOf(armHeadingMatch[0]);
const nextStepMatch = content.indexOf('## Step 2:', armStart);
const armSection = content.substring(armStart, nextStepMatch);
// Should not contain tool invocation patterns
const toolPatterns = [
/```bash/,
/node "\$HOME/,
/gsd-tools\.cjs/,
/WebSearch/,
/Context7/,
/mcp__/,
];
for (const pattern of toolPatterns) {
assert.ok(
!pattern.test(armSection),
`Architectural Responsibility Mapping step must be pure reasoning (no tool calls), but found: ${pattern}`
);
}
});
test('mentions standard architectural tiers', () => {
const armStart = content.indexOf('Architectural Responsibility Map');
const nextStep = content.indexOf('## Step 2:', armStart);
const armSection = content.substring(armStart, nextStep);
// Should reference standard tiers
const tiers = ['browser', 'frontend', 'API', 'database'];
const foundTiers = tiers.filter(tier =>
armSection.toLowerCase().includes(tier.toLowerCase())
);
assert.ok(
foundTiers.length >= 3,
`Must mention at least 3 standard architectural tiers, found: ${foundTiers.join(', ')}`
);
});
test('specifies output format as a table in RESEARCH.md', () => {
const armStart = content.indexOf('Architectural Responsibility Map');
const nextStep = content.indexOf('## Step 2:', armStart);
const armSection = content.substring(armStart, nextStep);
assert.ok(
armSection.includes('|') && armSection.includes('Capability'),
'ARM step must specify a table output format with Capability column'
);
});
});
// ─── Planner: Architectural Responsibility Map Sanity Check ─────────────────
describe('planner: Architectural Responsibility Map sanity check', () => {
const plannerPath = path.join(AGENTS_DIR, 'gsd-planner.md');
const content = fs.readFileSync(plannerPath, 'utf-8');
test('references Architectural Responsibility Map', () => {
assert.ok(
content.includes('Architectural Responsibility Map'),
'gsd-planner.md must reference the Architectural Responsibility Map'
);
});
test('includes sanity check against the map', () => {
// Must mention checking/verifying plan tasks against the responsibility map
assert.ok(
content.includes('sanity check') || content.includes('sanity-check'),
'gsd-planner.md must include a sanity check against the Architectural Responsibility Map'
);
});
});
// ─── Plan Checker: Architectural Tier Verification Dimension ────────────────
describe('plan-checker: Architectural Tier verification dimension', () => {
const checkerPath = path.join(AGENTS_DIR, 'gsd-plan-checker.md');
const content = fs.readFileSync(checkerPath, 'utf-8');
test('has verification dimension for architectural tier', () => {
assert.ok(
content.includes('Architectural Responsibility Map') ||
content.includes('Architectural Tier'),
'gsd-plan-checker.md must have a verification dimension for architectural tier mapping'
);
});
test('verification dimension checks plans against the map', () => {
// Should have a dimension that references tier/responsibility checking
assert.ok(
content.includes('tier owner') || content.includes('tier mismatch') || content.includes('responsibility map'),
'plan-checker verification dimension must check for tier mismatches against the responsibility map'
);
});
});
// ─── Research Template: Architectural Responsibility Map Section ─────────────
describe('research template: Architectural Responsibility Map section', () => {
const templatePath = path.join(TEMPLATES_DIR, 'research.md');
const content = fs.readFileSync(templatePath, 'utf-8');
test('mentions Architectural Responsibility Map section', () => {
assert.ok(
content.includes('Architectural Responsibility Map'),
'Research template must include an Architectural Responsibility Map section'
);
});
test('template includes tier table format', () => {
const armStart = content.indexOf('Architectural Responsibility Map');
assert.ok(armStart !== -1, 'ARM section must exist');
const sectionEnd = content.indexOf('##', armStart + 10);
const section = content.substring(armStart, sectionEnd !== -1 ? sectionEnd : armStart + 500);
assert.ok(
section.includes('|') && (section.includes('Tier') || section.includes('tier')),
'Research template ARM section must include a table format with Tier column'
);
});
});

170
tests/plan-bounce.test.cjs Normal file
View File

@@ -0,0 +1,170 @@
/**
* Plan Bounce Tests
*
* Validates plan bounce hook feature (step 12.5 in plan-phase):
* - Config key registration (workflow.plan_bounce, workflow.plan_bounce_script, workflow.plan_bounce_passes)
* - Config template defaults
* - Workflow step 12.5 content in plan-phase.md
* - Flag handling (--bounce, --skip-bounce)
* - Backup/restore pattern (pre-bounce.md)
* - Frontmatter integrity validation
* - Re-runs checker on bounced plans
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const GSD_ROOT = path.join(__dirname, '..', 'get-shit-done');
const CONFIG_CJS_PATH = path.join(GSD_ROOT, 'bin', 'lib', 'config.cjs');
const CONFIG_TEMPLATE_PATH = path.join(GSD_ROOT, 'templates', 'config.json');
const PLAN_PHASE_PATH = path.join(GSD_ROOT, 'workflows', 'plan-phase.md');
describe('Plan Bounce: config keys', () => {
test('workflow.plan_bounce is in VALID_CONFIG_KEYS', () => {
const content = fs.readFileSync(CONFIG_CJS_PATH, 'utf-8');
assert.ok(
content.includes("'workflow.plan_bounce'"),
'VALID_CONFIG_KEYS should contain workflow.plan_bounce'
);
});
test('workflow.plan_bounce_script is in VALID_CONFIG_KEYS', () => {
const content = fs.readFileSync(CONFIG_CJS_PATH, 'utf-8');
assert.ok(
content.includes("'workflow.plan_bounce_script'"),
'VALID_CONFIG_KEYS should contain workflow.plan_bounce_script'
);
});
test('workflow.plan_bounce_passes is in VALID_CONFIG_KEYS', () => {
const content = fs.readFileSync(CONFIG_CJS_PATH, 'utf-8');
assert.ok(
content.includes("'workflow.plan_bounce_passes'"),
'VALID_CONFIG_KEYS should contain workflow.plan_bounce_passes'
);
});
});
describe('Plan Bounce: config template defaults', () => {
test('config template has plan_bounce default (false)', () => {
const template = JSON.parse(fs.readFileSync(CONFIG_TEMPLATE_PATH, 'utf-8'));
assert.strictEqual(
template.workflow.plan_bounce,
false,
'config template workflow.plan_bounce should default to false'
);
});
test('config template has plan_bounce_script default (null)', () => {
const template = JSON.parse(fs.readFileSync(CONFIG_TEMPLATE_PATH, 'utf-8'));
assert.strictEqual(
template.workflow.plan_bounce_script,
null,
'config template workflow.plan_bounce_script should default to null'
);
});
test('config template has plan_bounce_passes default (2)', () => {
const template = JSON.parse(fs.readFileSync(CONFIG_TEMPLATE_PATH, 'utf-8'));
assert.strictEqual(
template.workflow.plan_bounce_passes,
2,
'config template workflow.plan_bounce_passes should default to 2'
);
});
});
describe('Plan Bounce: plan-phase.md step 12.5', () => {
let content;
test('plan-phase.md contains step 12.5', () => {
content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('## 12.5'),
'plan-phase.md should contain step 12.5'
);
});
test('step 12.5 references plan bounce', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
// The step title should mention bounce
assert.ok(
/## 12\.5.*[Bb]ounce/i.test(content),
'step 12.5 should reference plan bounce in its title'
);
});
test('plan-phase.md has --bounce flag handling', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('--bounce'),
'plan-phase.md should handle --bounce flag'
);
});
test('plan-phase.md has --skip-bounce flag handling', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('--skip-bounce'),
'plan-phase.md should handle --skip-bounce flag'
);
});
test('plan-phase.md has backup pattern (pre-bounce.md)', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('pre-bounce.md'),
'plan-phase.md should reference pre-bounce.md backup files'
);
});
test('plan-phase.md has frontmatter integrity validation for bounced plans', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
// Should mention YAML frontmatter validation after bounce
assert.ok(
/frontmatter.*bounced|bounced.*frontmatter|YAML.*bounce|bounce.*YAML/i.test(content),
'plan-phase.md should validate frontmatter integrity on bounced plans'
);
});
test('plan-phase.md re-runs checker on bounced plans', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
// Should mention re-running plan checker after bounce
assert.ok(
/[Rr]e-run.*checker.*bounce|bounce.*checker.*re-run|checker.*bounced/i.test(content),
'plan-phase.md should re-run plan checker on bounced plans'
);
});
test('plan-phase.md references plan_bounce config keys', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('plan_bounce_script'),
'plan-phase.md should reference plan_bounce_script config'
);
assert.ok(
content.includes('plan_bounce_passes'),
'plan-phase.md should reference plan_bounce_passes config'
);
});
test('plan-phase.md disables bounce when --gaps flag is present', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
// Should mention that --gaps disables bounce
assert.ok(
/--gaps.*bounce|bounce.*--gaps/i.test(content),
'plan-phase.md should disable bounce when --gaps flag is present'
);
});
test('plan-phase.md restores original on script failure', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
// Should mention restoring from backup on failure
assert.ok(
/restore.*original|restore.*pre-bounce|original.*restore/i.test(content),
'plan-phase.md should restore original plan on script failure'
);
});
});

View File

@@ -0,0 +1,333 @@
'use strict';
/**
* Planner Language Regression Tests (#2091, #2092)
*
* Prevents time-based reasoning and complexity-as-scope-justification
* from leaking back into planning artifacts via future PRs.
*
* These tests scan agent definitions, workflow files, and references
* for prohibited patterns that import human-world constraints into
* an AI execution context where those constraints do not exist.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const AGENTS_DIR = path.join(ROOT, 'agents');
const WORKFLOWS_DIR = path.join(ROOT, 'get-shit-done', 'workflows');
const REFERENCES_DIR = path.join(ROOT, 'get-shit-done', 'references');
const TEMPLATES_DIR = path.join(ROOT, 'get-shit-done', 'templates');
/**
* Collect all .md files from a directory (non-recursive).
*/
function mdFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter(f => f.endsWith('.md'))
.map(f => ({ name: f, path: path.join(dir, f) }));
}
/**
* Collect all .md files recursively.
*/
function mdFilesRecursive(dir) {
if (!fs.existsSync(dir)) return [];
const results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...mdFilesRecursive(full));
} else if (entry.name.endsWith('.md')) {
results.push({ name: entry.name, path: full });
}
}
return results;
}
/**
* Files that define planning behavior — agents, workflows, references.
* These are the files where time-based and complexity-based scope
* reasoning must never appear.
*/
const PLANNING_FILES = [
...mdFiles(AGENTS_DIR),
...mdFiles(WORKFLOWS_DIR),
...mdFiles(REFERENCES_DIR),
...mdFilesRecursive(TEMPLATES_DIR),
];
// -- Prohibited patterns --
/**
* Time-based task sizing patterns.
* Matches "15-60 minutes", "X minutes Claude execution time", etc.
* Does NOT match operational timeouts ("timeout: 5 minutes"),
* API docs examples ("100 requests per 15 minutes"),
* or human-readable timeout descriptions in workflow execution steps.
*/
const TIME_SIZING_PATTERNS = [
// "N-M minutes" in task sizing context (not timeout context)
/each task[:\s]*\*?\*?\d+[-–]\d+\s*min/i,
// "minutes Claude execution time" or "minutes execution time"
/minutes?\s+(claude\s+)?execution\s+time/i,
// Duration-based sizing table rows: "< 15 min", "15-60 min", "> 60 min"
/[<>]\s*\d+\s*min\s*\|/i,
];
/**
* Complexity-as-scope-justification patterns.
* Matches "too complex to implement", "challenging feature", etc.
* Does NOT match legitimate uses like:
* - "complex domains" in research/discovery context (describing what to research)
* - "non-trivial" in verification context (confirming substantive code exists)
* - "challenging" in user-profiling context (quoting user reactions)
*/
const COMPLEXITY_SCOPE_PATTERNS = [
// "too complex to" — always a scope-reduction justification
/too\s+complex\s+to/i,
// "too difficult" — always a scope-reduction justification
/too\s+difficult/i,
// "is too complex for" — scope justification (e.g. "Phase X is too complex for")
/is\s+too\s+complex\s+for/i,
];
/**
* Files allowed to contain certain patterns because they document
* the prohibition itself, or use the terms in non-scope-reduction context.
*/
const ALLOWLIST = {
// Plan-checker scans FOR these patterns — it's a detection list, not usage
'gsd-plan-checker.md': ['complexity_scope', 'time_sizing'],
// Planner defines the prohibition and the authority limits — uses terms to explain what NOT to do
'gsd-planner.md': ['complexity_scope'],
// Debugger uses "30+ minutes" as anti-pattern detection, not task sizing
'gsd-debugger.md': ['time_sizing'],
// Doc-writer uses "15 minutes" in API rate limit example, "2 minutes" for doc quality
'gsd-doc-writer.md': ['time_sizing'],
// Discovery-phase uses time for level descriptions (operational, not scope)
'discovery-phase.md': ['time_sizing'],
// Explore uses "~30 seconds" as operational estimate
'explore.md': ['time_sizing'],
// Review uses "up to 5 minutes" for CodeRabbit timeout
'review.md': ['time_sizing'],
// Fast uses "under 2 minutes wall time" as operational constraint
'fast.md': ['time_sizing'],
// Execute-phase uses "timeout: 5 minutes" for test runner
'execute-phase.md': ['time_sizing'],
// Verify-phase uses "timeout: 5 minutes" for test runner
'verify-phase.md': ['time_sizing'],
// Map-codebase documents subagent_timeout
'map-codebase.md': ['time_sizing'],
// Help documents CodeRabbit timing
'help.md': ['time_sizing'],
};
function isAllowlisted(fileName, category) {
const entry = ALLOWLIST[fileName];
return entry && entry.includes(category);
}
// -- Tests --
describe('Planner language regression — time-based task sizing (#2092)', () => {
for (const file of PLANNING_FILES) {
test(`${file.name} must not use time-based task sizing`, () => {
if (isAllowlisted(file.name, 'time_sizing')) return;
const content = fs.readFileSync(file.path, 'utf-8');
for (const pattern of TIME_SIZING_PATTERNS) {
const match = content.match(pattern);
assert.ok(
!match,
[
`${file.name} contains time-based task sizing: "${match?.[0]}"`,
'Task sizing must use context-window percentage, not time units.',
'See issue #2092 for rationale.',
].join('\n')
);
}
});
}
});
describe('Planner language regression — complexity-as-scope-justification (#2092)', () => {
for (const file of PLANNING_FILES) {
test(`${file.name} must not use complexity to justify scope reduction`, () => {
if (isAllowlisted(file.name, 'complexity_scope')) return;
const content = fs.readFileSync(file.path, 'utf-8');
for (const pattern of COMPLEXITY_SCOPE_PATTERNS) {
const match = content.match(pattern);
assert.ok(
!match,
[
`${file.name} contains complexity-as-scope-justification: "${match?.[0]}"`,
'Scope decisions must be based on context cost, missing information,',
'or dependency conflicts — not perceived difficulty.',
'See issue #2092 for rationale.',
].join('\n')
);
}
});
}
});
describe('gsd-planner.md — required structural sections (#2091, #2092)', () => {
let plannerContent;
test('planner file exists and is readable', () => {
const plannerPath = path.join(AGENTS_DIR, 'gsd-planner.md');
assert.ok(fs.existsSync(plannerPath), 'agents/gsd-planner.md must exist');
plannerContent = fs.readFileSync(plannerPath, 'utf-8');
});
test('contains <planner_authority_limits> section', () => {
assert.ok(
plannerContent.includes('<planner_authority_limits>'),
'gsd-planner.md must contain a <planner_authority_limits> section defining what the planner cannot decide'
);
});
test('authority limits prohibit difficulty-based scope decisions', () => {
assert.ok(
plannerContent.includes('The planner has no authority to'),
'planner_authority_limits must explicitly state what the planner cannot decide'
);
});
test('authority limits list three legitimate split reasons: context cost, missing info, dependency', () => {
assert.ok(
plannerContent.includes('Context cost') || plannerContent.includes('context cost'),
'authority limits must list context cost as a legitimate split reason'
);
assert.ok(
plannerContent.includes('Missing information') || plannerContent.includes('missing information'),
'authority limits must list missing information as a legitimate split reason'
);
assert.ok(
plannerContent.includes('Dependency conflict') || plannerContent.includes('dependency conflict'),
'authority limits must list dependency conflict as a legitimate split reason'
);
});
test('task sizing uses context percentage, not time units', () => {
assert.ok(
plannerContent.includes('context consumption') || plannerContent.includes('context cost'),
'task sizing must reference context consumption, not time'
);
assert.ok(
!(/each task[:\s]*\*?\*?\d+[-–]\d+\s*min/i.test(plannerContent)),
'task sizing must not use minutes as sizing unit'
);
});
test('contains multi-source coverage audit (not just D-XX decisions)', () => {
assert.ok(
plannerContent.includes('Multi-Source Coverage Audit') ||
plannerContent.includes('multi-source coverage audit'),
'gsd-planner.md must contain a multi-source coverage audit, not just D-XX decision matrix'
);
});
test('coverage audit includes all four source types: GOAL, REQ, RESEARCH, CONTEXT', () => {
// The planner file or its referenced planner-source-audit.md must define all four types.
// The inline compact version uses **GOAL**, **REQ**, **RESEARCH**, **CONTEXT**.
const refPath = path.join(ROOT, 'get-shit-done', 'references', 'planner-source-audit.md');
const combined = plannerContent + (fs.existsSync(refPath) ? fs.readFileSync(refPath, 'utf-8') : '');
const hasGoal = combined.includes('**GOAL**');
const hasReq = combined.includes('**REQ**');
const hasResearch = combined.includes('**RESEARCH**');
const hasContext = combined.includes('**CONTEXT**');
assert.ok(hasGoal, 'coverage audit must include GOAL source type (ROADMAP.md phase goal)');
assert.ok(hasReq, 'coverage audit must include REQ source type (REQUIREMENTS.md)');
assert.ok(hasResearch, 'coverage audit must include RESEARCH source type (RESEARCH.md)');
assert.ok(hasContext, 'coverage audit must include CONTEXT source type (CONTEXT.md decisions)');
});
test('coverage audit defines MISSING item handling with developer escalation', () => {
assert.ok(
plannerContent.includes('Source Audit: Unplanned Items Found') ||
plannerContent.includes('MISSING'),
'coverage audit must define handling for MISSING items'
);
assert.ok(
plannerContent.includes('Awaiting developer decision') ||
plannerContent.includes('developer confirmation'),
'MISSING items must escalate to developer, not be silently dropped'
);
});
});
describe('plan-phase.md — source audit orchestration (#2091)', () => {
let workflowContent;
test('plan-phase workflow exists and is readable', () => {
const workflowPath = path.join(WORKFLOWS_DIR, 'plan-phase.md');
assert.ok(fs.existsSync(workflowPath), 'workflows/plan-phase.md must exist');
workflowContent = fs.readFileSync(workflowPath, 'utf-8');
});
test('step 9 handles Source Audit return from planner', () => {
assert.ok(
workflowContent.includes('Source Audit: Unplanned Items Found'),
'plan-phase.md step 9 must handle the Source Audit return from the planner'
);
});
test('step 9c exists for source audit gap handling', () => {
assert.ok(
workflowContent.includes('9c') && workflowContent.includes('Source Audit'),
'plan-phase.md must have a step 9c for handling source audit gaps'
);
});
test('step 9b does not use "too complex" language', () => {
// Extract just step 9b content (between "## 9b" and "## 9c" or "## 10")
const step9bMatch = workflowContent.match(/## 9b\.([\s\S]*?)(?=## 9c|## 10)/);
if (step9bMatch) {
const step9b = step9bMatch[1];
assert.ok(
!step9b.includes('too complex'),
'step 9b must not use "too complex" — use context budget language instead'
);
}
});
test('phase split recommendation uses context budget framing', () => {
assert.ok(
workflowContent.includes('context budget') || workflowContent.includes('context cost'),
'phase split recommendation must be framed in terms of context budget, not complexity'
);
});
});
describe('gsd-plan-checker.md — scope reduction detection includes time/complexity (#2092)', () => {
let checkerContent;
test('plan-checker exists and is readable', () => {
const checkerPath = path.join(AGENTS_DIR, 'gsd-plan-checker.md');
assert.ok(fs.existsSync(checkerPath), 'agents/gsd-plan-checker.md must exist');
checkerContent = fs.readFileSync(checkerPath, 'utf-8');
});
test('scope reduction scan includes complexity-based justification patterns', () => {
assert.ok(
checkerContent.includes('too complex') || checkerContent.includes('too difficult'),
'plan-checker scope reduction scan must detect complexity-based justification language'
);
});
test('scope reduction scan includes time-based justification patterns', () => {
assert.ok(
checkerContent.includes('would take') || checkerContent.includes('hours') || checkerContent.includes('minutes'),
'plan-checker scope reduction scan must detect time-based justification language'
);
});
});

View File

@@ -0,0 +1,219 @@
/**
* Tests for skill-manifest command
* TDD: RED phase — tests written before implementation
*/
const { describe, test, 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');
describe('skill-manifest', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('skill-manifest command exists and returns JSON', () => {
// Create a skills directory with one skill
const skillDir = path.join(tmpDir, '.claude', 'skills', 'test-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: test-skill',
'description: A test skill',
'---',
'',
'# Test Skill',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.ok(Array.isArray(manifest), 'Manifest should be an array');
});
test('generates manifest with correct structure from SKILL.md frontmatter', () => {
const skillDir = path.join(tmpDir, '.claude', 'skills', 'my-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: my-skill',
'description: Does something useful',
'---',
'',
'# My Skill',
'',
'TRIGGER when: user asks about widgets',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
assert.strictEqual(manifest[0].name, 'my-skill');
assert.strictEqual(manifest[0].description, 'Does something useful');
assert.strictEqual(manifest[0].path, 'my-skill');
});
test('empty skills directory produces empty manifest', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.ok(Array.isArray(manifest), 'Manifest should be an array');
assert.strictEqual(manifest.length, 0);
});
test('skills without SKILL.md are skipped', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
// Skill with SKILL.md
const goodDir = path.join(skillsDir, 'good-skill');
fs.mkdirSync(goodDir, { recursive: true });
fs.writeFileSync(path.join(goodDir, 'SKILL.md'), [
'---',
'name: good-skill',
'description: Has a SKILL.md',
'---',
'',
'# Good Skill',
].join('\n'));
// Skill without SKILL.md (just a directory)
const badDir = path.join(skillsDir, 'bad-skill');
fs.mkdirSync(badDir, { recursive: true });
fs.writeFileSync(path.join(badDir, 'README.md'), '# No SKILL.md here');
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
assert.strictEqual(manifest[0].name, 'good-skill');
});
test('manifest includes frontmatter fields from SKILL.md', () => {
const skillDir = path.join(tmpDir, '.claude', 'skills', 'rich-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: rich-skill',
'description: A richly documented skill',
'---',
'',
'# Rich Skill',
'',
'TRIGGER when: user mentions databases',
'DO NOT TRIGGER when: user asks about frontend',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
const skill = manifest[0];
assert.strictEqual(skill.name, 'rich-skill');
assert.strictEqual(skill.description, 'A richly documented skill');
assert.strictEqual(skill.path, 'rich-skill');
// triggers extracted from body text
assert.ok(Array.isArray(skill.triggers), 'triggers should be an array');
assert.ok(skill.triggers.length > 0, 'triggers should have at least one entry');
assert.ok(skill.triggers.some(t => t.includes('databases')), 'triggers should mention databases');
});
test('multiple skills are all included in manifest', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
for (const name of ['alpha', 'beta', 'gamma']) {
const dir = path.join(skillsDir, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'SKILL.md'), [
'---',
`name: ${name}`,
`description: The ${name} skill`,
'---',
'',
`# ${name}`,
].join('\n'));
}
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 3);
const names = manifest.map(s => s.name).sort();
assert.deepStrictEqual(names, ['alpha', 'beta', 'gamma']);
});
test('writes manifest to .planning/skill-manifest.json when --write flag is used', () => {
const skillDir = path.join(tmpDir, '.claude', 'skills', 'write-test');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: write-test',
'description: Tests write mode',
'---',
'',
'# Write Test',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills'), '--write'], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifestPath = path.join(tmpDir, '.planning', 'skill-manifest.json');
assert.ok(fs.existsSync(manifestPath), 'skill-manifest.json should be written to .planning/');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
assert.strictEqual(manifest.length, 1);
assert.strictEqual(manifest[0].name, 'write-test');
});
test('nonexistent skills directory returns empty manifest', () => {
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, 'nonexistent')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.ok(Array.isArray(manifest), 'Manifest should be an array');
assert.strictEqual(manifest.length, 0);
});
test('files in skills directory are ignored (only subdirectories scanned)', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
// A file, not a directory
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.md'), '# Not a skill');
// A valid skill directory
const skillDir = path.join(skillsDir, 'real-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: real-skill',
'description: A real skill',
'---',
'',
'# Real Skill',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
assert.strictEqual(manifest[0].name, 'real-skill');
});
});

136
tests/temp-subdir.test.cjs Normal file
View File

@@ -0,0 +1,136 @@
/**
* GSD Tools Tests - dedicated temp subdirectory
*
* Tests for issue #1975: GSD temp files should use a dedicated
* subdirectory (path.join(os.tmpdir(), 'gsd')) instead of writing
* directly to os.tmpdir().
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const {
reapStaleTempFiles,
} = require('../get-shit-done/bin/lib/core.cjs');
const GSD_TEMP_DIR = path.join(os.tmpdir(), 'gsd');
// ─── Dedicated temp subdirectory ────────────────────────────────────────────
describe('dedicated gsd temp subdirectory', () => {
describe('output() temp file placement', () => {
// output() writes to tmpfile when JSON > 50KB. We test indirectly by
// checking that reapStaleTempFiles scans the subdirectory.
test('gsd temp subdirectory path is os.tmpdir()/gsd', () => {
// The GSD_TEMP_DIR constant should resolve to <tmpdir>/gsd
assert.strictEqual(GSD_TEMP_DIR, path.join(os.tmpdir(), 'gsd'));
});
});
describe('reapStaleTempFiles with subdirectory', () => {
let testPrefix;
beforeEach(() => {
testPrefix = `gsd-tempsub-test-${Date.now()}-`;
// Ensure the gsd subdirectory exists for test setup
fs.mkdirSync(GSD_TEMP_DIR, { recursive: true });
});
test('removes stale files from gsd subdirectory', () => {
const stalePath = path.join(GSD_TEMP_DIR, `${testPrefix}stale.json`);
fs.writeFileSync(stalePath, '{}');
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(stalePath, oldTime, oldTime);
reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(stalePath), 'stale file in gsd subdir should be removed');
});
test('preserves fresh files in gsd subdirectory', () => {
const freshPath = path.join(GSD_TEMP_DIR, `${testPrefix}fresh.json`);
fs.writeFileSync(freshPath, '{}');
reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
assert.ok(fs.existsSync(freshPath), 'fresh file in gsd subdir should be preserved');
// Clean up
fs.unlinkSync(freshPath);
});
test('removes stale directories from gsd subdirectory', () => {
const staleDir = path.join(GSD_TEMP_DIR, `${testPrefix}dir`);
fs.mkdirSync(staleDir, { recursive: true });
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(staleDir, oldTime, oldTime);
reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(staleDir), 'stale directory in gsd subdir should be removed');
});
test('creates gsd subdirectory if it does not exist', () => {
// Use a unique nested path to avoid interfering with other tests
const uniqueSubdir = path.join(os.tmpdir(), `gsd-creation-test-${Date.now()}`);
// Verify it does not exist
if (fs.existsSync(uniqueSubdir)) {
fs.rmSync(uniqueSubdir, { recursive: true, force: true });
}
assert.ok(!fs.existsSync(uniqueSubdir), 'test subdir should not exist before test');
// reapStaleTempFiles should not throw even if subdir does not exist
// (it gets created or handled gracefully)
assert.doesNotThrow(() => {
reapStaleTempFiles(`gsd-creation-test-${Date.now()}-`, { maxAgeMs: 0 });
});
});
test('does not scan system tmpdir root for gsd- files', () => {
// Place a stale file in the OLD location (system tmpdir root)
const oldLocationPath = path.join(os.tmpdir(), `${testPrefix}old-location.json`);
fs.writeFileSync(oldLocationPath, '{}');
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(oldLocationPath, oldTime, oldTime);
// reapStaleTempFiles should NOT remove files from the old location
// because it now only scans the gsd subdirectory
reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
// The file in the old location should still exist (not scanned)
assert.ok(
fs.existsSync(oldLocationPath),
'files in system tmpdir root should NOT be scanned by reapStaleTempFiles'
);
// Clean up manually
fs.unlinkSync(oldLocationPath);
});
test('backward compat: reapStaleTempFilesLegacy cleans old location', () => {
// Place a stale file in the old location (system tmpdir root)
const oldLocationPath = path.join(os.tmpdir(), `${testPrefix}legacy.json`);
fs.writeFileSync(oldLocationPath, '{}');
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(oldLocationPath, oldTime, oldTime);
// The legacy reap function should still clean old-location files
// We import it if exported, or verify the main reap handles both
const core = require('../get-shit-done/bin/lib/core.cjs');
if (typeof core.reapStaleTempFilesLegacy === 'function') {
core.reapStaleTempFilesLegacy(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(oldLocationPath), 'legacy reap should clean old location');
} else {
// If no separate legacy function, the main output() should do a one-time
// migration sweep. We just verify the export shape is correct.
assert.ok(typeof core.reapStaleTempFiles === 'function');
// Clean up manually since we're not testing migration here
fs.unlinkSync(oldLocationPath);
}
});
});
});