From ef01b154677b8dad6fbc94d05dd6e0324afb989a Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 24 Aug 2026 13:49:26 -0300 Subject: [PATCH] =?UTF-8?q?refactor(OPS85):=20seed=20+=20p=C3=B3s-condi?= =?UTF-8?q?=C3=A7=C3=A3o=20do=20gerador=20na=20lib=20com=20pin=20unit?= =?UTF-8?q?=C3=A1rio;=20rest[0]=20no=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build-changelog.mjs | 18 ++++----- scripts/check-changelog-append-only.mjs | 4 +- scripts/lib/changelog.mjs | 29 ++++++++++++++ tests/unit/changelog.unit.spec.ts | 51 +++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/scripts/build-changelog.mjs b/scripts/build-changelog.mjs index 2dd46073..3889e186 100644 --- a/scripts/build-changelog.mjs +++ b/scripts/build-changelog.mjs @@ -11,7 +11,9 @@ * Usage: node scripts/build-changelog.mjs [--stdout] [--check] * --stdout print the aggregate to stdout instead of writing the file * --check verify the local aggregate (when present) is up to date — - * local sanity only, not a CI/gate requirement since OPS85 + * local sanity only, not a CI/gate requirement since OPS85. + * With the local file absent it is a no-op (nothing to compare). + * --stdout and --check together: --check wins (verification only). */ import { readdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' @@ -20,10 +22,10 @@ import { buildChangelog, CHANGELOG_AGGREGATE, CHANGELOG_DIR, - CHANGELOG_HEADER, CHANGELOG_HISTORY, listChangelogEntries, - splitChangelogBody, + missingAggregateEntries, + seedAggregateFromHistory, } from './lib/changelog.mjs' const repoRoot = process.cwd() @@ -53,8 +55,7 @@ if (aggregateContent === null) { } catch { die(`não consegui ler ${CHANGELOG_AGGREGATE} nem o seed ${CHANGELOG_HISTORY}`) } - const { blocks } = splitChangelogBody(historyContent) - aggregateContent = [CHANGELOG_HEADER, ...blocks].join('\n\n') + '\n' + aggregateContent = seedAggregateFromHistory(historyContent) } let files @@ -84,10 +85,9 @@ const next = buildChangelog({ // Post-condition (OPS85): no parsed entry may silently vanish from the // generated aggregate — replaces the old committed-aggregate multiset. -for (const { path, content } of withContent) { - if (!next.includes(content.trimEnd())) { - die(`entrada ausente do agregado gerado (perda silenciosa?): ${path}`) - } +const missing = missingAggregateEntries(withContent, next) +if (missing.length > 0) { + die(`entrada(s) ausente(s) do agregado gerado (perda silenciosa?): ${missing.join(', ')}`) } if (checkOnly) { diff --git a/scripts/check-changelog-append-only.mjs b/scripts/check-changelog-append-only.mjs index 183f7df9..0fd8b279 100644 --- a/scripts/check-changelog-append-only.mjs +++ b/scripts/check-changelog-append-only.mjs @@ -92,8 +92,8 @@ if (diffLines.length === 0) { // --no-renames guarantees the `STATUSpath` shape (no R entries). const changed = diffLines.map((line) => { - const [status, ...rest] = line.split('\t') - return { status, path: rest.join('\t') } + const [status, path] = line.split('\t') + return { status, path } }) const body = readPrBody() diff --git a/scripts/lib/changelog.mjs b/scripts/lib/changelog.mjs index b58489ca..faae41f6 100644 --- a/scripts/lib/changelog.mjs +++ b/scripts/lib/changelog.mjs @@ -36,6 +36,35 @@ const ENTRY_FILE_RE = /^(\d{4})-(\d{2})-(\d{2})-([a-z0-9-]+)\.md$/ /** Leading line of a "Recently resolved" entry block. */ const ENTRY_BLOCK_RE = /^\*\*Recently resolved \((\d{4}-\d{2}-\d{2})\)/ +/** + * Seeds the regenerable aggregate from the frozen HISTORY snapshot (OPS85): + * the snapshot's entry blocks become the seed blocks under the fresh + * CHANGELOG_HEADER. Used when the local aggregate file is absent. + * + * @param {string} historyContent - content of docs/CHANGELOG-AGENTS-HISTORY.md + * @returns {string} - aggregate-shaped content with CHANGELOG_HEADER + blocks + */ +export function seedAggregateFromHistory(historyContent) { + const { blocks } = splitChangelogBody(historyContent) + return [CHANGELOG_HEADER, ...blocks].join('\n\n') + '\n' +} + +/** + * Post-condition of the generator (OPS85): every parsed entry must appear + * in the generated aggregate, or the entry was silently lost — the role the + * old committed-aggregate multiset played. Substring check is honest for + * "no silent loss" (a vanishing block fails; see build-changelog.mjs). + * + * @param {{ path: string, content: string }[]} entries + * @param {string} aggregateContent + * @returns {string[]} - paths of entries missing from the aggregate + */ +export function missingAggregateEntries(entries, aggregateContent) { + return entries + .filter(({ content }) => !aggregateContent.includes(content.trimEnd())) + .map(({ path }) => path) +} + /** * `changelog-rewrite: ` — standalone-line escape in the PR body for * legitimate aggregate loss (header rewrites, D8-style restorations). Kept diff --git a/tests/unit/changelog.unit.spec.ts b/tests/unit/changelog.unit.spec.ts index 302328d5..061f5f09 100644 --- a/tests/unit/changelog.unit.spec.ts +++ b/tests/unit/changelog.unit.spec.ts @@ -6,11 +6,14 @@ import { assertChangelogAppendOnly, buildChangelog, CHANGELOG_AGGREGATE, + CHANGELOG_HEADER, CHANGELOG_HISTORY, CHANGELOG_REWRITE_ESCAPE_RE, entryDateOf, listChangelogEntries, + missingAggregateEntries, parseChangelogEntryName, + seedAggregateFromHistory, splitChangelogBody, } from '../../scripts/lib/changelog.mjs' @@ -207,6 +210,54 @@ describe('CHANGELOG_REWRITE_ESCAPE_RE', () => { }) }) +describe('seedAggregateFromHistory', () => { + const history = `${HEADER}\n\n${ENTRY_2026_08_12}\n` + + it('uses the fresh CHANGELOG_HEADER with the HISTORY blocks as seed', () => { + const seeded = seedAggregateFromHistory(history) + expect(seeded.startsWith(CHANGELOG_HEADER)).toBe(true) + expect(seeded).toContain(ENTRY_2026_08_12) + }) + + it('round-trips: build over the seed is idempotent and complete', () => { + const seeded = seedAggregateFromHistory(history) + const next = buildChangelog({ + entries: [{ content: ENTRY_2026_08_12 }, { content: ENTRY_2026_08_13 }], + aggregateContent: seeded, + }) + expect(next).toBe(`${CHANGELOG_HEADER}\n\n${ENTRY_2026_08_13}\n\n${ENTRY_2026_08_12}\n`) + expect( + missingAggregateEntries( + [ + { path: 'docs/changelog/2026-08-12-x.md', content: ENTRY_2026_08_12 }, + { path: 'docs/changelog/2026-08-13-y.md', content: ENTRY_2026_08_13 }, + ], + next, + ), + ).toEqual([]) + }) +}) + +describe('missingAggregateEntries', () => { + it('flags an entry absent from the generated aggregate', () => { + expect( + missingAggregateEntries( + [{ path: 'docs/changelog/2026-08-12-x.md', content: ENTRY_2026_08_12 }], + 'outro conteúdo', + ), + ).toEqual(['docs/changelog/2026-08-12-x.md']) + }) + + it('returns [] when every entry text appears', () => { + expect( + missingAggregateEntries( + [{ path: 'docs/changelog/2026-08-12-x.md', content: ENTRY_2026_08_12 }], + `prefácio\n\n${ENTRY_2026_08_12}\n`, + ), + ).toEqual([]) + }) +}) + describe('assertChangelogAppendOnly', () => { it('passes on a pure addition in docs/changelog/ with no other changelog diff', () => { expect(