Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ jobs:

- name: Run tests with coverage
# c8 v11 requires Node 20+ (engines: ^20.0.0 || >=22.0.0). Node 18 EOL April 2025.
# Use bash on all platforms so shell glob expansion works on Windows.
if: matrix.node-version != 18
shell: bash
run: npm run test:coverage

- name: Run tests (Node 18, coverage not supported)
if: matrix.node-version == 18
shell: bash
run: npm test
14 changes: 11 additions & 3 deletions get-shit-done/bin/lib/core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

// ─── Path helpers ────────────────────────────────────────────────────────────

/** Normalize a relative path to always use forward slashes (cross-platform). */
function toPosixPath(p) {
return p.split(path.sep).join('/');
}

// ─── Model Profile Table ─────────────────────────────────────────────────────

const MODEL_PROFILES = {
Expand Down Expand Up @@ -218,7 +225,7 @@ function searchPhaseInDir(baseDir, relBase, normalized) {

return {
found: true,
directory: path.join(relBase, match),
directory: toPosixPath(path.join(relBase, match)),
phase_number: phaseNumber,
phase_name: phaseName,
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
Expand All @@ -241,7 +248,7 @@ function findPhaseInternal(cwd, phase) {
const normalized = normalizePhaseName(phase);

// Search current phases first
const current = searchPhaseInDir(phasesDir, path.join('.planning', 'phases'), normalized);
const current = searchPhaseInDir(phasesDir, '.planning/phases', normalized);
if (current) return current;

// Search archived milestone phases (newest first)
Expand All @@ -259,7 +266,7 @@ function findPhaseInternal(cwd, phase) {
for (const archiveName of archiveDirs) {
const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
const archivePath = path.join(milestonesDir, archiveName);
const relBase = path.join('.planning', 'milestones', archiveName);
const relBase = '.planning/milestones/' + archiveName;
const result = searchPhaseInDir(archivePath, relBase, normalized);
if (result) {
result.archived = version;
Expand Down Expand Up @@ -409,4 +416,5 @@ module.exports = {
pathExistsInternal,
generateSlugInternal,
getMilestoneInfo,
toPosixPath,
};
22 changes: 11 additions & 11 deletions get-shit-done/bin/lib/init.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, output, error } = require('./core.cjs');
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error } = require('./core.cjs');

function cmdInitExecutePhase(cwd, phase, raw) {
if (!phase) {
Expand Down Expand Up @@ -139,19 +139,19 @@ function cmdInitPlanPhase(cwd, phase, raw) {
const files = fs.readdirSync(phaseDirFull);
const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
if (contextFile) {
result.context_path = path.join(phaseInfo.directory, contextFile);
result.context_path = toPosixPath(path.join(phaseInfo.directory, contextFile));
}
const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
if (researchFile) {
result.research_path = path.join(phaseInfo.directory, researchFile);
result.research_path = toPosixPath(path.join(phaseInfo.directory, researchFile));
}
const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
if (verificationFile) {
result.verification_path = path.join(phaseInfo.directory, verificationFile);
result.verification_path = toPosixPath(path.join(phaseInfo.directory, verificationFile));
}
const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md');
if (uatFile) {
result.uat_path = path.join(phaseInfo.directory, uatFile);
result.uat_path = toPosixPath(path.join(phaseInfo.directory, uatFile));
}
} catch {}
}
Expand Down Expand Up @@ -422,19 +422,19 @@ function cmdInitPhaseOp(cwd, phase, raw) {
const files = fs.readdirSync(phaseDirFull);
const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
if (contextFile) {
result.context_path = path.join(phaseInfo.directory, contextFile);
result.context_path = toPosixPath(path.join(phaseInfo.directory, contextFile));
}
const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
if (researchFile) {
result.research_path = path.join(phaseInfo.directory, researchFile);
result.research_path = toPosixPath(path.join(phaseInfo.directory, researchFile));
}
const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
if (verificationFile) {
result.verification_path = path.join(phaseInfo.directory, verificationFile);
result.verification_path = toPosixPath(path.join(phaseInfo.directory, verificationFile));
}
const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md');
if (uatFile) {
result.uat_path = path.join(phaseInfo.directory, uatFile);
result.uat_path = toPosixPath(path.join(phaseInfo.directory, uatFile));
}
} catch {}
}
Expand Down Expand Up @@ -469,7 +469,7 @@ function cmdInitTodos(cwd, area, raw) {
created: createdMatch ? createdMatch[1].trim() : 'unknown',
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: todoArea,
path: path.join('.planning', 'todos', 'pending', file),
path: '.planning/todos/pending/' + file,
});
} catch {}
}
Expand Down Expand Up @@ -629,7 +629,7 @@ function cmdInitProgress(cwd, raw) {
const phaseInfo = {
number: phaseNumber,
name: phaseName,
directory: path.join('.planning', 'phases', dir),
directory: '.planning/phases/' + dir,
status,
plan_count: plans.length,
summary_count: summaries.length,
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"scripts": {
"build:hooks": "node scripts/build-hooks.js",
"prepublishOnly": "npm run build:hooks",
"test": "node --test tests/*.test.cjs",
"test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node --test tests/*.test.cjs"
"test": "node scripts/run-tests.cjs",
"test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs"
}
}
29 changes: 29 additions & 0 deletions scripts/run-tests.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env node
// Cross-platform test runner — resolves test file globs via Node
// instead of relying on shell expansion (which fails on Windows PowerShell/cmd).
// Propagates NODE_V8_COVERAGE so c8 collects coverage from the child process.
'use strict';

const { readdirSync } = require('fs');
const { join } = require('path');
const { execFileSync } = require('child_process');

const testDir = join(__dirname, '..', 'tests');
const files = readdirSync(testDir)
.filter(f => f.endsWith('.test.cjs'))
.sort()
.map(f => join('tests', f));

if (files.length === 0) {
console.error('No test files found in tests/');
process.exit(1);
}

try {
execFileSync(process.execPath, ['--test', ...files], {
stdio: 'inherit',
env: { ...process.env },
});
} catch (err) {
process.exit(err.status || 1);
}
6 changes: 3 additions & 3 deletions tests/frontmatter-cli.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe('frontmatter set', () => {

test('handles JSON array value', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(`frontmatter set ${file} --field tags --value '["a","b"]'`);
const result = runGsdTools(['frontmatter', 'set', file, '--field', 'tags', '--value', '["a","b"]']);
assert.ok(result.success, `Command failed: ${result.error}`);

const content = fs.readFileSync(file, 'utf-8');
Expand Down Expand Up @@ -140,7 +140,7 @@ describe('frontmatter set', () => {
describe('frontmatter merge', () => {
test('merges multiple fields into frontmatter', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(`frontmatter merge ${file} --data '{"plan":"02","type":"tdd"}'`);
const result = runGsdTools(['frontmatter', 'merge', file, '--data', '{"plan":"02","type":"tdd"}']);
assert.ok(result.success, `Command failed: ${result.error}`);

const content = fs.readFileSync(file, 'utf-8');
Expand All @@ -153,7 +153,7 @@ describe('frontmatter merge', () => {

test('overwrites existing fields on conflict', () => {
const file = writeTempFile('---\nphase: 01\ntype: execute\n---\nbody');
const result = runGsdTools(`frontmatter merge ${file} --data '{"phase":"02"}'`);
const result = runGsdTools(['frontmatter', 'merge', file, '--data', '{"phase":"02"}']);
assert.ok(result.success, `Command failed: ${result.error}`);

const content = fs.readFileSync(file, 'utf-8');
Expand Down
29 changes: 22 additions & 7 deletions tests/helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,35 @@
* GSD Tools Test Helpers
*/

const { execSync } = require('child_process');
const { execSync, execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');

// Helper to run gsd-tools command
/**
* Run gsd-tools command.
*
* @param {string|string[]} args - Command string (shell-interpreted) or array
* of arguments (shell-bypassed via execFileSync, safe for JSON and dollar signs).
* @param {string} cwd - Working directory.
*/
function runGsdTools(args, cwd = process.cwd()) {
try {
const result = execSync(`node "${TOOLS_PATH}" ${args}`, {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
let result;
if (Array.isArray(args)) {
result = execFileSync(process.execPath, [TOOLS_PATH, ...args], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
} else {
result = execSync(`node "${TOOLS_PATH}" ${args}`, {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
}
return { success: true, output: result.trim() };
} catch (err) {
return {
Expand Down
2 changes: 1 addition & 1 deletion tests/init.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ describe('cmdInitTodos', () => {
assert.strictEqual(task1.title, 'Fix bug');
assert.strictEqual(task1.area, 'backend');
assert.strictEqual(task1.created, '2026-02-25');
assert.strictEqual(task1.path, path.join('.planning', 'todos', 'pending', 'task-1.md'));
assert.strictEqual(task1.path, '.planning/todos/pending/task-1.md');
});

test('area filter returns only matching todos', () => {
Expand Down
4 changes: 2 additions & 2 deletions tests/state.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ None
);

const result = runGsdTools(
"state add-decision --phase 11-01 --summary 'Benchmark prices moved from $0.50 to $2.00 to $5.00' --rationale 'track cost growth'",
['state', 'add-decision', '--phase', '11-01', '--summary', 'Benchmark prices moved from $0.50 to $2.00 to $5.00', '--rationale', 'track cost growth'],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
Expand Down Expand Up @@ -233,7 +233,7 @@ None
`
);

const result = runGsdTools("state add-blocker --text 'Waiting on vendor quote $1.00 before approval'", tmpDir);
const result = runGsdTools(['state', 'add-blocker', '--text', 'Waiting on vendor quote $1.00 before approval'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);

const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
Expand Down