Files
msd-core/tests/bug-2767-gsd-sdk-commit-files-flag.test.cjs
Tom Boucher 49dcabff26 fix(3749): port strategy-branch switching to SDK commit handler (#3763)
* test(3749): add RED test — SDK commit handler missing strategy-branch port

Structural assertions on sdk/src/query/commit.ts verify the branching-strategy
block (phase/milestone) is present. 9 tests fail today; pass after the fix.

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

* fix(3749): port PR #1279 strategy-branch logic from CJS to SDK commit handler

sdk/src/query/commit.ts lacked the branching-strategy block that PR #1279
added to commands.cjs:285-320. Pre-execution workflow commits (discuss-phase,
plan-phase) with branching_strategy:"phase" or "milestone" were landing on
whatever branch was active rather than the configured strategy branch.

Changes:
- sdk/src/query/phase.ts: export findPhaseByNumber() so commit.ts can look up
  a phase without going through the QueryHandler dispatch stack
- sdk/src/query/commit.ts: import loadConfig, findPhaseByNumber, getMilestoneInfo;
  add ensureStrategyBranch() helper (extracts the logic, preserving SRP);
  call it between the commit_docs check and the staging step

Semantics match the CJS path exactly: best-effort (errors are swallowed so a
misconfigured branching_strategy never aborts a commit), same phase-number
extraction from file paths, same checkout -b / checkout fallback sequence,
same current-branch guard to avoid repeated checkout calls.

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

* chore(3749): update changeset to reference PR #3763

* fix(3749): validate phase_branch_template before substitution

Before this commit, a missing or empty `phase_branch_template` (or
`milestone_branch_template`) would cause `.replace()` to throw inside
the try-block, which was swallowed by the surrounding catch → silent
strategy-skip with no observable signal.

Exports `validateBranchTemplate(template)` as a pure helper that
checks the template is a non-empty string BEFORE any `.replace()` call
is attempted.  Also exports `resolveStrategyBranchName(template,
phaseNumber, phaseSlug)` which validates that no `{placeholder}` tokens
survive substitution.

Both an invalid template and an unresolved-placeholder result now return
`{ ok: false, reason: '...' }` from `ensureStrategyBranch`, surfaced
distinctly — satisfying the no-silent-skip contract (codex finding 3).

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

* test(3749): replace source-includes with typed-IR assertions on extracted helpers

The original test file used `source.includes(...)` throughout — grep
theater that verifies text presence, not behavior (codex finding 4 /
test-rigor Contract 1 violation).

This commit upgrades the test suite:

1. Typed-IR unit tests (ts-node, skipped gracefully when unavailable):
   - `parsePhasesFromFiles`: empty, single-phase, mixed-phase, dotted
     phase numbers, root numeric tokens
   - `validateBranchTemplate`: undefined, empty, whitespace, valid
   - `resolveStrategyBranchName`: well-formed, unresolved placeholder,
     slug fallback

2. Structural assertions (retained and tightened) now verify:
   - Named exports of the three helpers exist (enabling typed-IR tests)
   - Caller halts on `strategyResult.ok === false` (Finding 2)
   - Mixed-phase rejection message contains "single phase" (Finding 1)
   - Template validation precedes resolveStrategyBranchName in source
     (Finding 3, using lastIndexOf to skip helper definitions)
   - `alreadyExists` guard and `branch_switch_failed` reason present
     (Finding 2)

Old structural tests that verified implementation tokens
(loadConfig, phase_branch_template, --abbrev-ref, etc.) are
retained as they verify observable contracts, not code style.

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

* fix(3749): bug-2767 tests skip cleanly when sdk/dist is absent (was hard-fail)

The 4 behavioral tests in bug-2767-gsd-sdk-commit-files-flag.test.cjs
invoke the built SDK CLI (sdk/dist/cli.js) end-to-end. When that dist is
absent, they threw MODULE_NOT_FOUND and were reported as 4 hard failures
rather than observable skips.

Apply the same `if (!existsSync(SDK_CLI)) { t.skip(...); return; }` guard
already used in bug-3019-help-passthrough.test.cjs. When dist is present,
all 4 tests run and pass. When dist is absent, all 4 emit a ﹣ skip line
with an actionable reason ('run `cd sdk && npm run build`'), leaving 0
hard failures and maintaining full observability.

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

* fix(3749): address pr-review-toolkit + codex review findings

- Add allow-test-rule annotation to satisfy lint-no-source-grep
- Remove dead identifiers (registerScript, tsConfigPath, os import)
- Standardize issue #1278 / PR #1279 references in JSDoc
- Document root-level phase-number false-positive in parsePhasesFromFiles
- Generalize resolveStrategyBranchName parameter naming (phase + milestone reuse)
- Add behavioral integration test for commit handler with branching_strategy: phase

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

* fix(3749): normalize extracted phase token in phaseTokenMatches

parsePhasesFromFiles extracts "1" from a path like ".planning/phases/1-setup/PLAN.md".
normalizePhaseName pads this to "01" before passing it to searchPhaseInDir, which calls
phaseTokenMatches("1-setup", "01"). extractPhaseToken("1-setup") returned "1" (raw,
unpadded), so "1" !== "01" and the phase directory was not found — ensureStrategyBranch
fell through with "phase not found" and never switched the branch.

Fix: normalize the extracted token via normalizePhaseName before comparing so that "1"
and "01" both resolve to "01" and match correctly. Applied to both the primary comparison
and the project-code-prefix-stripping retry path.

Verified by bug-3749-sdk-commit-strategy-branch-integration.test.cjs passing:
  ✔ commit with --files in a phase dir switches to the strategy branch
  ✔ commit with --files outside a phase dir skips branch switch

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:11:33 -04:00

319 lines
13 KiB
JavaScript

/**
* Bug #2767: Workflows pass paths positionally to `gsd-sdk query commit`.
*
* Runtime behavior under the buggy form (paths positional, no `--files`):
* 1. positional path tokens are joined into the commit subject (commit.ts:110); and
* 2. `filePaths` is empty, so the handler falls back to staging `.planning/`
* wholesale (commit.ts:136), silently swapping the user's intent.
*
* Under the well-formed form (`--files <path...>`):
* - subject is the message arg only;
* - exactly the listed files are staged;
* - `commit-to-subrepo` rejects when `--files` is absent (commit.ts:258).
*
* Note: the supplementary doc-lint's `isWellFormed` accepts any invocation that
* has no positional path args after the message — i.e. message-only commits pass
* regardless of any trailing comment. There is no required marker (`# message-only`
* or otherwise); the absence of positional path tokens is the sole signal.
*
* Primary test: invoke the actual `gsd-sdk query commit[-to-subrepo]` binary
* against a real tmp git project and assert the runtime behavior. Supplementary
* test: a doc-lint that scans every shipped .md file to catch regressions of
* the 50-file invocation cleanup landed in this PR. The behavioral tests are
* the contract; the lint is a defense-in-depth guard.
*/
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 { execFileSync } = require('node:child_process');
const { createTempGitProject, cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const SDK_CLI = path.join(REPO_ROOT, 'sdk', 'dist', 'cli.js');
/**
* Run a git command with hardcoded argv (no shell). Returns trimmed stdout.
*/
function git(projectDir, args) {
return execFileSync('git', args, { cwd: projectDir, encoding: 'utf-8' }).trim();
}
/**
* Invoke `gsd-sdk query <subcommand> <...args>` against a project dir.
* Returns { exitCode, stdout, stderr, json } where json is the parsed handler
* payload (the SDK prints a single JSON object to stdout for query handlers).
*/
function runSdkQuery(subcommand, args, projectDir) {
const argv = ['query', subcommand, ...args, '--project-dir', projectDir];
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
stdout = execFileSync(process.execPath, [SDK_CLI, ...argv], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GSD_SESSION_KEY: '' },
});
} catch (err) {
exitCode = err.status ?? 1;
stdout = err.stdout?.toString() ?? '';
stderr = err.stderr?.toString() ?? '';
}
// Extract the trailing JSON object — the CLI prints status lines before it.
let json = null;
const lastBrace = stdout.lastIndexOf('{');
if (lastBrace >= 0) {
try { json = JSON.parse(stdout.slice(lastBrace).trim()); } catch { /* leave null */ }
if (!json) {
try { json = JSON.parse(stdout.trim()); } catch { /* leave null */ }
}
}
return { exitCode, stdout, stderr, json };
}
function gitSubject(projectDir) {
return git(projectDir, ['log', '-1', '--pretty=%s']);
}
function gitFilesAt(projectDir) {
return git(projectDir, ['show', '--pretty=', '--name-only', 'HEAD'])
.split('\n').filter(Boolean).sort();
}
// ─── Behavioral SDK tests ────────────────────────────────────────────────────
describe('bug #2767 (behavioral): gsd-sdk query commit --files', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject('gsd-2767-');
fs.writeFileSync(path.join(tmpDir, 'foo.md'), 'foo body\n');
fs.writeFileSync(path.join(tmpDir, 'bar.md'), 'bar body\n');
// .planning/ change that MUST NOT leak into the commit when --files is used.
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'state\n');
});
afterEach(() => {
cleanup(tmpDir);
});
test('well-formed: --files <paths> stages exactly those files with clean subject', (t) => {
if (!fs.existsSync(SDK_CLI)) {
t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test');
return;
}
const message = 'test(#2767): well-formed commit';
const result = runSdkQuery('commit', [message, '--files', 'foo.md', 'bar.md'], tmpDir);
assert.equal(result.exitCode, 0, `cli failed: ${result.stderr}`);
assert.ok(result.json, `expected JSON body in stdout, got:\n${result.stdout}`);
assert.equal(result.json.committed, true, `commit failed: ${JSON.stringify(result.json)}`);
assert.equal(result.json.message, message, 'subject must be message-only — no path leakage');
assert.deepEqual(result.json.files.slice().sort(), ['bar.md', 'foo.md']);
// Cross-check via git itself.
assert.equal(gitSubject(tmpDir), message);
assert.deepEqual(gitFilesAt(tmpDir), ['bar.md', 'foo.md']);
// The .planning/STATE.md change must remain unstaged/unrelated.
const stillDirty = git(tmpDir, ['status', '--porcelain']);
assert.ok(stillDirty.includes('.planning/STATE.md'),
`.planning/STATE.md should remain unstaged, got status:\n${stillDirty}`);
});
test('buggy form (positional, no --files): paths leak into subject AND .planning/ fallback fires', (t) => {
if (!fs.existsSync(SDK_CLI)) {
t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test');
return;
}
// Documents the misbehavior #2767 prevents at every workflow call site.
// Any future change that makes the buggy form silently "do the right thing"
// trips this test and must justify the change.
const message = 'test(#2767): positional buggy';
const result = runSdkQuery('commit', [message, 'foo.md', 'bar.md'], tmpDir);
assert.equal(result.exitCode, 0);
assert.ok(result.json, `expected JSON body, got:\n${result.stdout}`);
assert.equal(result.json.committed, true);
// Subject got polluted with the path tokens.
assert.equal(result.json.message, `${message} foo.md bar.md`,
'paths leak into the commit subject under the buggy form (commit.ts:110)');
assert.equal(gitSubject(tmpDir), `${message} foo.md bar.md`);
// Fallback staged .planning/STATE.md, NOT foo.md/bar.md.
assert.deepEqual(gitFilesAt(tmpDir), ['.planning/STATE.md'],
'positional form triggers the .planning/ wholesale fallback (commit.ts:136)');
const dirty = git(tmpDir, ['status', '--porcelain']);
assert.ok(dirty.includes('foo.md') && dirty.includes('bar.md'),
`foo.md/bar.md should remain unstaged under the buggy form, got:\n${dirty}`);
});
test('positional form with no .planning/ change: returns "nothing staged"', (t) => {
if (!fs.existsSync(SDK_CLI)) {
t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test');
return;
}
// Reset the .planning/STATE.md change so the fallback has nothing to stage.
fs.rmSync(path.join(tmpDir, '.planning', 'STATE.md'), { force: true });
const result = runSdkQuery('commit', ['msg', 'foo.md'], tmpDir);
assert.equal(result.exitCode, 0);
assert.ok(result.json, `expected JSON body, got:\n${result.stdout}`);
assert.equal(result.json.committed, false);
assert.equal(result.json.reason, 'nothing staged',
'positional form ignores foo.md and finds nothing under .planning/ to fall back to');
});
});
describe('bug #2767 (behavioral): commit-to-subrepo requires --files', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject('gsd-2767-sub-');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['vendor/lib'] }, null, 2),
);
fs.mkdirSync(path.join(tmpDir, 'vendor', 'lib'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'vendor', 'lib', 'README.md'), 'sub\n');
});
afterEach(() => {
cleanup(tmpDir);
});
test('rejects with explicit error when --files is omitted', (t) => {
if (!fs.existsSync(SDK_CLI)) {
t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test');
return;
}
const result = runSdkQuery('commit-to-subrepo', ['only message, no files'], tmpDir);
assert.equal(result.exitCode, 0);
assert.ok(result.json, `expected JSON body, got:\n${result.stdout}`);
assert.equal(result.json.committed, false);
assert.equal(result.json.reason, '--files required for commit-to-subrepo',
'commit-to-subrepo enforces --files at runtime (commit.ts:258)');
});
});
// ─── Supplementary doc-lint (defense-in-depth) ───────────────────────────────
//
// The behavioral tests above prove the SDK semantics. This lint scans every
// shipped .md invocation to catch regressions in the 50-file workflow cleanup
// landed by this PR — without it, a future contributor adding a new workflow
// could reintroduce the positional form silently. allow-test-rule: doc-text
// invocations cannot be exercised end-to-end (they are agent-prompt strings
// rendered into chat, not invoked by gsd-tools.cjs), so a textual guard is
// the only available enforcement layer.
const SCAN_DIRS = ['agents', 'commands', 'get-shit-done', 'docs', 'scripts'];
const KNOWN_FLAGS = new Set(['--force', '--amend', '--no-verify', '--files']);
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
walk(full, out);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
out.push(full);
}
}
return out;
}
function extractInvocations(filePath) {
const text = fs.readFileSync(filePath, 'utf-8');
const lines = text.split(/\r?\n/);
const invocations = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/(gsd-sdk\s+query\s+commit(?:-to-subrepo)?\b.*)$/);
if (!m) continue;
const tail = m[1].match(/^gsd-sdk\s+query\s+commit(?:-to-subrepo)?(.?)/);
if (tail && tail[1] === '`') continue;
let cmd = m[1];
let j = i;
while (cmd.endsWith('\\') && j + 1 < lines.length) {
cmd = cmd.slice(0, -1).trimEnd() + ' ' + lines[j + 1].trim();
j++;
}
invocations.push({ line: i + 1, cmd, file: filePath });
}
return invocations;
}
function stripTail(cmd) {
let inSingle = false, inDouble = false;
for (let i = 0; i < cmd.length; i++) {
const c = cmd[i];
if (c === "'" && !inDouble) inSingle = !inSingle;
else if (c === '"' && !inSingle) inDouble = !inDouble;
else if (!inSingle && !inDouble) {
if (c === '#' && (i === 0 || /\s/.test(cmd[i - 1]))) return cmd.slice(0, i);
if (c === '|' || c === ';' || c === '>' || c === '<') return cmd.slice(0, i);
if (c === '&' && cmd[i + 1] === '&') return cmd.slice(0, i);
}
}
return cmd;
}
function tokenize(cmd) {
const out = [];
let cur = '', inSingle = false, inDouble = false;
for (let i = 0; i < cmd.length; i++) {
const c = cmd[i];
if (c === "'" && !inDouble) { inSingle = !inSingle; cur += c; continue; }
if (c === '"' && !inSingle) { inDouble = !inDouble; cur += c; continue; }
if (/\s/.test(c) && !inSingle && !inDouble) {
if (cur) { out.push(cur); cur = ''; }
continue;
}
cur += c;
}
if (cur) out.push(cur);
return out;
}
function isWellFormed(cmd) {
const truncated = stripTail(cmd);
const parts = tokenize(truncated);
if (parts.length < 3) return true;
const args = parts.slice(3);
if (args.length === 0) return true;
if (args.includes('--files')) return true;
let sawMessage = false;
for (const tok of args) {
if (KNOWN_FLAGS.has(tok)) continue;
if (!sawMessage) { sawMessage = true; continue; }
return false;
}
return true;
}
describe('bug #2767 (supplementary doc-lint): no positional invocations in shipped .md', () => {
const allFiles = SCAN_DIRS.flatMap(d => walk(path.join(REPO_ROOT, d)));
const allInvocations = allFiles.flatMap(extractInvocations);
test('lint scanned at least one invocation (sanity)', () => {
assert.ok(allInvocations.length > 0, 'lint scanned 0 invocations — globs are wrong');
});
test('every gsd-sdk query commit invocation either uses --files or has no path args', () => {
const broken = allInvocations.filter(({ cmd }) => !isWellFormed(cmd));
if (broken.length > 0) {
const detail = broken.map(({ file, line, cmd }) => {
const rel = path.relative(REPO_ROOT, file);
return ` ${rel}:${line}\n ${cmd}`;
}).join('\n');
assert.fail(
`${broken.length} \`gsd-sdk query commit\` invocation(s) pass paths positionally.\n` +
`The behavioral test above proves what goes wrong at runtime; this lint catches\n` +
`regressions in shipped workflow markdown.\n${detail}`
);
}
});
});