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:
201
tests/claude-md-path.test.cjs
Normal file
201
tests/claude-md-path.test.cjs
Normal 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'));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
177
tests/phase-researcher-app-aware.test.cjs
Normal file
177
tests/phase-researcher-app-aware.test.cjs
Normal 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
170
tests/plan-bounce.test.cjs
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
333
tests/planner-language-regression.test.cjs
Normal file
333
tests/planner-language-regression.test.cjs
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
219
tests/skill-manifest.test.cjs
Normal file
219
tests/skill-manifest.test.cjs
Normal 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
136
tests/temp-subdir.test.cjs
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user