Files
get-shit-done/sdk/src/query/helpers.test.ts
Tom Boucher eba0c99698 fix(#2623): resolve parent .planning root for sub_repos workspaces in SDK query dispatch (#2629)
* fix(#2623): resolve parent .planning root for sub_repos workspaces in SDK query dispatch

When `gsd-sdk query` is invoked from inside a `sub_repos`-listed child repo,
`projectDir` defaulted to `process.cwd()` which pointed at the child repo,
not the parent workspace that owns `.planning/`. Handlers then directly
checked `${projectDir}/.planning` and reported `project_exists: false`.

The legacy `gsd-tools.cjs` CLI does not have this gap — it calls
`findProjectRoot(cwd)` from `bin/lib/core.cjs`, which walks up from the
starting directory checking each ancestor's `.planning/config.json` for a
`sub_repos` entry that lists the starting directory's top-level segment.

This change ports that walk-up as a new `findProjectRoot` helper in
`sdk/src/query/helpers.ts` and applies it once in `cli.ts:main()` before
dispatching `query`, `run`, `init`, or `auto`. Resolution is idempotent:
if `projectDir` already owns `.planning/` (including an explicit
`--project-dir` pointing at the workspace root), the helper returns it
unchanged. The walk is capped at 10 parent levels and never crosses
`$HOME`. All filesystem errors are swallowed.

Regression coverage:
- `helpers.test.ts` — 8 unit tests covering own-`.planning` guard (#1362),
  sub_repos match, nested-path match, `planning.sub_repos` shape,
  heuristic fallback, unparseable config, legacy `multiRepo: true`.
- `sub-repos-root.integration.test.ts` — end-to-end baseline (reproduces
  the bug without the walk-up) and fixed behavior (walk-up + dispatch of
  `init.new-milestone` reports `project_exists: true` with the parent
  workspace as `project_root`).

sdk vitest: 1511 pass / 24 fail (all 24 failures pre-existing on main,
baseline is 26 failing — `comm -23` against baseline produces zero new
failures). CJS: 5410 pass / 0 fail.

Closes #2623

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2623): remove stray .planing typo from integration test setup

Address CodeRabbit nitpick: the mkdir('.planing') call on line 23 was
dead code from a typo, with errors silently swallowed via .catch(() => {}).
The test already creates '.planning' correctly on the next line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:58:23 -04:00

542 lines
19 KiB
TypeScript

/**
* 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,
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', () => {
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');
});
});
// ─── 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',
] as const;
describe('getRuntimeConfigDir', () => {
const saved: Record<string, string | undefined> = {};
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<Runtime, string> = {
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'),
};
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'],
['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<string, string | undefined> = {};
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('falls back to config.runtime when GSD_RUNTIME unset', () => {
expect(detectRuntime({ runtime: 'gemini' })).toBe('gemini');
});
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<string, string | undefined> = {};
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('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);
});
});