Merge remote-tracking branch 'upstream/next' into kimi-runtime-support
This commit is contained in:
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
275
tests/bug-730-milestone-phase-details-scope.test.cjs
Normal file
275
tests/bug-730-milestone-phase-details-scope.test.cjs
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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), '');
|
||||
});
|
||||
331
tests/verification-status.test.cjs
Normal file
331
tests/verification-status.test.cjs
Normal 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.',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
57
tests/verifier-spotcheck-test-discipline.test.cjs
Normal file
57
tests/verifier-spotcheck-test-discipline.test.cjs
Normal 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'
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
773
tests/worktree-base-ref.test.cjs
Normal file
773
tests/worktree-base-ref.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
609
tests/worktree-baseref-install.test.cjs
Normal file
609
tests/worktree-baseref-install.test.cjs
Normal 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)'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user