diff --git a/.changeset/calm-tunas-rally.md b/.changeset/calm-tunas-rally.md new file mode 100644 index 000000000..350590057 --- /dev/null +++ b/.changeset/calm-tunas-rally.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4049 +--- +writing any markdown file no longer converts tight multi-line lists to loose ones — a blank line was injected before every bullet following a wrapped item (61 blanks on a 1015-line ROADMAP via phase.complete; the defect sat in the write seam every .md write uses), and tight vs loose lists render differently so this was a rendering change plus large misleading diffs, not just whitespace (#3854) diff --git a/scripts/lint-allow-test-rule-refs.unverified-ceiling.json b/scripts/lint-allow-test-rule-refs.unverified-ceiling.json index 0b280d272..afdf1f882 100644 --- a/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +++ b/scripts/lint-allow-test-rule-refs.unverified-ceiling.json @@ -1,3 +1,3 @@ { - "maxFiles": 281 + "maxFiles": 282 } diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index f0badbe74..94d335a41 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -263,6 +263,14 @@ "audit-workstream-layouts.test.cjs" ], "issue": "#3804/#3805 \u2014 audit-layout and acknowledged-marker suites drive the real audit-uat CLI against on-disk fixtures | #3817 \u2014 regression suite proving countReal counts the truncation remainder marker instead of treating it as one phantom item" + }, + "shell-command-projection": { + "files": [ + "shell-command-projection-dispatch.test.cjs", + "shell-command-projection-md-normalize.test.cjs", + "shell-command-projection-path-sep.test.cjs" + ], + "issue": "#3854 \u2014 the tight-list suite pins the markdown write-normalizer's blank-line policy through the exported normalizeContent/platformWriteSync seam; a distinct concern from dispatch routing and path separation" } } } diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index 277630d34..6affca567 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -1151,7 +1151,11 @@ function _normalizeMd(content: string): string { if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) { if (i === 0 || !insideFence[i - 1]) result.push(''); } - if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 && prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) && prevTrimmed !== '---') result.push(''); + // #3854: the `!/^\s/.test(prev)` guard mirrors the after-a-bullet rule below — + // an indented non-bullet line is a CONTINUATION of the previous list item, not a + // preceding paragraph, so no separating blank may be injected before this bullet + // (that injection converted every tight multi-line list to a loose one on write). + if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 && prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) && !/^\s/.test(prev) && prevTrimmed !== '---') result.push(''); result.push(line); if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '') result.push(''); if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '') result.push(''); diff --git a/tests/shell-command-projection-md-normalize.test.cjs b/tests/shell-command-projection-md-normalize.test.cjs new file mode 100644 index 000000000..add35feba --- /dev/null +++ b/tests/shell-command-projection-md-normalize.test.cjs @@ -0,0 +1,111 @@ +// allow-test-rule: source-text-is-the-product (#3854) +// Asserts the markdown write-normalizer's blank-line policy through the +// exported seam (normalizeContent / platformWriteSync) — no source grepping. + +/** + * Tight-list preservation in markdown write normalization — shell-command-projection-md-normalize.test.cjs + * + * #3854: `phase.complete` (any .md write, really) injected one blank line + * before every bullet that follows a multi-line item's indented continuation + * line — converting tight markdown lists to loose ones (61 injected blanks on + * the reporter's real ROADMAP; tight and loose lists render differently, so + * it was a rendering change plus huge diff noise, not just whitespace). + * + * Root cause: `_normalizeMd`'s "separate a list from a preceding paragraph" + * rule inserted a blank before a bullet whose previous line "wasn't a bullet" + * — but an indented CONTINUATION line of the previous item also "isn't a + * bullet". The mirror-image after-a-bullet rule already guards against + * indented next lines; the before-a-bullet rule must too. + * + * These tests pin both directions: tight lists stay tight through the write + * seam, and the legitimate paragraph↔list separations the rule exists for + * still happen. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('node:os'); +const path = require('path'); +const { normalizeContent, platformWriteSync } = require('../gsd-core/bin/lib/shell-command-projection.cjs'); +const { cleanup } = require('./helpers.cjs'); + +const MD = 'roadmap.md'; + +describe('#3854: write normalization preserves tight multi-line lists', () => { + test('a bullet following a multi-line item\'s continuation gets NO injected blank', () => { + const tight = [ + '# Roadmap v1.0', + '', + '- **RC-1 — first rule** whose text wraps onto', + ' a continuation line', + '- **RC-2 — second rule** also wrapping onto', + ' its continuation line', + '- **RC-3 — third rule** single line', + '', + ].join('\n'); + const { content } = normalizeContent(MD, tight); + assert.ok( + !content.includes('a continuation line\n\n- **RC-2'), + 'no blank may be injected between a wrapped item\'s last continuation and the next item (tight list stays tight)' + ); + assert.ok( + !content.includes('its continuation line\n\n- **RC-3'), + 'same for every following item' + ); + assert.strictEqual( + content.split('\n').filter((l) => l.trim() === '').length, + tight.split('\n').filter((l) => l.trim() === '').length, + 'blank-line count must round-trip unchanged' + ); + }); + + test('numbered tight multi-line lists are preserved too', () => { + const tight = [ + '1. First rule with a wrapped', + ' continuation line', + '2. Second rule', + ].join('\n') + '\n'; + const { content } = normalizeContent(MD, tight); + assert.ok( + !content.includes('continuation line\n\n2.'), + 'no blank between a wrapped numbered item\'s continuation and the next item' + ); + }); + + test('the paragraph→list separation the rule exists for STILL happens', () => { + const doc = 'A lead-in paragraph.\n- first item\n'; + const { content } = normalizeContent(MD, doc); + assert.ok( + content.includes('A lead-in paragraph.\n\n- first item'), + 'a list following a paragraph still gets its separating blank' + ); + }); + + test('heading/list separations are unchanged (regression pin on the rule\'s purpose)', () => { + const doc = '## Section\n- item\n'; + const { content } = normalizeContent(MD, doc); + assert.ok(content.includes('## Section\n\n- item'), 'heading→list keeps its blank'); + }); + + test('normalization is idempotent on a tight list (no one-shot growth, no compounding)', () => { + const tight = '- a\n wrapped continuation\n- b\n'; + const once = normalizeContent(MD, tight).content; + const twice = normalizeContent(MD, once).content; + assert.strictEqual(once, twice, 'second pass must be a no-op'); + assert.strictEqual(once, tight, 'and the first pass must not have grown the document'); + }); + + test('the write seam (platformWriteSync) lands the same bytes — end-to-end guard', () => { + const osTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3854-')); + try { + const target = path.join(osTmp, 'ROADMAP.md'); + const tight = '# Roadmap v1.0\n\n- item one wraps\n continuation one\n- item two wraps\n continuation two\n'; + platformWriteSync(target, tight); + const onDisk = fs.readFileSync(target, 'utf-8'); + assert.strictEqual(onDisk, tight, 'platformWriteSync must not convert the tight list to a loose one'); + } finally { + cleanup(osTmp); + } + }); +});