Files
msd-core/tests/quick-batch-command-router.test.cjs
Tom Boucher 515191f07d feat(#3677): quick-batch hardening and acceptance (#4240)
* chore(#3677): checkpoint design artifacts (gitignored, dev-only)

* test(#3677): add failing regression test for the crash-window duplicate-dispatch gap (RED)

Independently re-traces resume-mode.md/planner-wave.md/worktree-dispatch.md/
merge-wave.md and src/quick-batch.cts's resumeBatch (lines 894-899) and
confirms the prior research pass's Open Question 1: a coordinator crash
between Step 6 (executor commits, SUMMARY.md written) and Step 7 (merge)
leaves BATCH.json at "pending" with no STATE.md row yet (only written in
Step 9), so --resume's eligibility re-derivation would dispatch a second
executor into a new worktree for the same item, orphaning the first.

This test asserts worktree-dispatch.md's Step 6 excludes an item whose
SUMMARY.md already exists from the spawn set, mirroring planner-wave.md's
existing PLAN.md-existence check one layer earlier. Fails against the
current worktree-dispatch.md, which has no such guard.

See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §1
for the full trace and fix-location rationale.

* fix(#3677): guard worktree-dispatch.md against re-dispatching an already-executed item (GREEN)

worktree-dispatch.md's Step 6 re-derives eligibility every dispatch round
via the same quick-batch resume call resume-mode.md uses, but had no check
for "did this item already finish executing" the way planner-wave.md
already checks "did this item already get planned" (PLAN.md existence)
before re-planning. A coordinator crash between Step 6 (executor commits,
SUMMARY.md written) and Step 7 (merge) left the item eligible for a second
dispatch on --resume, orphaning the first worktree's real, already-
committed work and silently losing it once the second executor's SUMMARY.md
write clobbered the first at the same item_dir path.

Adds a SUMMARY.md-existence exclusion before spawn-plan is computed,
symmetric to planner-wave.md's PLAN.md check. The excluded item is not
lost: merge-wave.md's own mergeable-wave criterion (status=pending,
SUMMARY.md on disk, not yet merged) already picks it up independently of
this eligible/spawn list.

Workflow-prose-only fix — touches no already-merged/reviewed .cts module.
See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §1
for the fix-location rationale (why not resumeBatch itself).

* test(#3677): add real-git coverage for worktree-ownership tampering, scope drift, and submodules

Closes the three coverage gaps identified in 40-design.md §2/§3 (#3677,
epic #3344 Phase 5's own AC bullets: "arbitrary-worktree ownership
attempts", "scope drift", "submodules"):

- Arbitrary-worktree ownership tampering: a manifest entry naming a
  non-agent branch is silently dropped at normalization before any git
  subprocess runs; a manifest entry naming a plausible agent-branch that
  was never actually created by this repo's own worktree.create (a
  genuinely foreign repo/branch) is blocked via base_mismatch. Both leave
  the foreign location and repoRoot's HEAD provably untouched.

- Advisory scope drift: a committed path outside declared files_modified
  still merges successfully (advisory, never blocking) while surfacing a
  scope_out_of_declared warning naming the drifted path; an exact
  declared-scope match produces zero warnings (boundary case).

- Real .gitmodules submodule integration: a repo containing a real local
  git submodule merges cleanly through executeWorktreeWaveCleanupPlan for
  an unrelated plan; a real gitlink pointer bump (declared) merges cleanly
  with the superproject tree reflecting the new pinned commit; an
  undeclared bump is advisory-only and surfaces a scope warning naming
  vendor/sub, same as any other undeclared modification.

No src/*.cts changes — all three gaps were coverage-only; the underlying
primitives already behaved correctly (independently verified against real
git subprocess output before writing each assertion).

* docs(#3677): document how to diagnose a preserved quick-batch worktree

Extends the one-sentence "worktree is preserved (never deleted)" mention
into a concrete diagnosis procedure: where the preserved directory is, how
to read the executor's real commits/diff against the plan's declared
files_modified, how to read the item's own SUMMARY.md independent of merge
outcome, how to manually merge-and-clean-up or discard, and how to re-run
--resume afterward. Also documents that a SUMMARY.md-written-but-still-
pending item (the crash-window case fixed in this same PR) needs no manual
intervention — --resume routes it straight to the merge step.

* chore(#3677): checkpoint final acceptance-evidence mapping (gitignored, dev-only)

* fix(#3677): make crash-window duplicate-dispatch guard behaviorally provable and durably recoverable

Orthogonal review (Spec finding): the crash-window regression test added
earlier this phase only asserted readStep('worktree-dispatch.md') + regex
matches against the markdown prose — proving the DOCUMENTATION says the
right thing, never that the runtime condition (pending status + on-disk
SUMMARY.md + absent STATE row) is actually handled correctly. #3677's own
"Alternatives considered" explicitly rejects "document recovery without
fault injection" for exactly this reason.

Extracts the filtering decision into a pure, independently testable
function, filterAlreadyExecuted(eligibleIds, executedIds) in
src/quick-batch-dispatch.cts, wired to a new `quick-batch filter-executed`
CLI verb (src/quick-batch-command-router.cts) — the same pure-decision-
then-CLI-wired pattern computeSpawnPlan/computeMergeOrder already
establish. worktree-dispatch.md now calls this verb explicitly instead of
only describing the decision in prose. A genuine fixture-based test in
tests/quick-batch.test.cjs constructs a REAL BATCH.json (createBatch),
writes a REAL SUMMARY.md on disk at the item's real item_dir, calls the
REAL resumeBatch, and proves both that resumeBatch alone still reports the
item eligible AND that filterAlreadyExecuted (fed a real filesystem check)
correctly excludes it. The prior prose-assertion tests are kept — they now
prove the workflow markdown is correctly WIRED to the verb — but are no
longer the only proof.

Self-discovered defect while building that fixture (fixed inline, not
deferred): tracing merge-wave.md against /gsd:quick's own prior art
(QUICK_WORKTREE_MANIFEST=$(mktemp ...), quick.md:415) showed
$QUICK_BATCH_WORKTREE_MANIFEST is a fresh PER-PROCESS temp file. A resumed
coordinator correctly does not re-dispatch an already-executed item (this
fix), but nothing durably recorded that item's worktree_path/branch/base
either — Step 7 in the resumed process would have had no data to build its
cleanup-wave entry from. Adds dispatched_worktree/dispatched_branch/
dispatched_base to QuickBatchItem (src/quick-batch.cts) — deliberately NOT
a reuse of the pre-existing `worktree` field, whose loadBatch validation
requires the path to exist on disk (verified empirically: reusing it made
the batch permanently unloadable the moment a legitimately-merged worktree
was removed). worktree-dispatch.md persists the triple once a worktree is
created; merge-wave.md falls back to it when the ephemeral manifest lacks
an entry, clears it after a successful merge, and fails closed rather than
guessing if no record exists anywhere.

See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §9.1
and §9.3 for the full trace, empirical verification notes, and rejected
alternatives (reusing `worktree` directly).

* test(#3677): prove the arbitrary-worktree-ownership boundary against two real sibling worktrees

Orthogonal review (Security finding): the two existing ownership-tampering
tests didn't test ownership — one was trivially rejected by
WORKTREE_AGENT_BRANCH_RE's shape check before any git call (proves branch-
NAME filtering, not ownership), the other pointed at a wholly separate,
never-linked foreign repo, so merge-base failed immediately because the
branch didn't exist as a ref at all. Neither exercised the real scenario:
a manifest entry whose worktree_path/branch are swapped to point at a
DIFFERENT, GENUINELY-REGISTERED sibling worktree of the SAME repoRoot,
with a branch name passing the shape check and a base in allowed_bases.

Investigated executeWorktreeWaveCleanupPlan (src/worktree-safety.cts)
directly: this is NOT a reachable gap. Git enforces branch-per-worktree
uniqueness, so a swapped-in entry.branch can only match worktree_path's
ACTUAL checked-out branch if it names that sibling's own real, uniquely-
generated branch name — which manifest tampering confined to one batch's
own record has no way to know (branch names are
agent-<quick_id>[-<timestamp>]-shaped, and quick_id allocation is
collision-checked GLOBALLY across every existing quick task and batch, not
merely within one batch).

Adds a stronger test that empirically proves this: two REAL, concurrently-
alive sibling worktrees of the same repo (both via real `git worktree add`,
both WORKTREE_AGENT_BRANCH_RE-passing, both sharing one merge-base), with
worktree_path/branch swapped between them in both directions. Both attempts
are blocked via branch_mismatch; both real worktrees, their branches, and
one sibling's real uncommitted-to-main commit survive completely untouched.
Supplements (does not replace) the original two tests, which still prove
distinct, real boundaries.

See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §9.2
for the full trace, including the one explicitly-documented (not fixed)
trust boundary this investigation surfaced: the primitive defends against
fabricated data, not a caller bug that misattributes a real-but-wrong
item's own triple to a different item.

* chore(#3677): checkpoint design-doc addendum for review pass 2 findings (gitignored, dev-only)

* docs(#3677): add changeset for PR 4240

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:47:22 -04:00

531 lines
20 KiB
JavaScript

'use strict';
/**
* quick-batch-command-router.test.cjs — Behavioral tests for the
* `gsd-tools quick-batch` command router (#3676, Phase 4 of epic #3344 /
* ADR-1239 "Quick-batch binding").
*
* Module: gsd-core/bin/lib/quick-batch-command-router.cjs
* (compiled from src/quick-batch-command-router.cts)
*
* Follows `tests/roadmap-command-router.test.cjs`'s pattern: unit-level
* tests inject `_quickBatch`/`_quickBatchDispatch` mocks (same `_`-prefix
* seam convention `graphify-command-router.cts` established) and assert on
* recorded call shapes; a smaller set of end-to-end tests drive the REAL
* compiled router through `gsd-tools quick-batch <verb>` via `runGsdTools`
* to prove the `HOST_COMMAND_ROUTERS` wiring itself (test-matrix rows 46-47).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { routeQuickBatchCommand } = require('../gsd-core/bin/lib/quick-batch-command-router.cjs');
const { runGsdTools, cleanup } = require('./helpers.cjs');
function mkTmpProject() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'quick-batch-router-'));
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
return dir;
}
// ─── Unit-level: argument shaping against injected mocks ───────────────────
describe('quick-batch-command-router: argument shaping (mocked modules)', () => {
test('create requires --file', () => {
let message = null;
routeQuickBatchCommand({
args: ['quick-batch', 'create'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: {},
_quickBatchDispatch: {},
});
assert.match(message, /--file/);
});
test('create parses --file/--base-revision/--options and forwards to parseTaskListFromFile + createBatch', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'create', '--file', 'tasks.md', '--base-revision', 'deadbeef', '--options', '{"note":"x"}'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {
parseTaskListFromFile: (cwd, filePath) => {
calls.push({ fn: 'parseTaskListFromFile', cwd, filePath });
return { ok: true, value: [{ description: 'a' }, { description: 'b' }] };
},
createBatch: (cwd, items, options) => {
calls.push({ fn: 'createBatch', cwd, items, options });
return { ok: true, value: { batchId: 'x' } };
},
},
_quickBatchDispatch: {},
});
assert.equal(calls[0].fn, 'parseTaskListFromFile');
assert.equal(calls[0].filePath, 'tasks.md');
assert.equal(calls[1].fn, 'createBatch');
assert.deepEqual(calls[1].items, [{ description: 'a' }, { description: 'b' }]);
assert.equal(calls[1].options.baseRevision, 'deadbeef');
assert.deepEqual(calls[1].options.batchOptions, { note: 'x' });
});
test('update requires --batch and --updates', () => {
let message = null;
routeQuickBatchCommand({
args: ['quick-batch', 'update', '--batch', 'b1'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: {},
_quickBatchDispatch: {},
});
assert.match(message, /--updates/);
});
test('update parses --updates JSON and forwards to updateBatchItems', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'update', '--batch', 'b1', '--updates', '[{"quickId":"260101-abc","dependsOn":[]}]'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {
updateBatchItems: (cwd, batchId, updates) => {
calls.push({ cwd, batchId, updates });
return { ok: true, value: { manifest: {} } };
},
},
_quickBatchDispatch: {},
});
assert.equal(calls[0].batchId, 'b1');
assert.deepEqual(calls[0].updates, [{ quickId: '260101-abc', dependsOn: [] }]);
});
test('update rejects malformed --updates JSON before calling updateBatchItems', () => {
let message = null;
let called = false;
routeQuickBatchCommand({
args: ['quick-batch', 'update', '--batch', 'b1', '--updates', 'not-json'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: { updateBatchItems: () => { called = true; return { ok: true, value: {} }; } },
_quickBatchDispatch: {},
});
assert.match(message, /not valid JSON/);
assert.equal(called, false);
});
test('resume requires --batch', () => {
let message = null;
routeQuickBatchCommand({
args: ['quick-batch', 'resume'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: {},
_quickBatchDispatch: {},
});
assert.match(message, /--batch/);
});
test('resume forwards --current-base-revision when present', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'resume', '--batch', 'b1', '--current-base-revision', 'cafebabe'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {
resumeBatch: (cwd, batchId, options) => {
calls.push({ cwd, batchId, options });
return { ok: true, value: { eligible: [] } };
},
},
_quickBatchDispatch: {},
});
assert.equal(calls[0].options.currentBaseRevision, 'cafebabe');
});
test('complete requires all of --batch/--quick-id/--description/--date/--commit', () => {
let message = null;
routeQuickBatchCommand({
args: ['quick-batch', 'complete', '--batch', 'b1', '--quick-id', '260101-abc'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: {},
_quickBatchDispatch: {},
});
assert.match(message, /Usage: gsd-tools quick-batch complete/);
});
test('effective-concurrency forwards jobs/task-count/capacity/isolation/mutating to the dispatch module', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'effective-concurrency', '--jobs', '4', '--task-count', '8', '--capacity', '3', '--isolation', 'none', '--mutating'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: {
computeEffectiveConcurrency: (input) => { calls.push(input); return 1; },
},
});
assert.deepEqual(calls[0], { jobs: 4, taskCount: 8, capacity: 3, isolation: 'none', mutating: true });
});
test('effective-concurrency accepts --jobs auto', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'effective-concurrency', '--jobs', 'auto', '--task-count', '8', '--capacity', '3', '--isolation', 'harness-worktree'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: {
computeEffectiveConcurrency: (input) => { calls.push(input); return 3; },
},
});
assert.equal(calls[0].jobs, 'auto');
assert.equal(calls[0].mutating, false, '--mutating omitted defaults to false');
});
test('merge-eligible parses --wave-order/--ready JSON arrays', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'merge-eligible', '--wave-order', '["a","b"]', '--ready', '["a"]'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: {
computeMergeOrder: (waveOrder, readyIds) => { calls.push({ waveOrder, readyIds }); return ['a']; },
},
});
assert.deepEqual(calls[0].waveOrder, ['a', 'b']);
assert.deepEqual([...calls[0].readyIds], ['a']);
});
test('spawn-plan forwards eligible/capacity/in-flight/refused', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'spawn-plan', '--eligible', '["a","b","c"]', '--capacity', '2', '--in-flight', '0', '--refused', '["b"]'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: {
computeSpawnPlan: (input) => { calls.push(input); return { spawn: [], pending: [] }; },
},
});
assert.deepEqual(calls[0], { eligibleIds: ['a', 'b', 'c'], capacity: 2, currentInFlight: 0, refused: ['b'] });
});
// #3677: crash-window duplicate-dispatch guard CLI verb.
test('filter-executed forwards eligible/executed to filterAlreadyExecuted', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'filter-executed', '--eligible', '["a","b","c"]', '--executed', '["b"]'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: {
filterAlreadyExecuted: (eligibleIds, executedIds) => { calls.push({ eligibleIds, executedIds }); return { spawnEligible: [], alreadyExecuted: [] }; },
},
});
assert.deepEqual(calls[0], { eligibleIds: ['a', 'b', 'c'], executedIds: ['b'] });
});
test('filter-executed rejects a missing --eligible', () => {
let message = null;
routeQuickBatchCommand({
args: ['quick-batch', 'filter-executed', '--executed', '["b"]'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: {},
_quickBatchDispatch: {},
});
assert.match(message, /--eligible/);
});
test('filter-executed rejects a missing --executed', () => {
let message = null;
routeQuickBatchCommand({
args: ['quick-batch', 'filter-executed', '--eligible', '["a"]'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: {},
_quickBatchDispatch: {},
});
assert.match(message, /--executed/);
});
test('verification-routing rejects an invalid --status', () => {
let message = null;
routeQuickBatchCommand({
args: ['quick-batch', 'verification-routing', '--status', 'bogus'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: {},
_quickBatchDispatch: {},
});
assert.match(message, /--status/);
});
test('verification-routing forwards a valid --status', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'verification-routing', '--status', 'gaps_found'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: { routeVerificationOutcome: (status) => { calls.push(status); return { action: 'fail' }; } },
});
assert.deepEqual(calls, ['gaps_found']);
});
test('merge-routing rejects an invalid --kind', () => {
let message = null;
routeQuickBatchCommand({
args: ['quick-batch', 'merge-routing', '--kind', 'bogus'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: {},
_quickBatchDispatch: {},
});
assert.match(message, /--kind/);
});
test('merge-routing forwards --kind and optional --detail', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'merge-routing', '--kind', 'merge_failed', '--detail', 'conflict'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: { routeMergeOutcome: (outcome) => { calls.push(outcome); return { action: 'fail' }; } },
});
assert.deepEqual(calls[0], { kind: 'merge_failed', detail: 'conflict' });
});
test('cleanup-entry requires --worktree-path/--branch/--expected-base/--plan-content', () => {
let message = null;
routeQuickBatchCommand({
args: ['quick-batch', 'cleanup-entry', '--branch', 'b'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: {},
_quickBatchDispatch: {},
});
assert.match(message, /Usage: gsd-tools quick-batch cleanup-entry/);
});
test('cleanup-entry forwards all fields, defaulting --agent-id to null when omitted', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'cleanup-entry', '--worktree-path', '/tmp/wt', '--branch', 'b', '--expected-base', 'main', '--plan-content', 'text', '--allowed-bases', '["main"]'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: { buildCleanupManifestEntry: (input) => { calls.push(input); return {}; } },
});
assert.deepEqual(calls[0], {
agentId: null,
worktreePath: '/tmp/wt',
branch: 'b',
expectedBase: 'main',
allowedBases: ['main'],
planContent: 'text',
});
});
test('parse-args forwards everything after -- to parseQuickBatchArgs', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'parse-args', '--', '--jobs', '4', '--validate'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: {
parseQuickBatchArgs: (rawArgs) => { calls.push(rawArgs); return { ok: true, value: {} }; },
},
});
assert.deepEqual(calls[0], ['--jobs', '4', '--validate']);
});
// #3676 security fix: `--text` accepts the ENTIRE raw $ARGUMENTS string as
// ONE argv element (the caller quotes it, e.g. `--text "$ARGUMENTS"`), so
// shell word-splitting/pathname-expansion on attacker-influenced task text
// never happens before this parser sees it. The split into tokens happens
// HERE, in Node, which never glob-expands.
test('parse-args --text splits the whole string into tokens itself (no shell involvement)', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'parse-args', '--text', '--jobs 4 --validate'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: {
parseQuickBatchArgs: (rawArgs) => { calls.push(rawArgs); return { ok: true, value: {} }; },
},
});
assert.deepEqual(calls[0], ['--jobs', '4', '--validate']);
});
test('parse-args --text with an embedded glob-shaped token passes it through literally, unexpanded', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'parse-args', '--text', '- fix files matching *.txt\n- second task'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: {
parseQuickBatchArgs: (rawArgs) => { calls.push(rawArgs); return { ok: true, value: {} }; },
},
});
// The glob-shaped token survives as literal text tokens — never
// expanded to matching filenames, because it never passed through a
// shell glob context (the caller quoted it; this handler's own
// whitespace split is not glob-aware).
assert.ok(calls[0].includes('*.txt'));
});
test('parse-args --text with only whitespace produces an empty token array', () => {
const calls = [];
routeQuickBatchCommand({
args: ['quick-batch', 'parse-args', '--text', ' '],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
_quickBatch: {},
_quickBatchDispatch: {
parseQuickBatchArgs: (rawArgs) => { calls.push(rawArgs); return { ok: true, value: {} }; },
},
});
assert.deepEqual(calls[0], []);
});
test('a domain Result failure (ok:false) is routed through error(), not treated as success', () => {
let message = null;
routeQuickBatchCommand({
args: ['quick-batch', 'resume', '--batch', 'b1'],
cwd: '/tmp/proj',
raw: true,
error: (msg) => { message = msg; },
_quickBatch: { resumeBatch: () => ({ ok: false, reason: 'no BATCH.json found for batch b1' }) },
_quickBatchDispatch: {},
});
assert.equal(message, 'no BATCH.json found for batch b1');
});
});
// ─── End-to-end: real router wiring via HOST_COMMAND_ROUTERS (rows 46-47) ──
describe('quick-batch-command-router: end-to-end via gsd-tools (rows 46-47)', () => {
test('row 47: unknown subcommand errors via the Hub\'s manifest check, same shape as graphify\'s', () => {
const dir = mkTmpProject();
try {
const result = runGsdTools(['quick-batch', 'nonsense'], dir);
assert.equal(result.success, false);
assert.match(result.error, /Unknown quick-batch subcommand\. Available:/);
} finally {
cleanup(dir);
}
});
test('row 46: create -> resume round-trips a real batch through the real router', () => {
const dir = mkTmpProject();
try {
const tasksFile = path.join(dir, '.planning', 'tasks.md');
fs.writeFileSync(tasksFile, '- first task\n- second task\n');
const created = runGsdTools(['quick-batch', 'create', '--file', tasksFile, '--raw'], dir);
assert.equal(created.success, true, `create failed: ${created.error}`);
const createdJson = JSON.parse(created.output);
assert.ok(createdJson.batchId);
const resumed = runGsdTools(['quick-batch', 'resume', '--batch', createdJson.batchId, '--raw'], dir);
assert.equal(resumed.success, true, `resume failed: ${resumed.error}`);
const resumedJson = JSON.parse(resumed.output);
assert.equal(resumedJson.eligible.length, 2, 'both items are eligible before any leaf runs');
} finally {
cleanup(dir);
}
});
test('effective-concurrency verb is reachable end-to-end and returns a number', () => {
const dir = mkTmpProject();
try {
const result = runGsdTools(['quick-batch', 'effective-concurrency', '--jobs', 'auto', '--task-count', '5', '--capacity', '3', '--isolation', 'harness-worktree', '--raw'], dir);
assert.equal(result.success, true, `command failed: ${result.error}`);
assert.deepEqual(JSON.parse(result.output), { concurrency: 3 });
} finally {
cleanup(dir);
}
});
// Security/Spec review fix (#3676 review pass 3): row 9 previously asserted
// rejection only at the pure parseQuickBatchArgs level, which has no I/O to
// begin with — it never proves the WORKFLOW-LEVEL invariant "a rejected
// --jobs value never reaches quick-batch create, so no partial BATCH.json
// exists." Assert that end-to-end: reject via the real CLI parse-args verb,
// then confirm .planning/quick-batches/ was never created at all.
describe('row 9: a rejected --jobs value leaves no partial BATCH.json (hostile)', () => {
for (const badJobs of ['0', '-1', 'abc']) {
test(`--jobs ${badJobs} is rejected and .planning/quick-batches/ stays absent`, () => {
const dir = mkTmpProject();
try {
const result = runGsdTools(['quick-batch', 'parse-args', '--raw', '--text', `--jobs ${badJobs}`], dir);
assert.equal(result.success, false, `expected rejection for --jobs ${badJobs}`);
assert.equal(
fs.existsSync(path.join(dir, '.planning', 'quick-batches')),
false,
'parse-args must never create .planning/quick-batches/ — createBatch is never reached after a rejected --jobs value',
);
} finally {
cleanup(dir);
}
});
}
});
// Spec review fix: row 18 previously only exercised loadBatch against a
// hand-corrupted BATCH.json — never a genuinely nonexistent batch
// directory (the actual row-18 shape: "--resume <unknown-batch-id>").
test('row 18: --resume <unknown-batch-id> fails closed with no batch directory ever created', () => {
const dir = mkTmpProject();
try {
// No .planning/quick-batches/<id>/ directory exists at all for this id —
// never created, never touched by any prior call in this test.
const result = runGsdTools(['quick-batch', 'resume', '--batch', '999999-zzz', '--raw'], dir);
assert.equal(result.success, false);
assert.match(result.error, /no BATCH\.json found for batch 999999-zzz/);
assert.equal(
fs.existsSync(path.join(dir, '.planning', 'quick-batches', '999999-zzz')),
false,
'resume must never create a batch directory for an unknown id',
);
} finally {
cleanup(dir);
}
});
});