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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user