fix(windows): cross-platform path separators, JSON quoting, and dollar signs

- Add toPosixPath() helper to normalize output paths to forward slashes
- Use string concatenation for relative base paths instead of path.join()
- Apply toPosixPath() to all user-facing file paths in init.cjs output
- Use array-based execFileSync in test helpers to bypass shell quoting
  issues with JSON args and dollar signs on Windows cmd.exe

Fixes 7 test failures on Windows: frontmatter set/merge (3), init
path assertions (2), and state dollar-amount corruption (2).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson
2026-02-27 11:34:36 -06:00
parent 02a5319777
commit 9c27da0261
5 changed files with 49 additions and 26 deletions

View File

@@ -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 = {
@@ -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,
@@ -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)
@@ -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;
@@ -419,4 +426,5 @@ module.exports = {
pathExistsInternal,
generateSlugInternal,
getMilestoneInfo,
toPosixPath,
};

View File

@@ -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) {
@@ -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 {}
}
@@ -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 {}
}
@@ -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 {}
}
@@ -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,

View File

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

View File

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

View File

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