From 2bce9b77b37b19de9d1819d666af49e4034a9ec5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 2 Sep 2026 11:18:51 +0300 Subject: [PATCH] build: validate changelog structure --- CHANGELOG.md | 26 +-- package.json | 3 +- tools/release-packages/package.json | 1 + tools/release-packages/src/changelog.ts | 191 ++++++++++++++++++ tools/release-packages/src/check-changelog.ts | 15 ++ .../release-packages/tests/changelog.test.ts | 146 +++++++++++++ 6 files changed, 368 insertions(+), 14 deletions(-) create mode 100644 tools/release-packages/src/changelog.ts create mode 100644 tools/release-packages/src/check-changelog.ts create mode 100644 tools/release-packages/tests/changelog.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e28686f9..7b1165c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,19 +35,6 @@ - Move MCP connections into their own Settings destination instead of presenting them as part of model configuration. - Pan horizontally with Shift+wheel while preserving native horizontal trackpad movement. -### Performance - -- Scope automation and Figma API layout reconciliation to graph nodes and parent containers actually changed by each mutation. -- Keep rapid trackpad zoom reversals and effect-heavy document navigation responsive by cancelling obsolete reconstruction and reusing safe raster snapshots. -- Show the FIG page list from a lightweight Kiwi scan before materializing the full document. -- Avoid redundant collaboration writes when synchronized node fields have not changed. -- Release obsolete streamed Markdown parser history after each AI response completes, preventing chat memory from multiplying with every streamed chunk. (#544) -- Open large documents faster by using cached world positions while finding layers under the pointer. (#527) -- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528) -- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500) -- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500) -- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph. - ### Fixed - Preserve source text offsets when resolving fallback languages after text-case transformations. @@ -94,6 +81,19 @@ - Preserve component links when pasting Figma instances so later component edits continue to update them. - Stop local MCP servers after the app disconnects instead of leaving orphaned background processes. (#494) +### Performance + +- Scope automation and Figma API layout reconciliation to graph nodes and parent containers actually changed by each mutation. +- Keep rapid trackpad zoom reversals and effect-heavy document navigation responsive by cancelling obsolete reconstruction and reusing safe raster snapshots. +- Show the FIG page list from a lightweight Kiwi scan before materializing the full document. +- Avoid redundant collaboration writes when synchronized node fields have not changed. +- Release obsolete streamed Markdown parser history after each AI response completes, preventing chat memory from multiplying with every streamed chunk. (#544) +- Open large documents faster by using cached world positions while finding layers under the pointer. (#527) +- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528) +- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500) +- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500) +- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph. + ## 0.14.0 — 2026-08-10 diff --git a/package.json b/package.json index 08a739305..9a20f228d 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/harness/tests/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests/ scripts/ tools/", "format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/harness/tests/ packages/vue/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests scripts/ tools/", "format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)", - "check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes", + "check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:changelog && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes", + "check:changelog": "bun tools/release-packages/src/check-changelog.ts", "check:deps": "knip --include unlisted,unresolved,binaries", "check:docs": "bun --filter @open-pencil/docs check", "check:audit": "bun audit --audit-level=critical", diff --git a/tools/release-packages/package.json b/tools/release-packages/package.json index 8a1adf62b..e7311e279 100644 --- a/tools/release-packages/package.json +++ b/tools/release-packages/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "check-changelog": "bun src/check-changelog.ts", "prepare-publish-dirs": "bun src/prepare-publish-dirs.ts", "validate-tarballs": "bun src/validate-tarballs.ts", "test": "bun test tests" diff --git a/tools/release-packages/src/changelog.ts b/tools/release-packages/src/changelog.ts new file mode 100644 index 000000000..dca11f86d --- /dev/null +++ b/tools/release-packages/src/changelog.ts @@ -0,0 +1,191 @@ +import { readFile } from 'node:fs/promises' + +import type { Heading, List, ListItem, Root, RootContent } from 'mdast' +import { fromMarkdown } from 'mdast-util-from-markdown' + +const ALLOWED_CATEGORIES = [ + 'Breaking changes', + 'Added', + 'Changed', + 'Fixed', + 'Performance', + 'Security' +] as const + +const CATEGORY_INDEX = new Map(ALLOWED_CATEGORIES.map((category, index) => [category, index])) +const RELEASE_HEADING = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)? — (\d{4}-\d{2}-\d{2})$/ + +export interface ChangelogValidationResult { + errors: string[] +} + +interface PositionedNode { + position?: { start: { line: number } } +} + +function headingText(heading: Heading): string { + return heading.children + .map((child) => ('value' in child && typeof child.value === 'string' ? child.value : '')) + .join('') + .trim() +} + +function line(node: PositionedNode): number { + return node.position?.start.line ?? 0 +} + +function listItemSource(source: string, item: ListItem): string { + const start = item.position?.start.offset + const end = item.position?.end.offset + if (start === undefined || end === undefined) return '' + return source.slice(start, end).trim().replace(/\s+/g, ' ') +} + +function sectionEnd(children: RootContent[], start: number): number { + for (let index = start + 1; index < children.length; index++) { + const node = children[index] + if (node?.type === 'heading' && node.depth === 2) return index + } + return children.length +} + +function categoryHasItems(children: RootContent[], start: number, end: number): boolean { + for (let index = start + 1; index < end; index++) { + const node = children[index] + if (node?.type === 'heading' && node.depth <= 3) break + if (node?.type === 'list' && !node.ordered && node.children.length > 0) return true + } + return false +} + +function isValidDate(value: string): boolean { + const [year, month, day] = value.split('-').map(Number) + if (year === undefined || month === undefined || day === undefined) return false + const date = new Date(Date.UTC(year, month - 1, day)) + return ( + date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day + ) +} + +function validateReleaseHeading(heading: Heading, errors: string[]): void { + const text = headingText(heading) + if (text === 'Unreleased') return + const match = RELEASE_HEADING.exec(text) + if (!match) { + errors.push( + `Line ${line(heading)}: invalid release heading "${text}"; expected "## x.y.z — YYYY-MM-DD".` + ) + return + } + const date = match[1] + if (date && !isValidDate(date)) { + errors.push(`Line ${line(heading)}: invalid release date "${date}".`) + } +} + +function validateUnreleased( + source: string, + children: RootContent[], + start: number, + end: number, + errors: string[] +): void { + const seenCategories = new Set() + const seenBullets = new Map() + let currentCategory: string | null = null + let previousCategoryIndex = -1 + + for (let index = start + 1; index < end; index++) { + const node = children[index] + if (!node) continue + + if (node.type === 'heading') { + if (node.depth !== 3) { + errors.push( + `Line ${line(node)}: Unreleased content may only use level-three category headings.` + ) + continue + } + + const category = headingText(node) + const categoryIndex = CATEGORY_INDEX.get(category as (typeof ALLOWED_CATEGORIES)[number]) + if (categoryIndex === undefined) { + currentCategory = null + errors.push( + `Line ${line(node)}: unsupported Unreleased category "${category}"; use ${ALLOWED_CATEGORIES.join(', ')}.` + ) + continue + } + if (seenCategories.has(category)) { + errors.push(`Line ${line(node)}: duplicate Unreleased category "${category}".`) + } + if (categoryIndex < previousCategoryIndex) { + errors.push(`Line ${line(node)}: Unreleased category "${category}" is out of order.`) + } + if (!categoryHasItems(children, index, end)) { + errors.push(`Line ${line(node)}: Unreleased category "${category}" is empty.`) + } + seenCategories.add(category) + currentCategory = category + previousCategoryIndex = Math.max(previousCategoryIndex, categoryIndex) + continue + } + + if (node.type === 'list' && !node.ordered) { + if (currentCategory === null) { + errors.push( + `Line ${line(node)}: Unreleased bullets must follow an allowed category heading.` + ) + } + const list = node as List + for (const item of list.children) { + const bullet = listItemSource(source, item) + if (!bullet) continue + const firstLine = seenBullets.get(bullet) + if (firstLine !== undefined) { + errors.push( + `Line ${line(item)}: duplicate Unreleased bullet (first seen on line ${firstLine}).` + ) + } else { + seenBullets.set(bullet, line(item)) + } + } + continue + } + + errors.push(`Line ${line(node)}: unexpected content in Unreleased.`) + } +} + +export function validateChangelog(source: string): ChangelogValidationResult { + const tree = fromMarkdown(source) as Root + const errors: string[] = [] + const releaseHeadings: Array<{ index: number; node: Heading }> = [] + for (const [index, node] of tree.children.entries()) { + if (node.type === 'heading' && node.depth === 2) releaseHeadings.push({ index, node }) + } + + const unreleased = releaseHeadings.filter(({ node }) => headingText(node) === 'Unreleased') + if (unreleased.length !== 1) { + errors.push(`Expected exactly one "## Unreleased" section; found ${unreleased.length}.`) + } + + for (const { node } of releaseHeadings) validateReleaseHeading(node, errors) + + const current = unreleased[0] + if (current) { + validateUnreleased( + source, + tree.children, + current.index, + sectionEnd(tree.children, current.index), + errors + ) + } + + return { errors } +} + +export async function validateChangelogFile(path: string): Promise { + return validateChangelog(await readFile(path, 'utf8')) +} diff --git a/tools/release-packages/src/check-changelog.ts b/tools/release-packages/src/check-changelog.ts new file mode 100644 index 000000000..c25596111 --- /dev/null +++ b/tools/release-packages/src/check-changelog.ts @@ -0,0 +1,15 @@ +import { resolve } from 'node:path' + +import { validateChangelogFile } from './changelog' + +const path = resolve(process.argv[2] ?? 'CHANGELOG.md') +const { errors } = await validateChangelogFile(path) + +if (errors.length > 0) { + process.stderr.write( + `Changelog validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}\n` + ) + process.exit(1) +} + +process.stdout.write(`Changelog validation passed: ${path}\n`) diff --git a/tools/release-packages/tests/changelog.test.ts b/tools/release-packages/tests/changelog.test.ts new file mode 100644 index 000000000..1f8231691 --- /dev/null +++ b/tools/release-packages/tests/changelog.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test' + +import { validateChangelog } from '../src/changelog' + +const validChangelog = `# Changelog + +## Unreleased + +### Breaking changes + +- Replace the old API. + +### Added + +- Add **rich** release notes. + +### Fixed + +- Preserve user data. + +## 1.2.0 — 2026-08-16 + +### Added + +- Ship the previous feature. + +## 0.1.0-alpha — 2026-03-01 + +### Added + +- Publish the alpha. +` + +function errors(source: string): string[] { + return validateChangelog(source).errors +} + +describe('validateChangelog', () => { + test('accepts canonical Unreleased and release sections', () => { + expect(errors(validChangelog)).toEqual([]) + }) + + test('requires exactly one Unreleased section', () => { + expect(errors(validChangelog.replace('## Unreleased', '## 1.3.0 — 2026-09-01'))).toContain( + 'Expected exactly one "## Unreleased" section; found 0.' + ) + expect(errors(`${validChangelog}\n## Unreleased\n`)).toContain( + 'Expected exactly one "## Unreleased" section; found 2.' + ) + }) + + test('rejects duplicate, unsupported, empty, and out-of-order categories', () => { + const source = `# Changelog + +## Unreleased + +### Fixed + +- Fix one issue. + +### Added + +- Add one feature. + +### Added + +### Internal + +- Refactor internals. + +## 1.0.0 — 2026-01-01 + +### Added + +- Ship version one. +` + const result = errors(source) + expect(result.some((error) => error.includes('category "Added" is out of order'))).toBe(true) + expect(result.some((error) => error.includes('duplicate Unreleased category "Added"'))).toBe( + true + ) + expect(result.some((error) => error.includes('category "Added" is empty'))).toBe(true) + expect( + result.some((error) => error.includes('unsupported Unreleased category "Internal"')) + ).toBe(true) + }) + + test('rejects duplicate Unreleased bullets while preserving Markdown distinctions', () => { + const source = `# Changelog + +## Unreleased + +### Added + +- Add **one** feature. +- Add **one** feature. +- Add one feature. + +## 1.0.0 — 2026-01-01 + +### Added + +- Add **one** feature. +` + const result = errors(source) + expect(result.filter((error) => error.includes('duplicate Unreleased bullet'))).toHaveLength(1) + }) + + test('rejects malformed release headings and impossible dates', () => { + const malformed = validChangelog.replace('## 1.2.0 — 2026-08-16', '## v1.2.0 - August 16, 2026') + expect(errors(malformed).some((error) => error.includes('invalid release heading'))).toBe(true) + + const impossible = validChangelog.replace('2026-08-16', '2026-02-31') + expect(errors(impossible).some((error) => error.includes('invalid release date'))).toBe(true) + }) + + test('rejects nested headings and uncategorized content inside Unreleased', () => { + const nested = validChangelog.replace( + '- Add **rich** release notes.', + '#### Details\n\n- Add **rich** release notes.' + ) + expect( + errors(nested).some((error) => + error.includes('Unreleased content may only use level-three category headings') + ) + ).toBe(true) + + const uncategorized = validChangelog.replace( + '## Unreleased\n\n### Breaking changes', + '## Unreleased\n\n- Uncategorized.\n\n### Breaking changes' + ) + expect( + errors(uncategorized).some((error) => + error.includes('Unreleased bullets must follow an allowed category heading') + ) + ).toBe(true) + + const prose = validChangelog.replace( + '## Unreleased\n\n### Breaking changes', + '## Unreleased\n\nUnexpected prose.\n\n### Breaking changes' + ) + expect(errors(prose).some((error) => error.includes('unexpected content in Unreleased'))).toBe( + true + ) + }) +})