From 626575cbc5cb300f20941c680c233a5e2b99cd23 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 10 Jun 2026 10:40:06 -0400 Subject: [PATCH] fix(#978): parse --force in milestone complete dispatcher so the guard's documented override works (#982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../978-milestone-complete-force-flag.md | 5 ++ gsd-core/bin/gsd-tools.cjs | 3 +- .../lint-regression-test-names.allowlist.json | 3 +- scripts/lint-test-file-count.allowlist.json | 1 + .../bug-978-milestone-complete-force.test.cjs | 89 +++++++++++++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 .changeset/978-milestone-complete-force-flag.md create mode 100644 tests/bug-978-milestone-complete-force.test.cjs diff --git a/.changeset/978-milestone-complete-force-flag.md b/.changeset/978-milestone-complete-force-flag.md new file mode 100644 index 000000000..be3117f51 --- /dev/null +++ b/.changeset/978-milestone-complete-force-flag.md @@ -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) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 50a2e04f1..d39deed7d 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -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); } diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json index 7bb0d836d..0027a5407 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -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" ] diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 8003cfa49..ac766c5f1 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -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", diff --git a/tests/bug-978-milestone-complete-force.test.cjs b/tests/bug-978-milestone-complete-force.test.cjs new file mode 100644 index 000000000..8cd74c883 --- /dev/null +++ b/tests/bug-978-milestone-complete-force.test.cjs @@ -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: ` 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', + ); + }); +});