mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
When ROADMAP.md uses unpadded phase numbers (e.g. "Phase 1:") and the phases/ directory uses zero-padded names (e.g. "01-auth"), the phasesByNumber Map held two separate entries — one keyed "1" from the ROADMAP heading scan and one keyed "01" from the directory scan — doubling phases_total in /gsd-stats output. Apply normalizePhaseName() to all Map keys in both the ROADMAP heading scan and the directory scan so the two code paths always produce the same canonical key and merge into a single entry. Closes #2195 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1784 lines
69 KiB
JavaScript
1784 lines
69 KiB
JavaScript
/**
|
|
* GSD Tools Tests - Commands
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { execSync } = require('node:child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
describe('history-digest command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('empty phases directory returns valid schema', () => {
|
|
const result = runGsdTools('history-digest', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const digest = JSON.parse(result.output);
|
|
|
|
assert.deepStrictEqual(digest.phases, {}, 'phases should be empty object');
|
|
assert.deepStrictEqual(digest.decisions, [], 'decisions should be empty array');
|
|
assert.deepStrictEqual(digest.tech_stack, [], 'tech_stack should be empty array');
|
|
});
|
|
|
|
test('nested frontmatter fields extracted correctly', () => {
|
|
// Create phase directory with SUMMARY containing nested frontmatter
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
const summaryContent = `---
|
|
phase: "01"
|
|
name: "Foundation Setup"
|
|
dependency-graph:
|
|
provides:
|
|
- "Database schema"
|
|
- "Auth system"
|
|
affects:
|
|
- "API layer"
|
|
tech-stack:
|
|
added:
|
|
- "prisma"
|
|
- "jose"
|
|
patterns-established:
|
|
- "Repository pattern"
|
|
- "JWT auth flow"
|
|
key-decisions:
|
|
- "Use Prisma over Drizzle"
|
|
- "JWT in httpOnly cookies"
|
|
---
|
|
|
|
# Summary content here
|
|
`;
|
|
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), summaryContent);
|
|
|
|
const result = runGsdTools('history-digest', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const digest = JSON.parse(result.output);
|
|
|
|
// Check nested dependency-graph.provides
|
|
assert.ok(digest.phases['01'], 'Phase 01 should exist');
|
|
assert.deepStrictEqual(
|
|
digest.phases['01'].provides.sort(),
|
|
['Auth system', 'Database schema'],
|
|
'provides should contain nested values'
|
|
);
|
|
|
|
// Check nested dependency-graph.affects
|
|
assert.deepStrictEqual(
|
|
digest.phases['01'].affects,
|
|
['API layer'],
|
|
'affects should contain nested values'
|
|
);
|
|
|
|
// Check nested tech-stack.added
|
|
assert.deepStrictEqual(
|
|
digest.tech_stack.sort(),
|
|
['jose', 'prisma'],
|
|
'tech_stack should contain nested values'
|
|
);
|
|
|
|
// Check patterns-established (flat array)
|
|
assert.deepStrictEqual(
|
|
digest.phases['01'].patterns.sort(),
|
|
['JWT auth flow', 'Repository pattern'],
|
|
'patterns should be extracted'
|
|
);
|
|
|
|
// Check key-decisions
|
|
assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions');
|
|
assert.ok(
|
|
digest.decisions.some(d => d.decision === 'Use Prisma over Drizzle'),
|
|
'Should contain first decision'
|
|
);
|
|
});
|
|
|
|
test('multiple phases merged into single digest', () => {
|
|
// Create phase 01
|
|
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(phase01Dir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phase01Dir, '01-01-SUMMARY.md'),
|
|
`---
|
|
phase: "01"
|
|
name: "Foundation"
|
|
provides:
|
|
- "Database"
|
|
patterns-established:
|
|
- "Pattern A"
|
|
key-decisions:
|
|
- "Decision 1"
|
|
---
|
|
`
|
|
);
|
|
|
|
// Create phase 02
|
|
const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api');
|
|
fs.mkdirSync(phase02Dir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phase02Dir, '02-01-SUMMARY.md'),
|
|
`---
|
|
phase: "02"
|
|
name: "API"
|
|
provides:
|
|
- "REST endpoints"
|
|
patterns-established:
|
|
- "Pattern B"
|
|
key-decisions:
|
|
- "Decision 2"
|
|
tech-stack:
|
|
added:
|
|
- "zod"
|
|
---
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('history-digest', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const digest = JSON.parse(result.output);
|
|
|
|
// Both phases present
|
|
assert.ok(digest.phases['01'], 'Phase 01 should exist');
|
|
assert.ok(digest.phases['02'], 'Phase 02 should exist');
|
|
|
|
// Decisions merged
|
|
assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions total');
|
|
|
|
// Tech stack merged
|
|
assert.deepStrictEqual(digest.tech_stack, ['zod'], 'tech_stack should have zod');
|
|
});
|
|
|
|
test('malformed SUMMARY.md skipped gracefully', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// Valid summary
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-01-SUMMARY.md'),
|
|
`---
|
|
phase: "01"
|
|
provides:
|
|
- "Valid feature"
|
|
---
|
|
`
|
|
);
|
|
|
|
// Malformed summary (no frontmatter)
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-02-SUMMARY.md'),
|
|
`# Just a heading
|
|
No frontmatter here
|
|
`
|
|
);
|
|
|
|
// Another malformed summary (broken YAML)
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-03-SUMMARY.md'),
|
|
`---
|
|
broken: [unclosed
|
|
---
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('history-digest', tmpDir);
|
|
assert.ok(result.success, `Command should succeed despite malformed files: ${result.error}`);
|
|
|
|
const digest = JSON.parse(result.output);
|
|
assert.ok(digest.phases['01'], 'Phase 01 should exist');
|
|
assert.ok(
|
|
digest.phases['01'].provides.includes('Valid feature'),
|
|
'Valid feature should be extracted'
|
|
);
|
|
});
|
|
|
|
test('flat provides field still works (backward compatibility)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-01-SUMMARY.md'),
|
|
`---
|
|
phase: "01"
|
|
provides:
|
|
- "Direct provides"
|
|
---
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('history-digest', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const digest = JSON.parse(result.output);
|
|
assert.deepStrictEqual(
|
|
digest.phases['01'].provides,
|
|
['Direct provides'],
|
|
'Direct provides should work'
|
|
);
|
|
});
|
|
|
|
test('inline array syntax supported', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-01-SUMMARY.md'),
|
|
`---
|
|
phase: "01"
|
|
provides: [Feature A, Feature B]
|
|
patterns-established: ["Pattern X", "Pattern Y"]
|
|
---
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('history-digest', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const digest = JSON.parse(result.output);
|
|
assert.deepStrictEqual(
|
|
digest.phases['01'].provides.sort(),
|
|
['Feature A', 'Feature B'],
|
|
'Inline array should work'
|
|
);
|
|
assert.deepStrictEqual(
|
|
digest.phases['01'].patterns.sort(),
|
|
['Pattern X', 'Pattern Y'],
|
|
'Inline quoted array should work'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phases list command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('summary-extract command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('missing file returns error', () => {
|
|
const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
|
|
assert.ok(result.success, `Command should succeed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.error, 'File not found', 'should report missing file');
|
|
});
|
|
|
|
test('extracts all fields from SUMMARY.md', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-01-SUMMARY.md'),
|
|
`---
|
|
one-liner: Set up Prisma with User and Project models
|
|
key-files:
|
|
- prisma/schema.prisma
|
|
- src/lib/db.ts
|
|
tech-stack:
|
|
added:
|
|
- prisma
|
|
- zod
|
|
patterns-established:
|
|
- Repository pattern
|
|
- Dependency injection
|
|
key-decisions:
|
|
- Use Prisma over Drizzle: Better DX and ecosystem
|
|
- Single database: Start simple, shard later
|
|
requirements-completed:
|
|
- AUTH-01
|
|
- AUTH-02
|
|
---
|
|
|
|
# Summary
|
|
|
|
Full summary content here.
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct');
|
|
assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted');
|
|
assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted');
|
|
assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted');
|
|
assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted');
|
|
assert.strictEqual(output.decisions.length, 2, 'decisions extracted');
|
|
assert.deepStrictEqual(output.requirements_completed, ['AUTH-01', 'AUTH-02'], 'requirements completed extracted');
|
|
});
|
|
|
|
test('selective extraction with --fields', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-01-SUMMARY.md'),
|
|
`---
|
|
one-liner: Set up database
|
|
key-files:
|
|
- prisma/schema.prisma
|
|
tech-stack:
|
|
added:
|
|
- prisma
|
|
patterns-established:
|
|
- Repository pattern
|
|
key-decisions:
|
|
- Use Prisma: Better DX
|
|
requirements-completed:
|
|
- AUTH-01
|
|
---
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files,requirements_completed', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included');
|
|
assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included');
|
|
assert.deepStrictEqual(output.requirements_completed, ['AUTH-01'], 'requirements_completed included');
|
|
assert.strictEqual(output.tech_added, undefined, 'tech_added excluded');
|
|
assert.strictEqual(output.patterns, undefined, 'patterns excluded');
|
|
assert.strictEqual(output.decisions, undefined, 'decisions excluded');
|
|
});
|
|
|
|
test('extracts one-liner from body when not in frontmatter', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-01-SUMMARY.md'),
|
|
`---
|
|
phase: "01"
|
|
key-files:
|
|
- src/lib/db.ts
|
|
---
|
|
|
|
# Phase 1: Foundation Summary
|
|
|
|
**JWT auth with refresh rotation using jose library**
|
|
|
|
## Performance
|
|
|
|
- **Duration:** 28 min
|
|
- **Tasks:** 5
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.one_liner, 'JWT auth with refresh rotation using jose library',
|
|
'one-liner should be extracted from body **bold** line');
|
|
});
|
|
|
|
test('handles missing frontmatter fields gracefully', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-01-SUMMARY.md'),
|
|
`---
|
|
one-liner: Minimal summary
|
|
---
|
|
|
|
# Summary
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted');
|
|
assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty');
|
|
assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty');
|
|
assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty');
|
|
assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty');
|
|
assert.deepStrictEqual(output.requirements_completed, [], 'requirements_completed defaults to empty');
|
|
});
|
|
|
|
test('parses key-decisions with rationale', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-01-SUMMARY.md'),
|
|
`---
|
|
key-decisions:
|
|
- Use Prisma: Better DX than alternatives
|
|
- JWT tokens: Stateless auth for scalability
|
|
---
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed');
|
|
assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed');
|
|
assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary');
|
|
assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// init commands tests
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('progress command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('renders JSON progress', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0 MVP\n`
|
|
);
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
|
|
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2');
|
|
|
|
const result = runGsdTools('progress json', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.total_plans, 2, '2 total plans');
|
|
assert.strictEqual(output.total_summaries, 1, '1 summary');
|
|
assert.strictEqual(output.percent, 50, '50%');
|
|
assert.strictEqual(output.phases.length, 1, '1 phase');
|
|
assert.strictEqual(output.phases[0].status, 'In Progress', 'phase in progress');
|
|
});
|
|
|
|
test('renders bar format', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0\n`
|
|
);
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
|
|
|
|
const result = runGsdTools('progress bar --raw', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
assert.ok(result.output.includes('1/1'), 'should include count');
|
|
assert.ok(result.output.includes('100%'), 'should include 100%');
|
|
});
|
|
|
|
test('renders table format', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0 MVP\n`
|
|
);
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
|
|
const result = runGsdTools('progress table --raw', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
assert.ok(result.output.includes('Phase'), 'should have table header');
|
|
assert.ok(result.output.includes('foundation'), 'should include phase name');
|
|
});
|
|
|
|
test('does not crash when summaries exceed plans (orphaned SUMMARY.md)', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0 MVP\n`
|
|
);
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
// 1 plan but 2 summaries (orphaned SUMMARY.md after PLAN.md deletion)
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
|
|
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Orphaned summary');
|
|
|
|
// bar format - should not crash with RangeError
|
|
const barResult = runGsdTools('progress bar --raw', tmpDir);
|
|
assert.ok(barResult.success, `Bar format crashed: ${barResult.error}`);
|
|
assert.ok(barResult.output.includes('100%'), 'percent should be clamped to 100%');
|
|
|
|
// table format - should not crash with RangeError
|
|
const tableResult = runGsdTools('progress table --raw', tmpDir);
|
|
assert.ok(tableResult.success, `Table format crashed: ${tableResult.error}`);
|
|
|
|
// json format - percent should be clamped
|
|
const jsonResult = runGsdTools('progress json', tmpDir);
|
|
assert.ok(jsonResult.success, `JSON format crashed: ${jsonResult.error}`);
|
|
const output = JSON.parse(jsonResult.output);
|
|
assert.ok(output.percent <= 100, `percent should be <= 100 but got ${output.percent}`);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// todo complete command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('todo complete command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('moves todo from pending to completed', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pendingDir, 'add-dark-mode.md'),
|
|
`title: Add dark mode\narea: ui\ncreated: 2025-01-01\n`
|
|
);
|
|
|
|
const result = runGsdTools('todo complete add-dark-mode.md', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.completed, true);
|
|
|
|
// Verify moved
|
|
assert.ok(
|
|
!fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')),
|
|
'should be removed from pending'
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')),
|
|
'should be in completed'
|
|
);
|
|
|
|
// Verify completion timestamp added
|
|
const content = fs.readFileSync(
|
|
path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'),
|
|
'utf-8'
|
|
);
|
|
assert.ok(content.startsWith('completed:'), 'should have completed timestamp');
|
|
});
|
|
|
|
test('fails for nonexistent todo', () => {
|
|
const result = runGsdTools('todo complete nonexistent.md', tmpDir);
|
|
assert.ok(!result.success, 'should fail');
|
|
assert.ok(result.error.includes('not found'), 'error mentions not found');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// todo match-phase command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('todo match-phase command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
afterEach(() => cleanup(tmpDir));
|
|
|
|
test('returns empty matches when no todos exist', () => {
|
|
const result = runGsdTools('todo match-phase 01', tmpDir);
|
|
assert.ok(result.success, 'should succeed');
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.todo_count, 0);
|
|
assert.deepStrictEqual(output.matches, []);
|
|
});
|
|
|
|
test('matches todo by keyword overlap with phase name', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
|
|
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nNeed to handle token expiry for OAuth flows.');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n');
|
|
|
|
const result = runGsdTools('todo match-phase 01', tmpDir);
|
|
assert.ok(result.success, 'should succeed');
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.todo_count, 1, 'should find 1 todo');
|
|
assert.ok(output.matches.length > 0, 'should have matches');
|
|
assert.strictEqual(output.matches[0].title, 'Add OAuth token refresh');
|
|
assert.ok(output.matches[0].score > 0, 'score should be positive');
|
|
assert.ok(output.matches[0].reasons.length > 0, 'should have reasons');
|
|
});
|
|
|
|
test('does not match unrelated todo', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
|
|
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token expiry.');
|
|
fs.writeFileSync(path.join(pendingDir, 'unrelated-todo.md'),
|
|
'title: Fix CSS grid layout in dashboard\narea: ui\ncreated: 2026-03-01\n\nGrid columns break on mobile.');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n');
|
|
|
|
const result = runGsdTools('todo match-phase 01', tmpDir);
|
|
assert.ok(result.success, 'should succeed');
|
|
const output = JSON.parse(result.output);
|
|
const matchTitles = output.matches.map(m => m.title);
|
|
assert.ok(matchTitles.includes('Add OAuth token refresh'), 'auth todo should match');
|
|
assert.ok(!matchTitles.includes('Fix CSS grid layout in dashboard'), 'unrelated todo should not match');
|
|
});
|
|
|
|
test('matches todo by area overlap', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
|
|
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token handling.');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 01: Auth System\n\n**Goal:** Build auth module\n');
|
|
|
|
const result = runGsdTools('todo match-phase 01', tmpDir);
|
|
const output = JSON.parse(result.output);
|
|
const authMatch = output.matches.find(m => m.title === 'Add OAuth token refresh');
|
|
assert.ok(authMatch, 'should find auth todo');
|
|
const hasAreaReason = authMatch.reasons.some(r => r.startsWith('area:'));
|
|
assert.ok(hasAreaReason, 'should match on area');
|
|
});
|
|
|
|
test('sorts matches by score descending', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pendingDir, 'weak-match.md'),
|
|
'title: Check token format\narea: general\ncreated: 2026-03-01\n\nToken format validation.');
|
|
fs.writeFileSync(path.join(pendingDir, 'strong-match.md'),
|
|
'title: Session management authentication OAuth token handling\narea: auth\ncreated: 2026-03-01\n\nSession auth OAuth tokens.');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login, session handling, and token management\n');
|
|
|
|
const result = runGsdTools('todo match-phase 01', tmpDir);
|
|
const output = JSON.parse(result.output);
|
|
assert.ok(output.matches.length >= 2, 'should have multiple matches');
|
|
for (let i = 1; i < output.matches.length; i++) {
|
|
assert.ok(output.matches[i - 1].score >= output.matches[i].score,
|
|
`match ${i-1} score (${output.matches[i-1].score}) should be >= match ${i} score (${output.matches[i].score})`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// scaffold command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('scaffold command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('scaffolds context file', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
|
|
|
const result = runGsdTools('scaffold context --phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.created, true);
|
|
|
|
// Verify file content
|
|
const content = fs.readFileSync(
|
|
path.join(tmpDir, '.planning', 'phases', '03-api', '03-CONTEXT.md'),
|
|
'utf-8'
|
|
);
|
|
assert.ok(content.includes('Phase 3'), 'should reference phase number');
|
|
assert.ok(content.includes('Decisions'), 'should have decisions section');
|
|
assert.ok(content.includes('Discretion Areas'), 'should have discretion section');
|
|
});
|
|
|
|
test('scaffolds UAT file', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
|
|
|
const result = runGsdTools('scaffold uat --phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.created, true);
|
|
|
|
const content = fs.readFileSync(
|
|
path.join(tmpDir, '.planning', 'phases', '03-api', '03-UAT.md'),
|
|
'utf-8'
|
|
);
|
|
assert.ok(content.includes('User Acceptance Testing'), 'should have UAT heading');
|
|
assert.ok(content.includes('Test Results'), 'should have test results section');
|
|
});
|
|
|
|
test('scaffolds verification file', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
|
|
|
const result = runGsdTools('scaffold verification --phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.created, true);
|
|
|
|
const content = fs.readFileSync(
|
|
path.join(tmpDir, '.planning', 'phases', '03-api', '03-VERIFICATION.md'),
|
|
'utf-8'
|
|
);
|
|
assert.ok(content.includes('Goal-Backward Verification'), 'should have verification heading');
|
|
});
|
|
|
|
test('scaffolds phase directory', () => {
|
|
const result = runGsdTools('scaffold phase-dir --phase 5 --name User Dashboard', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.created, true);
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '05-user-dashboard')),
|
|
'directory should be created'
|
|
);
|
|
});
|
|
|
|
test('does not overwrite existing files', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Existing content');
|
|
|
|
const result = runGsdTools('scaffold context --phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.created, false, 'should not overwrite');
|
|
assert.strictEqual(output.reason, 'already_exists');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdGenerateSlug tests (CMD-01)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('generate-slug command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('converts normal text to slug', () => {
|
|
const result = runGsdTools('generate-slug "Hello World"', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.slug, 'hello-world');
|
|
});
|
|
|
|
test('strips special characters', () => {
|
|
const result = runGsdTools('generate-slug "Test@#$%^Special!!!"', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.slug, 'test-special');
|
|
});
|
|
|
|
test('preserves numbers', () => {
|
|
const result = runGsdTools('generate-slug "Phase 3 Plan"', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.slug, 'phase-3-plan');
|
|
});
|
|
|
|
test('strips leading and trailing hyphens', () => {
|
|
const result = runGsdTools('generate-slug "---leading-trailing---"', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.slug, 'leading-trailing');
|
|
});
|
|
|
|
test('fails when no text provided', () => {
|
|
const result = runGsdTools('generate-slug', tmpDir);
|
|
assert.ok(!result.success, 'should fail without text');
|
|
assert.ok(result.error.includes('text required'), 'error should mention text required');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdCurrentTimestamp tests (CMD-01)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('current-timestamp command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('date format returns YYYY-MM-DD', () => {
|
|
const result = runGsdTools('current-timestamp date', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}$/, 'should be YYYY-MM-DD format');
|
|
});
|
|
|
|
test('filename format returns ISO without colons or fractional seconds', () => {
|
|
const result = runGsdTools('current-timestamp filename', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/, 'should replace colons with hyphens and strip fractional seconds');
|
|
});
|
|
|
|
test('full format returns full ISO string', () => {
|
|
const result = runGsdTools('current-timestamp full', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, 'should be full ISO format');
|
|
});
|
|
|
|
test('default (no format) returns full ISO string', () => {
|
|
const result = runGsdTools('current-timestamp', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, 'default should be full ISO format');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdListTodos tests (CMD-02)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('list-todos command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('empty directory returns zero count', () => {
|
|
const result = runGsdTools('list-todos', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.count, 0, 'count should be 0');
|
|
assert.deepStrictEqual(output.todos, [], 'todos should be empty');
|
|
});
|
|
|
|
test('returns multiple todos with correct fields', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(pendingDir, 'add-tests.md'), 'title: Add unit tests\narea: testing\ncreated: 2026-01-15\n');
|
|
fs.writeFileSync(path.join(pendingDir, 'fix-bug.md'), 'title: Fix login bug\narea: auth\ncreated: 2026-01-20\n');
|
|
|
|
const result = runGsdTools('list-todos', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.count, 2, 'should have 2 todos');
|
|
assert.strictEqual(output.todos.length, 2, 'todos array should have 2 entries');
|
|
|
|
const testTodo = output.todos.find(t => t.file === 'add-tests.md');
|
|
assert.ok(testTodo, 'add-tests.md should be in results');
|
|
assert.strictEqual(testTodo.title, 'Add unit tests');
|
|
assert.strictEqual(testTodo.area, 'testing');
|
|
assert.strictEqual(testTodo.created, '2026-01-15');
|
|
});
|
|
|
|
test('area filter returns only matching todos', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(pendingDir, 'ui-task.md'), 'title: UI task\narea: ui\ncreated: 2026-01-01\n');
|
|
fs.writeFileSync(path.join(pendingDir, 'api-task.md'), 'title: API task\narea: api\ncreated: 2026-01-01\n');
|
|
|
|
const result = runGsdTools('list-todos ui', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.count, 1, 'should have 1 matching todo');
|
|
assert.strictEqual(output.todos[0].area, 'ui', 'should only return ui area');
|
|
});
|
|
|
|
test('area filter miss returns zero count', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(pendingDir, 'task.md'), 'title: Some task\narea: backend\ncreated: 2026-01-01\n');
|
|
|
|
const result = runGsdTools('list-todos nonexistent-area', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.count, 0, 'should have 0 matching todos');
|
|
});
|
|
|
|
test('malformed files use defaults', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
|
|
// File with no title or area fields
|
|
fs.writeFileSync(path.join(pendingDir, 'malformed.md'), 'some random content\nno fields here\n');
|
|
|
|
const result = runGsdTools('list-todos', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.count, 1, 'malformed file should still be counted');
|
|
assert.strictEqual(output.todos[0].title, 'Untitled', 'missing title defaults to Untitled');
|
|
assert.strictEqual(output.todos[0].area, 'general', 'missing area defaults to general');
|
|
assert.strictEqual(output.todos[0].created, 'unknown', 'missing created defaults to unknown');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdVerifyPathExists tests (CMD-02)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('verify-path-exists command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('existing file returns exists=true with type=file', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'test-file.txt'), 'hello');
|
|
|
|
const result = runGsdTools('verify-path-exists test-file.txt', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.exists, true);
|
|
assert.strictEqual(output.type, 'file');
|
|
});
|
|
|
|
test('existing directory returns exists=true with type=directory', () => {
|
|
fs.mkdirSync(path.join(tmpDir, 'test-dir'), { recursive: true });
|
|
|
|
const result = runGsdTools('verify-path-exists test-dir', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.exists, true);
|
|
assert.strictEqual(output.type, 'directory');
|
|
});
|
|
|
|
test('missing path returns exists=false', () => {
|
|
const result = runGsdTools('verify-path-exists nonexistent/path', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.exists, false);
|
|
assert.strictEqual(output.type, null);
|
|
});
|
|
|
|
test('absolute path resolves correctly', () => {
|
|
const absFile = path.join(tmpDir, 'abs-test.txt');
|
|
fs.writeFileSync(absFile, 'content');
|
|
|
|
const result = runGsdTools(`verify-path-exists ${absFile}`, tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.exists, true);
|
|
assert.strictEqual(output.type, 'file');
|
|
});
|
|
|
|
test('fails when no path provided', () => {
|
|
const result = runGsdTools('verify-path-exists', tmpDir);
|
|
assert.ok(!result.success, 'should fail without path');
|
|
assert.ok(result.error.includes('path required'), 'error should mention path required');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdResolveModel tests (CMD-03)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('resolve-model command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('known agent returns model and profile without unknown_agent', () => {
|
|
const result = runGsdTools('resolve-model gsd-planner', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.ok(output.model, 'should have model field');
|
|
assert.ok(output.profile, 'should have profile field');
|
|
assert.strictEqual(output.unknown_agent, undefined, 'should not have unknown_agent for known agent');
|
|
});
|
|
|
|
test('unknown agent returns unknown_agent=true', () => {
|
|
const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.unknown_agent, true, 'should flag unknown agent');
|
|
});
|
|
|
|
test('default profile fallback when no config exists', () => {
|
|
// tmpDir has no config.json, so defaults to balanced profile
|
|
const result = runGsdTools('resolve-model gsd-executor', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.profile, 'balanced', 'should default to balanced profile');
|
|
assert.ok(output.model, 'should resolve a model');
|
|
});
|
|
|
|
test('fails when no agent-type provided', () => {
|
|
const result = runGsdTools('resolve-model', tmpDir);
|
|
assert.ok(!result.success, 'should fail without agent-type');
|
|
assert.ok(result.error.includes('agent-type required'), 'error should mention agent-type required');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdCommit tests (CMD-04)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('commit command', () => {
|
|
const { createTempGitProject } = require('./helpers.cjs');
|
|
const { execSync } = require('child_process');
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempGitProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('skips when commit_docs is false', () => {
|
|
// Write config with commit_docs: false
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ commit_docs: false })
|
|
);
|
|
|
|
const result = runGsdTools('commit "test message"', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.committed, false);
|
|
assert.strictEqual(output.reason, 'skipped_commit_docs_false');
|
|
});
|
|
|
|
test('skips when .planning is gitignored', () => {
|
|
// Add .planning/ to .gitignore and commit it so git recognizes the ignore
|
|
fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.planning/\n');
|
|
execSync('git add .gitignore', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git commit -m "add gitignore"', { cwd: tmpDir, stdio: 'pipe' });
|
|
|
|
const result = runGsdTools('commit "test message"', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.committed, false);
|
|
assert.strictEqual(output.reason, 'skipped_gitignored');
|
|
});
|
|
|
|
test('handles nothing to commit', () => {
|
|
// Don't modify any files after initial commit
|
|
const result = runGsdTools('commit "test message"', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.committed, false);
|
|
assert.strictEqual(output.reason, 'nothing_to_commit');
|
|
});
|
|
|
|
test('creates real commit with correct hash', () => {
|
|
// Create a new file in .planning/
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'test-file.md'), '# Test\n');
|
|
|
|
const result = runGsdTools('commit "test: add test file" --files .planning/test-file.md', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.committed, true, 'should have committed');
|
|
assert.ok(output.hash, 'should have a commit hash');
|
|
assert.strictEqual(output.reason, 'committed');
|
|
|
|
// Verify via git log
|
|
const gitLog = execSync('git log --oneline -1', { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
|
assert.ok(gitLog.includes('test: add test file'), 'git log should contain the commit message');
|
|
assert.ok(gitLog.includes(output.hash), 'git log should contain the returned hash');
|
|
});
|
|
|
|
test('amend mode works without crashing', () => {
|
|
// Create a file and commit it first
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'amend-file.md'), '# Initial\n');
|
|
execSync('git add .planning/amend-file.md', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git commit -m "initial file"', { cwd: tmpDir, stdio: 'pipe' });
|
|
|
|
// Modify the file and amend
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'amend-file.md'), '# Amended\n');
|
|
|
|
const result = runGsdTools('commit "ignored" --files .planning/amend-file.md --amend', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.committed, true, 'amend should succeed');
|
|
|
|
// Verify only 2 commits total (initial setup + amended)
|
|
const logCount = execSync('git log --oneline', { cwd: tmpDir, encoding: 'utf-8' }).trim().split('\n').length;
|
|
assert.strictEqual(logCount, 2, 'should have 2 commits (initial + amended)');
|
|
});
|
|
test('creates strategy branch before first commit when branching_strategy is milestone', () => {
|
|
// Configure milestone branching strategy
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({
|
|
commit_docs: true,
|
|
branching_strategy: 'milestone',
|
|
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
|
})
|
|
);
|
|
// getMilestoneInfo reads ROADMAP.md for milestone version/name
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'## v1.0: Initial Release\n\n### Phase 1: Setup\n'
|
|
);
|
|
|
|
// Create a file to commit
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'test-context.md'), '# Context\n');
|
|
|
|
const result = runGsdTools('commit "docs: add context" --files .planning/test-context.md', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.committed, true, 'should have committed');
|
|
|
|
// Verify we're on the strategy branch
|
|
const { execFileSync } = require('child_process');
|
|
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
|
assert.strictEqual(branch, 'gsd/v1.0-initial-release', 'should be on milestone branch');
|
|
});
|
|
|
|
test('creates strategy branch before first commit when branching_strategy is phase', () => {
|
|
// Configure phase branching strategy
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({
|
|
commit_docs: true,
|
|
branching_strategy: 'phase',
|
|
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
|
})
|
|
);
|
|
// Create ROADMAP.md with a phase
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n## Phase 1: Setup\nGoal: Initial setup\n'
|
|
);
|
|
|
|
// Create a context file for phase 1
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01-setup', '01-CONTEXT.md'), '# Context\n');
|
|
|
|
const result = runGsdTools(
|
|
'commit "docs(01): add context" --files .planning/phases/01-setup/01-CONTEXT.md',
|
|
tmpDir
|
|
);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.committed, true, 'should have committed');
|
|
|
|
// Verify we're on the strategy branch
|
|
const { execFileSync } = require('child_process');
|
|
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
|
assert.strictEqual(branch, 'gsd/phase-01-setup', 'should be on phase branch');
|
|
});
|
|
|
|
test('decimal phase numbers are captured correctly in branching strategy', () => {
|
|
// Configure phase branching strategy
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({
|
|
commit_docs: true,
|
|
branching_strategy: 'phase',
|
|
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
|
})
|
|
);
|
|
// Create ROADMAP.md with a decimal phase
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '45.14-golden-capture'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n## Phase 45.14: Golden Capture\nGoal: Capture golden standard\n'
|
|
);
|
|
|
|
// Create a context file for phase 45.14
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '45.14-golden-capture', '45.14-CONTEXT.md'), '# Context\n');
|
|
|
|
const result = runGsdTools(
|
|
'commit "docs(45.14): add context" --files .planning/phases/45.14-golden-capture/45.14-CONTEXT.md',
|
|
tmpDir
|
|
);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.committed, true, 'should have committed');
|
|
|
|
// Verify we're on the correct branch (45.14, not 14)
|
|
const { execFileSync } = require('child_process');
|
|
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
|
assert.strictEqual(branch, 'gsd/phase-45.14-golden-capture', 'should be on decimal phase branch, not integer-only');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdWebsearch tests (CMD-05)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('websearch command', () => {
|
|
const { cmdWebsearch } = require('../get-shit-done/bin/lib/commands.cjs');
|
|
let origFetch;
|
|
let origApiKey;
|
|
let origWriteSync;
|
|
let captured;
|
|
|
|
beforeEach(() => {
|
|
origFetch = global.fetch;
|
|
origApiKey = process.env.BRAVE_API_KEY;
|
|
origWriteSync = fs.writeSync;
|
|
captured = '';
|
|
// output() uses fs.writeSync(1, data) since #1276 — mock it to capture output
|
|
fs.writeSync = (fd, data) => { if (fd === 1) captured += data; return Buffer.byteLength(String(data)); };
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = origFetch;
|
|
if (origApiKey !== undefined) {
|
|
process.env.BRAVE_API_KEY = origApiKey;
|
|
} else {
|
|
delete process.env.BRAVE_API_KEY;
|
|
}
|
|
fs.writeSync = origWriteSync;
|
|
});
|
|
|
|
test('returns available=false when BRAVE_API_KEY is unset', async () => {
|
|
delete process.env.BRAVE_API_KEY;
|
|
|
|
await cmdWebsearch('test query', {}, false);
|
|
|
|
const output = JSON.parse(captured);
|
|
assert.strictEqual(output.available, false);
|
|
assert.ok(output.reason.includes('BRAVE_API_KEY'), 'should mention missing API key');
|
|
});
|
|
|
|
test('returns error when no query provided', async () => {
|
|
process.env.BRAVE_API_KEY = 'test-key';
|
|
|
|
await cmdWebsearch(null, {}, false);
|
|
|
|
const output = JSON.parse(captured);
|
|
assert.strictEqual(output.available, false);
|
|
assert.ok(output.error.includes('Query required'), 'should mention query required');
|
|
});
|
|
|
|
test('returns results for successful API response', async () => {
|
|
process.env.BRAVE_API_KEY = 'test-key';
|
|
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({
|
|
web: {
|
|
results: [
|
|
{ title: 'Test Result', url: 'https://example.com', description: 'A test result', age: '1d' },
|
|
],
|
|
},
|
|
}),
|
|
});
|
|
|
|
await cmdWebsearch('test query', { limit: 5, freshness: 'pd' }, false);
|
|
|
|
const output = JSON.parse(captured);
|
|
assert.strictEqual(output.available, true);
|
|
assert.strictEqual(output.query, 'test query');
|
|
assert.strictEqual(output.count, 1);
|
|
assert.strictEqual(output.results[0].title, 'Test Result');
|
|
assert.strictEqual(output.results[0].url, 'https://example.com');
|
|
assert.strictEqual(output.results[0].age, '1d');
|
|
});
|
|
|
|
test('constructs correct URL parameters', async () => {
|
|
process.env.BRAVE_API_KEY = 'test-key';
|
|
let capturedUrl = '';
|
|
|
|
global.fetch = async (url) => {
|
|
capturedUrl = url;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ web: { results: [] } }),
|
|
};
|
|
};
|
|
|
|
await cmdWebsearch('node.js testing', { limit: 5, freshness: 'pd' }, false);
|
|
|
|
const parsed = new URL(capturedUrl);
|
|
assert.strictEqual(parsed.searchParams.get('q'), 'node.js testing', 'query param should decode to original string');
|
|
assert.strictEqual(parsed.searchParams.get('count'), '5', 'count param should be 5');
|
|
assert.strictEqual(parsed.searchParams.get('freshness'), 'pd', 'freshness param should be pd');
|
|
});
|
|
|
|
test('handles API error (non-200 status)', async () => {
|
|
process.env.BRAVE_API_KEY = 'test-key';
|
|
|
|
global.fetch = async () => ({
|
|
ok: false,
|
|
status: 429,
|
|
});
|
|
|
|
await cmdWebsearch('test query', {}, false);
|
|
|
|
const output = JSON.parse(captured);
|
|
assert.strictEqual(output.available, false);
|
|
assert.ok(output.error.includes('429'), 'error should include status code');
|
|
});
|
|
|
|
test('handles network failure', async () => {
|
|
process.env.BRAVE_API_KEY = 'test-key';
|
|
|
|
global.fetch = async () => {
|
|
throw new Error('Network timeout');
|
|
};
|
|
|
|
await cmdWebsearch('test query', {}, false);
|
|
|
|
const output = JSON.parse(captured);
|
|
assert.strictEqual(output.available, false);
|
|
assert.strictEqual(output.error, 'Network timeout');
|
|
});
|
|
});
|
|
|
|
describe('stats command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('returns valid JSON with empty project', () => {
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
assert.ok(Array.isArray(stats.phases), 'phases should be an array');
|
|
assert.strictEqual(stats.total_plans, 0);
|
|
assert.strictEqual(stats.total_summaries, 0);
|
|
assert.strictEqual(stats.percent, 0);
|
|
assert.strictEqual(stats.phases_completed, 0);
|
|
assert.strictEqual(stats.phases_total, 0);
|
|
assert.strictEqual(stats.requirements_total, 0);
|
|
assert.strictEqual(stats.requirements_complete, 0);
|
|
});
|
|
|
|
test('counts phases, plans, and summaries correctly', () => {
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
const p2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.mkdirSync(p2, { recursive: true });
|
|
|
|
// Phase 1: 2 plans, 2 summaries, passing verification (complete)
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
|
|
|
|
// Phase 2: 1 plan, 0 summaries (planned)
|
|
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
|
|
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
assert.strictEqual(stats.phases_total, 2);
|
|
assert.strictEqual(stats.phases_completed, 1);
|
|
assert.strictEqual(stats.total_plans, 3);
|
|
assert.strictEqual(stats.total_summaries, 2);
|
|
assert.strictEqual(stats.percent, 50);
|
|
assert.strictEqual(stats.plan_percent, 67);
|
|
});
|
|
|
|
test('counts requirements from REQUIREMENTS.md', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
|
`# Requirements
|
|
|
|
## v1 Requirements
|
|
|
|
- [x] **AUTH-01**: User can sign up
|
|
- [x] **AUTH-02**: User can log in
|
|
- [ ] **API-01**: REST endpoints
|
|
- [ ] **API-02**: GraphQL support
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
assert.strictEqual(stats.requirements_total, 4);
|
|
assert.strictEqual(stats.requirements_complete, 2);
|
|
});
|
|
|
|
test('reads last activity from STATE.md', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Last Activity:** 2025-06-15\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
assert.strictEqual(stats.last_activity, '2025-06-15');
|
|
});
|
|
|
|
test('reads last activity from plain STATE.md template format', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# Project State\n\n## Current Position\n\nPhase: 1 of 2 (Foundation)\nPlan: 1 of 1 in current phase\nStatus: In progress\nLast activity: 2025-06-16 — Finished plan 01-01\n`
|
|
);
|
|
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
assert.strictEqual(stats.last_activity, '2025-06-16 — Finished plan 01-01');
|
|
});
|
|
|
|
test('includes roadmap-only phases in totals and preserves hyphenated names', () => {
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '14-auth-hardening');
|
|
const p2 = path.join(tmpDir, '.planning', 'phases', '15-proof-generation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.mkdirSync(p2, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '14-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '14-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
|
fs.writeFileSync(path.join(p2, '15-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p2, '15-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p2, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [x] **Phase 14: Auth Hardening**
|
|
- [x] **Phase 15: Proof Generation**
|
|
- [ ] **Phase 16: Multi-Claim Verification & UX**
|
|
|
|
## Milestone v1.0 Growth
|
|
|
|
### Phase 14: Auth Hardening
|
|
**Goal:** Improve auth checks
|
|
|
|
### Phase 15: Proof Generation
|
|
**Goal:** Improve proof generation
|
|
|
|
### Phase 16: Multi-Claim Verification & UX
|
|
**Goal:** Support multi-claim verification
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
assert.strictEqual(stats.phases_total, 3);
|
|
assert.strictEqual(stats.phases_completed, 2);
|
|
assert.strictEqual(stats.percent, 67);
|
|
assert.strictEqual(stats.plan_percent, 100);
|
|
assert.strictEqual(
|
|
stats.phases.find(p => p.number === '16')?.name,
|
|
'Multi-Claim Verification & UX'
|
|
);
|
|
assert.strictEqual(
|
|
stats.phases.find(p => p.number === '16')?.status,
|
|
'Not Started'
|
|
);
|
|
});
|
|
|
|
test('reports git commit count and first commit date from repository history', () => {
|
|
execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git config user.email "test@example.com"', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git config user.name "Test User"', { cwd: tmpDir, stdio: 'pipe' });
|
|
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n');
|
|
execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git commit -m "initial commit"', {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe',
|
|
env: {
|
|
...process.env,
|
|
GIT_AUTHOR_DATE: '2026-01-01T00:00:00Z',
|
|
GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z',
|
|
},
|
|
});
|
|
|
|
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Updated\n');
|
|
execSync('git add README.md', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git commit -m "second commit"', {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe',
|
|
env: {
|
|
...process.env,
|
|
GIT_AUTHOR_DATE: '2026-02-01T00:00:00Z',
|
|
GIT_COMMITTER_DATE: '2026-02-01T00:00:00Z',
|
|
},
|
|
});
|
|
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
assert.strictEqual(stats.git_commits, 2);
|
|
assert.strictEqual(stats.git_first_commit_date, '2026-01-01');
|
|
});
|
|
|
|
test('table format renders readable output', () => {
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
|
|
|
const result = runGsdTools('stats table', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const parsed = JSON.parse(result.output);
|
|
assert.ok(parsed.rendered, 'table format should include rendered field');
|
|
assert.ok(parsed.rendered.includes('Statistics'), 'should include Statistics header');
|
|
assert.ok(parsed.rendered.includes('| Phase |'), 'should include table header');
|
|
assert.ok(parsed.rendered.includes('| 1 |'), 'should include phase row');
|
|
assert.ok(parsed.rendered.includes('1/1 phases'), 'should report phase progress');
|
|
});
|
|
|
|
test('phase with summaries but no verification is Executed, not Complete', () => {
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
|
assert.strictEqual(phase.status, 'Executed', 'should be Executed without verification');
|
|
assert.strictEqual(stats.phases_completed, 0, 'unverified phase should not count as completed');
|
|
});
|
|
|
|
test('phase with passing verification is Complete', () => {
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
|
assert.strictEqual(phase.status, 'Complete', 'should be Complete with passing verification');
|
|
assert.strictEqual(stats.phases_completed, 1);
|
|
});
|
|
|
|
test('phase with gaps_found verification is Executed', () => {
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Verification');
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
|
assert.strictEqual(phase.status, 'Executed', 'gaps_found should show as Executed');
|
|
});
|
|
|
|
test('phase with human_needed verification shows Needs Review', () => {
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: human_needed\n---\n# Verification');
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
|
assert.strictEqual(phase.status, 'Needs Review', 'human_needed should show as Needs Review');
|
|
});
|
|
|
|
test('progress command also uses verification-aware status', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0 MVP\n`
|
|
);
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('progress json', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification');
|
|
});
|
|
|
|
test('does not duplicate phases when ROADMAP uses unpadded numbers and dirs use padded numbers', () => {
|
|
// ROADMAP.md uses "Phase 1:" (unpadded) but directory is "01-auth" (padded).
|
|
// Without normalization, the Map holds two entries: "1" and "01", doubling phases_total.
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Milestone v1',
|
|
'',
|
|
'### Phase 1: Auth',
|
|
'**Goal:** Authentication',
|
|
].join('\n')
|
|
);
|
|
|
|
const result = runGsdTools('stats', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const stats = JSON.parse(result.output);
|
|
assert.strictEqual(stats.phases_total, 1, 'unpadded ROADMAP heading and padded dir should merge into one phase');
|
|
assert.strictEqual(stats.phases_completed, 1);
|
|
assert.strictEqual(stats.phases.length, 1);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// check-commit command (#1395)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('check-commit command', () => {
|
|
const { createTempGitProject } = require('./helpers.cjs');
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempGitProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('allows commit when commit_docs is true', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ commit_docs: true })
|
|
);
|
|
const result = runGsdTools('check-commit', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.allowed, true);
|
|
});
|
|
|
|
test('allows commit when no .planning/ files staged and commit_docs is false', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ commit_docs: false })
|
|
);
|
|
// Stage a non-planning file
|
|
fs.writeFileSync(path.join(tmpDir, 'src.js'), 'console.log("hi")');
|
|
execSync('git add src.js', { cwd: tmpDir, stdio: 'pipe' });
|
|
|
|
const result = runGsdTools('check-commit', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.allowed, true);
|
|
});
|
|
|
|
test('blocks commit when .planning/ files staged and commit_docs is false', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ commit_docs: false })
|
|
);
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
|
|
execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' });
|
|
|
|
const result = runGsdTools('check-commit', tmpDir);
|
|
assert.ok(!result.success, 'should block commit');
|
|
assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files');
|
|
assert.ok(result.error.includes('unstage'), 'error should suggest unstage command');
|
|
});
|
|
});
|