test(#1971): consolidate 48 gsd-tools CLI regression tests into subcommand suites
Fold 48 issue-named gsd-tools CLI regression files into the canonical test file that owns each subcommand subject (state, roadmap, phase, milestone, audit, config, router/dispatch, stats, verify, health, etc.), preserving every assertion and its origin issue number as provenance (block-scoped describe wrappers, 299 subtests conserved 1:1). No monolithic gsd-tools.test.cjs created — routes into 18 existing per-subject suites. Removes 48 tests/ files. Regenerates regression-name allowlist (271->231), ratchets the file-count allowlist across 6 buckets (audit/milestone/phase/roadmap/state/verify), and makes 10 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes 10 stale ids). Repoints one CONTEXT.md symptom ref and ADR-3524's parity-test ref. lint:ci green. Part of epic #1969. Closes #1971. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -949,3 +949,855 @@ describe('CommandRoutingHub — exitReason? field on InvalidArgs (#1644 / amendm
|
||||
`Extended InvalidArgs must not be coerced to HandlerFailure; got kind: ${result.kind}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-167-query-meta-command.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-167-query-meta-command (consolidation epic #1969 B2 #1971)", () => {
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
test('bug #167: query meta-command prefixes direct gsd-tools calls', () => {
|
||||
const direct = runGsdTools(['init.progress']);
|
||||
assert.equal(direct.success, true, `init.progress failed: ${direct.error || direct.output}`);
|
||||
|
||||
const meta = runGsdTools(['query', 'init.progress']);
|
||||
assert.equal(meta.success, true, `query init.progress failed: ${meta.error || meta.output}`);
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(meta.output),
|
||||
JSON.parse(direct.output),
|
||||
'query-prefixed and direct invocations should return identical init.progress payloads'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-1818-unknown-flags.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-1818-unknown-flags (consolidation epic #1969 B2 #1971)", () => {
|
||||
/**
|
||||
* Regression test for bug #1818, updated for #3019.
|
||||
*
|
||||
* Original #1818 invariant: gsd-tools must NOT silently ignore --help/-h
|
||||
* and proceed with a destructive command — that turned AI-agent
|
||||
* hallucinations into accidental data loss (e.g. `phases clear --help`
|
||||
* deleting phase dirs because the flag was dropped).
|
||||
*
|
||||
* #3019 update: the same destructive-protection invariant still holds,
|
||||
* but the response shape changed. Previously --help → non-zero error
|
||||
* exit. Now --help → render top-level usage and exit 0 WITHOUT running
|
||||
* the command. Both shapes satisfy the original invariant ("the
|
||||
* destructive command did not execute"); the new shape also restores
|
||||
* subcommand discoverability for `gsd-sdk query <subcommand> --help`.
|
||||
*
|
||||
* The tests therefore assert two things:
|
||||
* 1. The destructive command did NOT run (anti-hallucination invariant).
|
||||
* 2. The output contains the top-level usage (#3019 discoverability).
|
||||
*
|
||||
* --version remains rejected — it's never a valid gsd-tools flag and has
|
||||
* no discovery use-case.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { runGsdTools, createTempProject, cleanup, isUsageOutput } = require('./helpers.cjs');
|
||||
|
||||
describe('unknown flag guard (bug #1818, updated for #3019)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// ── --help renders usage and does NOT run the destructive command ────────
|
||||
|
||||
test('phases clear --help renders usage and does NOT clear phase dirs', () => {
|
||||
// Create a sentinel phase dir so we can assert it survives.
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-99');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), 'sentinel');
|
||||
|
||||
const result = runGsdTools(['phases', 'clear', '--help'], tmpDir);
|
||||
assert.strictEqual(result.success, true, 'help renders, no error exit');
|
||||
assert.ok(isUsageOutput(result.output), `expected top-level usage, got: ${result.output}`);
|
||||
// Anti-hallucination invariant: the destructive command did NOT run.
|
||||
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive — clear must not have executed');
|
||||
assert.ok(fs.existsSync(path.join(phaseDir, 'PLAN.md')));
|
||||
});
|
||||
|
||||
test('generate-slug hello --help renders usage and does NOT emit a slug', () => {
|
||||
const ok = runGsdTools(['generate-slug', 'hello'], tmpDir);
|
||||
assert.strictEqual(ok.success, true, 'control: generate-slug works without --help');
|
||||
// The control output is just the slug; the help output is the usage.
|
||||
const slugOut = ok.output;
|
||||
assert.ok(slugOut && !isUsageOutput(slugOut), `control should not be usage: ${slugOut}`);
|
||||
|
||||
const result = runGsdTools(['generate-slug', 'hello', '--help'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output), 'help renders top-level usage');
|
||||
assert.notEqual(result.output, slugOut, 'help output must differ from the slug — generate-slug must not have run');
|
||||
});
|
||||
|
||||
test('phase complete --help renders usage and does NOT mark a phase complete', () => {
|
||||
const result = runGsdTools(['phase', 'complete', '--help'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
// success:true + isUsageOutput is sufficient: if the destructive path
|
||||
// had executed it would have emitted a phase-resolution error to stderr
|
||||
// (success:false), not the usage to stdout (success:true).
|
||||
});
|
||||
|
||||
test('state load --help renders usage', () => {
|
||||
const result = runGsdTools(['state', 'load', '--help'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
});
|
||||
|
||||
// ── -h shorthand: same shape ─────────────────────────────────────────────
|
||||
|
||||
test('phases clear -h renders usage and does NOT clear phase dirs', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-42');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
const result = runGsdTools(['phases', 'clear', '-h'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive');
|
||||
});
|
||||
|
||||
test('generate-slug hello -h renders usage', () => {
|
||||
const result = runGsdTools(['generate-slug', 'hello', '-h'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
});
|
||||
|
||||
// ── --version is still rejected — no discovery use-case ──────────────────
|
||||
|
||||
test('generate-slug hello --version is rejected', () => {
|
||||
const result = runGsdTools(['generate-slug', 'hello', '--version'], tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.match(result.error, /--version/);
|
||||
});
|
||||
|
||||
// ── current-timestamp --help: same as the others ─────────────────────────
|
||||
|
||||
test('current-timestamp --help renders usage', () => {
|
||||
const result = runGsdTools(['current-timestamp', '--help'], tmpDir);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/feat-3255-json-errors-mode.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:feat-3255-json-errors-mode (consolidation epic #1969 B2 #1971)", () => {
|
||||
/**
|
||||
* Tests for the --json-errors mode added in #3255.
|
||||
*
|
||||
* When gsd-tools is invoked with --json-errors, all error() calls emit a
|
||||
* structured JSON object to stderr:
|
||||
*
|
||||
* { ok: false, reason: "<error_code>", message: "<human text>" }
|
||||
*
|
||||
* This lets tests assert on typed reason codes instead of grepping free-form
|
||||
* stderr text. All assertions below parse the captured stderr via JSON.parse
|
||||
* and inspect typed fields — never result.error.includes() (#2974 / k001).
|
||||
*
|
||||
* Covered error paths (representative set, each exercises a different branch):
|
||||
* 1. Unknown top-level command → reason: "sdk_unknown_command"
|
||||
* 2. Unknown dotted command → reason: "sdk_unknown_command"
|
||||
* 3. Missing required argument → reason: "usage" (--pick without value)
|
||||
* 4. Config key not found → reason: "config_key_not_found"
|
||||
* 5. Unknown subcommand → reason: "sdk_unknown_command"
|
||||
* 6. GSD_JSON_ERRORS=1 env var → same structured output without --flag
|
||||
* 7. Successful command unaffected
|
||||
* 8. Error object shape is stable ({ok, reason, message})
|
||||
* 9. Single error line per invocation
|
||||
* 10. Unknown flag → reason: "usage"
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Helper: run gsd-tools with --json-errors and parse the structured stderr.
|
||||
// Returns the parsed object, or throws if stderr is not valid JSON.
|
||||
function runJsonErrors(args, tmpDir, env = {}) {
|
||||
const allArgs = ['--json-errors', ...args];
|
||||
const result = runGsdTools(allArgs, tmpDir, env);
|
||||
// Must have failed
|
||||
assert.strictEqual(result.success, false,
|
||||
`Expected failure with --json-errors for args: ${args.join(' ')}\nstdout: ${result.output}\nstderr: ${result.error}`);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.error);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`--json-errors must emit valid JSON on stderr.\n` +
|
||||
`Args: ${args.join(' ')}\n` +
|
||||
`stderr: ${result.error}\n` +
|
||||
`parse error: ${e.message}`
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
describe('feat #3255: --json-errors mode emits structured error objects', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// ── 1. Unknown top-level command ─────────────────────────────────────────
|
||||
|
||||
test('unknown top-level command emits { ok: false, reason: "sdk_unknown_command" }', () => {
|
||||
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
|
||||
|
||||
assert.strictEqual(parsed.ok, false,
|
||||
'error object must have ok: false');
|
||||
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
||||
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||
'message must be a non-empty string');
|
||||
});
|
||||
|
||||
// ── 2. Unknown dotted command ────────────────────────────────────────────
|
||||
|
||||
test('unknown dotted command (foo.bar) emits { ok: false, reason: "sdk_unknown_command" }', () => {
|
||||
const parsed = runJsonErrors(['foo.bar'], tmpDir);
|
||||
|
||||
assert.strictEqual(parsed.ok, false,
|
||||
'error object must have ok: false');
|
||||
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
||||
`dotted unknown command reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||
'message must be a non-empty string');
|
||||
});
|
||||
|
||||
// ── 3. Missing --pick value ───────────────────────────────────────────────
|
||||
|
||||
test('--pick without value emits { ok: false, reason: "usage" }', () => {
|
||||
const parsed = runJsonErrors(['generate-slug', 'test-text', '--pick'], tmpDir);
|
||||
|
||||
assert.strictEqual(parsed.ok, false,
|
||||
'error object must have ok: false');
|
||||
assert.strictEqual(parsed.reason, 'usage',
|
||||
`missing --pick value reason must be "usage", got: ${parsed.reason}`);
|
||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||
'message must be a non-empty string');
|
||||
});
|
||||
|
||||
// ── 4. Config key not found ───────────────────────────────────────────────
|
||||
|
||||
test('config-get for absent key emits { ok: false, reason: "config_key_not_found" }', () => {
|
||||
// Initialise config.json first so we reach the "key not found" branch
|
||||
// rather than the "no config.json" branch.
|
||||
runGsdTools(['config-ensure-section'], tmpDir);
|
||||
|
||||
const parsed = runJsonErrors(['config-get', 'nonexistent_config_key_xyzzy'], tmpDir);
|
||||
|
||||
assert.strictEqual(parsed.ok, false,
|
||||
'error object must have ok: false');
|
||||
assert.strictEqual(parsed.reason, 'config_key_not_found',
|
||||
`reason must be "config_key_not_found", got: ${parsed.reason}`);
|
||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||
'message must be a non-empty string');
|
||||
});
|
||||
|
||||
// ── 5. Unknown subcommand within a domain ────────────────────────────────
|
||||
|
||||
test('unknown intel subcommand emits { ok: false, reason: "sdk_unknown_command" }', () => {
|
||||
const parsed = runJsonErrors(['intel', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||
|
||||
assert.strictEqual(parsed.ok, false,
|
||||
'error object must have ok: false');
|
||||
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
||||
`unknown subcommand reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||
'message must be a non-empty string');
|
||||
});
|
||||
|
||||
// ── 6. GSD_JSON_ERRORS=1 env var activates structured mode ───────────────
|
||||
|
||||
test('GSD_JSON_ERRORS=1 env var produces same structured error as --json-errors flag', () => {
|
||||
// Run with env var instead of --json-errors flag
|
||||
const result = runGsdTools(
|
||||
['totally-unknown-command-xyzzy'],
|
||||
tmpDir,
|
||||
{ GSD_JSON_ERRORS: '1' }
|
||||
);
|
||||
assert.strictEqual(result.success, false,
|
||||
'command must fail');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.error);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
|
||||
`stderr: ${result.error}\n` +
|
||||
`parse error: ${e.message}`
|
||||
);
|
||||
}
|
||||
assert.strictEqual(parsed.ok, false,
|
||||
'error object must have ok: false');
|
||||
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
||||
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
||||
});
|
||||
|
||||
// ── 7. Successful commands are unaffected by --json-errors ───────────────
|
||||
|
||||
test('successful command with --json-errors flag still succeeds normally', () => {
|
||||
const result = runGsdTools(
|
||||
['--json-errors', 'generate-slug', 'hello-world'],
|
||||
tmpDir
|
||||
);
|
||||
assert.strictEqual(result.success, true,
|
||||
`Successful command must not be broken by --json-errors flag.\nstderr: ${result.error}`);
|
||||
assert.ok(result.output.length > 0,
|
||||
'stdout must be non-empty for successful generate-slug');
|
||||
});
|
||||
|
||||
// ── 8. Error object shape is stable (no extra top-level keys) ────────────
|
||||
|
||||
test('error object contains exactly {ok, reason, message} — no extra keys', () => {
|
||||
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
|
||||
|
||||
const keys = Object.keys(parsed).sort();
|
||||
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
|
||||
`error object must have exactly {ok, reason, message}. Got keys: ${keys.join(', ')}`);
|
||||
});
|
||||
|
||||
// ── 9. Multiple errors in one session: only the first error is emitted ───
|
||||
|
||||
test('only one error JSON line is emitted per invocation (process exits on first error)', () => {
|
||||
const result = runGsdTools(
|
||||
['--json-errors', 'totally-unknown-command-xyzzy'],
|
||||
tmpDir
|
||||
);
|
||||
assert.strictEqual(result.success, false, 'must fail');
|
||||
const lines = result.error.trim().split('\n').filter(l => l.length > 0);
|
||||
assert.strictEqual(lines.length, 1,
|
||||
`stderr must contain exactly one JSON line, got ${lines.length}:\n${result.error}`);
|
||||
// Also verify the single line is valid JSON
|
||||
const parsed = JSON.parse(lines[0]);
|
||||
assert.strictEqual(parsed.ok, false);
|
||||
});
|
||||
|
||||
// ── 10. Unknown flag emits { ok: false, reason: "usage" } ────────────────
|
||||
|
||||
test('unknown version flag emits { ok: false, reason: "usage" }', () => {
|
||||
const parsed = runJsonErrors(['--version', 'generate-slug', 'x'], tmpDir);
|
||||
|
||||
assert.strictEqual(parsed.ok, false, 'error object must have ok: false');
|
||||
assert.strictEqual(parsed.reason, 'usage',
|
||||
`--version flag reason must be "usage", got: ${parsed.reason}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/feat-3310-followup-typed-codes.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:feat-3310-followup-typed-codes (consolidation epic #1969 B2 #1971)", () => {
|
||||
/**
|
||||
* Follow-up tests for #3310: every remaining `error()` call at a subcommand
|
||||
* boundary or usage check in `gsd-tools.cjs` carries a typed `ERROR_REASON`.
|
||||
*
|
||||
* #3304 wired four representative paths (unknown top-level command, unknown
|
||||
* intel subcommand, missing --pick value, --version flag). The rest fell
|
||||
* through to `ERROR_REASON.UNKNOWN`. This file locks the post-#3310 contract:
|
||||
*
|
||||
* - Every "Unknown <subsystem> subcommand" emits reason: "sdk_unknown_command".
|
||||
* - Every "Usage: ..." / missing-required-arg path emits reason: "usage".
|
||||
*
|
||||
* All assertions parse stderr via JSON.parse — never `.includes()` — per the
|
||||
* #2974 / CONTRIBUTING.md "Prohibited: Raw Text Matching" rule.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Run gsd-tools with GSD_JSON_ERRORS=1 (env-var activation, exercises the
|
||||
// path #3304 added alongside the --json-errors flag) and parse the
|
||||
// structured stderr. Returns the parsed object; throws if stderr is not JSON.
|
||||
function runJsonErrors(args, tmpDir, env = {}) {
|
||||
const result = runGsdTools(args, tmpDir, { ...env, GSD_JSON_ERRORS: '1' });
|
||||
assert.strictEqual(result.success, false,
|
||||
`Expected failure with GSD_JSON_ERRORS=1 for args: ${args.join(' ')}\n` +
|
||||
`stdout: ${result.output}\nstderr: ${result.error}`);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.error);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
|
||||
`Args: ${args.join(' ')}\nstderr: ${result.error}\nparse error: ${e.message}`
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Assert the typed-IR contract: object shape + reason. Keeps the per-test
|
||||
// boilerplate minimal so each error-path test reads as a single fact.
|
||||
function assertTypedError(parsed, expectedReason, label) {
|
||||
assert.strictEqual(parsed.ok, false,
|
||||
`${label}: error object must have ok: false`);
|
||||
assert.strictEqual(parsed.reason, expectedReason,
|
||||
`${label}: reason must be "${expectedReason}", got: ${parsed.reason}`);
|
||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||
`${label}: message must be a non-empty string`);
|
||||
}
|
||||
|
||||
describe('feat #3310: typed ERROR_REASON codes on remaining error paths', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// ── Unknown <subsystem> subcommand → SDK_UNKNOWN_COMMAND ────────────────
|
||||
// Each of these used to fall through to reason: "unknown" before #3310.
|
||||
|
||||
test('unknown template subcommand → sdk_unknown_command', () => {
|
||||
const parsed = runJsonErrors(['template', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||
assertTypedError(parsed, 'sdk_unknown_command', 'template');
|
||||
});
|
||||
|
||||
test('unknown frontmatter subcommand → sdk_unknown_command', () => {
|
||||
// frontmatter expects subcommand at args[1] and file at args[2]; pass a
|
||||
// bogus subcommand with a placeholder file so we definitely reach the
|
||||
// unknown-subcommand branch, not an earlier validation.
|
||||
const parsed = runJsonErrors(
|
||||
['frontmatter', 'bogus-subcommand-xyzzy', 'placeholder.md'],
|
||||
tmpDir
|
||||
);
|
||||
assertTypedError(parsed, 'sdk_unknown_command', 'frontmatter');
|
||||
});
|
||||
|
||||
test('unknown requirements subcommand → sdk_unknown_command', () => {
|
||||
const parsed = runJsonErrors(['requirements', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||
assertTypedError(parsed, 'sdk_unknown_command', 'requirements');
|
||||
});
|
||||
|
||||
test('unknown milestone subcommand → sdk_unknown_command', () => {
|
||||
const parsed = runJsonErrors(['milestone', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||
assertTypedError(parsed, 'sdk_unknown_command', 'milestone');
|
||||
});
|
||||
|
||||
test('unknown uat subcommand → sdk_unknown_command', () => {
|
||||
const parsed = runJsonErrors(['uat', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||
assertTypedError(parsed, 'sdk_unknown_command', 'uat');
|
||||
});
|
||||
|
||||
test('unknown todo subcommand → sdk_unknown_command', () => {
|
||||
const parsed = runJsonErrors(['todo', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||
assertTypedError(parsed, 'sdk_unknown_command', 'todo');
|
||||
});
|
||||
|
||||
test('unknown workstream subcommand → sdk_unknown_command', () => {
|
||||
const parsed = runJsonErrors(['workstream', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||
assertTypedError(parsed, 'sdk_unknown_command', 'workstream');
|
||||
});
|
||||
|
||||
test('unknown graphify subcommand → sdk_unknown_command', () => {
|
||||
const parsed = runJsonErrors(['graphify', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||
assertTypedError(parsed, 'sdk_unknown_command', 'graphify');
|
||||
});
|
||||
|
||||
test('unknown learnings subcommand → sdk_unknown_command', () => {
|
||||
const parsed = runJsonErrors(['learnings', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||
assertTypedError(parsed, 'sdk_unknown_command', 'learnings');
|
||||
});
|
||||
|
||||
// ── Missing required positional/flag values → USAGE ─────────────────────
|
||||
// These previously emitted reason: "unknown" because the second argument
|
||||
// to error() was absent.
|
||||
|
||||
test('missing --cwd value → usage', () => {
|
||||
// The --cwd flag is consumed before the command dispatcher; passing it
|
||||
// bare with no following value triggers the usage error at L253/L258.
|
||||
const parsed = runJsonErrors(['--cwd'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', '--cwd missing value');
|
||||
});
|
||||
|
||||
test('invalid --cwd directory → usage', () => {
|
||||
// --cwd <nonexistent-path> hits the existsSync / isDirectory check at L264.
|
||||
const parsed = runJsonErrors(
|
||||
['--cwd', '/this/path/should/not/exist/anywhere/xyzzy', 'state', 'load'],
|
||||
tmpDir
|
||||
);
|
||||
assertTypedError(parsed, 'usage', 'invalid --cwd directory');
|
||||
});
|
||||
|
||||
test('intel query missing term → usage', () => {
|
||||
const parsed = runJsonErrors(['intel', 'query'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', 'intel query missing term');
|
||||
});
|
||||
|
||||
test('intel patch-meta missing file path → usage', () => {
|
||||
const parsed = runJsonErrors(['intel', 'patch-meta'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', 'intel patch-meta missing file');
|
||||
});
|
||||
|
||||
test('intel extract-exports missing file path → usage', () => {
|
||||
const parsed = runJsonErrors(['intel', 'extract-exports'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', 'intel extract-exports missing file');
|
||||
});
|
||||
|
||||
test('graphify query missing term → usage', () => {
|
||||
const parsed = runJsonErrors(['graphify', 'query'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', 'graphify query missing term');
|
||||
});
|
||||
|
||||
test('learnings query missing --tag → usage', () => {
|
||||
const parsed = runJsonErrors(['learnings', 'query'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', 'learnings query missing --tag');
|
||||
});
|
||||
|
||||
test('learnings prune missing --older-than → usage', () => {
|
||||
const parsed = runJsonErrors(['learnings', 'prune'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', 'learnings prune missing --older-than');
|
||||
});
|
||||
|
||||
test('learnings delete missing id → usage', () => {
|
||||
const parsed = runJsonErrors(['learnings', 'delete'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', 'learnings delete missing id');
|
||||
});
|
||||
|
||||
test('extract-messages missing project arg → usage', () => {
|
||||
// L877 — args[1] is undefined or starts with '--'.
|
||||
const parsed = runJsonErrors(['extract-messages'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', 'extract-messages missing project');
|
||||
});
|
||||
|
||||
test('write-profile missing --input → usage', () => {
|
||||
const parsed = runJsonErrors(['write-profile'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', 'write-profile missing --input');
|
||||
});
|
||||
|
||||
test('detect-custom-files missing --config-dir → usage', () => {
|
||||
const parsed = runJsonErrors(['detect-custom-files'], tmpDir);
|
||||
assertTypedError(parsed, 'usage', 'detect-custom-files missing --config-dir');
|
||||
});
|
||||
|
||||
test('detect-custom-files invalid --config-dir → usage', () => {
|
||||
const parsed = runJsonErrors(
|
||||
['detect-custom-files', '--config-dir', '/nonexistent/path/xyzzy'],
|
||||
tmpDir
|
||||
);
|
||||
assertTypedError(parsed, 'usage', 'detect-custom-files invalid --config-dir');
|
||||
});
|
||||
|
||||
// ── Shape regression guard: every newly-typed path emits the canonical
|
||||
// {ok, reason, message} object — no leakage of reason: "unknown". ────
|
||||
|
||||
test('every remaining typed path emits the canonical {ok, reason, message} shape', () => {
|
||||
const probes = [
|
||||
['template', 'bogus'],
|
||||
['frontmatter', 'bogus', 'placeholder.md'],
|
||||
['requirements', 'bogus'],
|
||||
['milestone', 'bogus'],
|
||||
['uat', 'bogus'],
|
||||
['todo', 'bogus'],
|
||||
['workstream', 'bogus'],
|
||||
['graphify', 'bogus'],
|
||||
['learnings', 'bogus'],
|
||||
['intel', 'query'],
|
||||
['extract-messages'],
|
||||
['write-profile'],
|
||||
['detect-custom-files'],
|
||||
];
|
||||
for (const args of probes) {
|
||||
const parsed = runJsonErrors(args, tmpDir);
|
||||
const keys = Object.keys(parsed).sort();
|
||||
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
|
||||
`args ${args.join(' ')}: keys must be exactly {ok,reason,message}, got ${keys.join(',')}`);
|
||||
assert.notStrictEqual(parsed.reason, 'unknown',
|
||||
`args ${args.join(' ')}: reason must be a typed code, not the fallback "unknown"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-853-bg-dispatch-runtime-gating.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-853-bg-dispatch-runtime-gating (consolidation epic #1969 B2 #1971)", () => {
|
||||
'use strict';
|
||||
/**
|
||||
* Regression guard — bug(#853): /gsd-manager and /gsd-autonomous --interactive
|
||||
* silently skipped worktree isolation + independent verification because they
|
||||
* dispatched Plan/Execute via Agent(run_in_background=true). On Claude Code a
|
||||
* backgrounded agent has no Agent/Task tool, so it cannot spawn the nested
|
||||
* subagents (worktree executors, plan-checker, verifier). The workflows must
|
||||
* now resolve dispatch capability from the registry (#1708) and run inline
|
||||
* everywhere except runtimes where dispatch.background && dispatch.backgroundDispatch
|
||||
* are both true (currently: codex, cursor).
|
||||
*
|
||||
* Phase B (#1708): the prose `RUNTIME === 'codex'` rule is graduated to a typed
|
||||
* `gsd_run query dispatch-should-flatten` query backed by shouldFlattenDispatch()
|
||||
* from host-integration.cjs and the documentation-sourced capability registry.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempProject, cleanup: cleanupDir, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||
const MANAGER = fs.readFileSync(path.join(WORKFLOWS_DIR, 'manager.md'), 'utf8');
|
||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||
const AUTONOMOUS = fs.readFileSync(path.join(WORKFLOWS_DIR, 'autonomous.md'), 'utf8');
|
||||
|
||||
describe('bug-853 — manager/autonomous gate background dispatch by runtime', () => {
|
||||
test('manager.md resolves dispatch-should-flatten before dispatching plan/execute', () => {
|
||||
// Two dispatch sites (plan + execute), each must use dispatch-should-flatten.
|
||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||
const matches = MANAGER.match(/dispatch-should-flatten/g) || [];
|
||||
assert.ok(matches.length >= 2, 'manager.md must use dispatch-should-flatten for both plan and execute dispatch');
|
||||
});
|
||||
|
||||
test('manager.md documents why most runtimes cannot background-dispatch', () => {
|
||||
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
|
||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||
assert.match(MANAGER, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
|
||||
});
|
||||
|
||||
test('manager.md gates background dispatch on FLATTEN=false and runs plan/execute inline otherwise', () => {
|
||||
// Background path uses FLATTEN is false
|
||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||
assert.match(MANAGER, /If `FLATTEN` is `false`[\s\S]{0,400}?run_in_background=true/);
|
||||
// Inline is the default/else branch for plan — anchored on FLATTEN=true language (not runtime name)
|
||||
assert.match(
|
||||
MANAGER,
|
||||
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
|
||||
);
|
||||
// Inline is the default/else branch for execute — anchored on FLATTEN=true language (not runtime name)
|
||||
assert.match(
|
||||
MANAGER,
|
||||
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
|
||||
);
|
||||
});
|
||||
|
||||
test('manager.md compound action preamble uses FLATTEN language (not hardcoded runtime names)', () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||
const compoundActionSection = MANAGER.match(
|
||||
/### Compound Action \(background \+ inline\)[\s\S]*?Inline verification:/,
|
||||
);
|
||||
assert.ok(compoundActionSection, 'manager.md must document compound action runtime dispatch');
|
||||
|
||||
// Must gate on FLATTEN being false (not runtime name)
|
||||
assert.match(
|
||||
compoundActionSection[0],
|
||||
/If `FLATTEN` is `false`[\s\S]{0,400}?Spawn all background agents first[\s\S]{0,300}?plan\/execute/,
|
||||
);
|
||||
// Otherwise / inline branch must reference FLATTEN being true
|
||||
assert.match(
|
||||
compoundActionSection[0],
|
||||
/Otherwise[\s\S]{0,260}?`FLATTEN`[\s\S]{0,260}?`true`[\s\S]{0,260}?inline/,
|
||||
);
|
||||
// Must NOT still hardcode "On Codex:" in this section
|
||||
assert.doesNotMatch(
|
||||
compoundActionSection[0],
|
||||
/\*\*On Codex:\*\*/,
|
||||
);
|
||||
// Must NOT still hardcode "On Claude Code or any other non-Codex runtime:"
|
||||
assert.doesNotMatch(
|
||||
compoundActionSection[0],
|
||||
/On Claude Code or any other non-Codex runtime:/,
|
||||
);
|
||||
});
|
||||
|
||||
test('autonomous.md gates interactive background dispatch using dispatch-should-flatten', () => {
|
||||
// Two dispatch sites (3b plan + 3c execute), each must use dispatch-should-flatten.
|
||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||
const autoFlattenMatches = AUTONOMOUS.match(/dispatch-should-flatten/g) || [];
|
||||
assert.ok(autoFlattenMatches.length >= 2, 'autonomous.md must use dispatch-should-flatten in both 3b (plan) and 3c (execute) interactive branches');
|
||||
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
|
||||
assert.match(AUTONOMOUS, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
|
||||
});
|
||||
|
||||
test('autonomous.md gates interactive background dispatch on FLATTEN=false; runs plan/execute inline otherwise', () => {
|
||||
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-plan-phase is nearby
|
||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/);
|
||||
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-execute-phase is nearby
|
||||
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/);
|
||||
// Inline is the otherwise/else branch for plan — anchored on FLATTEN=true language (not runtime name)
|
||||
assert.match(
|
||||
AUTONOMOUS,
|
||||
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
|
||||
);
|
||||
// Inline is the otherwise/else branch for execute — anchored on FLATTEN=true language (not runtime name)
|
||||
assert.match(
|
||||
AUTONOMOUS,
|
||||
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch-should-flatten query — behavioral', () => {
|
||||
// #853 / #1708: The typed query replaces prose-level RUNTIME===codex checks.
|
||||
// shouldFlattenDispatch returns false only when both dispatch.background AND
|
||||
// dispatch.backgroundDispatch are true in the capability registry.
|
||||
//
|
||||
// Registry values (from host-integration-capability-matrix.md):
|
||||
// codex: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
|
||||
// claude: background=true, backgroundDispatch=false → shouldFlatten=true (must inline)
|
||||
// cursor: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
|
||||
// unknown: no entry → fail-closed → shouldFlatten=true (must inline)
|
||||
|
||||
test('runtime=codex → shouldFlatten=false (background dispatch safe)', () => {
|
||||
const tmpDir = createTempProject();
|
||||
try {
|
||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
||||
GSD_RUNTIME: 'codex',
|
||||
});
|
||||
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
||||
assert.strictEqual(result.output, 'false', `codex should return false (may background), got: ${result.output}`);
|
||||
} finally {
|
||||
cleanupDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime=claude → shouldFlatten=true (must inline)', () => {
|
||||
const tmpDir = createTempProject();
|
||||
try {
|
||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
||||
GSD_RUNTIME: 'claude',
|
||||
});
|
||||
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
||||
assert.strictEqual(result.output, 'true', `claude should return true (must inline), got: ${result.output}`);
|
||||
} finally {
|
||||
cleanupDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime=cursor → shouldFlatten=false (background dispatch safe)', () => {
|
||||
const tmpDir = createTempProject();
|
||||
try {
|
||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
||||
GSD_RUNTIME: 'cursor',
|
||||
});
|
||||
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
||||
assert.strictEqual(result.output, 'false', `cursor should return false (may background), got: ${result.output}`);
|
||||
} finally {
|
||||
cleanupDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown runtime → shouldFlatten=true (fail-closed → must inline)', () => {
|
||||
// An unknown runtime has no registry entry → dispatch is null → fail-closed to true.
|
||||
const tmpDir = createTempProject();
|
||||
try {
|
||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
||||
GSD_RUNTIME: 'unknown-runtime-xyz',
|
||||
});
|
||||
// The query must succeed (exit 0) even for unknown runtimes — fail-closed not crash-closed.
|
||||
assert.ok(result.success, `Expected success (fail-closed), got error: ${result.error}`);
|
||||
assert.strictEqual(result.output, 'true', `unknown runtime should return true (fail-closed), got: ${result.output}`);
|
||||
} finally {
|
||||
cleanupDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('--json flag returns structured { runtime, shouldFlatten, dispatch }', () => {
|
||||
const tmpDir = createTempProject();
|
||||
try {
|
||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--json'], tmpDir, {
|
||||
GSD_RUNTIME: 'codex',
|
||||
});
|
||||
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.output);
|
||||
} catch {
|
||||
assert.fail(`Expected valid JSON output, got: ${result.output}`);
|
||||
}
|
||||
assert.strictEqual(parsed.runtime, 'codex');
|
||||
assert.strictEqual(parsed.shouldFlatten, false);
|
||||
assert.ok(parsed.dispatch !== null && typeof parsed.dispatch === 'object', 'dispatch should be an object');
|
||||
assert.strictEqual(parsed.dispatch.backgroundDispatch, true);
|
||||
} finally {
|
||||
cleanupDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('config.runtime takes precedence when GSD_RUNTIME not set', () => {
|
||||
// GSD_RUNTIME > config.runtime > 'claude'
|
||||
// Write config.json with runtime=codex; no GSD_RUNTIME override.
|
||||
const tmpDir = createTempProject();
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ runtime: 'codex' }),
|
||||
'utf-8',
|
||||
);
|
||||
// Override GSD_RUNTIME to '' (empty string) so any ambient value is cleared.
|
||||
// resolveRuntimeNameFromCandidates treats empty string as absent (normalizes
|
||||
// to '' which is falsy → skipped → falls through to config.runtime=codex).
|
||||
// This is the only way to suppress an ambient GSD_RUNTIME since runGsdTools
|
||||
// merges { ...process.env, ...TEST_ENV_BASE, ...env } — passing '' as the
|
||||
// override overwrites the ambient value at the correct merge position.
|
||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
||||
GSD_RUNTIME: '',
|
||||
});
|
||||
// config.runtime=codex with GSD_RUNTIME cleared → codex backgrounds → shouldFlatten=false
|
||||
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
||||
assert.strictEqual(result.output, 'false', `config.runtime=codex (GSD_RUNTIME cleared) should return false (may background), got: ${result.output}`);
|
||||
} finally {
|
||||
cleanupDir(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user