From 460f92e727b38aa165ab08317e719598acb54461 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 16 Mar 2026 15:39:34 -0400 Subject: [PATCH] feat: normalize generated markdown for markdownlint compliance (#1112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add normalizeMd() utility that fixes common markdownlint violations in GSD-generated .planning/ files, improving IDE readability: MD022 — Blank lines around headings MD031 — Blank lines around fenced code blocks MD032 — Blank lines around lists MD012 — No multiple consecutive blank lines (collapsed to max 2) MD047 — Files end with a single newline Applied at key write points: - state.cjs: writeStateMd() normalizes STATE.md on every write - frontmatter.cjs: frontmatter set/merge commands normalize output - template.cjs: template fill normalizes generated plan/summary files - milestone.cjs: MILESTONES.md archive writes normalized Includes 14 new tests covering all rules, edge cases (CRLF, frontmatter, ordered lists, code blocks, real-world STATE.md patterns). Closes #1040 --- get-shit-done/bin/lib/core.cjs | 105 ++++++++++++++++++++++++ get-shit-done/bin/lib/frontmatter.cjs | 6 +- get-shit-done/bin/lib/milestone.cjs | 10 +-- get-shit-done/bin/lib/state.cjs | 4 +- get-shit-done/bin/lib/template.cjs | 4 +- tests/core.test.cjs | 110 ++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 12 deletions(-) diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 5b089a35c..8c1b427f8 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -131,6 +131,110 @@ function isGitIgnored(cwd, targetPath) { } } +// ─── Markdown normalization ───────────────────────────────────────────────── + +/** + * Normalize markdown to fix common markdownlint violations. + * Applied at write points so GSD-generated .planning/ files are IDE-friendly. + * + * Rules enforced: + * MD022 — Blank lines around headings + * MD031 — Blank lines around fenced code blocks + * MD032 — Blank lines around lists + * MD012 — No multiple consecutive blank lines (collapsed to 2 max) + * MD047 — Files end with a single newline + */ +function normalizeMd(content) { + if (!content || typeof content !== 'string') return content; + + // Normalize line endings to LF for consistent processing + let text = content.replace(/\r\n/g, '\n'); + + const lines = text.split('\n'); + const result = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const prev = i > 0 ? lines[i - 1] : ''; + const prevTrimmed = prev.trimEnd(); + const trimmed = line.trimEnd(); + + // MD022: Blank line before headings (skip first line and frontmatter delimiters) + if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') { + result.push(''); + } + + // MD031: Blank line before fenced code blocks + if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) { + result.push(''); + } + + // MD032: Blank line before lists (- item, * item, N. item, - [ ] item) + if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 && + prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) && + prevTrimmed !== '---') { + result.push(''); + } + + result.push(line); + + // MD022: Blank line after headings + if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) { + const next = lines[i + 1]; + if (next !== undefined && next.trimEnd() !== '') { + result.push(''); + } + } + + // MD031: Blank line after closing fenced code blocks + if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) { + const next = lines[i + 1]; + if (next !== undefined && next.trimEnd() !== '') { + result.push(''); + } + } + + // MD032: Blank line after last list item in a block + if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) { + const next = lines[i + 1]; + if (next !== undefined && next.trimEnd() !== '' && + !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) && + !/^\s/.test(next)) { + // Only add blank line if next line is not a continuation/indented line + result.push(''); + } + } + } + + text = result.join('\n'); + + // MD012: Collapse 3+ consecutive blank lines to 2 + text = text.replace(/\n{3,}/g, '\n\n'); + + // MD047: Ensure file ends with exactly one newline + text = text.replace(/\n*$/, '\n'); + + return text; +} + +/** Check if line index i is inside an already-open fenced code block */ +function isInsideFencedBlock(lines, i) { + let fenceCount = 0; + for (let j = 0; j < i; j++) { + if (/^```/.test(lines[j].trimEnd())) fenceCount++; + } + return fenceCount % 2 === 1; +} + +/** Check if a ``` line is a closing fence (odd number of fences up to and including this one) */ +function isClosingFence(lines, i) { + let fenceCount = 0; + for (let j = 0; j <= i; j++) { + if (/^```/.test(lines[j].trimEnd())) fenceCount++; + } + return fenceCount % 2 === 0; +} + function execGit(cwd, args) { const result = spawnSync('git', args, { cwd, @@ -479,6 +583,7 @@ module.exports = { loadConfig, isGitIgnored, execGit, + normalizeMd, escapeRegex, normalizePhaseName, comparePhaseNum, diff --git a/get-shit-done/bin/lib/frontmatter.cjs b/get-shit-done/bin/lib/frontmatter.cjs index 93dccb5c3..67ee4466a 100644 --- a/get-shit-done/bin/lib/frontmatter.cjs +++ b/get-shit-done/bin/lib/frontmatter.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { safeReadFile, output, error } = require('./core.cjs'); +const { safeReadFile, normalizeMd, output, error } = require('./core.cjs'); // ─── Parsing engine ─────────────────────────────────────────────────────────── @@ -255,7 +255,7 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) { try { parsedValue = JSON.parse(value); } catch { parsedValue = value; } fm[field] = parsedValue; const newContent = spliceFrontmatter(content, fm); - fs.writeFileSync(fullPath, newContent, 'utf-8'); + fs.writeFileSync(fullPath, normalizeMd(newContent), 'utf-8'); output({ updated: true, field, value: parsedValue }, raw, 'true'); } @@ -269,7 +269,7 @@ function cmdFrontmatterMerge(cwd, filePath, data, raw) { try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; } Object.assign(fm, mergeData); const newContent = spliceFrontmatter(content, fm); - fs.writeFileSync(fullPath, newContent, 'utf-8'); + fs.writeFileSync(fullPath, normalizeMd(newContent), 'utf-8'); output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true'); } diff --git a/get-shit-done/bin/lib/milestone.cjs b/get-shit-done/bin/lib/milestone.cjs index 369344558..6fd032798 100644 --- a/get-shit-done/bin/lib/milestone.cjs +++ b/get-shit-done/bin/lib/milestone.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, getMilestonePhaseFilter, output, error } = require('./core.cjs'); +const { escapeRegex, getMilestonePhaseFilter, normalizeMd, output, error } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { writeStateMd } = require('./state.cjs'); @@ -169,21 +169,21 @@ function cmdMilestoneComplete(cwd, version, options, raw) { const existing = fs.readFileSync(milestonesPath, 'utf-8'); if (!existing.trim()) { // Empty file — treat like new - fs.writeFileSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`, 'utf-8'); + fs.writeFileSync(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`), 'utf-8'); } else { // Insert after the header line(s) for reverse chronological order (newest first) const headerMatch = existing.match(/^(#{1,3}\s+[^\n]*\n\n?)/); if (headerMatch) { const header = headerMatch[1]; const rest = existing.slice(header.length); - fs.writeFileSync(milestonesPath, header + milestoneEntry + rest, 'utf-8'); + fs.writeFileSync(milestonesPath, normalizeMd(header + milestoneEntry + rest), 'utf-8'); } else { // No recognizable header — prepend the entry - fs.writeFileSync(milestonesPath, milestoneEntry + existing, 'utf-8'); + fs.writeFileSync(milestonesPath, normalizeMd(milestoneEntry + existing), 'utf-8'); } } } else { - fs.writeFileSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`, 'utf-8'); + fs.writeFileSync(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`), 'utf-8'); } // Update STATE.md diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index a12662004..f8caad9c1 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error } = require('./core.cjs'); +const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, output, error } = require('./core.cjs'); const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs'); // Shared helper: extract a field value from STATE.md content. @@ -680,7 +680,7 @@ function syncStateFrontmatter(content, cwd) { */ function writeStateMd(statePath, content, cwd) { const synced = syncStateFrontmatter(content, cwd); - fs.writeFileSync(statePath, synced, 'utf-8'); + fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8'); } function cmdStateJson(cwd, raw) { diff --git a/get-shit-done/bin/lib/template.cjs b/get-shit-done/bin/lib/template.cjs index f99a12469..cd6a47785 100644 --- a/get-shit-done/bin/lib/template.cjs +++ b/get-shit-done/bin/lib/template.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { normalizePhaseName, findPhaseInternal, generateSlugInternal, toPosixPath, output, error } = require('./core.cjs'); +const { normalizePhaseName, findPhaseInternal, generateSlugInternal, normalizeMd, toPosixPath, output, error } = require('./core.cjs'); const { reconstructFrontmatter } = require('./frontmatter.cjs'); function cmdTemplateSelect(cwd, planPath, raw) { @@ -214,7 +214,7 @@ function cmdTemplateFill(cwd, templateType, options, raw) { return; } - fs.writeFileSync(outPath, fullContent, 'utf-8'); + fs.writeFileSync(outPath, normalizeMd(fullContent), 'utf-8'); const relPath = toPosixPath(path.relative(cwd, outPath)); output({ created: true, path: relPath, template: templateType }, raw, relPath); } diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 5965790a0..b20328f32 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -17,6 +17,7 @@ const { escapeRegex, generateSlugInternal, normalizePhaseName, + normalizeMd, comparePhaseNum, safeReadFile, pathExistsInternal, @@ -814,3 +815,112 @@ describe('getMilestonePhaseFilter', () => { assert.strictEqual(filter.phaseCount, 0); }); }); + +// ─── normalizeMd ───────────────────────────────────────────────────────────── + +describe('normalizeMd', () => { + test('returns null/undefined/empty unchanged', () => { + assert.strictEqual(normalizeMd(null), null); + assert.strictEqual(normalizeMd(undefined), undefined); + assert.strictEqual(normalizeMd(''), ''); + }); + + test('MD022: adds blank lines around headings', () => { + const input = 'Some text\n## Heading\nMore text\n'; + const result = normalizeMd(input); + assert.ok(result.includes('\n\n## Heading\n\n'), 'heading should have blank lines around it'); + }); + + test('MD032: adds blank line before list after non-list content', () => { + const input = 'Some text\n- item 1\n- item 2\n'; + const result = normalizeMd(input); + assert.ok(result.includes('Some text\n\n- item 1'), 'list should have blank line before it'); + }); + + test('MD032: adds blank line after list before non-list content', () => { + const input = '- item 1\n- item 2\nSome text\n'; + const result = normalizeMd(input); + assert.ok(result.includes('- item 2\n\nSome text'), 'list should have blank line after it'); + }); + + test('MD032: does not add extra blank lines between list items', () => { + const input = '- item 1\n- item 2\n- item 3\n'; + const result = normalizeMd(input); + assert.ok(result.includes('- item 1\n- item 2\n- item 3'), 'consecutive list items should not get blank lines'); + }); + + test('MD031: adds blank lines around fenced code blocks', () => { + const input = 'Some text\n```js\ncode\n```\nMore text\n'; + const result = normalizeMd(input); + assert.ok(result.includes('Some text\n\n```js'), 'code block should have blank line before'); + assert.ok(result.includes('```\n\nMore text'), 'code block should have blank line after'); + }); + + test('MD012: collapses 3+ consecutive blank lines to 2', () => { + const input = 'Line 1\n\n\n\n\nLine 2\n'; + const result = normalizeMd(input); + assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines'); + assert.ok(result.includes('Line 1\n\nLine 2'), 'should collapse to double newline'); + }); + + test('MD047: ensures file ends with single newline', () => { + const input = 'Content'; + const result = normalizeMd(input); + assert.ok(result.endsWith('\n'), 'should end with newline'); + assert.ok(!result.endsWith('\n\n'), 'should not end with double newline'); + }); + + test('MD047: trims trailing multiple newlines', () => { + const input = 'Content\n\n\n'; + const result = normalizeMd(input); + assert.ok(result.endsWith('Content\n'), 'should end with single newline after content'); + }); + + test('preserves frontmatter delimiters', () => { + const input = '---\nkey: value\n---\n\n# Heading\n\nContent\n'; + const result = normalizeMd(input); + assert.ok(result.startsWith('---\n'), 'should preserve opening frontmatter'); + assert.ok(result.includes('---\n\n# Heading'), 'should preserve frontmatter closing'); + }); + + test('handles CRLF line endings', () => { + const input = 'Some text\r\n## Heading\r\nMore text\r\n'; + const result = normalizeMd(input); + assert.ok(!result.includes('\r'), 'should normalize to LF'); + assert.ok(result.includes('\n\n## Heading\n\n'), 'should add blank lines around heading'); + }); + + test('handles ordered lists', () => { + const input = 'Some text\n1. First\n2. Second\nMore text\n'; + const result = normalizeMd(input); + assert.ok(result.includes('Some text\n\n1. First'), 'ordered list should have blank line before'); + }); + + test('does not add blank line between table and list', () => { + const input = '| Col |\n|-----|\n| val |\n- item\n'; + const result = normalizeMd(input); + // Table rows start with |, should not add extra blank before list after table + assert.ok(result.includes('| val |\n\n- item'), 'list after table should have blank line'); + }); + + test('complex real-world STATE.md-like content', () => { + const input = [ + '# Project State', + '## Current Position', + 'Phase: 5 of 10', + 'Status: Executing', + '## Decisions', + '- Decision 1', + '- Decision 2', + '## Blockers', + 'None', + ].join('\n'); + const result = normalizeMd(input); + // Every heading should have blank lines around it + assert.ok(result.includes('\n\n## Current Position\n\n'), 'section heading needs blank lines'); + assert.ok(result.includes('\n\n## Decisions\n\n'), 'decisions heading needs blank lines'); + assert.ok(result.includes('\n\n## Blockers\n\n'), 'blockers heading needs blank lines'); + // List should have blank line before it + assert.ok(result.includes('\n\n- Decision 1'), 'list needs blank line before'); + }); +});