* 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:
5
.changeset/calm-tunas-rally.md
Normal file
5
.changeset/calm-tunas-rally.md
Normal 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)
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"maxFiles": 281
|
||||
"maxFiles": 282
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
111
tests/shell-command-projection-md-normalize.test.cjs
Normal file
111
tests/shell-command-projection-md-normalize.test.cjs
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user