From f927e2c85b9caa18895f5300074e392f1ba1df83 Mon Sep 17 00:00:00 2001 From: riki137 Date: Thu, 6 Aug 2026 12:59:44 +0200 Subject: [PATCH 1/2] fix: normalize headings with markdown link targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recognize and normalize changelog headings that carry a markdown link target — reference-style `## [Unreleased][unreleased]` and inline `## [Unreleased](unreleased)` — on both the Unreleased and versioned sections. SECTION_RE now tolerates an optional `[ref]`/`(url)` after the bracketed heading text; the target is discarded since link refs are regenerated on release. Replaces the 1.5.0 handling of the reversed `## (Unreleased)[unreleased]` shape, which no tool emits. --- CHANGELOG.md | 4 +++ src/core/changelog.ts | 19 +++++------ .../changelog-in.md | 4 +-- .../changelog-out.md | 2 +- .../meta.json | 0 tests/unit/changelog.test.ts | 32 +++++++------------ 6 files changed, 27 insertions(+), 34 deletions(-) rename tests/fixtures/{12-paren-bracket-unreleased => 12-link-target-unreleased}/changelog-in.md (63%) rename tests/fixtures/{12-paren-bracket-unreleased => 12-link-target-unreleased}/changelog-out.md (77%) rename tests/fixtures/{12-paren-bracket-unreleased => 12-link-target-unreleased}/meta.json (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c3930..fec65a4 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] +### Fixed + +- Headings that carry a markdown link target are now recognized and normalized — reference-style `## [Unreleased][unreleased]` and inline `## [Unreleased](unreleased)`, on both the Unreleased and versioned sections. + ## [1.5.0] - 2026-08-06 ### Added diff --git a/src/core/changelog.ts b/src/core/changelog.ts index 4daa50a..0f769cc 100644 --- a/src/core/changelog.ts +++ b/src/core/changelog.ts @@ -42,22 +42,19 @@ 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*$/; -// 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, '## '); -} +// The heading text may carry a markdown link target — reference-style +// `## [Unreleased][unreleased]` or inline `## [Unreleased](unreleased)`. Only +// the bracketed text (group 1) is kept; the target is discarded and link refs +// are regenerated on release. +const SECTION_RE = + /^##\s+\[([^\]]+)\](?:\[[^\]]*\]|\([^)]*\))?(?:\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2}))?\s*$/; function isSectionHeading(line: string): boolean { - return SECTION_RE.test(stripHeadingParenPrefix(line)); + return SECTION_RE.test(line); } function matchSection(line: string): RegExpExecArray | null { - return SECTION_RE.exec(stripHeadingParenPrefix(line)); + return SECTION_RE.exec(line); } export function parse(text: string): Changelog { diff --git a/tests/fixtures/12-paren-bracket-unreleased/changelog-in.md b/tests/fixtures/12-link-target-unreleased/changelog-in.md similarity index 63% rename from tests/fixtures/12-paren-bracket-unreleased/changelog-in.md rename to tests/fixtures/12-link-target-unreleased/changelog-in.md index 228e41d..6fd9bee 100644 --- a/tests/fixtures/12-paren-bracket-unreleased/changelog-in.md +++ b/tests/fixtures/12-link-target-unreleased/changelog-in.md @@ -1,10 +1,10 @@ # Changelog -## (Unreleased)[unreleased] +## [Unreleased][unreleased] ### Added -- Reversed-link-syntax `(Unreleased)[unreleased]` heading gets normalized on release. +- Link-target `[Unreleased][unreleased]` heading gets normalized on release. ## [0.5.0] - 2026-04-01 diff --git a/tests/fixtures/12-paren-bracket-unreleased/changelog-out.md b/tests/fixtures/12-link-target-unreleased/changelog-out.md similarity index 77% rename from tests/fixtures/12-paren-bracket-unreleased/changelog-out.md rename to tests/fixtures/12-link-target-unreleased/changelog-out.md index 514b82a..3673c0a 100644 --- a/tests/fixtures/12-paren-bracket-unreleased/changelog-out.md +++ b/tests/fixtures/12-link-target-unreleased/changelog-out.md @@ -6,7 +6,7 @@ ### Added -- Reversed-link-syntax `(Unreleased)[unreleased]` heading gets normalized on release. +- Link-target `[Unreleased][unreleased]` heading gets normalized on release. ## [0.5.0] - 2026-04-01 diff --git a/tests/fixtures/12-paren-bracket-unreleased/meta.json b/tests/fixtures/12-link-target-unreleased/meta.json similarity index 100% rename from tests/fixtures/12-paren-bracket-unreleased/meta.json rename to tests/fixtures/12-link-target-unreleased/meta.json diff --git a/tests/unit/changelog.test.ts b/tests/unit/changelog.test.ts index ce47272..58ea22f 100644 --- a/tests/unit/changelog.test.ts +++ b/tests/unit/changelog.test.ts @@ -38,33 +38,25 @@ 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'); + test('normalizes reference-link heading `[Unreleased][unreleased]`', () => { + 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'); + test('normalizes inline-link heading `[Unreleased](unreleased)`', () => { + const cl = parse('## [Unreleased](unreleased)\n\n- foo\n'); + expect(cl.unreleased).not.toBeNull(); 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('supports link-target headings on versioned sections too', () => { + const ref = parse('## [1.2.3][1.2.3] - 2025-01-01\n'); + expect(ref.releases[0]!.version).toBe('1.2.3'); + expect(ref.releases[0]!.date).toBe('2025-01-01'); + const inline = parse('## [1.2.3](https://x/1.2.3) - 2025-01-01\n'); + expect(inline.releases[0]!.version).toBe('1.2.3'); + expect(inline.releases[0]!.date).toBe('2025-01-01'); }); test('captures dates and versions accurately', () => { From 14958c2c50254ec8e5a9bbc16002ca20ebf038d0 Mon Sep 17 00:00:00 2001 From: riki137 Date: Thu, 6 Aug 2026 14:36:10 +0200 Subject: [PATCH 2/2] fix: address SonarCloud findings on link-target heading regex Split the combined heading regex into three simpler regexes to cut cyclomatic complexity below Sonar's threshold and use \d instead of [0-9]. Parameterize the three near-identical unreleased-heading tests into one test.each block. --- src/core/changelog.ts | 40 ++++++++++++++++++++++++++---------- tests/unit/changelog.test.ts | 20 ++++++------------ 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/core/changelog.ts b/src/core/changelog.ts index 0f769cc..5d41a30 100644 --- a/src/core/changelog.ts +++ b/src/core/changelog.ts @@ -42,19 +42,37 @@ export interface ReleaseOptions { } const LINK_REF_RE = /^\[([^\]]+)\]:\s*(.+?)\s*$/; +const SECTION_START_RE = /^##\s+\[([^\]]+)\]/; // The heading text may carry a markdown link target — reference-style -// `## [Unreleased][unreleased]` or inline `## [Unreleased](unreleased)`. Only -// the bracketed text (group 1) is kept; the target is discarded and link refs -// are regenerated on release. -const SECTION_RE = - /^##\s+\[([^\]]+)\](?:\[[^\]]*\]|\([^)]*\))?(?:\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2}))?\s*$/; +// `## [Unreleased][unreleased]` or inline `## [Unreleased](unreleased)`. It is +// discarded here; link refs are regenerated on release. +const LINK_TARGET_RE = /^\[[^\]]*\]|^\([^)]*\)/; +const SECTION_DATE_RE = /^\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/; -function isSectionHeading(line: string): boolean { - return SECTION_RE.test(line); +interface SectionMatch { + name: string; + date: string | null; } -function matchSection(line: string): RegExpExecArray | null { - return SECTION_RE.exec(line); +function matchSection(line: string): SectionMatch | null { + const start = SECTION_START_RE.exec(line); + if (!start) { + return null; + } + let rest = line.slice(start[0].length); + const target = LINK_TARGET_RE.exec(rest); + if (target) { + rest = rest.slice(target[0].length); + } + if (rest.trim() === '') { + return { name: start[1]!, date: null }; + } + const date = SECTION_DATE_RE.exec(rest); + return date ? { name: start[1]!, date: date[1]! } : null; +} + +function isSectionHeading(line: string): boolean { + return matchSection(line) !== null; } export function parse(text: string): Changelog { @@ -189,8 +207,8 @@ function splitHeaderAndSections(mainLines: string[]): { i++; continue; } - const version = m[1]!; - const date = m[2] ?? null; + const version = m.name; + const date = m.date; i++; const body: string[] = []; diff --git a/tests/unit/changelog.test.ts b/tests/unit/changelog.test.ts index 58ea22f..3723a97 100644 --- a/tests/unit/changelog.test.ts +++ b/tests/unit/changelog.test.ts @@ -32,20 +32,12 @@ describe('parse', () => { expect(parse('# t\r\n\r\n## [Unreleased]\r\n').eol).toBe('\r\n'); }); - test('treats lowercase `unreleased` heading as Unreleased', () => { - const cl = parse('## [unreleased]\n\n- foo\n'); - expect(cl.unreleased).not.toBeNull(); - expect(cl.unreleased!.version).toBe('Unreleased'); - }); - - test('normalizes reference-link heading `[Unreleased][unreleased]`', () => { - const cl = parse('## [Unreleased][unreleased]\n\n- foo\n'); - expect(cl.unreleased).not.toBeNull(); - expect(cl.unreleased!.version).toBe('Unreleased'); - }); - - test('normalizes inline-link heading `[Unreleased](unreleased)`', () => { - const cl = parse('## [Unreleased](unreleased)\n\n- foo\n'); + test.each([ + ['lowercase `unreleased` heading', '## [unreleased]\n\n- foo\n'], + ['reference-link heading `[Unreleased][unreleased]`', '## [Unreleased][unreleased]\n\n- foo\n'], + ['inline-link heading `[Unreleased](unreleased)`', '## [Unreleased](unreleased)\n\n- foo\n'], + ])('normalizes %s to Unreleased', (_label, input) => { + const cl = parse(input); expect(cl.unreleased).not.toBeNull(); expect(cl.unreleased!.version).toBe('Unreleased'); });