refactor(OPS85): seed + pós-condição do gerador na lib com pin unitário; rest[0] no guard

This commit is contained in:
Francisco Solla 2026-08-24 13:49:26 -03:00
parent cbe30604c0
commit ef01b15467
4 changed files with 91 additions and 11 deletions

View file

@ -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) {

View file

@ -92,8 +92,8 @@ if (diffLines.length === 0) {
// --no-renames guarantees the `STATUS<TAB>path` 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()

View file

@ -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: <motivo>` — standalone-line escape in the PR body for
* legitimate aggregate loss (header rewrites, D8-style restorations). Kept

View file

@ -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(