fix: parseMustHavesBlock quoted strings + gsd state complete-phase (#2744)

* fix: parseMustHavesBlock quoted strings + gsd state complete-phase

Bug #2734: parseMustHavesBlock dropped quoted truths containing ':' because
fully-quoted strings like `"App-side UUIDv4: generated locally"` fell into
the kv-parse branch, the regex failed (value starts with '"'), and current
stayed as empty {}. Fix: detect fully-quoted strings before the ':' check
and extract them directly. Two regression tests added to frontmatter.test.cjs.

Bug #2735: `gsd state complete-phase` subcommand was missing — unknown
subcommands fell through to cmdStateLoad. Added cmdStateCompletePhase to
state.cjs (updates Status, Last Activity, and Current Position to COMPLETE),
exported it, and wired it into the case 'state': dispatch in gsd-tools.cjs.

Closes #2734
Closes #2735

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

* fix(state): unknown subcommand returns explicit error instead of silent fallthrough

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-26 13:37:43 -04:00
committed by GitHub
parent 3246810876
commit 1405728292
5 changed files with 129 additions and 2 deletions

View File

@@ -483,14 +483,18 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
} else if (subcommand === 'prune') {
const { 'keep-recent': keepRecent, 'dry-run': dryRun } = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3', dryRun: !!dryRun }, raw);
} else if (subcommand === 'complete-phase') {
state.cmdStateCompletePhase(cwd, args, raw);
} else if (subcommand === 'milestone-switch') {
// Bug #2630: reset STATE.md frontmatter + Current Position for new milestone.
// NB: the flag is `--milestone`, not `--version` — gsd-tools reserves
// `--version` as a globally-invalid help flag (see NEVER_VALID_FLAGS above).
const { milestone, name } = parseNamedArgs(args, ['milestone', 'name']);
state.cmdStateMilestoneSwitch(cwd, milestone, name, raw);
} else {
} else if (subcommand === undefined || subcommand === 'load') {
state.cmdStateLoad(cwd, raw);
} else {
error(`Unknown state subcommand: "${subcommand}". Available: load, json, get, patch, update, advance-plan, record-metric, update-progress, add-decision, add-blocker, resolve-blocker, record-session, begin-phase, signal-waiting, signal-resume, planned-phase, validate, sync, prune, complete-phase, milestone-switch`);
}
break;
}

View File

@@ -244,8 +244,13 @@ function parseMustHavesBlock(content, blockName) {
if (current) items.push(current);
current = {};
const afterDash = trimmed.slice(2);
const trimmedAfterDash = afterDash.trim();
// Check if it's a fully-quoted string (may contain ':' inside the quotes)
if ((trimmedAfterDash.startsWith('"') && trimmedAfterDash.endsWith('"')) ||
(trimmedAfterDash.startsWith("'") && trimmedAfterDash.endsWith("'"))) {
current = trimmedAfterDash.slice(1, -1);
// Check if it's a simple string item (no colon means not a key-value)
if (!afterDash.includes(':')) {
} else if (!afterDash.includes(':')) {
current = afterDash.replace(/^["']|["']$/g, '');
} else {
// Key-value on same line as dash: "- path: value"

View File

@@ -1683,6 +1683,81 @@ function cmdStatePrune(cwd, options, raw) {
}, raw, totalPruned > 0 ? 'true' : 'false');
}
/**
* Mark the current phase as COMPLETE in STATE.md.
* Updates Status, Last Activity, and the Current Position section to reflect
* that the phase execution is finished and the project is ready for the next phase.
* Implements the `gsd state complete-phase` subcommand (issue #2735).
*/
function cmdStateCompletePhase(cwd, args, raw) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw);
return;
}
const today = new Date().toISOString().split('T')[0];
const updated = [];
readModifyWriteStateMd(statePath, (content) => {
// Read the current phase number for descriptive messages
const currentPhase = stateExtractField(content, 'Current Phase') ||
stateExtractField(content, 'Phase') ||
'?';
// Update Status field
const statusValue = `Phase ${currentPhase} complete`;
let result = stateReplaceField(content, 'Status', statusValue);
if (result) { content = result; updated.push('Status'); }
// Update Last Activity date
result = stateReplaceField(content, 'Last Activity', today);
if (result) { content = result; updated.push('Last Activity'); }
// Update Last Activity Description
const activityDesc = `Phase ${currentPhase} marked complete`;
result = stateReplaceField(content, 'Last Activity Description', activityDesc);
if (result) { content = result; updated.push('Last Activity Description'); }
// Update ## Current Position section
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const positionMatch = content.match(positionPattern);
if (positionMatch) {
const header = positionMatch[1];
let posBody = positionMatch[2];
// Update Phase line to show COMPLETE
const newPhase = `Phase: ${currentPhase} — COMPLETE`;
if (/^Phase:/m.test(posBody)) {
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
}
// Update Status line if present
const newStatus = `Status: Phase ${currentPhase} complete`;
if (/^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, newStatus);
}
// Update Last activity line if present
const newActivity = `Last activity: ${today} -- Phase ${currentPhase} marked complete`;
if (/^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
}
content = content.replace(positionPattern, `${header}${posBody}`);
updated.push('Current Position');
}
return content;
}, cwd);
output(
{ updated, phase: stateExtractField(fs.readFileSync(planningPaths(cwd).state, 'utf-8'), 'Current Phase') || '?' },
raw,
updated.length > 0 ? 'true' : 'false',
);
}
module.exports = {
stateExtractField,
stateReplaceField,
@@ -1705,6 +1780,7 @@ module.exports = {
cmdStateJson,
cmdStateBeginPhase,
cmdStatePlannedPhase,
cmdStateCompletePhase,
cmdStateValidate,
cmdStateSync,
cmdStatePrune,

View File

@@ -66,6 +66,13 @@ describe('dispatcher error paths', () => {
assert.ok(result.error.includes('Invalid --cwd'), `Expected "Invalid --cwd" in stderr, got: ${result.error}`);
});
// Unknown subcommand: state
test('state unknown subcommand errors', () => {
const result = runGsdTools('state bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown state subcommand'), `Expected "Unknown state subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: template
test('template unknown subcommand errors', () => {
const result = runGsdTools('template bogus', tmpDir);

View File

@@ -512,6 +512,41 @@ must_haves:
assert.strictEqual(result[0].min_lines, 100);
});
test('#2734: quoted truth containing ":" is preserved as a string — not dropped', () => {
// When a dash-item is a fully-quoted string that contains ':', the old code
// fell into the key-value branch, failed the kvMatch regex (because the value
// started with '"'), and silently left current as {}, losing the string.
const content = `---
phase: 01
must_haves:
truths:
- "App-side UUIDv4: generated locally"
- "No colon in this one"
- "Another colon: example"
---
`;
const result = parseMustHavesBlock(content, 'truths');
assert.ok(Array.isArray(result), 'should return an array');
assert.strictEqual(result.length, 3, `expected 3 truths, got ${result.length}: ${JSON.stringify(result)}`);
assert.strictEqual(result[0], 'App-side UUIDv4: generated locally');
assert.strictEqual(result[1], 'No colon in this one');
assert.strictEqual(result[2], 'Another colon: example');
});
test('#2734: single-quoted truth containing ":" is preserved as a string', () => {
const content = `---
phase: 01
must_haves:
truths:
- 'Key: value pattern preserved'
---
`;
const result = parseMustHavesBlock(content, 'truths');
assert.ok(Array.isArray(result), 'should return an array');
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0], 'Key: value pattern preserved');
});
test('handles nested arrays within artifact objects', () => {
const content = `---
phase: 01