From bcceccf9e21a2e7f817c5aa02ac3b7961f3f7cb9 Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Wed, 19 Aug 2026 21:40:30 +0000 Subject: [PATCH 1/9] chore(lib-features): code cleanup --- libs/features/src/host/lifecycle.liveness.browser.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/features/src/host/lifecycle.liveness.browser.spec.ts b/libs/features/src/host/lifecycle.liveness.browser.spec.ts index 33d49b74..78c9fca1 100644 --- a/libs/features/src/host/lifecycle.liveness.browser.spec.ts +++ b/libs/features/src/host/lifecycle.liveness.browser.spec.ts @@ -40,6 +40,7 @@ const TARGET = ({ name: 'target' }) /** * Drives this page's reported visibility, the way a phone backgrounding the tab does. + * * @param value - The state `document.visibilityState` should report. */ function setPageVisibility(value: 'visible' | 'hidden'): void { @@ -50,6 +51,7 @@ function setPageVisibility(value: 'visible' | 'hidden'): void { /** * Builds a shell wired to the real watchdog and the real visibility observer, * mounted as an in-document frame. + * * @returns The handle, its channel double, and the errors it emitted. */ function setup() { From 3ecc0c9c88709a601e813d0e82d515f9a9985dee Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Wed, 19 Aug 2026 21:41:29 +0000 Subject: [PATCH 2/9] chore: update versions for lib-features --- libs/features/CHANGELOG.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/libs/features/CHANGELOG.md b/libs/features/CHANGELOG.md index 2cddd833..e7960690 100644 --- a/libs/features/CHANGELOG.md +++ b/libs/features/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## [0.7.1](https://github.com/AndrewRedican/hyperfrontend/compare/ee192ea7bb541593e6590243a08a29b68d69a1ac...4943d02ebe5bc10fc471f4d6a96c6b87812d0b76) - 2026-08-19 +## [0.7.1](https://github.com/AndrewRedican/hyperfrontend/compare/ee192ea7bb541593e6590243a08a29b68d69a1ac...bcceccf9e21a2e7f817c5aa02ac3b7961f3f7cb9) - 2026-08-19 ### Bug Fixes @@ -66,25 +66,25 @@ All notable changes to this project will be documented in this file. ### Breaking Changes -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** coordinate presentation host-side with contract-declared display modes -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** throw when registering v2 security without a shared key -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** is Open stays false after open() until the handshake completes; ready() rejects when no host answers +- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** coordinate presentation host-side with contract-declared display modes +- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** throw when registering v2 security without a shared key +- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** is Open stays false after open() until the handshake completes; ready() rejects when no host answers ### Features - adopt a reloaded feature on its existing mount -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** ⚠️ BREAKING: coordinate presentation host-side with contract-declared display modes +- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** ⚠️ BREAKING: coordinate presentation host-side with contract-declared display modes - delegate frame permissions and add host-only sandbox containment - add four-state liveness, closing flush window, and dirty state - validate action payloads on send and receive - announce contract versions and gate compatibility - add hostee security options and shared registration - add allow-open option to the nx build executor -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** ⚠️ BREAKING: adopt asynchronous wire-gated open with origin pinning and timeout errors +- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** ⚠️ BREAKING: adopt asynchronous wire-gated open with origin pinning and timeout errors ### Bug Fixes -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** ⚠️ BREAKING: throw when registering v2 security without a shared key +- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** ⚠️ BREAKING: throw when registering v2 security without a shared key ## [0.2.0](https://github.com/AndrewRedican/hyperfrontend/compare/6b5a02be62850b0509b9fd71ad9232655cf5fbbf...47a37497608ac765af3efb30f0b5e01950bae425) - 2026-07-05 From 04ee81a551d1ca66c05425ba62f8039b02b3f56c Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Wed, 19 Aug 2026 23:44:24 +0000 Subject: [PATCH 3/9] fix(lib-versioning): lift breaking markers off changelog item text --- libs/versioning/src/changelog/parse/README.md | 2 +- .../src/changelog/parse/breaking.spec.ts | 97 +++++++++++++ .../src/changelog/parse/breaking.ts | 133 ++++++++++++++++++ libs/versioning/src/changelog/parse/index.ts | 2 + .../src/changelog/parse/parser.spec.ts | 14 ++ libs/versioning/src/changelog/parse/parser.ts | 10 +- 6 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 libs/versioning/src/changelog/parse/breaking.spec.ts create mode 100644 libs/versioning/src/changelog/parse/breaking.ts diff --git a/libs/versioning/src/changelog/parse/README.md b/libs/versioning/src/changelog/parse/README.md index 8c110742..77d838cb 100644 --- a/libs/versioning/src/changelog/parse/README.md +++ b/libs/versioning/src/changelog/parse/README.md @@ -2,4 +2,4 @@ Tokenizer and parser for converting Keep-a-Changelog markdown into structured `Changelog` objects. -`tokenize` walks markdown character-by-character to emit a typed `Token[]` stream: headings, list items, links, and section markers. `parseChangelog` consumes that stream and assembles entries, sections, and items in their declared order. Per-line helpers (`parseVersionFromHeading`, `parseCommitRefs`, `parseIssueRefs`, `parseScopeFromItem`) extract the structured fragments embedded in entry headers and bullet items. No regex is used anywhere in the parsing pipeline, keeping behavior linear in input length and free of catastrophic backtracking risk. +`tokenize` walks markdown character-by-character to emit a typed `Token[]` stream: headings, list items, links, and section markers. `parseChangelog` consumes that stream and assembles entries, sections, and items in their declared order. Per-line helpers (`parseVersionFromHeading`, `parseCommitRefs`, `parseIssueRefs`, `parseBreakingFromItem`, `parseScopeFromItem`) extract the structured fragments embedded in entry headers and bullet items. `parseBreakingFromItem` lifts the breaking-change markers an item opens with onto the item's `breaking` flag, which is what keeps a parse then serialize round trip a fixpoint: the serializer re-emits exactly one marker, and any run of markers left by an earlier round trip collapses back to that one. No regex is used anywhere in the parsing pipeline, keeping behavior linear in input length and free of catastrophic backtracking risk. diff --git a/libs/versioning/src/changelog/parse/breaking.spec.ts b/libs/versioning/src/changelog/parse/breaking.spec.ts new file mode 100644 index 00000000..2baf6db4 --- /dev/null +++ b/libs/versioning/src/changelog/parse/breaking.spec.ts @@ -0,0 +1,97 @@ +import { parseBreakingFromItem } from './breaking' + +describe('parseBreakingFromItem', () => { + it('lifts the bold marker off the text', () => { + expect(parseBreakingFromItem('**BREAKING** drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts a bold marker with the colon inside', () => { + expect(parseBreakingFromItem('**BREAKING:** drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts a bold marker with the colon outside', () => { + expect(parseBreakingFromItem('**BREAKING**: drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts a bracketed marker', () => { + expect(parseBreakingFromItem('[BREAKING] drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts a bracketed marker with a trailing colon', () => { + expect(parseBreakingFromItem('[BREAKING]: drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts a warning-sign marker', () => { + expect(parseBreakingFromItem('⚠️ BREAKING: drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts a warning sign written without its variation selector', () => { + expect(parseBreakingFromItem('⚠ BREAKING: drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts a warning sign written without a following space', () => { + expect(parseBreakingFromItem('⚠️BREAKING: drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts a marker that runs straight into the description', () => { + expect(parseBreakingFromItem('**BREAKING:**drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts a marker that is the whole item', () => { + expect(parseBreakingFromItem('**BREAKING**')).toEqual({ breaking: true, text: '' }) + }) + + it('lifts a bare marker', () => { + expect(parseBreakingFromItem('BREAKING: drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts the BREAKING CHANGE spelling', () => { + expect(parseBreakingFromItem('BREAKING CHANGE: drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('lifts the BREAKING CHANGES spelling', () => { + expect(parseBreakingFromItem('**BREAKING CHANGES:** drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('matches the marker case-insensitively', () => { + expect(parseBreakingFromItem('**breaking:** drop the sync open')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) + + it('collapses a run of markers left by earlier round trips', () => { + expect(parseBreakingFromItem('**BREAKING** **BREAKING:** **BREAKING:** ⚠️ BREAKING: drop the sync open')).toEqual({ + breaking: true, + text: 'drop the sync open', + }) + }) + + it('leaves an unmarked item alone', () => { + expect(parseBreakingFromItem('add a retry budget')).toEqual({ breaking: false, text: 'add a retry budget' }) + }) + + it('leaves prose that merely opens with the word alone', () => { + expect(parseBreakingFromItem('Breaking apart the parser')).toEqual({ breaking: false, text: 'Breaking apart the parser' }) + }) + + it('leaves a scope prefix alone', () => { + expect(parseBreakingFromItem('**api:** add an endpoint')).toEqual({ breaking: false, text: '**api:** add an endpoint' }) + }) + + it('leaves an unterminated bold marker alone', () => { + expect(parseBreakingFromItem('**BREAKING drop the sync open')).toEqual({ breaking: false, text: '**BREAKING drop the sync open' }) + }) + + it('leaves a longer word that merely starts with the marker alone', () => { + expect(parseBreakingFromItem('**BREAKINGS** drop the sync open')).toEqual({ + breaking: false, + text: '**BREAKINGS** drop the sync open', + }) + }) + + it('leaves text shorter than the marker alone', () => { + expect(parseBreakingFromItem('BRE')).toEqual({ breaking: false, text: 'BRE' }) + }) + + it('trims surrounding whitespace', () => { + expect(parseBreakingFromItem(' **BREAKING** drop the sync open ')).toEqual({ breaking: true, text: 'drop the sync open' }) + }) +}) diff --git a/libs/versioning/src/changelog/parse/breaking.ts b/libs/versioning/src/changelog/parse/breaking.ts new file mode 100644 index 00000000..d9a462e3 --- /dev/null +++ b/libs/versioning/src/changelog/parse/breaking.ts @@ -0,0 +1,133 @@ +/** + * Result of {@link parseBreakingFromItem}: the breaking flag plus the item + * text left once its leading markers are removed. + */ +export type BreakingFromItem = { + /** Whether the item opened with at least one breaking marker */ + breaking: boolean + /** The item text with every leading breaking marker stripped */ + text: string +} + +/** + * Parses the breaking-change markers a changelog item can open with, lifting + * them out of the text and onto a flag. Serializing re-emits exactly one + * marker, so an item survives a parse then serialize round trip unchanged; a + * run of markers left behind by earlier round trips collapses back to a single + * flag. + * + * Recognized openers, case-insensitive and repeatable: `**BREAKING**`, + * `**BREAKING:**`, `[BREAKING]`, `⚠️ BREAKING:`, a bare `BREAKING:`, and the + * `BREAKING CHANGE`/`BREAKING CHANGES` spelling of each. A bare mention needs + * its colon to count, so prose such as `Breaking apart the parser` is left + * alone. Scanning is character-by-character to stay ReDoS-safe. + * + * @param text - The changelog item text, without its list marker + * @returns The breaking flag and the text that follows the markers + * + * @example Lifting markers out of item text + * ```typescript + * parseBreakingFromItem('**BREAKING** **BREAKING:** drop the sync open') + * // => { breaking: true, text: 'drop the sync open' } + * + * parseBreakingFromItem('add a retry budget') + * // => { breaking: false, text: 'add a retry budget' } + * ``` + */ +export function parseBreakingFromItem(text: string): BreakingFromItem { + const trimmed = text.trim() + let pos = 0 + let breaking = false + + while (pos < trimmed.length) { + const next = matchBreakingMarker(trimmed, pos) + if (next === -1) break + breaking = true + pos = next + } + + return { breaking, text: trimmed.slice(pos) } +} + +/** + * Matches a single breaking marker, tolerating the bold, bracketed, + * warning-sign, and bare spellings plus any spaces that trail it. + * + * @param text - The text being scanned + * @param start - Index the marker has to start at + * @returns The index just past the marker, or -1 when none matches + */ +function matchBreakingMarker(text: string, start: number): number { + let pos = start + let closer = '' + + if (text.startsWith('**', pos)) { + closer = '**' + pos += 2 + } else if (text[pos] === '[') { + closer = ']' + pos += 1 + } else if (text[pos] === '⚠') { + pos += 1 + // why: the warning sign is usually written with an emoji variation selector + if (text[pos] === '\uFE0F') pos += 1 + while (text[pos] === ' ') pos++ + } + + pos = matchBreakingWord(text, pos) + if (pos === -1) return -1 + + const colon = text[pos] === ':' + if (colon) pos += 1 + + if (closer.length > 0) { + if (!text.startsWith(closer, pos)) return -1 + pos += closer.length + if (text[pos] === ':') pos += 1 + } else if (!colon) { + return -1 + } + + while (text[pos] === ' ') pos++ + + return pos +} + +/** + * Matches the literal `BREAKING`, optionally continued as `BREAKING CHANGE` + * or `BREAKING CHANGES`. + * + * @param text - The text being scanned + * @param start - Index the word has to start at + * @returns The index just past the word, or -1 when it does not match + */ +function matchBreakingWord(text: string, start: number): number { + const afterWord = matchLiteral(text, start, 'breaking') + if (afterWord === -1) return -1 + + const afterChanges = matchLiteral(text, afterWord, ' changes') + if (afterChanges !== -1) return afterChanges + + const afterChange = matchLiteral(text, afterWord, ' change') + if (afterChange !== -1) return afterChange + + return afterWord +} + +/** + * Matches a lowercase literal at a position, ignoring case. + * + * @param text - The text being scanned + * @param start - Index the literal has to start at + * @param literal - The lowercase literal to match against + * @returns The index just past the literal, or -1 when it does not match + */ +function matchLiteral(text: string, start: number, literal: string): number { + if (start + literal.length > text.length) return -1 + + for (let i = 0; i < literal.length; i++) { + if (text[start + i]?.toLowerCase() !== literal[i]) return -1 + } + + return start + literal.length +} diff --git a/libs/versioning/src/changelog/parse/index.ts b/libs/versioning/src/changelog/parse/index.ts index 2035fcac..721d053a 100644 --- a/libs/versioning/src/changelog/parse/index.ts +++ b/libs/versioning/src/changelog/parse/index.ts @@ -3,7 +3,9 @@ * * @module @hyperfrontend/versioning/changelog/parse */ +export type { BreakingFromItem } from './breaking' export type { Token, TokenType } from './tokenizer' +export { parseBreakingFromItem } from './breaking' export { parseVersionFromHeading, parseCommitRefs, parseIssueRefs, parseScopeFromItem } from './line' export { parseChangelog } from './parser' export { tokenize } from './tokenizer' diff --git a/libs/versioning/src/changelog/parse/parser.spec.ts b/libs/versioning/src/changelog/parse/parser.spec.ts index aebf965d..8b680264 100644 --- a/libs/versioning/src/changelog/parse/parser.spec.ts +++ b/libs/versioning/src/changelog/parse/parser.spec.ts @@ -192,6 +192,20 @@ Based on [Keep a Changelog](https://keepachangelog.com). expect(items?.[1]?.breaking).toBe(false) }) + it('reads the serialized breaking marker back off an item', () => { + const content = `## [1.0.0] + +### Breaking Changes +- **BREAKING** Changed API +` + + const changelog = parseChangelog(content) + + expect(changelog.entries[0]?.sections[0]?.items[0]).toEqual( + expect.objectContaining({ description: 'Changed API', scope: undefined, breaking: true }) + ) + }) + it('detects breaking change with ! prefix', () => { const content = `## [1.0.0] diff --git a/libs/versioning/src/changelog/parse/parser.ts b/libs/versioning/src/changelog/parse/parser.ts index ef83b5f5..eeb9b252 100644 --- a/libs/versioning/src/changelog/parse/parser.ts +++ b/libs/versioning/src/changelog/parse/parser.ts @@ -4,6 +4,7 @@ import type { Token } from './tokenizer' import { createURL } from '@hyperfrontend/immutable-api-utils/built-in-copy/url' import { createChangelogItem } from '../models/entry' import { getSectionType } from '../models/section' +import { parseBreakingFromItem } from './breaking' import { parseVersionFromHeading, parseScopeFromItem, parseCommitRefs, parseIssueRefs } from './line' import { tokenize } from './tokenizer' @@ -300,11 +301,12 @@ function parseItem(state: ParserState): ChangelogItem | null { } const text = token.value - const { scope, description } = parseScopeFromItem(text) + const marker = parseBreakingFromItem(text) + const { scope, description } = parseScopeFromItem(marker.text) const commits = parseCommitRefs(text, state.repositoryUrl) const references = parseIssueRefs(text, state.repositoryUrl) - const breaking = isBreakingItem(text) + const breaking = marker.breaking || isBreakingItem(marker.text) advance(state) @@ -317,7 +319,9 @@ function parseItem(state: ParserState): ChangelogItem | null { } /** - * Checks if an item indicates a breaking change. + * Checks if an item indicates a breaking change. Runs on the text left after + * {@link parseBreakingFromItem} has removed the leading markers, so the + * serializer's own marker cannot feed back into the next parse. * * @param text - The text content of the item * @returns True if the item indicates a breaking change From 077b759939b3e8e3054a17b832ecb62c40c4b057 Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Wed, 19 Aug 2026 23:44:29 +0000 Subject: [PATCH 4/9] fix(lib-versioning): stop duplicating the breaking marker in item text --- .../src/flow/steps/generate-changelog.spec.ts | 4 ++-- libs/versioning/src/flow/steps/generate-changelog.ts | 12 ++---------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/libs/versioning/src/flow/steps/generate-changelog.spec.ts b/libs/versioning/src/flow/steps/generate-changelog.spec.ts index 1c1523b3..25da4b08 100644 --- a/libs/versioning/src/flow/steps/generate-changelog.spec.ts +++ b/libs/versioning/src/flow/steps/generate-changelog.spec.ts @@ -185,7 +185,7 @@ describe('Generate Changelog Step', () => { expect(breakingSection?.items[0].description).toContain('**core:**') }) - it('adds breaking indicator to feature items', async () => { + it('flags breaking feature items', async () => { const step = createGenerateChangelogStep() const ctx = createMockContext({ nextVersion: '2.0.0', @@ -203,7 +203,7 @@ describe('Generate Changelog Step', () => { const entry = result.stateUpdates?.changelogEntry const featuresSection = entry.sections.find((s: { type: string }) => s.type === 'features') - expect(featuresSection?.items[0].description).toContain('⚠️ BREAKING:') + expect(featuresSection?.items[0]).toEqual(expect.objectContaining({ description: 'breaking feature', breaking: true })) }) }) diff --git a/libs/versioning/src/flow/steps/generate-changelog.ts b/libs/versioning/src/flow/steps/generate-changelog.ts index 783506e8..6c4fff5b 100644 --- a/libs/versioning/src/flow/steps/generate-changelog.ts +++ b/libs/versioning/src/flow/steps/generate-changelog.ts @@ -130,10 +130,6 @@ function classifiedCommitToItem(classified: ClassifiedCommit): ChangelogItem { text = `${scopePrefix}${text}` } - if (commit.breaking) { - text = `⚠️ BREAKING: ${text}` - } - return createChangelogItem(text, { source: classified.source, indirect, @@ -155,11 +151,7 @@ function commitToItem(commit: ConventionalCommit): ChangelogItem { text = `${scopePrefix}${text}` } - if (commit.breaking) { - text = `⚠️ BREAKING: ${text}` - } - - return createChangelogItem(text) + return createChangelogItem(text, { breaking: commit.breaking }) } /** @@ -330,7 +322,7 @@ export function createGenerateChangelogStep(): FlowStep { breakingCommits.map((c) => { const text = c.breakingDescription ?? c.subject const prefix = formatScopePrefix(c.scope) - return createChangelogItem(prefix ? `${prefix}${text}` : text) + return createChangelogItem(prefix ? `${prefix}${text}` : text, { breaking: true }) }) ) ) From 53af0abbeebf521a145079d7252adf57014100fd Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Wed, 19 Aug 2026 23:44:34 +0000 Subject: [PATCH 5/9] fix(lib-versioning): skip references the description already carries --- .../src/changelog/serialize/to-string.spec.ts | 38 +++++++++++++++++++ .../src/changelog/serialize/to-string.ts | 23 +++++++---- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/libs/versioning/src/changelog/serialize/to-string.spec.ts b/libs/versioning/src/changelog/serialize/to-string.spec.ts index 99f48bcc..0023e026 100644 --- a/libs/versioning/src/changelog/serialize/to-string.spec.ts +++ b/libs/versioning/src/changelog/serialize/to-string.spec.ts @@ -144,6 +144,44 @@ describe('serializeChangelog', () => { expect(result).toContain('#456') }) + it('omits a commit reference the description already carries', () => { + const changelog = createChangelog({ + header: { title: '# Changelog', description: [], links: [] }, + entries: [ + createChangelogEntry('1.0.0', { + date: '2024-01-01', + sections: [ + createChangelogSection('features', 'Features', [ + createChangelogItem('New feature (abc1234)', { commits: [createCommitRef('abc1234')] }), + ]), + ], + }), + ], + metadata: { format: 'conventional', isConventional: true, warnings: [] }, + }) + + expect(serializeChangelog(changelog)).toContain('- New feature (abc1234)\n') + }) + + it('omits an issue reference the description already carries', () => { + const changelog = createChangelog({ + header: { title: '# Changelog', description: [], links: [] }, + entries: [ + createChangelogEntry('1.0.0', { + date: '2024-01-01', + sections: [ + createChangelogSection('fixes', 'Bug Fixes', [ + createChangelogItem('Fixed issue #123', { references: [createIssueRef(123, 'issue')] }), + ]), + ], + }), + ], + metadata: { format: 'conventional', isConventional: true, warnings: [] }, + }) + + expect(serializeChangelog(changelog)).toContain('- Fixed issue #123\n') + }) + it('serializes with compare URL', () => { const changelog = createChangelog({ header: { title: '# Changelog', description: [], links: [] }, diff --git a/libs/versioning/src/changelog/serialize/to-string.ts b/libs/versioning/src/changelog/serialize/to-string.ts index 041de1ec..efd402c9 100644 --- a/libs/versioning/src/changelog/serialize/to-string.ts +++ b/libs/versioning/src/changelog/serialize/to-string.ts @@ -184,6 +184,10 @@ function serializeSection(section: ChangelogSection, opts: Required): s parts.push(item.description) - if (opts.includeCommits && item.commits.length > 0) { - parts.push(' (') - parts.push(item.commits.map(serializeCommitRef).join(', ')) - parts.push(')') + if (opts.includeCommits) { + const commits = item.commits.filter((ref) => !item.description.includes(ref.shortHash)) + if (commits.length > 0) { + parts.push(' (') + parts.push(commits.map(serializeCommitRef).join(', ')) + parts.push(')') + } } - if (opts.includeReferences && item.references.length > 0) { - const refs = item.references.map(serializeIssueRef).join(', ') - parts.push(` ${refs}`) + if (opts.includeReferences) { + const references = item.references.filter((ref) => !item.description.includes(`#${ref.number}`)) + if (references.length > 0) { + parts.push(` ${references.map(serializeIssueRef).join(', ')}`) + } } return parts.join('') From a44c85c3834cfcd8448c5b49b0ed3af9d9e70d84 Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Wed, 19 Aug 2026 23:44:39 +0000 Subject: [PATCH 6/9] test(lib-versioning): assert changelog rewrites reach a fixpoint --- .../src/changelog/round-trip.spec.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 libs/versioning/src/changelog/round-trip.spec.ts diff --git a/libs/versioning/src/changelog/round-trip.spec.ts b/libs/versioning/src/changelog/round-trip.spec.ts new file mode 100644 index 00000000..5c84048f --- /dev/null +++ b/libs/versioning/src/changelog/round-trip.spec.ts @@ -0,0 +1,98 @@ +import { parseChangelog } from './parse/parser' +import { serializeChangelog } from './serialize/to-string' + +/** + * Rewrites a changelog the way the write-changelog flow step does: parse the + * whole file, then serialize the whole file back out. + * + * @param markdown - The changelog markdown to rewrite + * @returns The rewritten markdown + */ +function regenerate(markdown: string): string { + return serializeChangelog(parseChangelog(markdown)) +} + +const CLEAN = `# Changelog + +All notable changes to this project will be documented in this file. + +## 0.7.1 - 2026-08-19 + +### Breaking Changes + +- **BREAKING** coordinate presentation host-side with contract-declared display modes +- **BREAKING** **api:** throw when registering v2 security without a shared key + +### Features + +- adopt a reloaded feature on its existing mount +` + +const REFERENCED = `# Changelog + +All notable changes to this project will be documented in this file. + +## 1.0.0 - 2026-08-19 + +### Bug Fixes + +- close the leak #123 +- fix the thing (abc1234) +- fix the thing again (0123456789abcdef0123456789abcdef01234567) +- handle (beefcafe) tokens +` + +const ACCUMULATED = `# Changelog + +All notable changes to this project will be documented in this file. + +## 0.7.1 - 2026-08-19 + +### Breaking Changes + +- **BREAKING** **BREAKING:** **BREAKING:** coordinate presentation host-side with contract-declared display modes +- **BREAKING** **BREAKING:** **api:** throw when registering v2 security without a shared key + +### Features + +- adopt a reloaded feature on its existing mount +- **BREAKING** **BREAKING:** **BREAKING:** ⚠️ BREAKING: adopt asynchronous wire-gated open +` + +describe('changelog round trip', () => { + it('leaves serialized output untouched', () => { + expect(regenerate(CLEAN)).toBe(CLEAN) + }) + + it('stays a fixpoint across repeated rewrites', () => { + expect(regenerate(regenerate(CLEAN))).toBe(regenerate(CLEAN)) + }) + + it('collapses accumulated breaking markers to a single marker', () => { + expect(regenerate(ACCUMULATED)).toContain('- **BREAKING** coordinate presentation host-side with contract-declared display modes\n') + }) + + it('collapses accumulated markers ahead of a scope prefix', () => { + expect(regenerate(ACCUMULATED)).toContain('- **BREAKING** **api:** throw when registering v2 security without a shared key\n') + }) + + it('collapses the warning-sign spelling of the marker too', () => { + expect(regenerate(ACCUMULATED)).toContain('- **BREAKING** adopt asynchronous wire-gated open\n') + }) + + it('settles accumulated markers after a single rewrite', () => { + expect(regenerate(regenerate(ACCUMULATED))).toBe(regenerate(ACCUMULATED)) + }) + + it('leaves items that never carried a marker alone', () => { + expect(regenerate(ACCUMULATED)).toContain('- adopt a reloaded feature on its existing mount\n') + }) + + it('leaves items carrying issue and commit references untouched', () => { + expect(regenerate(REFERENCED)).toBe(REFERENCED) + }) + + it('keeps referenced items a fixpoint across repeated rewrites', () => { + expect(regenerate(regenerate(REFERENCED))).toBe(REFERENCED) + }) +}) From 8bbbc107df1dccc41357a3c7462860a6cbe95d06 Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Wed, 19 Aug 2026 23:44:43 +0000 Subject: [PATCH 7/9] chore(lib-features): correct recent changelog entry content --- libs/features/CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/features/CHANGELOG.md b/libs/features/CHANGELOG.md index e7960690..ceeae5f2 100644 --- a/libs/features/CHANGELOG.md +++ b/libs/features/CHANGELOG.md @@ -66,25 +66,25 @@ All notable changes to this project will be documented in this file. ### Breaking Changes -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** coordinate presentation host-side with contract-declared display modes -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** throw when registering v2 security without a shared key -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** is Open stays false after open() until the handshake completes; ready() rejects when no host answers +- **BREAKING** coordinate presentation host-side with contract-declared display modes +- **BREAKING** throw when registering v2 security without a shared key +- **BREAKING** is Open stays false after open() until the handshake completes; ready() rejects when no host answers ### Features - adopt a reloaded feature on its existing mount -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** ⚠️ BREAKING: coordinate presentation host-side with contract-declared display modes +- **BREAKING** coordinate presentation host-side with contract-declared display modes - delegate frame permissions and add host-only sandbox containment - add four-state liveness, closing flush window, and dirty state - validate action payloads on send and receive - announce contract versions and gate compatibility - add hostee security options and shared registration - add allow-open option to the nx build executor -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** ⚠️ BREAKING: adopt asynchronous wire-gated open with origin pinning and timeout errors +- **BREAKING** adopt asynchronous wire-gated open with origin pinning and timeout errors ### Bug Fixes -- **BREAKING** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** **BREAKING:** ⚠️ BREAKING: throw when registering v2 security without a shared key +- **BREAKING** throw when registering v2 security without a shared key ## [0.2.0](https://github.com/AndrewRedican/hyperfrontend/compare/6b5a02be62850b0509b9fd71ad9232655cf5fbbf...47a37497608ac765af3efb30f0b5e01950bae425) - 2026-07-05 From 514941cca7b348b15dbc663174874f6237fc4f06 Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Wed, 19 Aug 2026 23:44:45 +0000 Subject: [PATCH 8/9] chore(lib-nexus): correct recent changelog entry content --- libs/nexus/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/nexus/CHANGELOG.md b/libs/nexus/CHANGELOG.md index 9fa8862d..09e2457c 100644 --- a/libs/nexus/CHANGELOG.md +++ b/libs/nexus/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to this project will be documented in this file. ### Breaking Changes -- **BREAKING:** open fires only after a counterpr completes the handshake; unanswered connects time out instead of opening +- **BREAKING** open fires only after a counterpr completes the handshake; unanswered connects time out instead of opening ### Features @@ -22,7 +22,7 @@ All notable changes to this project will be documented in this file. - gate the handshake on a contract compatibility rule - activate security negotiation and transport attachment - rebuild the security transport on the real wire protocol and export it -- **BREAKING** **BREAKING:** ⚠️ BREAKING: replace channel self-activation with the wire handshake, pinned origins, and connect deadline +- **BREAKING** replace channel self-activation with the wire handshake, pinned origins, and connect deadline ### Bug Fixes From 8d30b1ad93d742704b175dd6751bdd641632557e Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Thu, 20 Aug 2026 00:48:26 +0000 Subject: [PATCH 9/9] chore: update versions for lib-features, lib-versioning --- apps/package-e2e/versioning/package.json | 2 +- libs/builder/package.json | 2 +- libs/features/CHANGELOG.md | 2 +- libs/features/package.json | 2 +- libs/versioning/CHANGELOG.md | 8 ++++++++ libs/versioning/package.json | 2 +- 6 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/package-e2e/versioning/package.json b/apps/package-e2e/versioning/package.json index 4f2f4b4a..d5d99126 100644 --- a/apps/package-e2e/versioning/package.json +++ b/apps/package-e2e/versioning/package.json @@ -6,7 +6,7 @@ "pack-install": "cd ../../../dist/libs/versioning && npm pack && cd - && npm install ../../../dist/libs/versioning/*.tgz" }, "dependencies": { - "@hyperfrontend/versioning": "file:../../../tmp/e2e-packs/hyperfrontend-versioning-0.6.2.tgz" + "@hyperfrontend/versioning": "file:../../../tmp/e2e-packs/hyperfrontend-versioning-0.6.3.tgz" }, "devDependencies": { "@types/jest": "29.5.14", diff --git a/libs/builder/package.json b/libs/builder/package.json index 38a5236c..539babee 100644 --- a/libs/builder/package.json +++ b/libs/builder/package.json @@ -7,7 +7,7 @@ "@hyperfrontend/logging": "0.1.1", "@hyperfrontend/immutable-api-utils": "0.1.4", "@hyperfrontend/project-scope": "0.2.4", - "@hyperfrontend/versioning": "0.6.2", + "@hyperfrontend/versioning": "0.6.3", "@rollup/plugin-commonjs": "29.0.0", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "16.0.3", diff --git a/libs/features/CHANGELOG.md b/libs/features/CHANGELOG.md index ceeae5f2..bbd68a41 100644 --- a/libs/features/CHANGELOG.md +++ b/libs/features/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## [0.7.1](https://github.com/AndrewRedican/hyperfrontend/compare/ee192ea7bb541593e6590243a08a29b68d69a1ac...bcceccf9e21a2e7f817c5aa02ac3b7961f3f7cb9) - 2026-08-19 +## [0.7.1](https://github.com/AndrewRedican/hyperfrontend/compare/ee192ea7bb541593e6590243a08a29b68d69a1ac...514941cca7b348b15dbc663174874f6237fc4f06) - 2026-08-20 ### Bug Fixes diff --git a/libs/features/package.json b/libs/features/package.json index 9d314d70..0335c837 100644 --- a/libs/features/package.json +++ b/libs/features/package.json @@ -13,7 +13,7 @@ "@hyperfrontend/project-scope": "0.2.4", "@hyperfrontend/questions": "0.3.0", "@hyperfrontend/ui-utils": "0.0.6", - "@hyperfrontend/versioning": "0.6.2", + "@hyperfrontend/versioning": "0.6.3", "tslib": "2.8.1", "typescript": "5.9.3" }, diff --git a/libs/versioning/CHANGELOG.md b/libs/versioning/CHANGELOG.md index fe09ce43..dead5168 100644 --- a/libs/versioning/CHANGELOG.md +++ b/libs/versioning/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [0.6.3](https://github.com/AndrewRedican/hyperfrontend/compare/74110dd15151bfc8360ef4edcdb1086cb003c909...514941cca7b348b15dbc663174874f6237fc4f06) - 2026-08-20 + +### Bug Fixes + +- skip references the description already carries +- stop duplicating the breaking marker in item text +- lift breaking markers off changelog item text + ## [0.6.2](https://github.com/AndrewRedican/hyperfrontend/compare/5f116abb8ba6355dfb283fa03b7481e5eb029480...47a37497608ac765af3efb30f0b5e01950bae425) - 2026-07-05 ### Bug Fixes diff --git a/libs/versioning/package.json b/libs/versioning/package.json index 43d53b9c..69ac7abd 100644 --- a/libs/versioning/package.json +++ b/libs/versioning/package.json @@ -1,6 +1,6 @@ { "name": "@hyperfrontend/versioning", - "version": "0.6.2", + "version": "0.6.3", "description": "Versioning library with changelog parsing, conventional commits, and semver flow orchestration.", "dependencies": { "@hyperfrontend/immutable-api-utils": "0.1.4",