* fix(#3019): query --help reaches handler instead of short-circuiting to top-level usage The query argv parser in sdk/src/cli.ts harvested -h/--help as a global flag and main() short-circuited dispatch when args.help was true. Net effect: every `gsd-sdk query <anything> --help` printed top-level USAGE instead of contextual subcommand help. There was no path for users to discover what arguments a query subcommand accepts — they had to trigger "required" errors by trial and error. Two-layer fix: 1. sdk/src/cli.ts (parseCliArgsQueryPermissive) - Push -h / --help onto queryArgv instead of consuming them silently, so the registered handler / gsd-tools.cjs fallback gets to interpret the flag and render contextual help. - Only honor the global help flag when there is NO real subcommand to dispatch to (i.e. queryArgv contains only help flags). Preserves `gsd-sdk query --help` → top-level USAGE while letting `gsd-sdk query phase add --help` reach the handler. 2. get-shit-done/bin/gsd-tools.cjs - Render top-level usage on --help / -h / -? / --usage instead of erroring with "Unknown flag". The discovery hint in the usage text points users at the working method (run without args → error names required arguments) and references #3019 for tracking subcommand- level help printers. - --version remains rejected (no discovery use-case). #1818 anti-hallucination invariant preserved: the destructive command NEVER executes when --help is present. The new shape returns success:true + usage on stdout instead of the old success:false + error on stderr — both satisfy "destructive command did not run", and the new shape also restores discoverability. Tests: - sdk/src/cli.test.ts: 4 new vitest cases covering #3019 — query argv parser keeps --help with subcommand, parses -h short flag, preserves bare `query --help` top-level behavior, preserves --help position when intermixed with other query flags. - tests/bug-3019-help-passthrough.test.cjs: 5 node:test cases on the fallback — bare gsd-tools (no args) errors with usage; --help renders usage on stdout exit 0; -h same; subcommand --help renders usage; usage hint mentions discovery method (without prose substring matching — parses into typed sections). - tests/bug-1818-unknown-flags.test.cjs: rewritten to assert the new invariant ("destructive command did not run" + "usage was rendered") instead of the old shape ("--help is rejected with non-zero exit"). Each destructive test seeds a sentinel artifact (phase dir, slug output) and asserts it survives. Verification: - 47/47 vitest pass on sdk/src/cli.test.ts - 5/5 pass on tests/bug-3019-help-passthrough.test.cjs - 8/8 pass on tests/bug-1818-unknown-flags.test.cjs (rewritten) - 6763/6763 pass on full node:test suite - lint-no-source-grep clean (0 violations) Closes #3019 * fix(#3019): SDK fallback forwards plain-text help, broader usage list (CR) CodeRabbit on PR #3026 (4 findings — 1 Major outside-diff, 2 inline, 1 nitpick): 1. **Major outside-diff** — sdk/src/cli.ts:442-454. The fallback path that delegates to gsd-tools.cjs called parseCliQueryJsonOutput (JSON.parse) on stdout. Now that gsd-tools renders plain-text usage on --help, JSON.parse threw "Unexpected token 'U'". Wrapped the parse in try/catch — on parse failure, forward the plain stdout verbatim so subcommand help reaches the user. Regression test: tests/bug-3019-help-passthrough.test.cjs spawns the built SDK and asserts `gsd-sdk query phase --help` exits 0, stdout contains the gsd-tools usage, and stderr does NOT contain a JSON-parse error. 2. .changeset/help-passthrough.md:3 — `pr: TBD` → `pr: 3026`. 3. gsd-tools.cjs:346 (TOP_LEVEL_USAGE): - Removed self-referencing `#3019` link (immediately stale after this PR merges). - Expanded Commands list from 17 → all 47 dispatcher cases: agent-skills, audit-open, audit-uat, check-commit, commit, … phase, phases, roadmap, milestone, validate, progress, intel, graphify, learnings, etc. — the bulk of the surface that was previously unreachable via --help discovery. 4. Nitpick: `isUsageOutput` was duplicated in bug-1818 and bug-3019-help-passthrough tests. Moved to tests/helpers.cjs with structural-comment, removed both duplicates. Verification: 47/47 vitest pass, 14/14 regression tests pass, 6764/6764 full suite, lint clean. * test(#3019): use t.skip() instead of bare return when SDK not built (CR) CodeRabbit follow-up on PR #3026: The integration test guarded against missing sdk/dist/cli.js with a bare `return;` — node:test counts that as a passing test (0 assertions exercised, 0 failures). On a CI checkout that hasn't run the SDK build, the #3026 regression test silently green-lit and no signal ever surfaced that the integration check was skipped. Switched to `t.skip(...)` via the test context parameter so the omission shows up in the test report. The unit-level fix (sdk/src/cli.ts) is still covered by vitest, so the skip only affects the end-to-end spawn-built-SDK check. Verification: 6/6 pass when SDK is built; 5 pass + 1 skip when not.
174 lines
6.4 KiB
JavaScript
174 lines
6.4 KiB
JavaScript
/**
|
|
* GSD Tools Test Helpers
|
|
*/
|
|
|
|
const { execSync, execFileSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
|
|
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 command.
|
|
*
|
|
* @param {string|string[]} args - Command string (shell-interpreted) or array
|
|
* of arguments (shell-bypassed via execFileSync, safe for JSON and dollar signs).
|
|
* @param {string} cwd - Working directory.
|
|
* @param {object} [env] - Optional env overrides merged on top of process.env.
|
|
* Pass { HOME: cwd } to sandbox ~/.gsd/ lookups in tests that assert concrete
|
|
* config values that could be overridden by a developer's defaults.json.
|
|
*/
|
|
function runGsdTools(args, cwd = process.cwd(), env = {}) {
|
|
try {
|
|
let result;
|
|
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...env };
|
|
if (Array.isArray(args)) {
|
|
result = execFileSync(process.execPath, [TOOLS_PATH, ...args], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: childEnv,
|
|
});
|
|
} else {
|
|
// Split shell-style string into argv, stripping surrounding quotes, so we
|
|
// can invoke execFileSync with process.execPath instead of relying on
|
|
// `node` being on PATH (it isn't in Claude Code shell sessions).
|
|
// Apply shell-style quote removal: strip surrounding quotes from quoted
|
|
// sequences anywhere in a token (handles both "foo bar" and --"foo bar").
|
|
const argv = (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
|
.map(t => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
|
|
result = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: childEnv,
|
|
});
|
|
}
|
|
return { success: true, output: result.trim() };
|
|
} catch (err) {
|
|
return {
|
|
success: false,
|
|
output: err.stdout?.toString().trim() || '',
|
|
error: err.stderr?.toString().trim() || err.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Create a bare temp directory (no .planning/ structure)
|
|
function createTempDir(prefix = 'gsd-test-') {
|
|
return fs.mkdtempSync(path.join(require('os').tmpdir(), prefix));
|
|
}
|
|
|
|
// Create temp directory structure
|
|
function createTempProject(prefix = 'gsd-test-') {
|
|
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), prefix));
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
|
return tmpDir;
|
|
}
|
|
|
|
// Create temp directory with initialized git repo and at least one commit
|
|
function createTempGitProject(prefix = 'gsd-test-') {
|
|
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), prefix));
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
|
|
|
execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git config commit.gpgsign false', { cwd: tmpDir, stdio: 'pipe' });
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
|
'# Project\n\nTest project.\n'
|
|
);
|
|
|
|
execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git commit -m "initial commit"', { cwd: tmpDir, stdio: 'pipe' });
|
|
|
|
return tmpDir;
|
|
}
|
|
|
|
function cleanup(tmpDir) {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
|
|
/**
|
|
* Parse a Markdown frontmatter block into a flat key→value map.
|
|
*
|
|
* Handles the YAML scalar forms emitted by the install converters:
|
|
* key: "json-encoded value" → JSON.parse
|
|
* key: 'value with ''escape'' → strip quotes, unescape ''
|
|
* key: bare value → trimmed string
|
|
*
|
|
* Multi-line and block scalars are out of scope — every converter in
|
|
* `bin/install.js` emits single-line scalars only. Throws if the content
|
|
* has no closed `---` block so a regression in the emitter shape fails
|
|
* loudly rather than silently returning {}.
|
|
*
|
|
* Tests use this helper instead of `result.includes('key: value')` to
|
|
* follow the project's "tests parse, never grep" convention.
|
|
*
|
|
* @param {string} content - Full file content beginning with `---`.
|
|
* @returns {Record<string, string>} Map of frontmatter keys to decoded values.
|
|
*/
|
|
function parseFrontmatter(content) {
|
|
if (!content.startsWith('---')) {
|
|
throw new Error(`parseFrontmatter: content must start with '---', got: ${content.slice(0, 40)}`);
|
|
}
|
|
// CRLF tolerance: a Windows-authored file split on `\n` would leave a
|
|
// trailing `\r` on every line, making `lines[i] === '---'` fail to
|
|
// recognize delimiters. Same goes for whitespace-padded delimiter lines.
|
|
// Normalize via a CRLF-aware split + trimmed comparison.
|
|
const lines = content.split(/\r?\n/);
|
|
let openIdx = -1;
|
|
let closeIdx = -1;
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
if (lines[i].trim() === '---') {
|
|
if (openIdx === -1) openIdx = i;
|
|
else { closeIdx = i; break; }
|
|
}
|
|
}
|
|
if (openIdx === -1 || closeIdx === -1) {
|
|
throw new Error('parseFrontmatter: no closed --- block');
|
|
}
|
|
const fields = {};
|
|
for (const line of lines.slice(openIdx + 1, closeIdx)) {
|
|
const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
if (!match) continue; // skip block-list items, blank lines, comments
|
|
const [, key, rawValue] = match;
|
|
const value = rawValue.trim();
|
|
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
fields[key] = JSON.parse(value);
|
|
} else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
|
|
fields[key] = value.slice(1, -1).replace(/''/g, "'");
|
|
} else {
|
|
fields[key] = value;
|
|
}
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
// #3026 CR: shared `--help` output check used by bug-1818 + bug-3019 tests.
|
|
// Render-on-help shape is `Usage: gsd-tools …\nCommands: …` — both lines
|
|
// must be present; structural test, not prose substring matching.
|
|
function isUsageOutput(text) {
|
|
return /Usage:\s*gsd-tools/.test(text) && /Commands:/.test(text);
|
|
}
|
|
|
|
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, TOOLS_PATH };
|