/** * Unit tests for shared query helpers. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { GSDError } from '../errors.js'; import { escapeRegex, normalizePhaseName, comparePhaseNum, extractPhaseToken, phaseTokenMatches, toPosixPath, stateExtractField, planningPaths, normalizeMd, resolvePathUnderProject, resolveAgentsDir, getRuntimeConfigDir, detectRuntime, resolveGlobalSkillsBase, resolveGlobalSkillDir, resolveGlobalSkillMarkdownPath, renderGlobalSkillsBaseDisplayPath, renderGlobalSkillDisplayPath, findProjectRoot, SUPPORTED_RUNTIMES, type Runtime, } from './helpers.js'; import { homedir } from 'node:os'; // ─── escapeRegex ──────────────────────────────────────────────────────────── describe('escapeRegex', () => { it('escapes dots', () => { expect(escapeRegex('foo.bar')).toBe('foo\\.bar'); }); it('escapes brackets', () => { expect(escapeRegex('test[0]')).toBe('test\\[0\\]'); }); it('escapes all regex special characters', () => { expect(escapeRegex('a.*+?^${}()|[]\\')).toBe('a\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\'); }); it('returns plain strings unchanged', () => { expect(escapeRegex('hello')).toBe('hello'); }); }); // ─── normalizePhaseName ───────────────────────────────────────────────────── describe('normalizePhaseName', () => { it('pads single digit to 2 digits', () => { expect(normalizePhaseName('9')).toBe('09'); }); it('strips project code prefix', () => { expect(normalizePhaseName('CK-01')).toBe('01'); }); it('preserves letter suffix', () => { expect(normalizePhaseName('12A')).toBe('12A'); }); it('preserves decimal parts', () => { expect(normalizePhaseName('12.1')).toBe('12.1'); }); it('strips project code and normalizes numeric part', () => { // PROJ-42 -> strip PROJ- prefix -> 42 -> pad to 42 expect(normalizePhaseName('PROJ-42')).toBe('42'); }); it('handles already-padded numbers', () => { expect(normalizePhaseName('01')).toBe('01'); }); }); // ─── comparePhaseNum ──────────────────────────────────────────────────────── describe('comparePhaseNum', () => { it('compares numeric phases', () => { expect(comparePhaseNum('01-foo', '02-bar')).toBeLessThan(0); }); it('compares letter suffixes', () => { expect(comparePhaseNum('12A-foo', '12B-bar')).toBeLessThan(0); }); it('sorts no-decimal before decimal', () => { expect(comparePhaseNum('12-foo', '12.1-bar')).toBeLessThan(0); }); it('returns 0 for equal phases', () => { expect(comparePhaseNum('01-name', '01-other')).toBe(0); }); it('falls back to string comparison for custom IDs', () => { const result = comparePhaseNum('AUTH-name', 'PROJ-name'); expect(typeof result).toBe('number'); }); }); // ─── extractPhaseToken ────────────────────────────────────────────────────── describe('extractPhaseToken', () => { it('extracts plain numeric token', () => { expect(extractPhaseToken('01-foundation')).toBe('01'); }); it('extracts project-code-prefixed token', () => { expect(extractPhaseToken('CK-01-name')).toBe('CK-01'); }); it('extracts letter suffix token', () => { expect(extractPhaseToken('12A-name')).toBe('12A'); }); it('extracts decimal token', () => { expect(extractPhaseToken('999.6-name')).toBe('999.6'); }); }); // ─── phaseTokenMatches ────────────────────────────────────────────────────── describe('phaseTokenMatches', () => { it('matches normalized numeric phase', () => { expect(phaseTokenMatches('09-foundation', '09')).toBe(true); }); it('matches after stripping project code', () => { expect(phaseTokenMatches('CK-01-name', '01')).toBe(true); }); it('does not match different phases', () => { expect(phaseTokenMatches('09-foundation', '10')).toBe(false); }); }); // ─── toPosixPath ──────────────────────────────────────────────────────────── describe('toPosixPath', () => { it('converts backslashes to forward slashes', () => { expect(toPosixPath('a\\b\\c')).toBe('a/b/c'); }); it('preserves already-posix paths', () => { expect(toPosixPath('a/b/c')).toBe('a/b/c'); }); }); // ─── stateExtractField ────────────────────────────────────────────────────── describe('stateExtractField', () => { it('extracts bold field value', () => { const content = '**Phase:** 10\n**Plan:** 1'; expect(stateExtractField(content, 'Phase')).toBe('10'); }); it('extracts plain field value', () => { const content = 'Status: executing\nPlan: 1'; expect(stateExtractField(content, 'Status')).toBe('executing'); }); it('returns null for missing field', () => { expect(stateExtractField('no fields here', 'Missing')).toBeNull(); }); it('is case-insensitive', () => { const content = '**phase:** 10'; expect(stateExtractField(content, 'Phase')).toBe('10'); }); it('does not treat YAML progress: block as body Progress field', () => { const content = [ '---', 'progress:', ' total: 5', ' done: 2', '---', '', '**Progress:** 40%', ].join('\n'); expect(stateExtractField(content, 'Progress')).toBe('40%'); }); }); // ─── planningPaths ────────────────────────────────────────────────────────── describe('planningPaths', () => { afterEach(() => { delete process.env['GSD_WORKSTREAM']; delete process.env['GSD_PROJECT']; }); it('returns all expected keys', () => { const paths = planningPaths('/proj'); expect(paths).toHaveProperty('planning'); expect(paths).toHaveProperty('state'); expect(paths).toHaveProperty('roadmap'); expect(paths).toHaveProperty('project'); expect(paths).toHaveProperty('config'); expect(paths).toHaveProperty('phases'); expect(paths).toHaveProperty('requirements'); }); it('uses posix paths', () => { const paths = planningPaths('/proj'); expect(paths.state).toContain('.planning/STATE.md'); expect(paths.config).toContain('.planning/config.json'); }); it('uses GSD_PROJECT env when no explicit workstream is provided', () => { process.env['GSD_PROJECT'] = 'proj-scope'; const paths = planningPaths('/proj'); expect(paths.planning).toContain('/proj/.planning/proj-scope'); }); it('explicit workstream overrides GSD_PROJECT env', () => { process.env['GSD_PROJECT'] = 'proj-scope'; const paths = planningPaths('/proj', 'ws-a'); expect(paths.planning).toContain('/proj/.planning/workstreams/ws-a'); expect(paths.planning).not.toContain('proj-scope'); }); }); // ─── normalizeMd ─────────────────────────────────────────────────────────── describe('normalizeMd', () => { it('converts CRLF to LF', () => { const result = normalizeMd('line1\r\nline2\r\n'); expect(result).not.toContain('\r'); expect(result).toContain('line1\nline2'); }); it('ensures terminal newline', () => { const result = normalizeMd('no trailing newline'); expect(result).toMatch(/\n$/); }); it('collapses 3+ consecutive blank lines to 2', () => { const result = normalizeMd('a\n\n\n\nb'); // Should have at most 2 consecutive newlines (1 blank line between) expect(result).not.toContain('\n\n\n'); }); it('preserves content inside code fences', () => { const input = '```\n code with trailing spaces \n```\n'; const result = normalizeMd(input); expect(result).toContain(' code with trailing spaces '); }); it('adds blank line before headings when missing', () => { const result = normalizeMd('some text\n# Heading\n'); expect(result).toContain('some text\n\n# Heading'); }); it('returns empty-ish content unchanged', () => { expect(normalizeMd('')).toBe(''); expect(normalizeMd(null as unknown as string)).toBe(null); }); it('handles normal markdown without changes', () => { const input = '# Title\n\nSome text.\n\n## Section\n\nMore text.\n'; const result = normalizeMd(input); expect(result).toBe(input); }); }); // ─── resolvePathUnderProject ──────────────────────────────────────────────── describe('resolvePathUnderProject', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-path-')); await writeFile(join(tmpDir, 'safe.md'), 'x', 'utf-8'); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('resolves a relative file under the project root', async () => { const p = await resolvePathUnderProject(tmpDir, 'safe.md'); expect(p.endsWith('safe.md')).toBe(true); }); it('rejects paths that escape the project root', async () => { await expect(resolvePathUnderProject(tmpDir, '../../etc/passwd')).rejects.toThrow(GSDError); }); }); // ─── Runtime-aware agents dir resolution (#2402) ─────────────────────────── const RUNTIME_ENV_VARS = [ 'GSD_AGENTS_DIR', 'GSD_RUNTIME', 'CLAUDE_CONFIG_DIR', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'KILO_CONFIG_DIR', 'KILO_CONFIG', 'XDG_CONFIG_HOME', 'GEMINI_CONFIG_DIR', 'CODEX_HOME', 'COPILOT_CONFIG_DIR', 'ANTIGRAVITY_CONFIG_DIR', 'CURSOR_CONFIG_DIR', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR', 'TRAE_CONFIG_DIR', 'QWEN_CONFIG_DIR', 'CODEBUDDY_CONFIG_DIR', 'CLINE_CONFIG_DIR', 'HERMES_HOME', ] as const; describe('getRuntimeConfigDir', () => { const saved: Record = {}; beforeEach(() => { for (const k of RUNTIME_ENV_VARS) { saved[k] = process.env[k]; delete process.env[k]; } }); afterEach(() => { for (const k of RUNTIME_ENV_VARS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }); const defaults: Record = { claude: join(homedir(), '.claude'), opencode: join(homedir(), '.config', 'opencode'), kilo: join(homedir(), '.config', 'kilo'), gemini: join(homedir(), '.gemini'), codex: join(homedir(), '.codex'), copilot: join(homedir(), '.copilot'), antigravity: join(homedir(), '.gemini', 'antigravity'), cursor: join(homedir(), '.cursor'), windsurf: join(homedir(), '.codeium', 'windsurf'), augment: join(homedir(), '.augment'), trae: join(homedir(), '.trae'), qwen: join(homedir(), '.qwen'), codebuddy: join(homedir(), '.codebuddy'), cline: join(homedir(), '.cline'), hermes: join(homedir(), '.hermes'), }; for (const runtime of SUPPORTED_RUNTIMES) { it(`resolves default path for ${runtime}`, () => { expect(getRuntimeConfigDir(runtime)).toBe(defaults[runtime]); }); } const envOverrides: Array<[Runtime, string, string]> = [ ['claude', 'CLAUDE_CONFIG_DIR', '/x/claude'], ['gemini', 'GEMINI_CONFIG_DIR', '/x/gemini'], ['codex', 'CODEX_HOME', '/x/codex'], ['copilot', 'COPILOT_CONFIG_DIR', '/x/copilot'], ['antigravity', 'ANTIGRAVITY_CONFIG_DIR', '/x/antigravity'], ['cursor', 'CURSOR_CONFIG_DIR', '/x/cursor'], ['windsurf', 'WINDSURF_CONFIG_DIR', '/x/windsurf'], ['augment', 'AUGMENT_CONFIG_DIR', '/x/augment'], ['trae', 'TRAE_CONFIG_DIR', '/x/trae'], ['qwen', 'QWEN_CONFIG_DIR', '/x/qwen'], ['codebuddy', 'CODEBUDDY_CONFIG_DIR', '/x/codebuddy'], ['cline', 'CLINE_CONFIG_DIR', '/x/cline'], ['hermes', 'HERMES_HOME', '/x/hermes'], ['opencode', 'OPENCODE_CONFIG_DIR', '/x/opencode'], ['kilo', 'KILO_CONFIG_DIR', '/x/kilo'], ]; for (const [runtime, envVar, value] of envOverrides) { it(`${runtime} honors ${envVar}`, () => { process.env[envVar] = value; expect(getRuntimeConfigDir(runtime)).toBe(value); }); } it('opencode uses XDG_CONFIG_HOME when direct vars unset', () => { process.env.XDG_CONFIG_HOME = '/xdg'; expect(getRuntimeConfigDir('opencode')).toBe(join('/xdg', 'opencode')); }); it('opencode OPENCODE_CONFIG uses dirname', () => { process.env.OPENCODE_CONFIG = '/cfg/opencode.json'; expect(getRuntimeConfigDir('opencode')).toBe('/cfg'); }); it('kilo uses XDG_CONFIG_HOME when direct vars unset', () => { process.env.XDG_CONFIG_HOME = '/xdg'; expect(getRuntimeConfigDir('kilo')).toBe(join('/xdg', 'kilo')); }); }); describe('detectRuntime', () => { const saved: Record = {}; beforeEach(() => { for (const k of RUNTIME_ENV_VARS) { saved[k] = process.env[k]; delete process.env[k]; } }); afterEach(() => { for (const k of RUNTIME_ENV_VARS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }); it('defaults to claude with no signals', () => { expect(detectRuntime()).toBe('claude'); }); it('uses GSD_RUNTIME when set to a known runtime', () => { process.env.GSD_RUNTIME = 'codex'; expect(detectRuntime()).toBe('codex'); }); it('normalizes Codex env aliases to canonical codex runtime', () => { process.env.GSD_RUNTIME = 'codex-app'; expect(detectRuntime()).toBe('codex'); process.env.GSD_RUNTIME = 'codex_cli'; expect(detectRuntime()).toBe('codex'); }); it('normalizes common runtime env aliases to canonical runtime IDs', () => { const cases: Array<[string, Runtime]> = [ ['claude-code', 'claude'], ['gemini-cli', 'gemini'], ['opencode-cli', 'opencode'], ['qwen-code', 'qwen'], ['hermes-agent', 'hermes'], ['github-copilot', 'copilot'], ['cursor-nightly', 'cursor'], ['windsurf-next', 'windsurf'], ['augment-code', 'augment'], ['codebuddy-cli', 'codebuddy'], ['cline-cli', 'cline'], ]; for (const [alias, runtime] of cases) { process.env.GSD_RUNTIME = alias; expect(detectRuntime()).toBe(runtime); } }); it('falls back to config.runtime when GSD_RUNTIME unset', () => { expect(detectRuntime({ runtime: 'gemini' })).toBe('gemini'); }); it('normalizes Codex config aliases to canonical codex runtime', () => { expect(detectRuntime({ runtime: 'codex-cli' })).toBe('codex'); expect(detectRuntime({ runtime: 'codex app' })).toBe('codex'); }); it('normalizes common runtime config aliases to canonical runtime IDs', () => { const cases: Array<[string, Runtime]> = [ ['claude-cli', 'claude'], ['gemini-code', 'gemini'], ['open code', 'opencode'], ['hermes_cli', 'hermes'], ['antigravity-agent', 'antigravity'], ['trae-cli', 'trae'], ['kilo-cli', 'kilo'], ]; for (const [alias, runtime] of cases) { expect(detectRuntime({ runtime: alias })).toBe(runtime); } }); it('GSD_RUNTIME wins over config.runtime', () => { process.env.GSD_RUNTIME = 'codex'; expect(detectRuntime({ runtime: 'gemini' })).toBe('codex'); }); it('unknown GSD_RUNTIME falls through to config then claude', () => { process.env.GSD_RUNTIME = 'bogus'; expect(detectRuntime({ runtime: 'gemini' })).toBe('gemini'); expect(detectRuntime()).toBe('claude'); }); it('unknown config.runtime falls through to claude', () => { expect(detectRuntime({ runtime: 'bogus' })).toBe('claude'); }); }); describe('resolveAgentsDir (runtime-aware)', () => { const saved: Record = {}; beforeEach(() => { for (const k of RUNTIME_ENV_VARS) { saved[k] = process.env[k]; delete process.env[k]; } }); afterEach(() => { for (const k of RUNTIME_ENV_VARS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }); it('defaults to Claude agents dir with no args', () => { expect(resolveAgentsDir()).toBe(join(homedir(), '.claude', 'agents')); }); it('GSD_AGENTS_DIR short-circuits regardless of runtime', () => { process.env.GSD_AGENTS_DIR = '/explicit/agents'; expect(resolveAgentsDir('codex')).toBe('/explicit/agents'); expect(resolveAgentsDir('claude')).toBe('/explicit/agents'); }); it('appends /agents to the per-runtime config dir', () => { process.env.CODEX_HOME = '/codex'; expect(resolveAgentsDir('codex')).toBe(join('/codex', 'agents')); }); }); // ─── findProjectRoot (issue #2623) ───────────────────────────────────────── describe('runtime-global skills directory helpers', () => { const saved: Record = {}; beforeEach(() => { for (const k of RUNTIME_ENV_VARS) { saved[k] = process.env[k]; delete process.env[k]; } }); afterEach(() => { for (const k of RUNTIME_ENV_VARS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }); it('appends /skills for runtimes with a global skills directory', () => { process.env.CODEX_HOME = '/codex'; expect(resolveGlobalSkillsBase('codex')).toBe(join('/codex', 'skills')); expect(resolveGlobalSkillDir('codex', 'demo')).toBe(join('/codex', 'skills', 'demo')); expect(resolveGlobalSkillMarkdownPath('codex', 'demo')).toBe(join('/codex', 'skills', 'demo', 'SKILL.md')); }); it('returns null for cline and renders unsupported display path', () => { expect(resolveGlobalSkillsBase('cline')).toBeNull(); expect(renderGlobalSkillsBaseDisplayPath('cline')).toBe('(cline does not use a skills directory)'); expect(resolveGlobalSkillDir('cline', 'demo')).toBeNull(); expect(resolveGlobalSkillMarkdownPath('cline', 'demo')).toBeNull(); expect(renderGlobalSkillDisplayPath('cline', 'demo')).toBe('(cline does not use a skills directory)'); }); it('renders home-relative display paths with ~ for warnings', () => { expect(renderGlobalSkillsBaseDisplayPath('claude')).toBe('~/.claude/skills'); expect(renderGlobalSkillDisplayPath('claude', 'demo')).toBe(join('~/.claude/skills', 'demo')); }); it('rejects path-traversal segments — resolveGlobalSkillDir returns null for ../../foo', () => { process.env.CODEX_HOME = '/codex'; expect(resolveGlobalSkillDir('codex', '../../foo')).toBeNull(); expect(resolveGlobalSkillDir('codex', '../escape')).toBeNull(); expect(resolveGlobalSkillDir('codex', '')).toBeNull(); // Absolute path as skillName is also rejected expect(resolveGlobalSkillDir('codex', '/abs/path')).toBeNull(); // Legitimate name still works expect(resolveGlobalSkillDir('codex', 'demo')).toBe(join('/codex', 'skills', 'demo')); // resolveGlobalSkillMarkdownPath must also propagate the null for unsafe inputs expect(resolveGlobalSkillMarkdownPath('codex', '../../foo')).toBeNull(); expect(resolveGlobalSkillMarkdownPath('codex', '../escape')).toBeNull(); expect(resolveGlobalSkillMarkdownPath('codex', '')).toBeNull(); expect(resolveGlobalSkillMarkdownPath('codex', '/abs/path')).toBeNull(); }); }); describe('findProjectRoot (multi-repo .planning resolution)', () => { let workspace: string; beforeEach(async () => { workspace = await mkdtemp(join(tmpdir(), 'gsd-find-root-')); }); afterEach(async () => { await rm(workspace, { recursive: true, force: true }); }); it('returns startDir unchanged when startDir has its own .planning/', async () => { await mkdir(join(workspace, '.planning'), { recursive: true }); expect(findProjectRoot(workspace)).toBe(workspace); }); it('returns startDir unchanged when no ancestor has .planning/', () => { expect(findProjectRoot(workspace)).toBe(workspace); }); it('walks up to parent .planning/ when config lists the child in sub_repos (#2623)', async () => { // workspace/.planning/{config.json, PROJECT.md} // workspace/app/.git/ await mkdir(join(workspace, '.planning'), { recursive: true }); await writeFile( join(workspace, '.planning', 'config.json'), JSON.stringify({ sub_repos: ['app'] }), 'utf-8', ); const app = join(workspace, 'app'); await mkdir(join(app, '.git'), { recursive: true }); expect(findProjectRoot(app)).toBe(workspace); }); it('resolves parent root from deeply nested dir inside a sub_repo', async () => { await mkdir(join(workspace, '.planning'), { recursive: true }); await writeFile( join(workspace, '.planning', 'config.json'), JSON.stringify({ sub_repos: ['app'] }), 'utf-8', ); const nested = join(workspace, 'app', 'src', 'modules'); await mkdir(join(workspace, 'app', '.git'), { recursive: true }); await mkdir(nested, { recursive: true }); expect(findProjectRoot(nested)).toBe(workspace); }); it('supports planning.sub_repos nested config shape', async () => { await mkdir(join(workspace, '.planning'), { recursive: true }); await writeFile( join(workspace, '.planning', 'config.json'), JSON.stringify({ planning: { sub_repos: ['app'] } }), 'utf-8', ); const app = join(workspace, 'app'); await mkdir(join(app, '.git'), { recursive: true }); expect(findProjectRoot(app)).toBe(workspace); }); it('falls back to .git heuristic when parent has .planning/ but no matching sub_repos', async () => { await mkdir(join(workspace, '.planning'), { recursive: true }); // Config doesn't list the child, but child has .git and parent has .planning/. await writeFile( join(workspace, '.planning', 'config.json'), JSON.stringify({ sub_repos: [] }), 'utf-8', ); const app = join(workspace, 'app'); await mkdir(join(app, '.git'), { recursive: true }); expect(findProjectRoot(app)).toBe(workspace); }); it('swallows unparseable config.json and falls back to .git heuristic', async () => { await mkdir(join(workspace, '.planning'), { recursive: true }); await writeFile(join(workspace, '.planning', 'config.json'), '{ not json', 'utf-8'); const app = join(workspace, 'app'); await mkdir(join(app, '.git'), { recursive: true }); expect(findProjectRoot(app)).toBe(workspace); }); it('supports legacy multiRepo: true when child is inside a git repo', async () => { await mkdir(join(workspace, '.planning'), { recursive: true }); await writeFile( join(workspace, '.planning', 'config.json'), JSON.stringify({ multiRepo: true }), 'utf-8', ); const app = join(workspace, 'app'); await mkdir(join(app, '.git'), { recursive: true }); expect(findProjectRoot(app)).toBe(workspace); }); it('does not walk up when child has its own .planning/ (#1362 guard)', async () => { await mkdir(join(workspace, '.planning'), { recursive: true }); await writeFile( join(workspace, '.planning', 'config.json'), JSON.stringify({ sub_repos: ['app'] }), 'utf-8', ); const app = join(workspace, 'app'); await mkdir(join(app, '.planning'), { recursive: true }); expect(findProjectRoot(app)).toBe(app); }); });