diff --git a/get-shit-done/bin/lib/frontmatter.cjs b/get-shit-done/bin/lib/frontmatter.cjs index e44918117..28690b0f5 100644 --- a/get-shit-done/bin/lib/frontmatter.cjs +++ b/get-shit-done/bin/lib/frontmatter.cjs @@ -167,57 +167,86 @@ function parseMustHavesBlock(content, blockName) { if (!fmMatch) return []; const yaml = fmMatch[1]; - // Find the block (e.g., "truths:", "artifacts:", "key_links:") - const blockPattern = new RegExp(`^\\s{4}${blockName}:\\s*$`, 'm'); - const blockStart = yaml.search(blockPattern); + + // Find must_haves: first to detect its indentation level + const mustHavesMatch = yaml.match(/^(\s*)must_haves:\s*$/m); + if (!mustHavesMatch) return []; + const mustHavesIndent = mustHavesMatch[1].length; + + // Find the block (e.g., "truths:", "artifacts:", "key_links:") under must_haves + // It must be indented more than must_haves but we detect the actual indent dynamically + const blockPattern = new RegExp(`^(\\s+)${blockName}:\\s*$`, 'm'); + const blockMatch = yaml.match(blockPattern); + if (!blockMatch) return []; + + const blockIndent = blockMatch[1].length; + // The block must be nested under must_haves (more indented) + if (blockIndent <= mustHavesIndent) return []; + + // Find where the block starts in the yaml string + const blockStart = yaml.indexOf(blockMatch[0]); if (blockStart === -1) return []; const afterBlock = yaml.slice(blockStart); const blockLines = afterBlock.split(/\r?\n/).slice(1); // skip the header line + // List items are indented one level deeper than blockIndent + // Continuation KVs are indented one level deeper than list items const items = []; let current = null; + let listItemIndent = -1; // detected from first "- " line for (const line of blockLines) { - // Stop at same or lower indent level (non-continuation) + // Skip empty lines if (line.trim() === '') continue; const indent = line.match(/^(\s*)/)[1].length; - if (indent <= 4 && line.trim() !== '') break; // back to must_haves level or higher + // Stop at same or lower indent level than the block header + if (indent <= blockIndent && line.trim() !== '') break; - if (line.match(/^\s{6}-\s+/)) { - // New list item at 6-space indent - if (current) items.push(current); - current = {}; - // Check if it's a simple string item - const simpleMatch = line.match(/^\s{6}-\s+"?([^"]+)"?\s*$/); - if (simpleMatch && !line.includes(':')) { - current = simpleMatch[1]; - } else { - // Key-value on same line as dash: "- path: value" - const kvMatch = line.match(/^\s{6}-\s+(\w+):\s*"?([^"]*)"?\s*$/); - if (kvMatch) { - current = {}; - current[kvMatch[1]] = kvMatch[2]; + const trimmed = line.trim(); + + if (trimmed.startsWith('- ')) { + // Detect list item indent from the first occurrence + if (listItemIndent === -1) listItemIndent = indent; + + // Only treat as a top-level list item if at the expected indent + if (indent === listItemIndent) { + if (current) items.push(current); + current = {}; + const afterDash = trimmed.slice(2); + // Check if it's a simple string item (no colon means not a key-value) + if (!afterDash.includes(':')) { + current = afterDash.replace(/^["']|["']$/g, ''); + } else { + // Key-value on same line as dash: "- path: value" + const kvMatch = afterDash.match(/^(\w+):\s*"?([^"]*)"?\s*$/); + if (kvMatch) { + current = {}; + current[kvMatch[1]] = kvMatch[2]; + } } + continue; } - } else if (current && typeof current === 'object') { - // Continuation key-value at 8+ space indent - const kvMatch = line.match(/^\s{8,}(\w+):\s*"?([^"]*)"?\s*$/); - if (kvMatch) { - const val = kvMatch[2]; - // Try to parse as number - current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val; - } - // Array items under a key - const arrMatch = line.match(/^\s{10,}-\s+"?([^"]+)"?\s*$/); - if (arrMatch) { - // Find the last key added and convert to array + } + + if (current && typeof current === 'object' && indent > listItemIndent) { + // Continuation key-value or nested array item + if (trimmed.startsWith('- ')) { + // Array item under a key + const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, ''); const keys = Object.keys(current); const lastKey = keys[keys.length - 1]; if (lastKey && !Array.isArray(current[lastKey])) { current[lastKey] = current[lastKey] ? [current[lastKey]] : []; } - if (lastKey) current[lastKey].push(arrMatch[1]); + if (lastKey) current[lastKey].push(arrVal); + } else { + const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/); + if (kvMatch) { + const val = kvMatch[2]; + // Try to parse as number + current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val; + } } } } diff --git a/tests/frontmatter.test.cjs b/tests/frontmatter.test.cjs index a53020193..001a67267 100644 --- a/tests/frontmatter.test.cjs +++ b/tests/frontmatter.test.cjs @@ -330,6 +330,89 @@ must_haves: assert.deepStrictEqual(result, []); }); + test('parses key_links with 2-space indentation — issue #1356', () => { + // Real-world YAML uses 2-space indentation, not 4-space. + // The parser was hardcoded to expect 4-space indentation which caused + // "No must_haves.key_links found in frontmatter" for valid YAML. + const content = `--- +phase: 01-conversion-engine-iva-correctness +plan: 02 +type: execute +wave: 2 +depends_on: ["01-01"] +files_modified: + - src/features/currency/exchange-rate-store.ts + - src/features/currency/use-currency-config.ts +autonomous: true +requirements: + - CONV-02 + - CONV-03 + +must_haves: + truths: + - "All tests pass" + artifacts: + - path: "src/features/currency/use-currency-config.ts" + key_links: + - from: "src/features/currency/use-currency-config.ts" + to: "src/api/generated/company-config/company-config.ts" + via: "getCompanyConfigControllerFindAllQueryOptions" + pattern: "getCompanyConfigControllerFindAllQueryOptions" + - from: "src/features/currency/use-currency-config.ts" + to: "src/features/currency/exchange-rate-store.ts" + via: "useExchangeRateStore for MMKV persist" + pattern: "useExchangeRateStore" +--- + +# Plan body +`; + const result = parseMustHavesBlock(content, 'key_links'); + assert.ok(Array.isArray(result), 'should return an array'); + assert.strictEqual(result.length, 2, `expected 2 key_links, got ${result.length}: ${JSON.stringify(result)}`); + assert.strictEqual(result[0].from, 'src/features/currency/use-currency-config.ts'); + assert.strictEqual(result[0].to, 'src/api/generated/company-config/company-config.ts'); + assert.strictEqual(result[0].via, 'getCompanyConfigControllerFindAllQueryOptions'); + assert.strictEqual(result[0].pattern, 'getCompanyConfigControllerFindAllQueryOptions'); + assert.strictEqual(result[1].from, 'src/features/currency/use-currency-config.ts'); + assert.strictEqual(result[1].to, 'src/features/currency/exchange-rate-store.ts'); + assert.strictEqual(result[1].via, 'useExchangeRateStore for MMKV persist'); + assert.strictEqual(result[1].pattern, 'useExchangeRateStore'); + }); + + test('parses truths with 2-space indentation — issue #1356', () => { + const content = `--- +phase: 01 +must_haves: + truths: + - "All tests pass on CI" + - "Coverage exceeds 80%" +--- +`; + const result = parseMustHavesBlock(content, 'truths'); + assert.ok(Array.isArray(result), 'should return an array'); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0], 'All tests pass on CI'); + assert.strictEqual(result[1], 'Coverage exceeds 80%'); + }); + + test('parses artifacts with 2-space indentation — issue #1356', () => { + const content = `--- +phase: 01 +must_haves: + artifacts: + - path: "src/auth.ts" + provides: "JWT authentication" + min_lines: 100 +--- +`; + const result = parseMustHavesBlock(content, 'artifacts'); + assert.ok(Array.isArray(result), 'should return an array'); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].path, 'src/auth.ts'); + assert.strictEqual(result[0].provides, 'JWT authentication'); + assert.strictEqual(result[0].min_lines, 100); + }); + test('handles nested arrays within artifact objects', () => { const content = `--- phase: 01