@@ -228,6 +228,36 @@ def test_missing_unreleased_header_fails(self, mod, tmp_path):
228228 root = make_repo (tmp_path , changelog = MINIMAL_CHANGELOG .replace ("## [Unreleased]\n " , "" ))
229229 assert any ("Unreleased" in f for f in check_findings (mod , root ))
230230
231+ def test_check_rejects_symlink_fragment_before_reading (self , mod , tmp_path ):
232+ # A symlink at a fragment path must be a finding at check level (CI),
233+ # not just at compile time — its target is mutable out-of-band.
234+ root = make_repo (tmp_path )
235+ target = root / "real-content.md"
236+ target .write_text (GOOD_FRAGMENT )
237+ (root / "changelog.d" / "20260830-x.md" ).symlink_to (target )
238+ assert any ("not a regular file" in f for f in check_findings (mod , root ))
239+
240+ def test_check_rejects_dangling_symlink_without_crashing (self , mod , tmp_path ):
241+ root = make_repo (tmp_path )
242+ (root / "changelog.d" / "20260830-x.md" ).symlink_to (root / "does-not-exist.md" )
243+ assert any ("not a regular file" in f for f in check_findings (mod , root ))
244+
245+ def test_check_rejects_undecodable_fragment (self , mod , tmp_path ):
246+ root = make_repo (tmp_path )
247+ (root / "changelog.d" / "20260830-x.md" ).write_bytes (b"### Fixed\n - \xff \xfe junk\n " )
248+ assert any ("unreadable" in f for f in check_findings (mod , root ))
249+
250+ def test_duplicate_unreleased_headers_fail (self , mod , tmp_path ):
251+ # A second '## [Unreleased]' section could carry direct bullets that
252+ # the first-match slice never inspects; exactly one header is allowed.
253+ changelog = MINIMAL_CHANGELOG .replace (
254+ "## [1.2.0] - 2026-01-15\n " ,
255+ "## [Unreleased]\n \n ### Added\n - smuggled direct bullet\n \n "
256+ "## [1.2.0] - 2026-01-15\n " ,
257+ )
258+ root = make_repo (tmp_path , changelog = changelog )
259+ assert any ("exactly one is allowed" in f for f in check_findings (mod , root ))
260+
231261 def test_eof_only_unreleased_header_is_finding_not_traceback (self , mod , tmp_path ):
232262 # File ending exactly at the header with no trailing newline must
233263 # produce a validation finding, not a ValueError.
@@ -351,6 +381,18 @@ def test_exit4_rejects_noncanonical_header_date(self, mod, tmp_path):
351381 root = make_repo (tmp_path , changelog = changelog )
352382 assert run_compile (mod , root , version = "1.2.0" ) == 1
353383
384+ def test_compile_refuses_duplicate_unreleased_headers (self , mod , tmp_path ):
385+ # check runs as compile's first step, so a duplicate Unreleased
386+ # section blocks compilation before any insertion or deletion.
387+ changelog = MINIMAL_CHANGELOG .replace (
388+ "## [1.2.0] - 2026-01-15\n " ,
389+ "## [Unreleased]\n \n ### Added\n - smuggled direct bullet\n \n "
390+ "## [1.2.0] - 2026-01-15\n " ,
391+ )
392+ root = make_repo (tmp_path , changelog = changelog , fragments = {"20260830-x.md" : GOOD_FRAGMENT })
393+ assert run_compile (mod , root ) == 1
394+ assert (root / "changelog.d" / "20260830-x.md" ).exists ()
395+
354396 def test_exit4_target_header_at_eof_is_error_not_traceback (self , mod , tmp_path ):
355397 # An existing target header ending the file with no trailing newline
356398 # must produce the empty-section error, not a ValueError (the same
0 commit comments