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', + ); + }); +});