feat: normalize generated markdown for markdownlint compliance (#1112)

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
This commit is contained in:
Tom Boucher
2026-03-16 15:39:34 -04:00
committed by GitHub
parent f2adc0cec4
commit 460f92e727
6 changed files with 227 additions and 12 deletions

View File

@@ -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,

View File

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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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);
}

View File

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