fix(#978): parse --force in milestone complete dispatcher so the guard's documented override works (#982)

* fix(#978): parse --force in milestone complete dispatcher so the guard's documented override works

The dispatcher built `{ name, archivePhases }` but never parsed `--force`, so
`options.force` was always `undefined` and the guard inside `cmdMilestoneComplete`
(which tells users to "Re-run with --force to override") could never be bypassed.

Add `const force = args.includes('--force')` and pass it into the options object.
The guard already honors `options.force` — no changes to milestone.cts needed.

Closes #978

* chore(#978): backfill changeset pr number (982)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Tom Boucher
2026-06-10 10:40:06 -04:00
committed by GitHub
parent caca4d255c
commit 626575cbc5
5 changed files with 99 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 982
---
**`gsd-tools milestone complete --force` now actually overrides the unstarted-phase guard** — the dispatcher never parsed `--force`, so the guard's own documented escape hatch was inert. (#978)

View File

@@ -1155,7 +1155,8 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
if (subcommand === 'complete') {
const milestoneName = parseMultiwordArg(args, 'name');
const archivePhases = args.includes('--archive-phases');
milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases }, raw);
const force = args.includes('--force');
milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force }, raw);
} else {
error('Unknown milestone subcommand. Available: complete', ERROR_REASON.SDK_UNKNOWN_COMMAND);
}

View File

@@ -259,5 +259,6 @@
"bug-941-managed-hooks-registry-manifest.test.cjs",
"bug-947-hermes-gsd-prefix.test.cjs",
"bug-948-state-noop-write-guard.test.cjs",
"bug-950-quick-summary-status-complete.test.cjs"
"bug-950-quick-summary-status-complete.test.cjs",
"bug-978-milestone-complete-force.test.cjs"
]

View File

@@ -47,6 +47,7 @@
"milestone": {
"files": [
"bug-730-milestone-phase-details-scope.test.cjs",
"bug-978-milestone-complete-force.test.cjs",
"milestone-archive.test.cjs",
"milestone-helper.test.cjs",
"milestone-prefixed-convention.test.cjs",

View File

@@ -0,0 +1,89 @@
'use strict';
/**
* Regression test for bug #978: `gsd-tools milestone complete --force` was a
* dead flag. The milestone source (src/milestone.cts) has a guard that checks
* `options.force` and tells users to "Re-run with --force to override", but the
* CLI dispatcher (gsd-core/bin/gsd-tools.cjs) never parsed `--force` and never
* passed it into the options object. So `options.force` was always `undefined`
* and the guard could never be overridden regardless of what the user typed.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
/**
* Build a fixture where the guard will fire:
* - STATE.md has `milestone: <version>` so the guard's version-match check is
* satisfied.
* - ROADMAP.md lists a `### Phase 999.1: Backlog Work` heading for that
* milestone, but there is NO on-disk phase directory for it.
*
* This guarantees "unstarted phase" detection without touching any real phases.
*/
function makeGuardFixture(tmpDir, version) {
// STATE.md with frontmatter milestone field matching the version
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---\nmilestone: ${version}\n---\n# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`,
);
// ROADMAP.md — the heading must include the version so getMilestonePhaseFilter
// does not return missingExplicitVersion. Phase 999.1 has no on-disk dir.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap ${version}\n\n### Phase 999.1: Backlog Work\n**Goal:** Not started\n`,
);
}
describe('bug-978: milestone complete --force overrides unstarted-phase guard', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-bug-978-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('without --force the guard fires and emits the documented error message', () => {
makeGuardFixture(tmpDir, 'v1.0');
const result = runGsdTools(
['milestone', 'complete', 'v1.0', '--name', 'Regression Test'],
tmpDir,
);
assert.strictEqual(result.success, false, 'command should fail without --force');
assert.ok(
result.error.includes('Re-run with --force to override'),
`expected guard error message; got: ${result.error}`,
);
});
test('with --force the guard is bypassed and the command succeeds', () => {
makeGuardFixture(tmpDir, 'v1.0');
const result = runGsdTools(
['milestone', 'complete', 'v1.0', '--name', 'Regression Test', '--force'],
tmpDir,
);
assert.ok(
result.success,
`command should succeed with --force but failed: ${result.error}`,
);
const output = JSON.parse(result.output);
assert.strictEqual(output.version, 'v1.0');
// Milestone entry should have been created even though phase 999.1 has no dir
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')),
'MILESTONES.md should have been created',
);
});
});