From 4daf073f2095cfc75109febfaafae522fa65aa10 Mon Sep 17 00:00:00 2001 From: riki137 Date: Thu, 6 Aug 2026 10:58:32 +0200 Subject: [PATCH 1/3] feat: normalize (Unreleased)[unreleased]-style headings Support the reversed markdown-link heading shape some tools/humans produce for the Unreleased section, in addition to the already case-insensitive `[unreleased]`. --- src/core/changelog.ts | 3 +- .../changelog-in.md | 16 ++++++++++ .../changelog-out.md | 19 ++++++++++++ .../12-paren-bracket-unreleased/meta.json | 6 ++++ tests/unit/changelog.test.ts | 29 +++++++++++++++++++ 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/12-paren-bracket-unreleased/changelog-in.md create mode 100644 tests/fixtures/12-paren-bracket-unreleased/changelog-out.md create mode 100644 tests/fixtures/12-paren-bracket-unreleased/meta.json diff --git a/src/core/changelog.ts b/src/core/changelog.ts index dbfd494..603c91d 100644 --- a/src/core/changelog.ts +++ b/src/core/changelog.ts @@ -42,7 +42,8 @@ export interface ReleaseOptions { } const LINK_REF_RE = /^\[([^\]]+)\]:\s*(.+?)\s*$/; -const SECTION_RE = /^##\s+\[([^\]]+)\](?:\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2}))?\s*$/; +const SECTION_RE = + /^##\s+(?:\([^)]+\)\s*)?\[([^\]]+)\](?:\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2}))?\s*$/; export function parse(text: string): Changelog { const eol: '\n' | '\r\n' = text.includes('\r\n') ? '\r\n' : '\n'; diff --git a/tests/fixtures/12-paren-bracket-unreleased/changelog-in.md b/tests/fixtures/12-paren-bracket-unreleased/changelog-in.md new file mode 100644 index 0000000..228e41d --- /dev/null +++ b/tests/fixtures/12-paren-bracket-unreleased/changelog-in.md @@ -0,0 +1,16 @@ +# Changelog + +## (Unreleased)[unreleased] + +### Added + +- Reversed-link-syntax `(Unreleased)[unreleased]` heading gets normalized on release. + +## [0.5.0] - 2026-04-01 + +### Added + +- Initial. + +[unreleased]: https://github.com/foo/bar/compare/v0.5.0...main +[0.5.0]: https://github.com/foo/bar/compare/v0.4.0...v0.5.0 diff --git a/tests/fixtures/12-paren-bracket-unreleased/changelog-out.md b/tests/fixtures/12-paren-bracket-unreleased/changelog-out.md new file mode 100644 index 0000000..514b82a --- /dev/null +++ b/tests/fixtures/12-paren-bracket-unreleased/changelog-out.md @@ -0,0 +1,19 @@ +# Changelog + +## [Unreleased] + +## [0.6.0] - 2026-05-25 + +### Added + +- Reversed-link-syntax `(Unreleased)[unreleased]` heading gets normalized on release. + +## [0.5.0] - 2026-04-01 + +### Added + +- Initial. + +[Unreleased]: https://github.com/foo/bar/compare/v0.6.0...main +[0.6.0]: https://github.com/foo/bar/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/foo/bar/compare/v0.4.0...v0.5.0 diff --git a/tests/fixtures/12-paren-bracket-unreleased/meta.json b/tests/fixtures/12-paren-bracket-unreleased/meta.json new file mode 100644 index 0000000..2f2015f --- /dev/null +++ b/tests/fixtures/12-paren-bracket-unreleased/meta.json @@ -0,0 +1,6 @@ +{ + "version": "0.6.0", + "date": "2026-05-25", + "unreleasedUrl": "https://github.com/foo/bar/compare/v0.6.0...main", + "versionUrl": "https://github.com/foo/bar/compare/v0.5.0...v0.6.0" +} diff --git a/tests/unit/changelog.test.ts b/tests/unit/changelog.test.ts index 50395f7..ce47272 100644 --- a/tests/unit/changelog.test.ts +++ b/tests/unit/changelog.test.ts @@ -38,6 +38,35 @@ describe('parse', () => { expect(cl.unreleased!.version).toBe('Unreleased'); }); + test('normalizes reversed-link-syntax `(Unreleased)[unreleased]` heading', () => { + const cl = parse('## (Unreleased)[unreleased]\n\n- foo\n'); + expect(cl.unreleased).not.toBeNull(); + expect(cl.unreleased!.version).toBe('Unreleased'); + }); + + test('ignores paren text even when it mismatches the bracket name', () => { + const cl = parse('## (UNRELEASED)[Unreleased]\n\n- foo\n'); + expect(cl.unreleased!.version).toBe('Unreleased'); + }); + + test('tolerates extra whitespace between the paren and bracket groups', () => { + const cl = parse('## (Unreleased) [unreleased]\n\n- foo\n'); + expect(cl.unreleased!.version).toBe('Unreleased'); + }); + + test('supports the paren-bracket shape on versioned headings too', () => { + const cl = parse('## (1.2.3)[1.2.3] - 2025-01-01\n'); + expect(cl.releases).toHaveLength(1); + expect(cl.releases[0]!.version).toBe('1.2.3'); + expect(cl.releases[0]!.date).toBe('2025-01-01'); + }); + + test('does not treat real link syntax `[Unreleased](unreleased)` as a heading', () => { + const cl = parse('## [Unreleased](unreleased)\n\n- foo\n'); + expect(cl.unreleased).toBeNull(); + expect(cl.releases).toHaveLength(0); + }); + test('captures dates and versions accurately', () => { const cl = parse('## [1.2.3] - 2025-01-02\n'); expect(cl.releases).toHaveLength(1); From 4b1645568a74ff1c185c1d3061150141f8c26292 Mon Sep 17 00:00:00 2001 From: riki137 Date: Thu, 6 Aug 2026 10:59:03 +0200 Subject: [PATCH 2/3] docs: add changelog entry for unreleased-heading normalization --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eed787f..4990858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The `Unreleased` heading is now also recognized in the reversed markdown-link shape some tools/humans produce, e.g. `## (Unreleased)[unreleased]`, and normalized to `## [Unreleased]` on release. + ## [1.4.2] - 2026-07-16 ### Fixed From 26d98d8aa3c8a92095369f7ac8a4dfa1873c714f Mon Sep 17 00:00:00 2001 From: riki137 Date: Thu, 6 Aug 2026 11:01:41 +0200 Subject: [PATCH 3/3] fix: reduce SECTION_RE cognitive complexity flagged by SonarCloud Move the paren-prefix stripping for (Unreleased)[unreleased]-style headings into a separate, simpler regex applied before matching SECTION_RE, instead of inlining it as an optional group. --- src/core/changelog.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/core/changelog.ts b/src/core/changelog.ts index 603c91d..4daa50a 100644 --- a/src/core/changelog.ts +++ b/src/core/changelog.ts @@ -42,8 +42,23 @@ export interface ReleaseOptions { } const LINK_REF_RE = /^\[([^\]]+)\]:\s*(.+?)\s*$/; -const SECTION_RE = - /^##\s+(?:\([^)]+\)\s*)?\[([^\]]+)\](?:\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2}))?\s*$/; +const SECTION_RE = /^##\s+\[([^\]]+)\](?:\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2}))?\s*$/; +// Some tools/humans write the heading with a reversed-link-style paren prefix, +// e.g. `## (Unreleased)[unreleased]`. Strip it before matching SECTION_RE so +// that regex stays simple — the paren text itself is never used for anything. +const HEADING_PAREN_PREFIX_RE = /^##\s+\([^)]+\)\s*(?=\[)/; + +function stripHeadingParenPrefix(line: string): string { + return line.replace(HEADING_PAREN_PREFIX_RE, '## '); +} + +function isSectionHeading(line: string): boolean { + return SECTION_RE.test(stripHeadingParenPrefix(line)); +} + +function matchSection(line: string): RegExpExecArray | null { + return SECTION_RE.exec(stripHeadingParenPrefix(line)); +} export function parse(text: string): Changelog { const eol: '\n' | '\r\n' = text.includes('\r\n') ? '\r\n' : '\n'; @@ -99,7 +114,7 @@ function extractTrailingLinkRefs(lines: string[]): { lastLink = i; break; } - if (SECTION_RE.test(lines[i]!)) break; + if (isSectionHeading(lines[i]!)) break; } if (lastLink === -1) return { mainLines: lines, links: [], dropped: [] }; @@ -164,14 +179,14 @@ function splitHeaderAndSections(mainLines: string[]): { const sections: Section[] = []; let i = 0; - while (i < mainLines.length && !SECTION_RE.test(mainLines[i]!)) { + while (i < mainLines.length && !isSectionHeading(mainLines[i]!)) { header.push(mainLines[i]!); i++; } trimTrailingBlanks(header); while (i < mainLines.length) { - const m = SECTION_RE.exec(mainLines[i]!); + const m = matchSection(mainLines[i]!); if (!m) { // Should not happen if the file is well-formed; skip stray content between sections. i++; @@ -182,7 +197,7 @@ function splitHeaderAndSections(mainLines: string[]): { i++; const body: string[] = []; - while (i < mainLines.length && !SECTION_RE.test(mainLines[i]!)) { + while (i < mainLines.length && !isSectionHeading(mainLines[i]!)) { body.push(mainLines[i]!); i++; }