la-stack 0.4.2

Fast, stack-allocated linear algebra for fixed dimensions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
"""Tests for postprocess_changelog.py — trailing blanks, reflow, code blocks, summaries."""

from __future__ import annotations

from typing import TYPE_CHECKING

from postprocess_changelog import (
    _compact_entry,
    _inject_summary_sections,
    _is_duplicate_squash_heading,
    _is_isolated_body_heading,
    _max_pr_number,
    _normalize_entry_heading,
    _normalize_indented_heading,
    _normalize_squash_heading,
    _plain_summary,
    _process_code_fence,
    _reflow_line,
    _squash_heading_parts,
    normalize_entry_headings_text,
    postprocess,
    postprocess_text,
)

if TYPE_CHECKING:
    from pathlib import Path


class TestStripTrailingBlanks:
    def test_strips_trailing_blank_lines(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("# Changelog\n\n- Item\n\n\n\n", encoding="utf-8")

        postprocess(f)

        assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n"

    def test_preserves_single_trailing_newline(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("# Changelog\n\n- Item\n", encoding="utf-8")

        postprocess(f)

        assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n"

    def test_adds_trailing_newline_if_missing(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("# Changelog\n\n- Item", encoding="utf-8")

        postprocess(f)

        assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n"

    def test_preserves_internal_blank_lines(self, tmp_path: Path) -> None:
        content = "# Changelog\n\n## [1.0.0]\n\n### Added\n\n- Item\n\n\n\n"
        f = tmp_path / "CHANGELOG.md"
        f.write_text(content, encoding="utf-8")

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        assert result == "# Changelog\n\n## [1.0.0]\n\n### Added\n\n- Item\n"

    def test_single_newline_file(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("\n", encoding="utf-8")

        postprocess(f)

        assert f.read_text(encoding="utf-8") == "\n"

    def test_empty_file(self, tmp_path: Path) -> None:
        """
        Verifies that processing an empty changelog file results in a file containing exactly one newline.

        Creates an empty CHANGELOG.md at the provided temporary path, runs postprocess on it, and asserts the file's contents are "\n".
        """
        f = tmp_path / "CHANGELOG.md"
        f.write_text("", encoding="utf-8")

        postprocess(f)

        assert f.read_text(encoding="utf-8") == "\n"


class TestReflowLine:
    """Unit tests for the _reflow_line helper."""

    def test_short_line_unchanged(self) -> None:
        line = "- Short line `abc1234`"
        assert _reflow_line(line, max_width=160) == line

    def test_wraps_plain_text(self) -> None:
        line = "  " + "word " * 40
        result = _reflow_line(line.rstrip(), max_width=80)
        for part in result.split("\n"):
            assert len(part) <= 80

    def test_preserves_markdown_link(self) -> None:
        link = "[#235](https://github.com/acgetchell/delaunay/pull/235)"
        line = f"- Description text here {link}"
        result = _reflow_line(line, max_width=40)
        # The link must appear intact in one of the output lines.
        assert any(link in part for part in result.split("\n"))

    def test_preserves_code_span(self) -> None:
        span = "`orientation_from_matrix()`"
        line = f"  Use {span} for exact sign classification on finite inputs and more text padding"
        result = _reflow_line(line, max_width=60)
        assert any(span in part for part in result.split("\n"))

    def test_list_item_continuation_indent(self) -> None:
        line = "- " + "word " * 40
        result = _reflow_line(line.rstrip(), max_width=80)
        parts = result.split("\n")
        assert parts[0].startswith("- ")
        for cont in parts[1:]:
            assert cont.startswith("  ")

    def test_star_list_item(self) -> None:
        line = "* " + "word " * 40
        result = _reflow_line(line.rstrip(), max_width=80)
        parts = result.split("\n")
        assert parts[0].startswith("* ")
        for cont in parts[1:]:
            assert cont.startswith("  ")

    def test_indented_body_text(self) -> None:
        line = "  " + "word " * 40
        result = _reflow_line(line.rstrip(), max_width=80)
        parts = result.split("\n")
        for part in parts:
            assert part.startswith("  ")

    def test_single_long_token_kept(self) -> None:
        url = "https://github.com/acgetchell/delaunay/commit/" + "a" * 40
        line = f"- See [{url}]({url})"
        result = _reflow_line(line, max_width=80)
        # Cannot break inside the link; line may exceed max_width.
        assert url in result

    def test_commit_link_with_backticks(self) -> None:
        link = "[`a62437f`](https://github.com/acgetchell/delaunay/commit/a62437f25c27259f145d3c193ce149ee14b421c7)"
        pr1 = "[#235](https://github.com/acgetchell/delaunay/pull/235)"
        pr2 = "[#236](https://github.com/acgetchell/delaunay/pull/236)"
        line = f"- Use exact arithmetic for orientation predicates {pr1} {pr2} {link}"
        result = _reflow_line(line, max_width=160)
        parts = result.split("\n")
        # Every continuation line should be indented.
        for cont in parts[1:]:
            assert cont.startswith("  ")
        # All links must be intact.
        assert link in result


# ---------------------------------------------------------------------------
# Summary-section helpers
# ---------------------------------------------------------------------------

_OWNER_REPO = "acgetchell/delaunay"
_PR_URL = f"https://github.com/{_OWNER_REPO}/pull"
_COMMIT_URL = f"https://github.com/{_OWNER_REPO}/commit"


def _pr(n: int) -> str:
    """
    Return a Markdown-formatted pull request link for a given pull request number.

    Parameters:
        n (int): Pull request number.

    Returns:
        str: Markdown link in the form "[#<n>](<PR_URL>/<n>)".
    """
    return f"[#{n}]({_PR_URL}/{n})"


def _commit(short: str = "abc1234", full: str = "abc1234deadbeef0123456789") -> str:
    """
    Format a markdown link that references a commit using a short hash as link text and the full hash in the URL.

    Parameters:
        short (str): Short commit identifier used as the link text (rendered in backticks).
        full (str): Full commit hash used to construct the target URL.

    Returns:
        commit_link (str): Markdown link of the form [`<short>`](<commit_url>/<full>).
    """
    return f"[`{short}`]({_COMMIT_URL}/{full})"


def _merged_pr_summary_block(text: str) -> str:
    """Return the injected merged-PR summary block."""
    start = text.index("### Merged Pull Requests")
    end = text.find("\n### ", start + len("### Merged Pull Requests"))
    return text[start:] if end == -1 else text[start:end]


class TestCompactEntry:
    def test_strips_commit_hash_link(self) -> None:
        line = f"- Some feature {_commit()}"
        assert _compact_entry(line) == "- Some feature"

    def test_strips_breaking_prefix(self) -> None:
        line = f"- [**breaking**] Some change {_commit()}"
        assert _compact_entry(line, strip_breaking=True) == "- Some change"

    def test_preserves_pr_links(self) -> None:
        line = f"- Feature {_pr(42)} {_commit()}"
        assert _compact_entry(line) == f"- Feature {_pr(42)}"

    def test_keeps_breaking_when_not_stripped(self) -> None:
        line = f"- [**breaking**] Change {_commit()}"
        assert _compact_entry(line) == "- [**breaking**] Change"


class TestMaxPrNumber:
    def test_single_pr(self) -> None:
        assert _max_pr_number(f"- Feature {_pr(42)}") == 42

    def test_multiple_prs(self) -> None:
        assert _max_pr_number(f"- Feature {_pr(10)} {_pr(99)}") == 99

    def test_no_prs(self) -> None:
        assert _max_pr_number("- Plain entry") == 0


class TestSummarySections:
    @staticmethod
    def _changelog(entries: str) -> str:
        """
        Create a sample changelog file containing a header, a 1.0.0 release section dated
        2026-01-01, and an "Added" subsection populated with the provided entries.

        Parameters:
            entries (str): Markdown content to place under the "Added" subsection (should include any list markers or paragraphs).

        Returns:
            str: The full changelog content as a string.
        """
        return f"# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n{entries}\n"

    def test_injects_pr_summary(self) -> None:
        content = self._changelog(f"- Feature A {_pr(10)} {_commit()}\n- Plain commit {_commit('def5678', 'def5678deadbeef')}")
        result = _inject_summary_sections(content)
        summary_block = _merged_pr_summary_block(result)
        assert "### Merged Pull Requests" in summary_block
        # PR entry in summary (without commit hash).
        assert f"- Feature A {_pr(10)}" in summary_block
        assert "- Plain commit" not in summary_block
        # Plain entry only appears once (in Added, not in summary).
        plain_lines = [ln for ln in result.split("\n") if ln.startswith("- Plain commit")]
        assert len(plain_lines) == 1

    def test_injects_breaking_summary(self) -> None:
        content = self._changelog(f"- [**breaking**] Big change {_pr(5)} {_commit()}")
        result = _inject_summary_sections(content)
        assert "### ⚠️ Breaking Changes" in result
        assert "### Merged Pull Requests" in result
        # Breaking section appears before Merged PRs.
        assert result.index("### ⚠️ Breaking Changes") < result.index("### Merged Pull Requests")

    def test_injects_breaking_summary_from_marker_variants(self) -> None:
        content = self._changelog(f"- **BREAKING** Big change {_pr(5)} {_commit()}")
        result = _inject_summary_sections(content)
        assert "### ⚠️ Breaking Changes" in result
        assert f"- Big change {_pr(5)}" in result

    def test_injects_summary_from_star_bullets(self) -> None:
        content = self._changelog(f"* [**breaking**] Star change {_pr(7)} {_commit()}")
        result = _inject_summary_sections(content)

        assert "### ⚠️ Breaking Changes" in result
        assert "### Merged Pull Requests" in result
        assert f"* Star change {_pr(7)}" in result

    def test_pr_sorted_descending(self) -> None:
        """
        Verifies that PRs in the injected "Merged Pull Requests" summary are sorted in descending order by PR number.

        Constructs a changelog with three entries containing PR links and confirms the summary lists them in order: highest PR number first.
        """
        content = self._changelog(
            f"- First {_pr(5)} {_commit('aaa1111', 'aaa1111deadbeef')}\n"
            f"- Second {_pr(20)} {_commit('bbb2222', 'bbb2222deadbeef')}\n"
            f"- Third {_pr(10)} {_commit('ccc3333', 'ccc3333deadbeef')}"
        )
        result = _inject_summary_sections(content)
        lines = result.split("\n")
        pr_idx = next(i for i, ln in enumerate(lines) if "### Merged Pull Requests" in ln)
        pr_lines = [ln for ln in lines[pr_idx + 1 :] if ln.startswith("- ")][:3]
        assert "#20" in pr_lines[0]
        assert "#10" in pr_lines[1]
        assert "#5" in pr_lines[2]

    def test_no_summary_without_prs(self) -> None:
        content = self._changelog(f"- Plain commit {_commit()}")
        result = _inject_summary_sections(content)
        assert "### Merged Pull Requests" not in result

    def test_idempotent(self) -> None:
        content = self._changelog(f"- Feature {_pr(10)} {_commit()}")
        first = _inject_summary_sections(content)
        second = _inject_summary_sections(first)
        assert first == second

    def test_idempotent_breaking_only(self) -> None:
        """Breaking-only sections (no PR links) must not be double-injected."""
        content = self._changelog(f"- [**breaking**] Remove old API {_commit()}")
        first = _inject_summary_sections(content)
        assert "### ⚠️ Breaking Changes" in first
        # "Merged Pull Requests" should NOT appear (no PR link).
        assert "### Merged Pull Requests" not in first
        second = _inject_summary_sections(first)
        assert first == second

    def test_ignores_indented_sub_items(self) -> None:
        content = self._changelog(f"- Feature {_commit()}\n  - Sub-item {_pr(99)}")
        result = _inject_summary_sections(content)
        assert "### Merged Pull Requests" not in result

    def test_multiple_pr_links_preserved(self) -> None:
        content = self._changelog(f"- Feature {_pr(10)} {_pr(20)} {_commit()}")
        result = _inject_summary_sections(content)
        summary_block = _merged_pr_summary_block(result)
        assert f"- Feature {_pr(10)} {_pr(20)}" in summary_block

    def test_duplicate_summary_entries_are_collapsed(self) -> None:
        entry = f"- Feature A {_pr(10)} {_commit()}"
        content = self._changelog(f"{entry}\n{entry}")
        result = _inject_summary_sections(content)
        summary_block = _merged_pr_summary_block(result)
        assert summary_block.count(f"- Feature A {_pr(10)}") == 1


class TestListMarkerNormalization:
    """MD004: consistent ``-`` list markers."""

    def test_star_to_dash_at_column_zero(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("* item one\n* item two\n", encoding="utf-8")
        postprocess(f)
        result = f.read_text(encoding="utf-8")
        assert "* item" not in result
        assert "- item one" in result
        assert "- item two" in result

    def test_star_to_dash_indented(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("- parent item\n  * sub-item\n", encoding="utf-8")
        postprocess(f)
        assert "  - sub-item" in f.read_text(encoding="utf-8")

    def test_star_in_bold_not_changed(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("Some **bold** text\n", encoding="utf-8")
        postprocess(f)
        assert "**bold**" in f.read_text(encoding="utf-8")

    def test_star_inside_code_block_not_changed(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("```text\n* keep me\n```\n", encoding="utf-8")
        postprocess(f)
        assert "* keep me" in f.read_text(encoding="utf-8")


class TestBlankLineBeforeList:
    """MD032: blank lines around lists."""

    def test_inserts_blank_before_list_after_prose(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("Some prose.\n- list item\n", encoding="utf-8")
        postprocess(f)
        assert f.read_text(encoding="utf-8") == "Some prose.\n\n- list item\n"

    def test_no_double_blank(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("Some prose.\n\n- list item\n", encoding="utf-8")
        postprocess(f)
        assert f.read_text(encoding="utf-8") == "Some prose.\n\n- list item\n"

    def test_no_blank_between_consecutive_items(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("- one\n- two\n", encoding="utf-8")
        postprocess(f)
        assert f.read_text(encoding="utf-8") == "- one\n- two\n"

    def test_no_blank_after_heading(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("### Added\n- item\n", encoding="utf-8")
        postprocess(f)
        # Heading directly followed by list is fine per MD032.
        assert "\n\n- item" not in f.read_text(encoding="utf-8")


class TestIndentedHeadingNormalization:
    """MD023: commit-body headings are rendered as prose, not nested headings."""

    def test_indented_atx_heading_becomes_bold_prose(self) -> None:
        assert _normalize_indented_heading("  ## Correctness Fixes") == "  **Correctness Fixes**"

    def test_indented_atx_closing_sequence_becomes_bold_prose(self) -> None:
        assert _normalize_indented_heading("  ### API Design ###") == "  **API Design**"

    def test_column_zero_changelog_heading_is_preserved(self) -> None:
        assert _normalize_indented_heading("### Added") == "### Added"

    def test_column_zero_entry_heading_becomes_level_four(self) -> None:
        assert _normalize_entry_heading("## Duplicate Vertex Handling") == "#### Duplicate Vertex Handling"

    def test_column_zero_category_heading_is_preserved(self) -> None:
        assert _normalize_entry_heading("### Fixed") == "### Fixed"

    def test_archives_heading_is_preserved(self) -> None:
        assert _normalize_entry_heading("## Archives") == "## Archives"

    def test_bracketed_entry_heading_becomes_level_four(self) -> None:
        assert _normalize_entry_heading("## [Notes]") == "#### [Notes]"

    def test_bracketed_version_like_entry_heading_becomes_level_four(self) -> None:
        assert _normalize_entry_heading("## [1.2.3 Notes]") == "#### [1.2.3 Notes]"

    def test_release_heading_is_preserved(self) -> None:
        assert _normalize_entry_heading("## [v1.2.3] - 2026-05-22") == "## [v1.2.3] - 2026-05-22"

    def test_prerelease_heading_is_preserved(self) -> None:
        assert _normalize_entry_heading("## [1.2.3-rc.1+build.7]") == "## [1.2.3-rc.1+build.7]"

    def test_contextual_category_heading_becomes_level_four(self) -> None:
        assert _normalize_entry_heading("### Fixed: Add rollback") == "#### Fixed: Add rollback"

    def test_normalized_heading_is_idempotent(self) -> None:
        assert _normalize_indented_heading("  **Title**") == "  **Title**"

        once = _normalize_indented_heading("  ## Correctness Fixes")
        assert _normalize_indented_heading(once) == once

    def test_full_pipeline_normalizes_commit_body_headings(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text(
            "# Changelog\n\n"
            "## [1.0.0]\n\n"
            "### Performance\n\n"
            "- perf: improve Hilbert curve correctness\n\n"
            "  ## Correctness Fixes\n\n"
            "  - Add debug_assert guards\n\n"
            "  ## API Design\n\n"
            "  - Add HilbertError enum\n",
            encoding="utf-8",
        )

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        assert "  ## Correctness Fixes" not in result
        assert "  ## API Design" not in result
        assert "  **Correctness Fixes**" in result
        assert "  **API Design**" in result
        assert "### Performance" in result

    def test_full_pipeline_normalizes_unindented_commit_body_headings(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text(
            "# Changelog\n\n"
            "## [1.0.0]\n\n"
            "### Fixed\n\n"
            "- Handle degenerate configurations [#116](https://github.com/acgetchell/causal-triangulations/pull/116)\n"
            f"  {_commit()}\n\n"
            "## Duplicate Vertex Handling\n\n"
            "  - Add duplicate coordinate detection\n\n"
            "### Fixed: Add rollback on cell creation failure\n\n"
            "  Add rollback mechanisms when cell creation fails.\n",
            encoding="utf-8",
        )

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        assert "\n## Duplicate Vertex Handling" not in result
        assert "\n### Fixed: Add rollback on cell creation failure" not in result
        assert "#### Duplicate Vertex Handling" in result
        assert "#### Fixed: Add rollback on cell creation failure" in result
        assert "### Fixed" in result

    def test_existing_archive_normalization_preserves_fenced_headings(self) -> None:
        text = (
            "# Changelog - 0.5.x\n\n"
            "## [0.5.3] - 2025-10-31\n\n"
            "### Fixed\n\n"
            "```markdown\n"
            "## Example Heading\n"
            "### Fixed: Example\n"
            "```\n\n"
            "## Duplicate Vertex Handling\n"
        )

        result = normalize_entry_headings_text(text)

        assert "```markdown\n## Example Heading\n### Fixed: Example\n```" in result
        assert "#### Duplicate Vertex Handling" in result


class TestSquashHeadingNormalization:
    """GitHub squash-body pseudo-commit headings are rendered as prose."""

    def test_plain_summary_removes_links_and_conventional_prefix(self) -> None:
        line = f"- fix: Improve benchmark output {_pr(42)} {_commit()}"
        assert _plain_summary(line) == "improve benchmark output"

    def test_plain_summary_removes_breaking_marker(self) -> None:
        line = f"- [**breaking**] feat!: Remove old API {_pr(42)} {_commit()}"
        assert _plain_summary(line) == "remove old api"

    def test_squash_heading_parts_maps_kind_to_changelog_label(self) -> None:
        assert _squash_heading_parts("  - perf(core): speed up predicates") == (
            "  ",
            "Performance",
            "Speed up predicates",
        )

    def test_squash_heading_parts_ignores_commit_entries(self) -> None:
        assert _squash_heading_parts(f"- fix: actual commit {_commit()}") is None

    def test_conventional_squash_heading_becomes_bold_prose(self) -> None:
        assert _normalize_squash_heading("- fix: close the 4D retry collapse") == "**Fixed: Close the 4D retry collapse**"

    def test_nested_squash_heading_is_indented(self) -> None:
        assert _normalize_squash_heading("- Changed: harden flip diagnostics", nested=True) == "  **Changed: Harden flip diagnostics**"

    def test_commit_entry_is_preserved(self) -> None:
        line = f"- fix: actual commit {_commit()}"
        assert _normalize_squash_heading(line) == line

    def test_duplicate_squash_heading_matches_parent_summary(self) -> None:
        parent = _plain_summary("- Instrument large-scale 4D debugging")
        assert _is_duplicate_squash_heading("- feat: instrument large-scale 4D debugging", parent)

    def test_duplicate_squash_heading_rejects_distinct_heading(self) -> None:
        parent = _plain_summary("- Instrument large-scale 4D debugging")
        assert not _is_duplicate_squash_heading("- fix: close the 4D retry collapse", parent)

    def test_isolated_body_heading_requires_blank_neighbors(self) -> None:
        lines = ["- Parent entry", "", "- fix: child heading", "", "  - detail"]
        assert _is_isolated_body_heading(lines, 2)
        assert not _is_isolated_body_heading(lines, 4)

    def test_full_pipeline_drops_duplicate_squash_heading(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text(
            "# Changelog\n\n"
            "## [1.0.0]\n\n"
            "### Added\n\n"
            f"- Instrument large-scale 4D debugging {_commit('3af976e', '3af976ec2f7c33d49803b24ab8f1a7da598fea0b')}\n\n"
            "* feat: instrument large-scale 4D debugging\n\n"
            "  - Thread cavity-touched cells through insertion.\n\n"
            "* fix: close the 4D bulk repair retry collapse\n\n"
            "  - Raise the D>=4 per-insertion repair budget.\n",
            encoding="utf-8",
        )

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        assert "feat: instrument large-scale 4D debugging" not in result
        assert "**Fixed: Close the 4D bulk repair retry collapse**" in result
        assert "  - Thread cavity-touched cells through insertion." in result

    def test_full_pipeline_resets_parent_summary_at_version_heading(self, tmp_path: Path) -> None:
        """Version headings reset duplicate-squash tracking between releases."""
        f = tmp_path / "CHANGELOG.md"
        f.write_text(
            "# Changelog\n\n"
            "## [1.0.0]\n\n"
            "### Added\n\n"
            f"- Repeatable summary {_commit()}\n\n"
            "## [0.9.0]\n\n"
            "- fixed: repeatable summary\n\n"
            "  - Preserve this historical squash-body heading.\n",
            encoding="utf-8",
        )

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        assert "**Fixed: Repeatable summary**" in result
        assert "  - Preserve this historical squash-body heading." in result

    def test_full_pipeline_preserves_non_isolated_conventional_bullets(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text(
            "# Changelog\n\n"
            "## [1.0.0]\n\n"
            "### Documentation\n\n"
            f"- Update workflow docs {_commit()}\n\n"
            "  - Added: `just help-workflows` references throughout\n"
            "  - Expanded: Testing commands\n",
            encoding="utf-8",
        )

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        assert "  - Added: `just help-workflows` references throughout" in result
        assert "**Added: `just help-workflows`" not in result

    def test_full_pipeline_deindents_children_after_squash_heading(self) -> None:
        content = (
            "# Changelog\n\n"
            "## [1.0.0]\n\n"
            "### Added\n\n"
            f"- Identity-based SoS perturbation {_pr(272)} {_commit('a125d98', 'a125d98deadbeef')}\n\n"
            f"  - feat: Canonical vertex ordering details {_pr(266)}\n\n"
            "    - Add canonical_points module with sorted_cell_points helpers\n"
        )
        result = postprocess_text(content)
        assert f"  **Added: Canonical vertex ordering details {_pr(266)}**" in result
        assert "\n  - Add canonical_points module with sorted_cell_points helpers\n" in result
        assert "\n    - Add canonical_points module" not in result


class TestCodeBlockLanguage:
    def test_process_code_fence_opens_and_tags_bare_fence(self) -> None:
        result: list[str] = []

        handled, in_code_block = _process_code_fence("```", result, in_code_block=False, next_line="let x = 1;")

        assert handled
        assert in_code_block
        assert result == ["```text"]

    def test_process_code_fence_closes_existing_block(self) -> None:
        result: list[str] = []

        handled, in_code_block = _process_code_fence("```", result, in_code_block=True, next_line=None)

        assert handled
        assert not in_code_block
        assert result == ["```"]

    def test_process_code_fence_adds_blank_after_closing_fence(self) -> None:
        result: list[str] = []

        handled, in_code_block = _process_code_fence("```", result, in_code_block=True, next_line="following prose")

        assert handled
        assert not in_code_block
        assert result == ["```", ""]

    def test_process_code_fence_ignores_regular_line(self) -> None:
        result: list[str] = []

        handled, in_code_block = _process_code_fence("regular text", result, in_code_block=False, next_line=None)

        assert not handled
        assert not in_code_block
        assert result == []

    def test_adds_language_to_bare_fence(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("  ```\n  let x = 1;\n  ```\n", encoding="utf-8")

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        assert "```text" in result

    def test_preserves_existing_language(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("```rust\nlet x = 1;\n```\n", encoding="utf-8")

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        assert "```rust" in result
        assert "```text" not in result

    def test_no_reflow_inside_code_block(self, tmp_path: Path) -> None:
        long_code = "  let very_long = " + "a" * 200 + ";"
        f = tmp_path / "CHANGELOG.md"
        f.write_text(f"```rust\n{long_code}\n```\n", encoding="utf-8")

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        assert long_code in result

    def test_adds_blank_after_code_block_before_prose(self, tmp_path: Path) -> None:
        f = tmp_path / "CHANGELOG.md"
        f.write_text("```text\ncode\n```\nfollowing prose\n", encoding="utf-8")

        postprocess(f)

        assert f.read_text(encoding="utf-8") == "```text\ncode\n```\n\nfollowing prose\n"


class TestIntegration:
    def test_full_changelog_reflow(self, tmp_path: Path) -> None:
        """Simulate a realistic changelog snippet with long lines."""
        long_entry = (
            "- Use exact arithmetic [#235](https://github.com/acgetchell/delaunay/pull/235) "
            "[#236](https://github.com/acgetchell/delaunay/pull/236) "
            "[`a62437f`](https://github.com/acgetchell/delaunay/commit/a62437f25c27259f145d3c193ce149ee14b421c7)"
        )
        long_body = "  " + "word " * 40
        content = f"# Changelog\n\n## [0.7.2]\n\n### Added\n\n{long_entry}\n\n{long_body.rstrip()}\n\n"

        f = tmp_path / "CHANGELOG.md"
        f.write_text(content, encoding="utf-8")

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        for line in result.split("\n"):
            if line.strip():
                assert len(line) <= 160 or "](" in line or "http" in line, f"Line too long ({len(line)}): {line[:80]}..."

    def test_summary_sections_in_full_pipeline(self, tmp_path: Path) -> None:
        """Summary sections are injected and survive reflow."""
        entry = f"- Feature {_pr(42)} {_commit()}"
        content = f"# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n{entry}\n"
        f = tmp_path / "CHANGELOG.md"
        f.write_text(content, encoding="utf-8")

        postprocess(f)

        result = f.read_text(encoding="utf-8")
        assert "### Merged Pull Requests" in result
        assert "### Added" in result
        # Summary appears before categorised sections.
        assert result.index("### Merged Pull Requests") < result.index("### Added")