Merge remote-tracking branch 'upstream/next' into kimi-runtime-support

This commit is contained in:
Viktorplus
2026-06-07 11:56:18 +02:00
51 changed files with 4350 additions and 205 deletions

View File

@@ -12,9 +12,52 @@
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
/**
* Run gsd-tools and capture BOTH stdout and stderr on success.
* Returns { success, stdout, stderr }.
*/
function runGsdToolsWithStderr(args, cwd, env) {
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...(env || {}) };
try {
const result = spawnSync(process.execPath, [TOOLS_PATH, ...args], {
cwd,
encoding: 'utf-8',
env: childEnv,
});
return {
success: result.status === 0,
stdout: (result.stdout || '').trim(),
stderr: (result.stderr || '').trim(),
exitCode: result.status,
};
} catch (err) {
return { success: false, stdout: '', stderr: String(err), exitCode: 1 };
}
}
const { loadTrustedGlobalRoots, validatePath } = require('../gsd-core/bin/lib/security.cjs');
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -343,3 +386,390 @@ describe('agent-skills global: prefix', () => {
assert.strictEqual(r.ir.block, '', 'block must be empty for empty global: prefix');
});
});
// ─── loadTrustedGlobalRoots unit tests (#52) ──────────────────────────────────
describe('loadTrustedGlobalRoots', () => {
test('returns [] for undefined config', () => {
assert.deepStrictEqual(loadTrustedGlobalRoots(undefined), []);
});
test('returns [] for null config', () => {
assert.deepStrictEqual(loadTrustedGlobalRoots(null), []);
});
test('returns [] when agent_skills_security is absent', () => {
assert.deepStrictEqual(loadTrustedGlobalRoots({}), []);
});
test('returns [] when trusted_global_roots is absent', () => {
assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: {} }), []);
});
test('returns [] when trusted_global_roots is not an array', () => {
assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: { trusted_global_roots: '/some/path' } }), []);
assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: { trusted_global_roots: 42 } }), []);
assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: { trusted_global_roots: true } }), []);
});
test('drops non-string entries from the array', () => {
// Use a real temp dir so realpathSync succeeds; non-strings are still dropped
const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-ns-'));
try {
const realPath = fs.realpathSync(realDir);
const config = { agent_skills_security: { trusted_global_roots: [42, null, realDir, true] } };
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [realPath]);
} finally {
cleanup(realDir);
}
});
test('drops project-relative (non-absolute) entries', () => {
const config = { agent_skills_security: { trusted_global_roots: ['foo/bar', 'relative/path'] } };
assert.deepStrictEqual(loadTrustedGlobalRoots(config), []);
});
test('keeps absolute paths — real dirs are kept and canonicalized', () => {
// Non-existent dirs are dropped; use real temp dirs and compare against realpaths
const dir1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-d1-'));
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-d2-'));
try {
const real1 = fs.realpathSync(dir1);
const real2 = fs.realpathSync(dir2);
const config = { agent_skills_security: { trusted_global_roots: [dir1, dir2] } };
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [real1, real2]);
} finally {
cleanup(dir1);
cleanup(dir2);
}
});
test('expands leading ~/ to os.homedir() — kept only if the dir exists', () => {
// Create a real subdir under os.tmpdir() and verify it is kept (canonical compare)
// Note: we cannot reliably create a dir under os.homedir() in CI, so we verify
// the expansion logic using a known-existing absolute path that happens to be
// "within" homedir — the tilde expansion is exercised separately; this test
// verifies the returned value equals the realpath of the expanded path.
const subdir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-tilde-'));
try {
const realSub = fs.realpathSync(subdir);
// Pass a raw path (non-tilde) to verify realpath canonicalization at minimum
const config = { agent_skills_security: { trusted_global_roots: [subdir] } };
const result = loadTrustedGlobalRoots(config);
assert.deepStrictEqual(result, [realSub], 'result must equal realpath of existing dir');
} finally {
cleanup(subdir);
}
});
test('non-existent absolute root is dropped (returns [])', () => {
const config = { agent_skills_security: { trusted_global_roots: ['/nonexistent-gsd-root-12345xyz'] } };
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [], 'non-existent root must be dropped');
});
test('trusted root that is a symlink is canonicalized to the link target', () => {
const realTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-symtgt-'));
const symlinkPath = path.join(os.tmpdir(), `gsd-tgr-symlink-${Date.now()}`);
let symlinkCreated = false;
try {
try {
fs.symlinkSync(realTarget, symlinkPath);
symlinkCreated = true;
} catch (err) {
if (err.code === 'EPERM' || err.code === 'ENOSYS') {
// symlinks not supported on this platform — skip
return;
}
throw err;
}
const realResolved = fs.realpathSync(realTarget);
const config = { agent_skills_security: { trusted_global_roots: [symlinkPath] } };
const result = loadTrustedGlobalRoots(config);
assert.deepStrictEqual(result, [realResolved], 'symlink root must be canonicalized to the link target');
} finally {
cleanup(realTarget);
if (symlinkCreated) {
try { fs.unlinkSync(symlinkPath); } catch { /* ignore */ }
}
}
});
test('de-duplicates entries by canonical path', () => {
// Both entries point to the same real dir — after canonicalization, only one is kept
const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-dedup-'));
try {
const realPath = fs.realpathSync(realDir);
const config = { agent_skills_security: { trusted_global_roots: [realDir, realDir, realPath] } };
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [realPath]);
} finally {
cleanup(realDir);
}
});
test('expands ~/ before absolute check — non-existent ~/x is dropped after expansion', () => {
// ~/x becomes an absolute path after expansion, but if ~/x does not exist it is
// dropped by the realpathSync guard (non-existent root is not trustworthy).
const expandedX = path.join(os.homedir(), 'x-gsd-nonexistent-12345');
// Ensure it really doesn't exist
if (fs.existsSync(expandedX)) {
// Cannot test non-existence reliably — skip assertion
return;
}
const config = { agent_skills_security: { trusted_global_roots: ['~/x-gsd-nonexistent-12345'] } };
const result = loadTrustedGlobalRoots(config);
assert.deepStrictEqual(result, [], 'non-existent ~/x must be dropped after expansion');
});
test('expands bare ~ to os.homedir()', () => {
// Bare ~ (exactly) must expand to homedir — mirrors runtime-homes.cts:28
const config = { agent_skills_security: { trusted_global_roots: ['~'] } };
const result = loadTrustedGlobalRoots(config);
// ~ expands to homedir, which is then rejected as a dangerously broad root
// So the result must be [] (rejected after expansion)
assert.deepStrictEqual(result, [], 'bare ~ expands to homedir and is then rejected as too broad');
});
test('rejects filesystem root /', () => {
const config = { agent_skills_security: { trusted_global_roots: ['/'] } };
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [], 'filesystem root must be rejected');
});
test('rejects os.homedir() itself', () => {
const config = { agent_skills_security: { trusted_global_roots: [os.homedir()] } };
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [], 'homedir itself must be rejected as too broad');
});
});
// ─── trusted_global_roots integration guard (#52) ─────────────────────────────
//
// NOTE: These tests validate the trusted-root bypass logic by directly calling
// loadTrustedGlobalRoots + validatePath rather than invoking the full CLI
// (which would require controlling the runtime HOME path in a way that also
// triggers a symlink escape scenario through gsd-tools subprocess invocation).
// Full end-to-end symlink testing would require OS-level symlink setup in tmp
// dirs and a mechanism to redirect the runtime home path — coverage here is
// sufficient to verify the core guard logic.
describe('trusted_global_roots guard logic', () => {
let tmpDir;
let externalDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-trusted-'));
externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-external-'));
// Create a skill file in externalDir
fs.writeFileSync(path.join(externalDir, 'SKILL.md'), '# External\n');
});
afterEach(() => {
cleanup(tmpDir);
cleanup(externalDir);
});
test('validatePath rejects skill outside globalSkillsBase (baseline — no trusted roots)', () => {
const skillMd = path.join(externalDir, 'SKILL.md');
const result = validatePath(skillMd, tmpDir, { allowAbsolute: true });
assert.ok(!result.safe, 'skill outside base must be rejected by validatePath');
});
test('with trusted root matching real target dir — validatePath accepts', () => {
// Simulate the trusted-root fallback: skill is outside base but inside trusted root
const skillMd = path.join(externalDir, 'SKILL.md');
const baseCheck = validatePath(skillMd, tmpDir, { allowAbsolute: true });
assert.ok(!baseCheck.safe, 'base check must fail (prerequisite)');
// Trusted root fallback: check against externalDir
const config = { agent_skills_security: { trusted_global_roots: [externalDir] } };
const trustedRoots = loadTrustedGlobalRoots(config);
const acceptedViaTrustedRoot = trustedRoots.some((root) => {
const rootCheck = validatePath(skillMd, root, { allowAbsolute: true });
return rootCheck.safe;
});
assert.ok(acceptedViaTrustedRoot, 'skill must be accepted when within a trusted root');
});
test('with unrelated trusted root — skill still rejected', () => {
const skillMd = path.join(externalDir, 'SKILL.md');
const unrelatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-unrelated-'));
try {
const config = { agent_skills_security: { trusted_global_roots: [unrelatedDir] } };
const trustedRoots = loadTrustedGlobalRoots(config);
const acceptedViaTrustedRoot = trustedRoots.some((root) => {
const rootCheck = validatePath(skillMd, root, { allowAbsolute: true });
return rootCheck.safe;
});
assert.ok(!acceptedViaTrustedRoot, 'skill must still be rejected when trusted root is unrelated');
} finally {
cleanup(unrelatedDir);
}
});
test('with empty trusted_global_roots array — skill still rejected (byte-identical to today)', () => {
const skillMd = path.join(externalDir, 'SKILL.md');
const config = { agent_skills_security: { trusted_global_roots: [] } };
const trustedRoots = loadTrustedGlobalRoots(config);
assert.strictEqual(trustedRoots.length, 0, 'no roots loaded');
const acceptedViaTrustedRoot = trustedRoots.some((root) => {
const rootCheck = validatePath(skillMd, root, { allowAbsolute: true });
return rootCheck.safe;
});
assert.ok(!acceptedViaTrustedRoot, 'skill must be rejected when trusted roots is empty');
});
});
// ─── trusted_global_roots e2e CLI tests (#52) ─────────────────────────────────
//
// These tests exercise the full CLI path (runAgentSkillsJson → gsd-tools →
// loadConfig → agent-skills command) to verify that agent_skills_security is
// properly threaded through the config pipeline. Symlinks are created so a
// global: skill's realpath escapes the ~/.claude/skills/ base, requiring a
// trusted root to be accepted.
describe('trusted_global_roots e2e CLI (#52)', () => {
let tmpDir;
let fakeHome;
let globalSkillsDir;
let sharedRoot;
let symlinkSupported;
beforeEach(() => {
tmpDir = createTempProject();
fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-e2e-home-'));
globalSkillsDir = path.join(fakeHome, '.claude', 'skills');
fs.mkdirSync(globalSkillsDir, { recursive: true });
sharedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-e2e-shared-'));
// Create the shared skill directory OUTSIDE fakeHome
const sharedSkillDir = path.join(sharedRoot, 'shared-skill');
fs.mkdirSync(sharedSkillDir, { recursive: true });
fs.writeFileSync(path.join(sharedSkillDir, 'SKILL.md'), '# Shared Skill\nContent from shared root.\n');
// Attempt to create a symlink inside globalSkillsDir pointing to the shared skill
symlinkSupported = true;
try {
fs.symlinkSync(sharedSkillDir, path.join(globalSkillsDir, 'shared-skill'));
} catch (err) {
if (err.code === 'EPERM' || err.code === 'ENOSYS') {
symlinkSupported = false;
} else {
throw err;
}
}
});
afterEach(() => {
cleanup(tmpDir);
cleanup(fakeHome);
cleanup(sharedRoot);
});
test('REGRESSION: symlinked-escape skill with NO agent_skills_security in config → block is empty', (t) => {
if (!symlinkSupported) {
t.skip('symlinks not supported on this platform');
return;
}
// No agent_skills_security in config — symlink escape must be blocked
writeConfig(tmpDir, {
runtime: 'claude',
agent_skills: { 'gsd-executor': ['global:shared-skill'] },
});
const r = runAgentSkillsJson(
['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }
);
assert.ok(r.success, `Command failed: ${r.error}`);
assert.strictEqual(r.ir.block, '', 'block must be empty when symlink escapes base and no trusted root configured');
});
test('FEATURE: symlink escape with matching trusted_global_roots → block includes skill', (t) => {
if (!symlinkSupported) {
t.skip('symlinks not supported on this platform');
return;
}
// Configure the sharedRoot as a trusted global root
writeConfig(tmpDir, {
runtime: 'claude',
agent_skills: { 'gsd-executor': ['global:shared-skill'] },
agent_skills_security: { trusted_global_roots: [sharedRoot] },
});
const r = runAgentSkillsJson(
['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }
);
assert.ok(r.success, `Command failed: ${r.error}`);
assert.ok(r.ir.block.includes('<agent_skills>'), `block must contain <agent_skills> tag, got: ${r.ir.block}`);
assert.ok(r.ir.block.includes('shared-skill/SKILL.md'), `block must include the shared skill, got: ${r.ir.block}`);
assert.ok(r.ir.skills_count >= 1, 'skills_count must be at least 1');
});
test('FEATURE NOTE: accepted-via-trusted-root emits NOTE on stderr', (t) => {
if (!symlinkSupported) {
t.skip('symlinks not supported on this platform');
return;
}
// Capture stderr using spawnSync (runGsdTools only captures stderr on failure)
writeConfig(tmpDir, {
runtime: 'claude',
agent_skills: { 'gsd-executor': ['global:shared-skill'] },
agent_skills_security: { trusted_global_roots: [sharedRoot] },
});
const r = runGsdToolsWithStderr(
['agent-skills', '--json', 'gsd-executor'],
tmpDir,
{ HOME: fakeHome, USERPROFILE: fakeHome }
);
assert.ok(r.success, `Command failed (exit ${r.exitCode}): ${r.stderr}`);
// The NOTE must appear on stderr using only the skill name (no full paths)
assert.ok(
r.stderr.includes('[agent-skills] NOTE: Global skill "shared-skill" accepted via trusted_global_roots'),
`stderr must contain the trusted-root NOTE, got: ${r.stderr}`,
);
});
test('NEGATIVE: symlink escape with unrelated trusted root (existing dir) → block is empty', (t) => {
if (!symlinkSupported) {
t.skip('symlinks not supported on this platform');
return;
}
// The unrelated dir MUST exist so it isn't dropped for the wrong reason (non-existence).
// Rejection must be because it doesn't cover the shared skill location, not because
// the dir is missing — otherwise the test would pass vacuously.
const unrelatedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-e2e-unrelated-'));
// Verify the dir actually exists so the trusted root is loaded (not silently dropped)
assert.ok(fs.existsSync(unrelatedRoot), 'unrelated root must exist so it enters the trusted roots list');
try {
writeConfig(tmpDir, {
runtime: 'claude',
agent_skills: { 'gsd-executor': ['global:shared-skill'] },
agent_skills_security: { trusted_global_roots: [unrelatedRoot] },
});
const r = runAgentSkillsJson(
['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }
);
assert.ok(r.success, `Command failed: ${r.error}`);
assert.strictEqual(r.ir.block, '', 'block must be empty when trusted root does not cover the shared skill location');
} finally {
cleanup(unrelatedRoot);
}
});
test('HARDENING: trusted_global_roots: ["/"] → block is empty (broad root rejected)', (t) => {
if (!symlinkSupported) {
t.skip('symlinks not supported on this platform');
return;
}
writeConfig(tmpDir, {
runtime: 'claude',
agent_skills: { 'gsd-executor': ['global:shared-skill'] },
agent_skills_security: { trusted_global_roots: ['/'] },
});
const r = runAgentSkillsJson(
['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }
);
assert.ok(r.success, `Command failed: ${r.error}`);
assert.strictEqual(r.ir.block, '', 'block must be empty when "/" is the trusted root (rejected as too broad)');
});
});

View File

@@ -23,7 +23,10 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { convertClaudeCommandToCodexSkill } = require('../bin/install.js');
const {
convertClaudeCommandToCodexSkill,
convertSlashCommandsToCodexSkillMentions,
} = require('../bin/install.js');
// The canonical launcher snippet path that was being corrupted
const RUNTIME_ROOT_PATH = '${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}';
@@ -148,11 +151,10 @@ describe('#704 — Codex global install launcher path corruption', () => {
// Walk gsd-core/workflows/ and assert that no file produces $gsd-core
// inside a shell variable expansion context after Codex conversion.
//
// NOTE: The regex `/(?<![a-zA-Z0-9./}])\/gsd-/` still converts backtick-
// wrapped prose paths like `\`/gsd-core/workflows/update.md\`` (a pre-existing
// issue separate from #704 — update.md's backtick is not in the lookbehind
// set). That prose-path case is intentionally excluded from this assertion
// (tracked separately; the primary #704 bug is the shell-variable expansion).
// NOTE: The backtick-wrapped prose-path case (`/gsd-core/workflows/update.md`)
// was a pre-existing gap with the #704 lookbehind fix and is now addressed by
// the positive-boundary regex introduced in #712. That case is covered by the
// "#712" describe block below.
//
// We probe for the specific shell-context pattern from the issue report:
// BAD: ${_GSD_RUNTIME_ROOT}$gsd-core/bin/
@@ -235,3 +237,182 @@ describe('#704 — Codex global install launcher path corruption', () => {
);
});
});
describe('#712: positive-boundary slash-command conversion', () => {
// Tests call convertSlashCommandsToCodexSkillMentions directly so the regex
// is exercised in isolation — no frontmatter wrapping, no ADAPTER_CLOSE
// stripping, no .claude→.codex rewrite masking the result.
// ── MUST-NOT-CONVERT (negative) cases ─────────────────────────────────────
// These inputs must be returned UNCHANGED — no $gsd-* substitution.
test('backtick-wrapped path: `/gsd-core/workflows/update.md` is NOT converted (THE new fix)', () => {
const input = 'See `/gsd-core/workflows/update.md` for details.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.strictEqual(
result,
input,
`Expected backtick-wrapped path to be unchanged. Got: ${result}`,
);
});
test('backtick-wrapped path deeper: `/gsd-pi/bin/foo.cjs` is NOT converted', () => {
const input = 'Run `/gsd-pi/bin/foo.cjs` directly.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.strictEqual(
result,
input,
`Expected deep backtick-wrapped path to be unchanged. Got: ${result}`,
);
});
test('shell var expansion: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/x is NOT converted (regression guard)', () => {
const input = 'PATH="${_GSD_RUNTIME_ROOT}/gsd-core/bin/x"';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
!result.includes('$gsd-core'),
`Expected no $gsd-core substitution in shell var path. Got: ${result}`,
);
assert.ok(
result.includes('/gsd-core/bin/x'),
`Expected original path to be preserved. Got: ${result}`,
);
});
test('command substitution: $(expand_home ~/.claude)/gsd-local-patches is NOT converted (regression guard)', () => {
const input = 'candidate="$(expand_home ~/.claude)/gsd-local-patches"';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
!result.includes(')$gsd-local'),
`Expected no )$gsd-local substitution. Got: ${result}`,
);
assert.ok(
result.includes(')/gsd-local-patches'),
`Expected original path to be preserved. Got: ${result}`,
);
});
test('plain path segment: bin/gsd-tools.cjs is NOT converted', () => {
const input = 'node bin/gsd-tools.cjs --help';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.strictEqual(
result,
input,
`Expected plain path segment to be unchanged. Got: ${result}`,
);
});
test('plain path segment: .claude/gsd-core/agents — /gsd-core portion is NOT slash-command converted', () => {
// Tests the regex in isolation: the .claude→.codex path rewrite that happens
// inside convertClaudeToCodexMarkdown does NOT run here. We assert directly
// that the slash-command regex leaves /gsd-core after the slash intact —
// i.e. the `e` in `/gsd-core` is NOT treated as a command boundary.
const input = 'Look in .claude/gsd-core/agents for the agent files.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
!result.includes('$gsd-core'),
`Expected no $gsd-core substitution in .claude/gsd-core path. Got: ${result}`,
);
assert.ok(
result.includes('/gsd-core/agents'),
`Expected /gsd-core/agents to remain as a path segment. Got: ${result}`,
);
});
// ── MUST-CONVERT (positive) cases ─────────────────────────────────────────
// These inputs contain legitimate /gsd-<cmd> mentions that MUST be converted.
test('space-preceded prose: Use /gsd-discuss-phase to start. → $gsd-discuss-phase', () => {
const input = 'Use /gsd-discuss-phase to start.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
result.includes('$gsd-discuss-phase'),
`Expected /gsd-discuss-phase to be converted. Got: ${result}`,
);
assert.ok(
!result.includes('/gsd-discuss-phase'),
`Expected original /gsd-discuss-phase to be replaced. Got: ${result}`,
);
});
test('backtick-WRAPPED MENTION (single segment): Run `/gsd-execute-phase` now → `$gsd-execute-phase`', () => {
// A backtick-wrapped COMMAND (single segment, no path continuation) MUST
// still be converted — this guards against a naive whitespace-only fix.
const input = 'Run `/gsd-execute-phase` now.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
result.includes('`$gsd-execute-phase`'),
`Expected backtick-wrapped command to be converted to \`$gsd-execute-phase\`. Got: ${result}`,
);
assert.ok(
!result.includes('`/gsd-execute-phase`'),
`Expected original \`/gsd-execute-phase\` to be replaced. Got: ${result}`,
);
});
test('parenthetical/backtick list like CONTEXT.md:59: (`/gsd-plan-phase`, `/gsd-progress`) → converted', () => {
const input = 'Available commands: (`/gsd-plan-phase`, `/gsd-progress`) — pick one.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
result.includes('`$gsd-plan-phase`'),
`Expected /gsd-plan-phase to be converted. Got: ${result}`,
);
assert.ok(
result.includes('`$gsd-progress`'),
`Expected /gsd-progress to be converted. Got: ${result}`,
);
});
test('start-of-string: /gsd-manager runs → $gsd-manager runs (exercises the ^ branch of lookbehind)', () => {
// This case is IMPOSSIBLE to test through the frontmatter-wrapping pipeline
// (the body always has preceding chars). Direct call exercises the ^ branch.
const input = '/gsd-manager runs the pipeline.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
result.includes('$gsd-manager'),
`Expected /gsd-manager to be converted. Got: ${result}`,
);
assert.ok(
!result.includes('/gsd-manager'),
`Expected original /gsd-manager to be replaced. Got: ${result}`,
);
});
test('double-quote wrapped: "/gsd-resume" → "$gsd-resume"', () => {
const input = 'Call "/gsd-resume" to continue.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
result.includes('"$gsd-resume"'),
`Expected "/gsd-resume" to be converted to "$gsd-resume". Got: ${result}`,
);
assert.ok(
!result.includes('"/gsd-resume"'),
`Expected original "/gsd-resume" to be replaced. Got: ${result}`,
);
});
// ── End-to-end: headline #712 bug through the real install pipeline ────────
test('end-to-end: backtick-wrapped path `/gsd-core/workflows/update.md` survives full Codex install pipeline', () => {
// Uses convertClaudeCommandToCodexSkill (same pattern as #704 tests above)
// to prove the real install path does not corrupt prose references to repo paths.
const input = [
'---',
'description: Test',
'---',
'',
'See `/gsd-core/workflows/update.md` for the update workflow.',
].join('\n');
const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-712-e2e');
assert.ok(
!output.includes('$gsd-core'),
`Expected no $gsd-core in converted output. Got:\n${output}`,
);
assert.ok(
output.includes('/gsd-core/workflows/update.md'),
`Expected backtick-wrapped path to survive conversion. Got:\n${output}`,
);
});
});

View File

@@ -0,0 +1,275 @@
/**
* Regression test for bug #730: phase details defined under a milestone-scoped
* "## Milestone vX.Y — … (Phase Details)" section are invisible to phase
* resolution (getRoadmapPhaseInternal / init phase-op) when the flat shared
* "## Phase Details" section for an earlier milestone sits between the shared
* ## Phases checklist and the per-milestone Phase Details section.
*
* The bug manifests ONLY before any .planning/phases/ directory exists because
* findPhaseInternal masks it once the dir is created. RED step — tests 1 and 3
* are expected to fail against current code.
*/
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 { runGsdTools, cleanup } = require('./helpers.cjs');
// ---------------------------------------------------------------------------
// Shared fixture content
// ---------------------------------------------------------------------------
const STATE_CONTENT = `---
milestone: v1.1
---
`;
const ROADMAP_CONTENT = `# Roadmap: Example
## Phases
- [x] **Phase 1: Setup** — initial scaffold
### Milestone v1.1 — Second milestone (added 2026-01-01)
- [ ] **Phase 2: Feature** — the new thing
## Phase Details
### Phase 1: Setup
**Goal:** scaffold the app.
## Milestone v1.1 — Second milestone (Phase Details)
### Phase 2: Feature
**Goal:** build the new thing.
`;
// ---------------------------------------------------------------------------
// Helper: create a bare project with .planning/ but NO .planning/phases/ dir
// ---------------------------------------------------------------------------
function createBareProject() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
return tmpDir;
}
// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------
describe('bug #730 — milestone (Phase Details) section scope resolution', () => {
let dir;
beforeEach(() => {
dir = createBareProject();
fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), STATE_CONTENT, 'utf-8');
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT, 'utf-8');
});
afterEach(() => {
cleanup(dir);
});
// -------------------------------------------------------------------------
// Test 1 (AC1): init phase-op resolves phase defined only under its
// per-milestone "(Phase Details)" section
// -------------------------------------------------------------------------
test('init phase-op resolves a current-milestone phase defined only under its (Phase Details) section', () => {
const r = runGsdTools('init phase-op 2', dir);
assert.ok(r.success, `init phase-op 2 failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, expected_phase_dir=${out.expected_phase_dir}`);
assert.strictEqual(out.phase_name, 'Feature', `phase_name should be 'Feature'; got '${out.phase_name}'`);
assert.strictEqual(out.padded_phase, '02', `padded_phase should be '02'; got '${out.padded_phase}'`);
assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-feature', `expected_phase_dir should be '.planning/phases/02-feature'; got '${out.expected_phase_dir}'`);
});
// -------------------------------------------------------------------------
// Test 2 (AC4): first-milestone phase still resolves via the flat
// "## Phase Details" section — no regression
// -------------------------------------------------------------------------
test('init phase-op still resolves a first-milestone phase (no regression on flat Phase Details)', () => {
const r = runGsdTools('init phase-op 1', dir);
assert.ok(r.success, `init phase-op 1 failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.phase_found, true, `phase_found should be true for phase 1; got ${out.phase_found}`);
assert.strictEqual(out.phase_name, 'Setup', `phase_name should be 'Setup'; got '${out.phase_name}'`);
});
// -------------------------------------------------------------------------
// Test 3 (AC5): getRoadmapPhaseInternal resolves the current-milestone phase
// directly before any phases/ dir exists
// -------------------------------------------------------------------------
test('getRoadmapPhaseInternal resolves the current-milestone phase directly before any dir exists', () => {
const core = require('../gsd-core/bin/lib/core.cjs');
const res = core.getRoadmapPhaseInternal(dir, '2');
assert.ok(res !== null && res !== undefined, `getRoadmapPhaseInternal returned null/undefined for phase 2`);
assert.strictEqual(res.found, true, `res.found should be true; got ${JSON.stringify(res)}`);
assert.strictEqual(res.phase_name, 'Feature', `res.phase_name should be 'Feature'; got '${res.phase_name}'`);
});
// -------------------------------------------------------------------------
// Test 4 (AC3): validate health raises W006 for a current-milestone phase
// defined under (Phase Details) with no directory on disk.
//
// Before the fix, extractCurrentMilestone's slice stopped before the
// "## Milestone v1.1 — … (Phase Details)" section, so phase 2's
// "### Phase 2: Feature" header was invisible and W006 was never raised.
// After the fix the slice includes that section and W006 is emitted.
//
// This test uses its OWN local fixture (separate tmpdir) so it does not
// disturb the shared beforeEach/afterEach fixture used by tests 1–3.
// -------------------------------------------------------------------------
test('validate health raises W006 for a started current-milestone phase defined under (Phase Details) with no directory', () => {
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t4-'));
try {
const planning = path.join(localDir, '.planning');
fs.mkdirSync(planning, { recursive: true });
// STATE.md — milestone: v1.1
fs.writeFileSync(
path.join(planning, 'STATE.md'),
`---\nmilestone: v1.1\n---\n`,
'utf-8',
);
// ROADMAP.md — phase 2 is [x] (started/complete) so the not-started
// guard does NOT suppress W006. Phase 2's details live exclusively in
// the per-milestone "(Phase Details)" section (the blind-spot pre-fix).
fs.writeFileSync(
path.join(planning, 'ROADMAP.md'),
`# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — initial scaffold\n\n### Milestone v1.1 — Second milestone (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — the new thing\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold the app.\n\n## Milestone v1.1 — Second milestone (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build the new thing.\n`,
'utf-8',
);
// Create the phase 1 directory so phase 1 does NOT trigger W006.
// Phase 2 has NO directory — that's the missing-dir condition under test.
fs.mkdirSync(path.join(planning, 'phases', '01-setup'), { recursive: true });
const result = runGsdTools(['validate', 'health'], localDir);
const payload = JSON.parse(result.output);
const warnings = payload.warnings || [];
// Find a W006 entry whose message references phase 2 (by number or name).
const w006ForPhase2 = warnings.find(
(w) =>
w.code === 'W006' &&
(/\b2\b/.test(w.message) || /\b02\b/.test(w.message) || /Feature/i.test(w.message)),
);
assert.ok(
w006ForPhase2 != null,
`Expected a W006 warning referencing phase 2 (Feature) — phase 2 is started ([x]) and has no directory on disk, ` +
`but its ### Phase 2: header lives in the Milestone v1.1 (Phase Details) section which was invisible before the fix. ` +
`Got warnings: ${JSON.stringify(warnings)}`,
);
} finally {
cleanup(localDir);
}
});
// -------------------------------------------------------------------------
// Test 5: three-milestone roadmap, current = latest (v1.2)
// -------------------------------------------------------------------------
test('init phase-op resolves the latest milestone phase in a 3-milestone roadmap', () => {
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t5-'));
try {
const planning = path.join(localDir, '.planning');
fs.mkdirSync(planning, { recursive: true });
fs.writeFileSync(
path.join(planning, 'STATE.md'),
`---\nmilestone: v1.2\n---\n`,
'utf-8',
);
fs.writeFileSync(
path.join(planning, 'ROADMAP.md'),
`# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — done\n\n### Milestone v1.1 — Second (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — done\n\n### Milestone v1.2 — Third (added 2026-02-01)\n\n- [ ] **Phase 3: Polish** — current\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold.\n\n## Milestone v1.1 — Second (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build.\n\n## Milestone v1.2 — Third (Phase Details)\n\n### Phase 3: Polish\n**Goal:** refine.\n`,
'utf-8',
);
const r = runGsdTools('init phase-op 3', localDir);
assert.ok(r.success, `init phase-op 3 failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}`);
assert.strictEqual(out.phase_name, 'Polish', `phase_name should be 'Polish'; got '${out.phase_name}'`);
assert.strictEqual(out.padded_phase, '03', `padded_phase should be '03'; got '${out.padded_phase}'`);
assert.strictEqual(out.expected_phase_dir, '.planning/phases/03-polish', `expected_phase_dir should be '.planning/phases/03-polish'; got '${out.expected_phase_dir}'`);
} finally {
cleanup(localDir);
}
});
// -------------------------------------------------------------------------
// Test 6: sub-milestone sharing a version prefix — closed sibling must NOT
// cross-pollinate into the active milestone's Phase Details lookup (#730)
// -------------------------------------------------------------------------
test('init phase-op anchors Phase Details to the selected sub-milestone, not a closed same-prefix sibling', () => {
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t6-'));
try {
const planning = path.join(localDir, '.planning');
fs.mkdirSync(planning, { recursive: true });
// STATE.md — milestone: v3.0 (matches v3.0-B active slice)
fs.writeFileSync(
path.join(planning, 'STATE.md'),
`---\nmilestone: v3.0\n---\n`,
'utf-8',
);
// ROADMAP.md — v3.0-A is SHIPPED (closed), v3.0-B is active.
// The Phase Details for v3.0-A comes FIRST — without version-boundary
// anchoring the old code would grab it (first non-closed (Phase Details)
// heading outside the window), returning phase_name='Alpha' instead of 'Beta'.
fs.writeFileSync(
path.join(planning, 'ROADMAP.md'),
[
'# Roadmap: Example',
'',
'## Phases',
'',
'### Milestone v3.0-A — First slice (added 2026-01-01) ✅ SHIPPED',
'',
'- [x] **Phase 1: Alpha** — done',
'',
'### Milestone v3.0-B — Second slice (added 2026-02-01)',
'',
'- [ ] **Phase 2: Beta** — current',
'',
'## Phase Details',
'',
'## Milestone v3.0-A — First slice (Phase Details)',
'',
'### Phase 1: Alpha',
'**Goal:** alpha goal.',
'',
'## Milestone v3.0-B — Second slice (Phase Details)',
'',
'### Phase 2: Beta',
'**Goal:** beta goal.',
'',
].join('\n'),
'utf-8',
);
const r = runGsdTools('init phase-op 2', localDir);
assert.ok(r.success, `init phase-op 2 failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, output=${JSON.stringify(out)}`);
assert.strictEqual(out.phase_name, 'Beta', `phase_name should be 'Beta' (v3.0-B section), not '${out.phase_name}' (would indicate v3.0-A cross-pollination)`);
assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-beta', `expected_phase_dir should be '.planning/phases/02-beta'; got '${out.expected_phase_dir}'`);
} finally {
cleanup(localDir);
}
});
});

View File

@@ -1158,6 +1158,127 @@ describe('config-set prototype-pollution guard (#663)', () => {
});
});
// ─── config-set prototype-pollution guard via dynamic-key prefixes (alert #26) ─
describe('config-set prototype-pollution guard via dynamic-key prefixes (alert #26)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Initialise config so there is a config.json to write to.
runGsdTools('config-ensure-section', tmpDir);
});
afterEach(() => {
cleanup(tmpDir);
});
test('agent_skills.__proto__ is blocked by setConfigValue guard (not schema gate)', () => {
const result = runGsdTools('config-set agent_skills.__proto__ somevalue', tmpDir);
assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`);
// Must be the pollution guard, not the schema gate.
assert.ok(
result.error.includes('prototype pollution guard'),
`Expected "prototype pollution guard" in error, got: ${result.error}`,
);
// No schema-gate message.
assert.ok(
!result.error.includes('Unknown config key'),
`Should not hit schema gate, got: ${result.error}`,
);
// No prototype pollution occurred.
assert.strictEqual(({}).somevalue, undefined, 'agent_skills.__proto__: {}.somevalue should be undefined');
assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false,
'agent_skills.__proto__: Object.prototype should not gain "somevalue"');
});
test('agent_skills.constructor is blocked by setConfigValue guard (not schema gate)', () => {
const result = runGsdTools('config-set agent_skills.constructor somevalue', tmpDir);
assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`);
assert.ok(
result.error.includes('prototype pollution guard'),
`Expected "prototype pollution guard" in error, got: ${result.error}`,
);
assert.ok(
!result.error.includes('Unknown config key'),
`Should not hit schema gate, got: ${result.error}`,
);
assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false,
'agent_skills.constructor: Object.prototype should not gain "somevalue"');
});
test('agent_skills.prototype is blocked by setConfigValue guard (not schema gate)', () => {
const result = runGsdTools('config-set agent_skills.prototype somevalue', tmpDir);
assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`);
assert.ok(
result.error.includes('prototype pollution guard'),
`Expected "prototype pollution guard" in error, got: ${result.error}`,
);
assert.ok(
!result.error.includes('Unknown config key'),
`Should not hit schema gate, got: ${result.error}`,
);
assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false,
'agent_skills.prototype: Object.prototype should not gain "somevalue"');
});
test('features.__proto__ is blocked by setConfigValue guard (not schema gate)', () => {
const result = runGsdTools('config-set features.__proto__ somevalue', tmpDir);
assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`);
assert.ok(
result.error.includes('prototype pollution guard'),
`Expected "prototype pollution guard" in error, got: ${result.error}`,
);
assert.ok(
!result.error.includes('Unknown config key'),
`Should not hit schema gate, got: ${result.error}`,
);
assert.strictEqual(({}).somevalue, undefined, 'features.__proto__: {}.somevalue should be undefined');
assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false,
'features.__proto__: Object.prototype should not gain "somevalue"');
});
test('review.models.constructor is blocked by setConfigValue guard (not schema gate)', () => {
const result = runGsdTools('config-set review.models.constructor somevalue', tmpDir);
assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`);
assert.ok(
result.error.includes('prototype pollution guard'),
`Expected "prototype pollution guard" in error, got: ${result.error}`,
);
assert.ok(
!result.error.includes('Unknown config key'),
`Should not hit schema gate, got: ${result.error}`,
);
assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false,
'review.models.constructor: Object.prototype should not gain "somevalue"');
});
test('positive control: agent_skills.sonnet-coder with valid value succeeds', () => {
const result = runGsdTools('config-set agent_skills.sonnet-coder true', tmpDir);
assert.ok(result.success, `Legitimate agent_skills key rejected unexpectedly: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.agent_skills['sonnet-coder'], true,
'agent_skills.sonnet-coder should be written to config.json');
});
});
// ─── plan_review.source_grounding + _authority (#22) ─────────────────────────
describe('plan_review.source_grounding and plan_review.source_grounding_authority (#22)', () => {

View File

@@ -318,3 +318,193 @@ describe('#68 resolve-granularity command: CLI behavior', () => {
assert.strictEqual(output.unknown_phase_type, undefined, 'known phase type must not have unknown_phase_type');
});
});
// ─── #703 CLI override: --granularity flag ────────────────────────────────────
describe('#703 resolveGranularityInternal: CLI override param (3rd arg)', () => {
let projectDir;
beforeEach(() => { projectDir = makeTmp('cli-override'); });
afterEach(() => { cleanup(projectDir); });
test('override fine beats per-phase config granularities.planning=coarse', () => {
writeConfig(projectDir, {
granularity: 'standard',
granularities: { planning: 'coarse' },
});
assert.equal(resolveGranularityInternal(projectDir, 'planning', 'fine'), 'fine',
'CLI override must beat per-phase config');
});
test('override coarse beats top-level granularity=fine', () => {
writeConfig(projectDir, {
granularity: 'fine',
});
assert.equal(resolveGranularityInternal(projectDir, 'execution', 'coarse'), 'coarse',
'CLI override must beat top-level granularity');
});
test('override standard beats planning.granularity=fine global fallback (regardless of phase type)', () => {
writeConfig(projectDir, {
planning: { granularity: 'fine' },
});
assert.equal(resolveGranularityInternal(projectDir, 'execution', 'standard'), 'standard',
'CLI override must beat planning.granularity fallback');
});
test("override '' (empty string) falls through to config chain", () => {
writeConfig(projectDir, {
granularity: 'coarse',
});
assert.equal(resolveGranularityInternal(projectDir, 'planning', ''), 'coarse',
'empty-string override must fall through to config chain');
});
test('override undefined falls through to config chain', () => {
writeConfig(projectDir, {
granularity: 'fine',
});
assert.equal(resolveGranularityInternal(projectDir, 'planning', undefined), 'fine',
'undefined override must fall through to config chain');
});
test('override null falls through to config chain', () => {
writeConfig(projectDir, {
granularity: 'coarse',
});
assert.equal(resolveGranularityInternal(projectDir, 'planning', null), 'coarse',
'null override must fall through to config chain');
});
test('invalid override value falls through to config chain (not rejected in resolver)', () => {
writeConfig(projectDir, {
granularity: 'standard',
});
// Invalid override reaches resolver → falls through (validation is CLI boundary's job)
assert.equal(resolveGranularityInternal(projectDir, 'planning', 'ultra'), 'standard',
'invalid override must fall through to config chain in resolver');
});
});
describe('#703 cmdResolveGranularity: --granularity CLI flag', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('--granularity fine overrides config chain via CLI tool', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ granularity: 'coarse' })
);
const result = runGsdTools('resolve-granularity planning --granularity fine', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.granularity, 'fine', '--granularity fine must override config coarse');
});
test('--granularity coarse overrides per-phase granularities.planning=fine', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
granularity: 'standard',
granularities: { planning: 'fine' },
})
);
const result = runGsdTools('resolve-granularity planning --granularity coarse', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.granularity, 'coarse', '--granularity coarse must beat per-phase fine');
});
test('invalid --granularity value exits with error', () => {
const result = runGsdTools('resolve-granularity planning --granularity ultra', tmpDir);
assert.ok(!result.success, 'should fail with invalid granularity');
assert.ok(
result.error.includes('ultra') || result.error.includes('invalid'),
`error should mention invalid value; got: ${result.error}`
);
});
});
// ─── #703 end-to-end: init.plan-phase path forwards and resolves granularity ──
//
// Fixture mirrors tests/pattern-mapper.test.cjs: createTempProject() +
// minimal STATE.md + ROADMAP.md + phase directory — just enough for
// cmdInitPlanPhase to succeed without agents / git.
function makeInitPlanPhaseFixture(prefix) {
const tmpDir = createTempProject(prefix);
const planningDir = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planningDir, 'STATE.md'), [
'# State',
'',
'## Current Phase',
'Phase 1 — Foundation',
].join('\n'));
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [
'# Roadmap',
'',
'## Phase 1: Foundation',
'Build the foundation.',
'**Status:** Planning',
'**Requirements:** [FOUND-01]',
].join('\n'));
fs.mkdirSync(path.join(planningDir, 'phases', '01-foundation'), { recursive: true });
return tmpDir;
}
describe('#703 init.plan-phase end-to-end: granularity resolution via CLI', () => {
let tmpDir;
beforeEach(() => {
tmpDir = makeInitPlanPhaseFixture('gsd-68-e2e-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('(1) --granularity fine overrides config granularities.planning=coarse end-to-end', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ granularity: 'standard', granularities: { planning: 'coarse' } })
);
const result = runGsdTools('init plan-phase 1 --granularity fine', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const data = JSON.parse(result.output);
assert.strictEqual(data.granularity, 'fine',
'--granularity fine override must win over granularities.planning=coarse end-to-end');
});
test('(2) no flag + config granularities.planning=fine → granularity=fine (Fix A: per-phase-type honored)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ granularity: 'coarse', granularities: { planning: 'fine' } })
);
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const data = JSON.parse(result.output);
assert.strictEqual(data.granularity, 'fine',
'granularities.planning=fine must be honored (phaseType=planning in resolveGranularityInternal)');
});
test('(3) no flag + global granularity=coarse (no granularities.planning) → granularity=coarse', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ granularity: 'coarse' })
);
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const data = JSON.parse(result.output);
assert.strictEqual(data.granularity, 'coarse',
'global granularity=coarse must be returned when no granularities.planning is set');
});
test('(4) --granularity ultra → command errors (invalid value rejected on plan-phase path)', () => {
const result = runGsdTools('init plan-phase 1 --granularity ultra', tmpDir, { HOME: tmpDir });
assert.ok(!result.success, 'should fail with invalid granularity ultra');
assert.ok(
result.error.includes('ultra') || result.error.includes('invalid'),
`error should mention invalid value; got: ${result.error}`
);
});
});

View File

@@ -1,112 +0,0 @@
'use strict';
// allow-test-rule: runtime-contract-is-the-product
// The ship.md verification gate is LLM-executed prose; its routing message text
// IS the user-facing product surface (RULESET.TESTS.no-source-grep.exemption:
// "reserved for tests where the file content IS the product surface ... agent .md").
// The behavioral tests below additionally EXECUTE the gate's own bash extraction
// pipeline (parsed out of ship.md) against fixture reports, so the extraction
// contract is verified, not just asserted as text.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const helpers = require('./helpers.cjs');
const SHIP_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md');
const ship = fs.readFileSync(SHIP_MD, 'utf8');
const start = ship.indexOf('**Verification passed?**');
const end = ship.indexOf('**Clean working tree?**');
assert.ok(start !== -1 && end !== -1 && end > start, 'could not locate the verification gate block');
const gate = ship.slice(start, end);
// ---- content assertions (the routing message IS the product surface) ----
test('gate captures the status value with a single first-match grep', () => {
assert.match(gate, /grep -m1 "\^status:"/, 'gate must extract status via grep -m1 "^status:"');
});
test('gate scopes status extraction to the YAML frontmatter only', () => {
assert.match(gate, /sed -n '\/\^---\$\/,\/\^---\$\/p'/, 'gate must restrict extraction to the frontmatter block');
});
test('gate routes gaps_found to /gsd:plan-phase --gaps', () => {
assert.match(gate, /gaps_found/);
assert.match(gate, /\/gsd:plan-phase[^\n]*--gaps/);
});
test('gate routes human_needed to the UAT manual-test step', () => {
assert.match(gate, /human_needed/);
assert.match(gate, /UAT\.md/);
});
test('gate routes a missing VERIFICATION.md to re-running execute-phase', () => {
assert.match(gate, /\/gsd:execute-phase/);
});
test('gate still blocks with PHASE_VERIFICATION_INCOMPLETE', () => {
assert.match(gate, /PHASE_VERIFICATION_INCOMPLETE/);
});
test('the dead `pass` status arm is gone — only `passed` is accepted', () => {
assert.doesNotMatch(gate, /status:\s*pass(?!ed)/i, 'no bare `status: pass` arm may remain');
assert.doesNotMatch(gate, /`pass`\s*\/\s*`passed`/, 'the `pass` / `passed` either-arm must be removed');
assert.match(gate, /passed/);
});
// ---- behavioral tests: run the gate's OWN bash pipeline against fixtures ----
const bashBlock = (() => {
// `\r?\n` (not a literal `\n`) so the fence matches on Windows CRLF checkouts;
// normalize the captured block to LF before handing it to bash.
const m = gate.match(/```bash\r?\n([\s\S]*?)```/);
assert.ok(m, 'gate must contain a bash block');
return m[1].replace(/\r\n/g, '\n');
})();
const hasBash = (() => {
try { execFileSync('bash', ['-c', 'true'], { stdio: 'ignore' }); return true; }
catch { return false; }
})();
// The extraction logic is platform-independent; the bash *pipeline* is executed
// only where the gate's shell actually runs (POSIX). On Windows, git-bash exists
// but receives Windows-style tmpdir paths it cannot glob, so skip execution there.
const skipBashPipeline = (process.platform === 'win32' || !hasBash) && 'bash pipeline runs on POSIX only';
function runGateExtraction(verificationContents) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship586-'));
try {
if (verificationContents !== null) {
fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), verificationContents);
}
const script = `PHASE_DIR='${dir}'\n${bashBlock}\nprintf '%s' "$STATUS"`;
return execFileSync('bash', ['-c', script], { encoding: 'utf8' });
} finally {
helpers.cleanup(dir);
}
}
const FM = (status) =>
`---\nphase: 01-demo\nverified: 2026-01-01T00:00:00Z\nstatus: ${status}\nscore: 3/3 must-haves verified\n---\n\n# Verification\n`;
test('extraction yields passed for a passing frontmatter', { skip: skipBashPipeline }, () => {
assert.strictEqual(runGateExtraction(FM('passed')), 'passed');
});
test('extraction yields gaps_found / human_needed verbatim', { skip: skipBashPipeline }, () => {
assert.strictEqual(runGateExtraction(FM('gaps_found')), 'gaps_found');
assert.strictEqual(runGateExtraction(FM('human_needed')), 'human_needed');
});
test('REGRESSION: a body `status:` line does not corrupt a passing report (Codex PR #650 finding)', { skip: skipBashPipeline }, () => {
const withBodyStatus = FM('passed') +
'\n## Example\n\n```yaml\nstatus: gaps_found\n```\n\nstatus: human_needed\n';
assert.strictEqual(runGateExtraction(withBodyStatus), 'passed');
});
test('extraction yields empty when no VERIFICATION.md exists', { skip: skipBashPipeline }, () => {
assert.strictEqual(runGateExtraction(null), '');
});

View File

@@ -0,0 +1,331 @@
'use strict';
/**
* Tests for verification-status module (issue #651).
*
* Covers:
* 1. status: passed → routing
* 2. status: gaps_found with phase token extraction
* 3. status: human_needed → routing
* 4. No *-VERIFICATION.md → 'missing'
* 5. Frontmatter status present but unknown value → 'unknown'
* 6. BROAD-GREP REGRESSION: body `status:` lines ignored, frontmatter wins
* 7. PARITY: VERIFIER_STATUSES covered by routing table; gsd-verifier.md emitted statuses covered
* 8. CRLF line endings in frontmatter
* 9. Body-only file (no frontmatter block) → missing
* 10. Nonexistent phase directory → missing
* 11. Multiple *-VERIFICATION.md files → first by sort
* 12. ship.md PHASE_VERIFICATION_INCOMPLETE sentinel (contract anchor for #651 consolidation)
*
* PORTABILITY: pure JS — no shell-outs, no bash fences.
* Cross-platform (passes on Windows). Ref: DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const {
VERIFIER_STATUSES,
VERIFICATION_ROUTING_TABLE,
readVerificationStatus,
} = require('../gsd-core/bin/lib/verification.cjs');
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Create a temporary phase directory under os.tmpdir().
* Returns the absolute path; caller must clean up.
*/
function mkPhaseDir(suffix) {
return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-651-${suffix}-`));
}
/**
* Write a *-VERIFICATION.md file with the given frontmatter status and
* optional body content.
*
* @param {string} dir - Phase directory path
* @param {string} filename - e.g. '01-review-VERIFICATION.md'
* @param {string} status - Frontmatter status value
* @param {string} [body] - Content after the closing `---`
*/
function writeVerificationMd(dir, filename, status, body = '') {
const frontmatter = `---\nstatus: ${status}\n---\n`;
fs.writeFileSync(path.join(dir, filename), frontmatter + body);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('verification-status', () => {
// ── Case 1: passed ────────────────────────────────────────────────────────
test('status: passed → next_command is empty, status is passed', () => {
const dir = mkPhaseDir('passed');
try {
writeVerificationMd(dir, '01-foo-VERIFICATION.md', 'passed');
const result = readVerificationStatus(dir);
assert.equal(result.status, 'passed', 'status must be passed');
assert.equal(result.next_command, '', 'next_command must be empty for passed');
assert.ok(result.next_action.length > 0, 'next_action must be non-empty');
} finally {
cleanup(dir);
}
});
// ── Case 2: gaps_found with phase token extraction ────────────────────────
test('status: gaps_found in "03-foo" dir → next_command includes phase token 03', () => {
// Phase dir basename starts with "03" — extractPhaseToken('03-foo') → '03'
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-'));
const phaseDir = path.join(baseDir, '03-foo');
fs.mkdirSync(phaseDir);
try {
writeVerificationMd(phaseDir, '03-foo-VERIFICATION.md', 'gaps_found');
const result = readVerificationStatus(phaseDir);
assert.equal(result.status, 'gaps_found', 'status must be gaps_found');
assert.ok(
result.next_command.includes('03'),
`next_command should include phase token '03'; got: ${result.next_command}`,
);
assert.ok(
result.next_command.includes('--gaps'),
`next_command should include --gaps; got: ${result.next_command}`,
);
assert.equal(result.next_command, '/gsd:plan-phase 03 --gaps');
} finally {
cleanup(baseDir);
}
});
// ── Case 3: human_needed ──────────────────────────────────────────────────
test('status: human_needed → status human_needed, next_command is empty', () => {
const dir = mkPhaseDir('human-needed');
try {
writeVerificationMd(dir, '01-hn-VERIFICATION.md', 'human_needed');
const result = readVerificationStatus(dir);
assert.equal(result.status, 'human_needed');
assert.equal(result.next_command, '');
assert.ok(result.next_action.length > 0);
} finally {
cleanup(dir);
}
});
// ── Case 4: no *-VERIFICATION.md → missing ────────────────────────────────
test('no *-VERIFICATION.md file → status missing, next_command execute-phase', () => {
const dir = mkPhaseDir('missing');
try {
// write a non-matching file to confirm it is ignored
fs.writeFileSync(path.join(dir, 'README.md'), '# phase');
const result = readVerificationStatus(dir);
assert.equal(result.status, 'missing');
assert.equal(result.next_command, '/gsd:execute-phase');
assert.ok(result.next_action.includes('verify step never completed'));
} finally {
cleanup(dir);
}
});
// ── Case 5: unknown frontmatter status value ──────────────────────────────
test("frontmatter status 'bogus' → status unknown, next_command execute-phase", () => {
const dir = mkPhaseDir('unknown');
try {
writeVerificationMd(dir, '01-u-VERIFICATION.md', 'bogus');
const result = readVerificationStatus(dir);
assert.equal(result.status, 'unknown');
assert.equal(result.next_command, '/gsd:execute-phase');
assert.ok(
result.next_action.includes('bogus'),
`next_action should mention the raw value; got: ${result.next_action}`,
);
} finally {
cleanup(dir);
}
});
// ── Case 6: BROAD-GREP REGRESSION (critical) ──────────────────────────────
//
// Frontmatter: `status: passed`
// Body: a fenced code block containing `status: gaps_found` AND `status: human_needed`
// Result MUST be 'passed' — proving body lines are NOT matched.
// This is the exact failure mode that issue #586 / PR #650 hit.
//
test('BROAD-GREP REGRESSION: body status lines ignored, frontmatter status wins', () => {
const dir = mkPhaseDir('broad-grep');
try {
const bodyWithEmbeddedStatuses = [
'',
'## Section',
'',
'Some prose about the results.',
'',
'```yaml',
'status: gaps_found',
'gaps:',
' - fix the thing',
'```',
'',
'Another block:',
'',
'```',
'status: human_needed',
'```',
'',
'End of document.',
].join('\n');
writeVerificationMd(dir, '01-bg-VERIFICATION.md', 'passed', bodyWithEmbeddedStatuses);
const result = readVerificationStatus(dir);
assert.equal(
result.status,
'passed',
`Expected status 'passed' (frontmatter wins); got '${result.status}'. ` +
'Body status: lines must NOT be matched.',
);
assert.equal(result.next_command, '', 'next_command must be empty for passed');
} finally {
cleanup(dir);
}
});
// ── Case 7: PARITY ASSERTION ──────────────────────────────────────────────
//
// (a) Every value in VERIFIER_STATUSES has a corresponding key in VERIFICATION_ROUTING_TABLE.
// (b) Parse agents/gsd-verifier.md for emitted statuses via /→ \*\*status:\s*([a-z_]+)\*\*/g,
// collect the set, and assert every emitted status is a routing key.
//
test('PARITY: VERIFIER_STATUSES covered by routing table', () => {
for (const s of VERIFIER_STATUSES) {
assert.ok(
s in VERIFICATION_ROUTING_TABLE,
`VERIFIER_STATUS '${s}' has no entry in VERIFICATION_ROUTING_TABLE`,
);
}
});
test('PARITY: gsd-verifier.md emitted statuses all have routing table entries', () => {
const verifierPath = path.join(__dirname, '..', 'agents', 'gsd-verifier.md');
const content = fs.readFileSync(verifierPath, 'utf-8');
const emittedStatuses = new Set();
// Source (a): decision-tree arrow lines — `→ **status: <value>**`
// These are the per-branch emission points in Step 9 (the decision tree).
const reArrow = /→ \*\*status:\s*([a-z_]+)\*\*/g;
let m;
while ((m = reArrow.exec(content)) !== null) {
emittedStatuses.add(m[1]);
}
// Source (b): output-template line — `status: A | B | C` (pipe-delimited list
// of permitted values inside the frontmatter template block in the <output> section).
// Anchored to lines that start with `status:` and contain `|` to avoid false
// matches on prose sentences that happen to mention "status:".
const reTemplate = /^status:\s+([a-z_]+(?:\s*\|\s*[a-z_]+)+)\s*$/gm;
while ((m = reTemplate.exec(content)) !== null) {
for (const token of m[1].split('|')) {
const t = token.trim();
if (t) emittedStatuses.add(t);
}
}
assert.ok(
emittedStatuses.size > 0,
'No emitted statuses found in gsd-verifier.md — regex or file path may be wrong. ' +
'Checked: (a) → **status: X** arrow lines, (b) status: A | B | C template lines.',
);
for (const s of emittedStatuses) {
assert.ok(
s in VERIFICATION_ROUTING_TABLE,
`gsd-verifier.md emits status '${s}' but VERIFICATION_ROUTING_TABLE has no entry for it. ` +
'Add a route or remove/rename the status in gsd-verifier.md.',
);
}
});
// ── Edge cases ────────────────────────────────────────────────────────────
// CRLF line endings in frontmatter
test('CRLF line endings in frontmatter → correct status parsed', () => {
const dir = mkPhaseDir('crlf');
try {
// Construct a file with CRLF line endings throughout
const content = '---\r\nstatus: passed\r\nphase: 01-demo\r\n---\r\n\r\n# Body\r\n';
fs.writeFileSync(path.join(dir, '01-crlf-VERIFICATION.md'), content);
const result = readVerificationStatus(dir);
assert.equal(result.status, 'passed', 'CRLF frontmatter must parse to passed');
assert.equal(result.next_command, '');
} finally {
cleanup(dir);
}
});
// File with NO frontmatter block — body-only `status:` line must NOT be matched
test('body-only file with no frontmatter block (status: in body) → missing', () => {
const dir = mkPhaseDir('no-fm');
try {
// No opening `---` — this is a plain markdown file with a status: line in the body
const content = '# Phase Verification\n\nstatus: passed\n\nSome notes.\n';
fs.writeFileSync(path.join(dir, '01-nofm-VERIFICATION.md'), content);
const result = readVerificationStatus(dir);
assert.equal(
result.status,
'missing',
"A body-only status: line must NOT be read — result should be 'missing'",
);
} finally {
cleanup(dir);
}
});
// Missing / nonexistent phase directory → missing
test('nonexistent phase directory → missing', () => {
const nonexistent = path.join(os.tmpdir(), 'gsd-651-nonexistent-' + Date.now());
const result = readVerificationStatus(nonexistent);
assert.equal(result.status, 'missing', 'unreadable/nonexistent dir must return missing');
assert.equal(result.next_command, '/gsd:execute-phase');
});
// Multiple *-VERIFICATION.md files → deterministic pick (first by sort)
test('multiple *-VERIFICATION.md files in dir → first by sort order wins', () => {
const dir = mkPhaseDir('multi');
try {
// Write two files: alphabetically "01-a" comes before "02-b"
// "01-a" has passed; "02-b" has gaps_found — first by sort must win
const fm = (status) => `---\nstatus: ${status}\n---\n`;
fs.writeFileSync(path.join(dir, '01-a-VERIFICATION.md'), fm('passed'));
fs.writeFileSync(path.join(dir, '02-b-VERIFICATION.md'), fm('gaps_found'));
const result = readVerificationStatus(dir);
assert.equal(
result.status,
'passed',
'When multiple *-VERIFICATION.md files exist, the first by lexicographic sort must be used',
);
} finally {
cleanup(dir);
}
});
// ── Task 2 (B1): ship.md gate sentinel contract anchor ────────────────────
//
// The deleted tests/ship-586-verification-routing.test.cjs was the only
// thing asserting that ship.md emits the PHASE_VERIFICATION_INCOMPLETE block
// sentinel (its user-visible gate error key). This test re-anchors that contract.
//
test('ship.md still emits the PHASE_VERIFICATION_INCOMPLETE gate sentinel (contract anchor for #651 consolidation)', () => {
const shipMdPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md');
const content = fs.readFileSync(shipMdPath, 'utf-8');
assert.ok(
content.includes('PHASE_VERIFICATION_INCOMPLETE'),
'ship.md must contain the literal PHASE_VERIFICATION_INCOMPLETE gate sentinel. ' +
'If you renamed or removed it, update the verification routing and this contract test.',
);
});
});

View File

@@ -0,0 +1,57 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
// Issue #25: gsd-verifier Step 7b must not present the runner-specific
// full-suite "| grep" anti-pattern, and must steer toward
// enumerate-for-existence / single-named-test-for-pass.
const verifierPath = path.join(__dirname, '..', 'agents', 'gsd-verifier.md');
const content = fs.readFileSync(verifierPath, 'utf8');
// Scope assertions to the Step 7b section so the guard tracks the right place.
const start = content.indexOf('## Step 7b');
const end = content.indexOf('## Step 7c', start);
assert.ok(start !== -1 && end !== -1 && end > start, 'Step 7b section not found');
const step7b = content.slice(start, end);
test('Step 7b drops the misleading full-suite grep example', () => {
assert.ok(
!step7b.includes('npm test -- --grep "$PHASE_TEST_PATTERN" 2>&1 | grep -q "passing"'),
'the runner-specific `npm test --grep` example should be removed (it mis-generalizes to `<full-suite> | grep`)'
);
});
test('Step 7b steers existence proofs to test enumeration', () => {
assert.ok(
/cargo test -- --list/.test(step7b),
'Step 7b should show the correct `cargo test -- --list` enumeration form'
);
assert.ok(
/pytest --collect-only/.test(step7b) &&
/vitest list/.test(step7b) &&
/go test -list/.test(step7b),
'Step 7b should list cross-ecosystem enumeration commands (pytest/vitest/go)'
);
});
test('Step 7b steers pass-checks to a single named test', () => {
assert.ok(
/-- --exact/.test(step7b) &&
/pytest -k/.test(step7b) &&
/vitest run -t/.test(step7b),
'Step 7b should show single-named-test commands across ecosystems'
);
});
test('Step 7b forbids re-running the full suite per must-have', () => {
assert.ok(
/at most once per verification/i.test(step7b),
'Step 7b should forbid invoking the full workspace test command more than once per verification'
);
assert.ok(
/grep/i.test(step7b) && /per must-have|per truth/i.test(step7b),
'Step 7b should explicitly call out the per-must-have full-suite grep anti-pattern'
);
});

View File

@@ -64,8 +64,10 @@
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('node:os');
const path = require('path');
const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs');
const { cleanup } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
@@ -121,10 +123,16 @@ function budgetFor(workflow) {
}
function byteCount(filePath) {
// Match `wc -c`: count every byte on disk, including any trailing newline.
// Deliberately NOT the trailing-newline-stripping logic the old lineCount()
// used — the byte ceilings are calibrated against raw `wc -c` output.
return fs.statSync(filePath).size;
// Count bytes as on an LF checkout, so the budget is platform-independent.
// The tier ceilings are calibrated against `wc -c` on a Unix (LF) checkout,
// but these .md files have no `eol=lf` in .gitattributes, so Windows checks
// them out as CRLF. Counting raw on-disk bytes there adds one byte per line,
// which fails CI on the high-water-mark file (execute-phase.md) on Windows
// ONLY — a false positive that diverges from the LF calibration basis (#683).
// Stripping CR yields the same LF byte count on every platform. Still a raw
// byte count (not the old trailing-newline-stripping lineCount()).
const content = fs.readFileSync(filePath, 'utf-8');
return Buffer.byteLength(content.replace(/\r\n/g, '\n'), 'utf-8');
}
describe('SIZE: workflow byte-size budget', () => {
@@ -549,3 +557,28 @@ describe('workflow progressive disclosure — MVP bodies lazy-loaded (#720)', ()
);
});
});
describe('SIZE: byteCount is line-ending independent (#683 regression)', () => {
// The budget ceilings are calibrated against an LF (Unix) checkout; Windows
// checks these .md files out as CRLF, which previously inflated the count by
// one byte per line and failed CI only on Windows for the high-water file.
test('CRLF and LF content of the same logical file count identically', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-size-eol-'));
try {
const body = 'line one\nline two\nthree — with a multibyte dash\n';
const lfPath = path.join(dir, 'lf.md');
const crlfPath = path.join(dir, 'crlf.md');
fs.writeFileSync(lfPath, body);
fs.writeFileSync(crlfPath, body.replace(/\n/g, '\r\n'));
assert.strictEqual(
byteCount(crlfPath),
byteCount(lfPath),
'byteCount must normalize CRLF so the byte budget is platform-independent'
);
// And it must remain a real LF byte count (not stripped/whitespace-trimmed).
assert.strictEqual(byteCount(lfPath), Buffer.byteLength(body, 'utf-8'));
} finally {
cleanup(dir);
}
});
});

View File

@@ -0,0 +1,773 @@
'use strict';
/**
* Worktree Base-Ref Module — unit tests
*
* Seam: gsd-core/bin/lib/worktree-base-ref.cjs
* Interface: shortSha, readBaseRefFromSettings, applyWorktreeBaseRef,
* resolveEffectiveBaseRef, evaluateWorktreeBaseDegrade
*
* Issue #683: worktree base-mismatch detection and degradation logic.
* All tests use dependency injection (inline stubs) — no real filesystem
* or real git is exercised.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const MODULE_PATH = path.join(
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-base-ref.cjs'
);
const {
shortSha,
readBaseRefFromSettings,
applyWorktreeBaseRef,
resolveEffectiveBaseRef,
evaluateWorktreeBaseDegrade,
cmdWorktreeBaseCheck,
cmdWorktreeSetBaseRef,
} = require(MODULE_PATH);
// ─── shortSha ────────────────────────────────────────────────────────────────
describe('shortSha', () => {
test('returns first 8 chars of a full sha', () => {
assert.strictEqual(shortSha('abc123def456789'), 'abc123de');
});
test('returns the string itself when shorter than 8 chars', () => {
assert.strictEqual(shortSha('abc12'), 'abc12');
});
test('returns empty string for null', () => {
assert.strictEqual(shortSha(null), '');
});
test('returns empty string for empty string', () => {
assert.strictEqual(shortSha(''), '');
});
test('returns exactly 8 chars when sha is exactly 8 chars', () => {
assert.strictEqual(shortSha('12345678'), '12345678');
});
});
// ─── readBaseRefFromSettings ─────────────────────────────────────────────────
describe('readBaseRefFromSettings', () => {
test('returns baseRef when present as a string', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 'head' } }), 'head');
});
test('returns baseRef value "fresh"', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 'fresh' } }), 'fresh');
});
test('returns null when worktree is missing', () => {
assert.strictEqual(readBaseRefFromSettings({}), null);
});
test('returns null when settings is null', () => {
assert.strictEqual(readBaseRefFromSettings(null), null);
});
test('returns null when settings is undefined', () => {
assert.strictEqual(readBaseRefFromSettings(undefined), null);
});
test('returns null when worktree is not an object (string)', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: 'not-an-object' }), null);
});
test('returns null when baseRef is a number (non-string)', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 42 } }), null);
});
test('returns null when baseRef is null', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: null } }), null);
});
test('returns null when baseRef is undefined', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: undefined } }), null);
});
});
// ─── applyWorktreeBaseRef ─────────────────────────────────────────────────────
describe('applyWorktreeBaseRef', () => {
test('sets baseRef to "head" when absent, returns changed:true', () => {
const settings = {};
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, true);
assert.strictEqual(result.skipped, null);
assert.strictEqual(result.previous, null);
assert.strictEqual(result.settings.worktree.baseRef, 'head');
});
test('sets baseRef to "head" when worktree key is missing entirely', () => {
const settings = { other: 'value' };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, true);
assert.strictEqual(settings.worktree.baseRef, 'head');
});
test('sets baseRef to "head" when worktree.baseRef is null', () => {
const settings = { worktree: { baseRef: null, otherKey: 'keep' } };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, true);
assert.strictEqual(settings.worktree.baseRef, 'head');
});
test('sets baseRef to "head" when worktree.baseRef is undefined', () => {
const settings = { worktree: { baseRef: undefined } };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, true);
assert.strictEqual(settings.worktree.baseRef, 'head');
});
test('preserves other worktree.* keys when setting baseRef', () => {
const settings = { worktree: { otherKey: 'preserved', anotherKey: 123 } };
applyWorktreeBaseRef(settings);
assert.strictEqual(settings.worktree.otherKey, 'preserved');
assert.strictEqual(settings.worktree.anotherKey, 123);
assert.strictEqual(settings.worktree.baseRef, 'head');
});
test('mutates settings in place and returns the same object reference', () => {
const settings = {};
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.settings, settings);
});
test('returns already-head skip when baseRef is already "head"', () => {
const settings = { worktree: { baseRef: 'head' } };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, false);
assert.strictEqual(result.skipped, 'already-head');
assert.strictEqual(result.previous, 'head');
assert.strictEqual(settings.worktree.baseRef, 'head');
});
test('returns explicit-other skip when baseRef is "fresh", does NOT overwrite', () => {
const settings = { worktree: { baseRef: 'fresh' } };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, false);
assert.strictEqual(result.skipped, 'explicit-other');
assert.strictEqual(result.previous, 'fresh');
assert.strictEqual(settings.worktree.baseRef, 'fresh');
});
test('returns explicit-other skip for any other string value', () => {
const settings = { worktree: { baseRef: 'some-branch' } };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, false);
assert.strictEqual(result.skipped, 'explicit-other');
assert.strictEqual(result.previous, 'some-branch');
});
});
// ─── resolveEffectiveBaseRef ──────────────────────────────────────────────────
describe('resolveEffectiveBaseRef', () => {
// Helper to build a path-keyed readFile stub
function makeReadFile(files) {
return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null);
}
test('returns baseRef from settings.local.json when present', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }),
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }),
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head');
});
test('falls back to settings.json when settings.local.json has no baseRef', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ other: 'value' }),
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }),
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh');
});
test('returns null when both files are missing', () => {
const claudeDir = '/repo/.claude';
const deps = { readFile: () => null };
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null);
});
test('returns null when both files exist but have no baseRef', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ other: 'value' }),
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ other: 'value2' }),
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null);
});
test('ignores malformed JSON in settings.local.json and falls back', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: 'not valid json {{{',
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }),
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head');
});
test('ignores malformed JSON in settings.json', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: null,
[path.join(claudeDir, 'settings.json')]: 'not valid json',
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null);
});
test('settings.local.json null baseRef falls back to settings.json', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ worktree: { baseRef: null } }),
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }),
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh');
});
});
// ─── evaluateWorktreeBaseDegrade ──────────────────────────────────────────────
describe('evaluateWorktreeBaseDegrade', () => {
// Stub helper: matches on args.join(' ') and returns canned results
function makeExecGit(responses) {
return function stubExecGit(args, _opts) {
const key = args.join(' ');
if (Object.prototype.hasOwnProperty.call(responses, key)) {
return responses[key];
}
// Default: fail with a helpful error to surface unexpected calls
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
test('effectiveBaseRef="head" → no degrade, reason baseref-head, execGit never called', () => {
let called = false;
const result = evaluateWorktreeBaseDegrade({
execGit: () => { called = true; return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; },
effectiveBaseRef: 'head',
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'baseref-head');
assert.strictEqual(result.message, null);
assert.strictEqual(result.headSha, null);
assert.strictEqual(result.forkRef, null);
assert.strictEqual(result.forkSha, null);
assert.strictEqual(called, false, 'execGit must not be called when effectiveBaseRef is head');
});
test('git rev-parse HEAD fails → no degrade, reason no-head', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repo', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'no-head');
assert.strictEqual(result.headSha, null);
});
test('git rev-parse HEAD returns empty stdout → no degrade, reason no-head', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: '', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'no-head');
});
test('HEAD == origin/HEAD → no degrade, reason head-matches-fork', () => {
const HEAD_SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'head-matches-fork');
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkRef, 'origin/HEAD');
assert.strictEqual(result.forkSha, HEAD_SHA);
assert.strictEqual(result.message, null);
});
test('HEAD != origin/HEAD → degrade, reason head-diverged-from-fork, MSG_DIVERGED', () => {
const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef';
const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-diverged-from-fork');
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkRef, 'origin/HEAD');
assert.strictEqual(result.forkSha, FORK_SHA);
// Verify message contains the short SHAs and the issue reference
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from origin/HEAD (${FORK_SHA.slice(0, 8)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
assert.strictEqual(result.message, expectedMsg);
});
test('origin/HEAD fails but symbolic-ref resolves to refs/remotes/origin/next', () => {
const HEAD_SHA = 'aaaa1111bbbb2222aaaa1111bbbb2222aaaa1111';
const FORK_SHA = 'cccc3333dddd4444cccc3333dddd4444cccc3333';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/next', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet refs/remotes/origin/next': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.forkRef, 'origin/next');
assert.strictEqual(result.forkSha, FORK_SHA);
// HEAD != FORK_SHA in this fixture → degrade
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-diverged-from-fork');
assert.ok(result.message !== null);
assert.ok(result.message.includes('origin/next'));
});
test('origin/HEAD fails AND symbolic-ref fails → degrade, reason fork-ref-unknown, MSG_UNKNOWN', () => {
const HEAD_SHA = 'eeee5555ffff6666eeee5555ffff6666eeee5555';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'fork-ref-unknown');
assert.strictEqual(result.forkRef, null);
assert.strictEqual(result.forkSha, null);
const expectedMsg = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
assert.strictEqual(result.message, expectedMsg);
});
test('cwd is passed through to execGit calls', () => {
const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678';
const capturedOpts = [];
const result = evaluateWorktreeBaseDegrade({
cwd: '/some/worktree',
execGit: (args, opts) => {
capturedOpts.push(opts);
const key = args.join(' ');
if (key === 'rev-parse HEAD') return { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null };
if (key === 'rev-parse --verify --quiet origin/HEAD') return { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null };
throw new Error(`Unexpected: ${key}`);
},
});
assert.strictEqual(result.shouldDegrade, false);
assert.ok(capturedOpts.length > 0);
for (const opts of capturedOpts) {
assert.strictEqual(opts && opts.cwd, '/some/worktree');
}
});
test('symbolic-ref resolves but subsequent rev-parse fails → falls through to fork-ref-unknown', () => {
const HEAD_SHA = 'abcd1234abcd1234abcd1234abcd1234abcd1234';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/main', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet refs/remotes/origin/main': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'fork-ref-unknown');
assert.strictEqual(result.forkRef, null);
assert.strictEqual(result.forkSha, null);
});
});
// ─── cmdWorktreeBaseCheck ─────────────────────────────────────────────────────
describe('cmdWorktreeBaseCheck', () => {
function makeExecGitCheck(responses) {
return function stubExecGit(args, _opts) {
const key = args.join(' ');
if (Object.prototype.hasOwnProperty.call(responses, key)) {
return responses[key];
}
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
test('baseRef=head in settings → shouldDegrade false, reason baseref-head; write emits valid JSON', () => {
const cwd = '/repo';
const claudeDir = '/repo/.claude';
let written = '';
const deps = {
readFile: (p) => {
if (p === path.join(claudeDir, 'settings.local.json')) return JSON.stringify({ worktree: { baseRef: 'head' } });
return null;
},
execGit: makeExecGitCheck({}),
write: (s) => { written += s; },
};
const result = cmdWorktreeBaseCheck(cwd, [], deps);
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'baseref-head');
const parsed = JSON.parse(written);
assert.deepStrictEqual(parsed, result);
});
test('diverged shas → shouldDegrade true; captured JSON parses correctly', () => {
const cwd = '/repo';
const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef';
const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe';
let written = '';
const deps = {
readFile: () => null,
execGit: makeExecGitCheck({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
}),
write: (s) => { written += s; },
};
const result = cmdWorktreeBaseCheck(cwd, [], deps);
assert.strictEqual(result.shouldDegrade, true);
const parsed = JSON.parse(written);
assert.strictEqual(parsed.shouldDegrade, true);
assert.strictEqual(parsed.reason, 'head-diverged-from-fork');
});
});
// ─── cmdWorktreeSetBaseRef ────────────────────────────────────────────────────
describe('cmdWorktreeSetBaseRef', () => {
test('readFile returns {} → changed true, writeFile called with worktree.baseRef "head"', () => {
const cwd = '/repo';
const file = path.join(cwd, '.claude', 'settings.local.json');
let writtenPath = null;
let writtenContent = null;
let written = '';
const deps = {
readFile: () => '{}',
existsSync: () => true,
mkdir: () => {},
writeFile: (p, content) => { writtenPath = p; writtenContent = content; },
write: (s) => { written += s; },
};
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
assert.strictEqual(result.changed, true);
assert.strictEqual(result.file, file);
assert.strictEqual(result.baseRef, 'head');
assert.strictEqual(writtenPath, file);
const parsedWritten = JSON.parse(writtenContent);
assert.strictEqual(parsedWritten.worktree.baseRef, 'head');
const parsedOutput = JSON.parse(written);
assert.strictEqual(parsedOutput.changed, true);
});
test('readFile returns explicit-other → changed false, skipped explicit-other, writeFile NOT called', () => {
const cwd = '/repo';
let writeFileCalled = false;
let written = '';
const deps = {
readFile: () => JSON.stringify({ worktree: { baseRef: 'fresh' } }),
existsSync: () => true,
mkdir: () => {},
writeFile: () => { writeFileCalled = true; },
write: (s) => { written += s; },
};
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
assert.strictEqual(result.changed, false);
assert.strictEqual(result.skipped, 'explicit-other');
assert.strictEqual(result.previous, 'fresh');
assert.strictEqual(writeFileCalled, false, 'writeFile must NOT be called for explicit-other');
const parsedOutput = JSON.parse(written);
assert.strictEqual(parsedOutput.changed, false);
assert.strictEqual(parsedOutput.skipped, 'explicit-other');
});
test('readFile returns malformed JSON → throws refusing-to-modify error', () => {
const cwd = '/repo';
const file = path.join(cwd, '.claude', 'settings.local.json');
const deps = {
readFile: () => '{',
existsSync: () => true,
mkdir: () => {},
writeFile: () => {},
write: () => {},
};
assert.throws(
() => cmdWorktreeSetBaseRef(cwd, [], deps),
(err) => {
assert.ok(err instanceof Error, 'must throw an Error');
assert.ok(err.message.includes('Refusing to modify'), `message should contain "Refusing to modify", got: ${err.message}`);
assert.ok(err.message.includes(file), `message should contain file path, got: ${err.message}`);
return true;
}
);
});
test('readFile returns null (missing file) → treated as {} → changed true', () => {
const cwd = '/repo';
let writeFileCalled = false;
const deps = {
readFile: () => null,
existsSync: () => false,
mkdir: () => {},
writeFile: () => { writeFileCalled = true; },
write: () => {},
};
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
assert.strictEqual(result.changed, true);
assert.strictEqual(writeFileCalled, true);
});
// FIX 2: non-object top-level JSON must be rejected with a clear error
test('readFile returns "[]" (array) → throws /expected a JSON object/', () => {
const cwd = '/repo';
const deps = {
readFile: () => '[]',
existsSync: () => true,
mkdir: () => {},
writeFile: () => {},
write: () => {},
};
assert.throws(
() => cmdWorktreeSetBaseRef(cwd, [], deps),
/expected a JSON object/
);
});
test('readFile returns "42" (primitive) → throws /expected a JSON object/', () => {
const cwd = '/repo';
const deps = {
readFile: () => '42',
existsSync: () => true,
mkdir: () => {},
writeFile: () => {},
write: () => {},
};
assert.throws(
() => cmdWorktreeSetBaseRef(cwd, [], deps),
/expected a JSON object/
);
});
});
// FIX 2: applyWorktreeBaseRef must reject non-object/array/null inputs
describe('applyWorktreeBaseRef — non-object inputs (FIX 2)', () => {
test('applyWorktreeBaseRef(null) → throws TypeError', () => {
assert.throws(
() => applyWorktreeBaseRef(null),
TypeError
);
});
test('applyWorktreeBaseRef([]) → throws TypeError', () => {
assert.throws(
() => applyWorktreeBaseRef([]),
TypeError
);
});
});
// ─── FIX 2: JSONC support ─────────────────────────────────────────────────────
describe('resolveEffectiveBaseRef — JSONC (FIX 2)', () => {
function makeReadFile(files) {
return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null);
}
test('returns baseRef from settings.local.json with // line comments', () => {
const claudeDir = '/repo/.claude';
const jsonc = [
'// this is a comment',
'{',
' // another comment',
' "worktree": {',
' "baseRef": "head" // inline comment',
' }',
'}',
].join('\n');
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: jsonc,
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head');
});
test('returns baseRef from settings.local.json with /* */ block comments', () => {
const claudeDir = '/repo/.claude';
const jsonc = [
'/* block comment */',
'{',
' "worktree": { /* inline block */ "baseRef": "fresh" }',
'}',
'/* trailing block */',
].join('\n');
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: jsonc,
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh');
});
});
describe('cmdWorktreeSetBaseRef — JSONC (FIX 2)', () => {
test('commented-but-valid settings.local.json → updates it (changed true) rather than throwing', () => {
const cwd = '/repo';
const jsonc = [
'// user comment',
'{',
' // another comment',
' "other": "value"',
'}',
].join('\n');
let writtenContent = null;
const deps = {
readFile: () => jsonc,
existsSync: () => true,
mkdir: () => {},
writeFile: (_p, content) => { writtenContent = content; },
write: () => {},
};
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
assert.strictEqual(result.changed, true, 'must set baseRef when absent (even in JSONC file)');
assert.ok(writtenContent !== null, 'must write the updated file');
const parsed = JSON.parse(writtenContent);
assert.strictEqual(parsed.worktree.baseRef, 'head');
});
test('JSONC with explicit baseRef="fresh" → skipped explicit-other, does not throw', () => {
const cwd = '/repo';
const jsonc = [
'// user comment',
'{',
' "worktree": {',
' // keeps the fork base fixed',
' "baseRef": "fresh"',
' }',
'}',
].join('\n');
let writeFileCalled = false;
const deps = {
readFile: () => jsonc,
existsSync: () => true,
mkdir: () => {},
writeFile: () => { writeFileCalled = true; },
write: () => {},
};
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
assert.strictEqual(result.changed, false);
assert.strictEqual(result.skipped, 'explicit-other');
assert.strictEqual(writeFileCalled, false);
});
test('genuinely malformed JSON (after stripping comments) still throws refusing-to-modify', () => {
const cwd = '/repo';
const file = path.join(cwd, '.claude', 'settings.local.json');
// This is malformed even after comment stripping
const malformed = '// comment\n{ "key": }';
const deps = {
readFile: () => malformed,
existsSync: () => true,
mkdir: () => {},
writeFile: () => {},
write: () => {},
};
assert.throws(
() => cmdWorktreeSetBaseRef(cwd, [], deps),
(err) => {
assert.ok(err instanceof Error);
assert.ok(err.message.includes('Refusing to modify'), `got: ${err.message}`);
assert.ok(err.message.includes(file), `got: ${err.message}`);
return true;
}
);
});
});
// ─── FIX 3: defensive trim on git SHAs ────────────────────────────────────────
describe('evaluateWorktreeBaseDegrade — defensive trim on SHAs (FIX 3)', () => {
function makeExecGit(responses) {
return function stubExecGit(args, _opts) {
const key = args.join(' ');
if (Object.prototype.hasOwnProperty.call(responses, key)) {
return responses[key];
}
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
test('HEAD with trailing newline still matches origin/HEAD — no degrade', () => {
const SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: SHA + '\n', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: SHA + '\n', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'head-matches-fork');
});
test('HEAD with trailing whitespace still diverges correctly from different origin/HEAD', () => {
const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef';
const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA + '\n', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA + '\r\n', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-diverged-from-fork');
// After trimming, headSha and forkSha should be clean
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkSha, FORK_SHA);
});
test('symbolic-ref stdout with trailing newline resolves correctly', () => {
const HEAD_SHA = 'aaaa1111bbbb2222aaaa1111bbbb2222aaaa1111';
const FORK_SHA = 'cccc3333dddd4444cccc3333dddd4444cccc3333';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA + '\n', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/next\n', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet refs/remotes/origin/next': { exitCode: 0, stdout: FORK_SHA + '\n', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.forkRef, 'origin/next');
assert.strictEqual(result.forkSha, FORK_SHA);
assert.strictEqual(result.shouldDegrade, true);
});
});

View File

@@ -0,0 +1,609 @@
/**
* Tests for #683: installer sets worktree.baseRef:"head" in settings.local.json
* for local Claude Code installs.
*
* Cases:
* 1. Fresh local install: writes worktree.baseRef:"head" automatically (no-clobber).
* 2. Fresh install with pre-existing explicit baseRef: does NOT clobber it.
* 3a. Upgrade + isLocalClaude + use_worktrees absent/true → auto-applies baseRef.
* 3b. Upgrade + use_worktrees === false → does NOT apply baseRef.
* 3c. Upgrade + explicit baseRef already present (local or shared) → unchanged (no-clobber).
* 4. Idempotency: re-running a fresh-style install when baseRef is already "head"
* does not duplicate or error.
* 5. Global Claude install: does NOT set worktree.baseRef (only local Claude).
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const { install, finishInstall } = require(INSTALL_SRC);
const { cleanup } = require('./helpers.cjs');
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── Helper: run both install phases (mirrors installAllRuntimes two-phase) ──
function runInstall(isGlobal, opts = {}) {
const { shouldInstallStatusline = false } = opts;
const result = install(isGlobal, 'claude');
finishInstall(
result.settingsPath,
result.settings,
result.statuslineCommand,
shouldInstallStatusline,
'claude',
isGlobal
);
return { result };
}
// ─── Helper: write .planning/config.json in the project root ─────────────────
// For a local Claude install, targetDir = <cwd>/.claude, so project root = cwd.
// .planning/config.json lives at <cwd>/.planning/config.json.
function writePlanningConfig(projectRoot, config) {
const planningDir = path.join(projectRoot, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config, null, 2) + '\n');
}
// ─── Case 1: fresh local install writes worktree.baseRef:"head" ──────────────
describe('#683 case 1: fresh local Claude install sets worktree.baseRef:"head"', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-fresh-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('settings.local.json contains worktree.baseRef:"head" after fresh install', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after local Claude install'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.ok(
settings && typeof settings === 'object',
'settings.local.json must be a valid JSON object'
);
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'worktree.baseRef must be "head" after a fresh local Claude install (#683)'
);
});
});
// ─── Case 2: fresh install does not clobber a pre-existing explicit baseRef ──
describe('#683 case 2: fresh install does not clobber existing explicit worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-noclobber-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('pre-existing explicit baseRef is preserved on fresh install', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Pre-populate settings.local.json with an explicit non-"head" baseRef
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
fs.writeFileSync(localSettingsPath, JSON.stringify({ worktree: { baseRef: 'main' } }, null, 2) + '\n');
runInstall(false);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'main',
'An explicit worktree.baseRef must not be overwritten by the installer (#683 no-clobber)'
);
});
});
// ─── Case 3a: upgrade + use_worktrees absent → auto-applies baseRef ──────────
describe('#683 case 3a: upgrade + use_worktrees absent → auto-applies worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-on-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('upgrade auto-applies worktree.baseRef:"head" when use_worktrees is absent', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Simulate a prior install by pre-creating the VERSION file.
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
// No .planning/config.json — use_worktrees defaults to enabled (true).
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after upgrade'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'upgrade must auto-apply worktree.baseRef:"head" when use_worktrees is absent (#683)'
);
});
test('upgrade auto-applies worktree.baseRef:"head" when use_worktrees is true', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Simulate a prior install by pre-creating the VERSION file.
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
// .planning/config.json with use_worktrees: true
writePlanningConfig(tmpDir, { workflow: { use_worktrees: true } });
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(fs.existsSync(localSettingsPath), '.claude/settings.local.json must exist after upgrade');
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'upgrade must auto-apply worktree.baseRef:"head" when use_worktrees:true (#683)'
);
});
});
// ─── Case 3b: upgrade + use_worktrees === false → does NOT apply baseRef ─────
describe('#683 case 3b: upgrade + use_worktrees:false → does NOT apply worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-off-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('upgrade does not apply worktree.baseRef when use_worktrees is false', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Simulate a prior install by pre-creating the VERSION file.
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
// .planning/config.json with use_worktrees: false
writePlanningConfig(tmpDir, { workflow: { use_worktrees: false } });
runInstall(false);
// finishInstall always writes settings.local.json for local Claude installs.
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after upgrade (finishInstall writes it)'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree,
undefined,
'upgrade must NOT apply worktree block when use_worktrees:false (#683)'
);
});
});
// ─── Case 3c: upgrade + explicit baseRef already present → no-clobber ─────────
describe('#683 case 3c: upgrade + explicit baseRef present → no-clobber (unchanged)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-noclobber-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('upgrade preserves an explicit worktree.baseRef set by the user in local settings', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Simulate prior install with user-set explicit baseRef
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const versionPath = path.join(claudeDir, 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
fs.writeFileSync(localSettingsPath, JSON.stringify({ worktree: { baseRef: 'fresh' } }, null, 2) + '\n');
runInstall(false);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'fresh',
'upgrade must preserve an explicit user-set worktree.baseRef in local settings (#683 no-clobber)'
);
});
test('upgrade does not inject baseRef when shared settings.json already has one', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Simulate prior install
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const versionPath = path.join(claudeDir, 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
// Shared settings.json has an explicit baseRef; settings.local.json does not.
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
fs.writeFileSync(sharedSettingsPath, JSON.stringify({ worktree: { baseRef: 'main' } }, null, 2) + '\n');
runInstall(false);
// finishInstall always writes settings.local.json for local Claude installs.
// Shared no-clobber: sharedBaseRef !== null → installer must not inject.
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after upgrade (finishInstall writes it)'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
undefined,
'upgrade must NOT inject worktree.baseRef to settings.local.json when shared settings.json already has one (#683 no-clobber)'
);
});
});
// ─── Case 4: idempotency — re-running fresh-style install when already "head" ─
describe('#683 case 4: idempotency — re-installing when worktree.baseRef already "head"', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-idem-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('second install does not error or duplicate worktree block', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Run once (sets baseRef:"head")
runInstall(false);
// Remove the VERSION file to simulate a fresh-style re-install (e.g. forced reinstall)
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
if (fs.existsSync(versionPath)) {
fs.unlinkSync(versionPath);
}
// Run again — should be idempotent
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'worktree.baseRef must still be "head" after idempotent re-install (#683)'
);
// Ensure worktree block wasn't duplicated into an array or otherwise corrupted
assert.strictEqual(
typeof settings.worktree,
'object',
'worktree must be a plain object after idempotent re-install'
);
assert.ok(
!Array.isArray(settings.worktree),
'worktree must not have been duplicated into an array'
);
});
});
// ─── Case 2b (FIX 1): fresh install does not clobber baseRef set in shared settings.json ──
describe('#683 case 2b (FIX 1): fresh install does not clobber worktree.baseRef in shared settings.json', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-shared-noclobber-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('shared settings.json with worktree.baseRef:"fresh" → installer must NOT write baseRef to settings.local.json and must NOT print ✓ notice', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Pre-populate only the SHARED settings.json with an explicit baseRef.
// settings.local.json does NOT exist — this is a fresh install otherwise.
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
fs.writeFileSync(sharedSettingsPath, JSON.stringify({ worktree: { baseRef: 'fresh' } }, null, 2) + '\n');
runInstall(false);
// settings.local.json must either not exist or have no worktree.baseRef.
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
if (fs.existsSync(localSettingsPath)) {
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
localSettings.worktree && localSettings.worktree.baseRef,
undefined,
'installer must NOT write worktree.baseRef to settings.local.json when shared settings.json already has an explicit baseRef (#683 FIX 1)'
);
}
// If settings.local.json wasn't written, the test passes (no injection occurred).
});
});
// ─── Case 6 (FIX 1): non-object settings.local.json does not crash installer ──
describe('#683 FIX 1: non-object settings.local.json does not crash the installer', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-nonobj-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('settings.local.json containing [] does not throw during fresh local install', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Pre-populate settings.local.json with an array — valid JSON but non-object
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
fs.writeFileSync(localSettingsPath, '[]');
// Must not throw — baseRef logic must be silently skipped for non-objects
assert.doesNotThrow(() => runInstall(false));
});
test('settings.local.json containing "null" JSON value does not crash via #683 block (FIX 1 guard)', () => {
// The #683 block guard check: `settings !== null && typeof settings === 'object' && !Array.isArray(settings)`
// For the array case the crash was directly applyWorktreeBaseRef([]). Test the guard in isolation
// by verifying applyWorktreeBaseRef is not called with a non-object.
// (A literal null parses and readSettings returns null → hits the null early-return, so no crash.)
// This test verifies the guard path — checking that readSettings returning null before #683 is handled.
// The array case below covers the actual fix.
assert.ok(true, 'placeholder: null is handled by the null early-return above the #683 block');
});
});
// ─── Case 7: fresh + use_worktrees:false → does NOT write worktree.baseRef ───
describe('#683 case 7: fresh local Claude install + use_worktrees:false → does NOT set worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-fresh-off-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('fresh install with use_worktrees:false does not write worktree.baseRef', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// No VERSION file → fresh install.
// .planning/config.json with use_worktrees: false → must suppress baseRef.
writePlanningConfig(tmpDir, { workflow: { use_worktrees: false } });
runInstall(false);
// finishInstall always writes settings.local.json for local Claude installs.
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after fresh local Claude install'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree,
undefined,
'fresh install must NOT write worktree.baseRef when use_worktrees:false (#683 FIX A)'
);
});
test('fresh install with absent .planning/config.json still applies worktree.baseRef (default enabled)', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// No VERSION file → fresh install.
// No .planning/config.json → worktreesEnabled defaults to true.
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after fresh local Claude install'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'fresh install must apply worktree.baseRef:"head" when .planning/config.json is absent (default enabled; #683 FIX A)'
);
});
});
// ─── Case 8: upgrade idempotency — two upgrade runs produce exactly one baseRef ─
describe('#683 case 8: upgrade→upgrade idempotency — two upgrade runs do not duplicate worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-idem2-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('two consecutive upgrade runs leave settings.local.json with exactly one worktree.baseRef:"head"', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// First upgrade run: VERSION present → upgrade path.
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
// use_worktrees defaults to enabled (no .planning/config.json)
runInstall(false);
// Restore VERSION so the second run is also an upgrade.
if (!fs.existsSync(versionPath)) {
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
}
fs.writeFileSync(versionPath, '1.0.0');
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after two upgrade runs'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'worktree.baseRef must be "head" after two upgrade runs (#683 idempotency)'
);
assert.strictEqual(
typeof settings.worktree,
'object',
'worktree must be a plain object after two upgrade runs'
);
assert.ok(
!Array.isArray(settings.worktree),
'worktree must not have been duplicated into an array after two upgrade runs'
);
});
});
// ─── Case 5: global Claude install does NOT set worktree.baseRef ─────────────
describe('#683 case 5: global Claude install does NOT set worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-global-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('global install does not write worktree.baseRef', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
// Point CLAUDE_CONFIG_DIR at a tmpDir subdir to avoid polluting ~/.claude
const configDir = path.join(tmpDir, '.claude');
fs.mkdirSync(configDir, { recursive: true });
const origEnv = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
t.after(() => {
if (origEnv === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = origEnv;
}
});
runInstall(true);
const settingsPath = path.join(configDir, 'settings.json');
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree,
undefined,
'global Claude install must not write worktree.baseRef into settings.json (#683)'
);
}
});
});