fix(#3854): write normalization preserves tight multi-line lists (#4049)

* test(#3854): write normalization must preserve tight multi-line lists (failing first)

* fix(#3854): no blank before a bullet whose previous line is an indented continuation

_normalizeMd's 'separate a list from a preceding paragraph' rule inserted
a blank before any bullet whose previous line wasn't a bullet — but an
INDENTED CONTINUATION of the previous multi-line item also isn't a
bullet. Every .md write (phase.complete in the report, but any write
through platformWriteSync) therefore converted tight lists to loose
ones: +61 blank lines on the reporter's 1015-line ROADMAP, one before
each bullet following a wrapped item. Tight and loose lists render
differently, so this was a rendering change plus misleading diff noise;
one-shot (idempotent afterwards), which is why integrity checks on
headings/content passed.

The guard is the mirror image of the after-a-bullet rule two lines
below, which already excludes indented next lines. Paragraph→list and
heading→list separations — the rule's purpose — are pinned unchanged by
the new suite.

* fix(#3854): review fold-ins — ceiling tracks next's 281 + this branch's marker (282), header/require nits

The ceiling is not ratcheted but must track the tree: origin/next raised
it to 281 (sibling branch's marker file); this tree adds one more
(shell-command-projection-md-normalize), so 282/282. Also fixes the test
header's stale pre-rename filename and hoists the inline require to the
file's single import.

* chore(#3854): changeset fragment (pr number backfilled after PR creation)

* chore(#3854): backfill changeset PR number (4049)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-29 14:37:21 -04:00
committed by GitHub
parent 529480b4a5
commit fd63889d1f
5 changed files with 130 additions and 2 deletions

View File

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

View File

@@ -1,3 +1,3 @@
{
"maxFiles": 281
"maxFiles": 282
}

View File

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

View File

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

View File

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