diff --git a/.agents/rules/agent-pr-workflow.mdc b/.agents/rules/agent-pr-workflow.mdc index 3932cdc0..d56c85ed 100644 --- a/.agents/rules/agent-pr-workflow.mdc +++ b/.agents/rules/agent-pr-workflow.mdc @@ -29,7 +29,7 @@ EOF When the environment says to use `ManagePullRequest` instead of `gh pr create`: - **`draft: false`** (or omit only if the tool’s default is Ready — today Cloud defaults to draft, so **always pass `draft: false`**). -- Right after create (or on `update_pr` if you opened draft by mistake): mark Ready if still draft, then enable auto-merge with `gh pr merge --auto --merge ` (write allowed for this step — same as `agent-pr-workflow`; do not leave the PR waiting on a human). +- Right after create (or on `update_pr` if you opened draft by mistake): mark Ready if still draft, then enable auto-merge with `gh pr merge --auto --rebase ` (write allowed for this step — same as `agent-pr-workflow`; do not leave the PR waiting on a human). - Never end a turn with an open agent PR left as **Draft** or without **auto-merge** armed — inclui PRs do `engineering-audit` (artefatos e remediações). ## Push (determinístico) @@ -51,7 +51,7 @@ git push --no-verify # WIP / cutover escape — CI repete tudo; ver AGENT-OPS Right after the PR exists and is **Ready**: ```bash -gh pr merge --auto --merge +gh pr merge --auto --rebase ``` Do not wait for human approval on normal deliveries. Auto-merge fires when **required** checks pass. @@ -84,4 +84,4 @@ There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` ve ## Safety net (GitHub Action) -`agent-pr-ready-automerge.yml` — PRs `cursor/*` → `main`: se Draft → `gh pr ready`; sempre arma `gh pr merge --auto --merge`. Cobre o caso em que o agente (ou o default draft do Cloud) esqueceu. Não há opt-out por skill (audit incluído). +`agent-pr-ready-automerge.yml` — PRs `cursor/*` → `main`: se Draft → `gh pr ready`; sempre arma `gh pr merge --auto --rebase`. Cobre o caso em que o agente (ou o default draft do Cloud) esqueceu. Não há opt-out por skill (audit incluído). diff --git a/.agents/skills/engineering-audit/SKILL.md b/.agents/skills/engineering-audit/SKILL.md index 6f73d3df..6e344de2 100644 --- a/.agents/skills/engineering-audit/SKILL.md +++ b/.agents/skills/engineering-audit/SKILL.md @@ -13,7 +13,7 @@ A skill roda sem supervisão humana no paradigma de agentes paralelos. Quatro di 1. **Precheck solitário, fail-closed.** Antes de qualquer varredura: `pnpm agent:pool -- status` (read-only). Pool `desligado` ou `pausado` → segue. Pool **ligado** → o agente Cloud não consegue pausá-lo (`gh workflow run` é escrita): **pare** com o remédio nomeado ("pause o pool — `pnpm agent:pool -- pause` — e re-dispare o audit"). Nunca rodar a varredura com o pool spawnando. 2. **Entrega = PR Ready + auto-merge.** O gate "apresentar os três artefatos antes de escrever" (passo 5) não se aplica em modo autônomo — não há humano interativo na sessão. Os artefatos são escritos na branch e o **PR Ready → `main` com auto-merge** (mesmo contrato `agent-pr-workflow`) é a entrega do plano. Fechar o PR antes do merge = rejeição (o ledger sobrevive no histórico da branch). As remediações **P0/P1 e os guardrails das misses colhidas** seguem na mesma sessão, cada um em PR próprio com gate completo + auto-merge. P2/P3 continuam só ledger/plano. -3. **Canais de PR:** fechar miss colhida = `Closes #N` no body do PR do guardrail (o flip `done`/`in-prod` no merge em `main` — keyword adjacente a cada número, `Closes #52, closes #73`, nunca `Closes #52, #73`). Em Cloud, preferir `ManagePullRequest` com **`draft: false`**, depois `gh pr merge --auto --merge`. **Sem exceção:** o PR dos *artefatos* do audit também é Ready + auto-merge (igual remediações P0/P1 e guardrails). Push: `pnpm push -u origin HEAD`. +3. **Canais de PR:** fechar miss colhida = `Closes #N` no body do PR do guardrail (o flip `done`/`in-prod` no merge em `main` — keyword adjacente a cada número, `Closes #52, closes #73`, nunca `Closes #52, #73`). Em Cloud, preferir `ManagePullRequest` com **`draft: false`**, depois `gh pr merge --auto --rebase`. **Sem exceção:** o PR dos *artefatos* do audit também é Ready + auto-merge (igual remediações P0/P1 e guardrails). Push: `pnpm push -u origin HEAD`. 4. **Delta desde o último audit é o foco declarado** (passo 1): a varredura cobre o repo inteiro, mas o esforço concentra-se no churn desde a data do último Pass — é o trabalho paralelo dos agentes desde o último audit que precisa ser consolidado. Read-only audit that sweeps the Teqo codebase for code smells, drift from documented patterns, and consolidation opportunities, then produces a findings ledger and a remediation plan. **No fixes in the audit run** — deliverables are updated docs, presented for sign-off before writing — exceto as remediações P0/P1 declaradas acima. Sign-off: interativo = apresentação prévia dos artefatos; autônomo (Cursor Cloud) = PR Ready + auto-merge dos artefatos (ver "Modo autônomo (Cursor Cloud)"). @@ -166,7 +166,7 @@ Rules: 1. `docs/IMPROVE-CODE-QUALITY-PLAN.md` — new Pass section (context, audit headlines with numbers, workstreams, decisions), matching earlier-pass format. 2. `docs/TECH-DEBT.md` — new rows in the same tables/columns, marked `open — Pass N`; stale rows closed with evidence. 3. `docs/plans/entrega-engenharia-pN.md` (pt-BR, like the other plans) — workstreams ordered P0→P1→P2; each item: goal, evidence, target shape, migration path (Branch by Abstraction / Parallel Change for wide merges), pins to write first, full gate, rollback. Items too big for one delivery → Issue rastreável via `plan-issue`. Every workstream item declares its **Guarda determinística** (class 1–6, step 4b), and the plan carries a **guard map** section: new guards shipped / existing guards hardened / judgment-only residue with the convention that stands in. -4. **Interativo (desktop):** present ALL THREE for sign-off before writing. Then write and stop. **Autônomo (Cursor Cloud):** não há sign-off interativo — escreva os três na branch e abra o PR (**Ready**, base `main`, **`gh pr merge --auto --merge`** — mesmo fluxo `agent-pr-workflow`; "Modo autônomo", item 2). As remediações P0/P1 e os guardrails das misses colhidas seguem na mesma sessão, em PRs próprios com gate completo + auto-merge, sem esperar o merge dos artefatos. +4. **Interativo (desktop):** present ALL THREE for sign-off before writing. Then write and stop. **Autônomo (Cursor Cloud):** não há sign-off interativo — escreva os três na branch e abra o PR (**Ready**, base `main`, **`gh pr merge --auto --rebase`** — mesmo fluxo `agent-pr-workflow`; "Modo autônomo", item 2). As remediações P0/P1 e os guardrails das misses colhidas seguem na mesma sessão, em PRs próprios com gate completo + auto-merge, sem esperar o merge dos artefatos. ## Execution rules for the eventual implementation (record in the plan) diff --git a/.agents/skills/plan-issue/SKILL.md b/.agents/skills/plan-issue/SKILL.md index 4fd69199..7323581f 100644 --- a/.agents/skills/plan-issue/SKILL.md +++ b/.agents/skills/plan-issue/SKILL.md @@ -94,7 +94,7 @@ Ordem topológica (dependente cita ID do dependido). Por item: 2. **Explorar o código o mínimo** — só para apontar **direção** (rotas/pastas/domínios prováveis) e evitar duplicar algo já entregue. Não inventar signatures, collections novas como decisão travada, nem diagramas de componentes. 3. **Superfície UI (A–D)** como dica para quem for executar — não semear brief Impeccable completo. Classe **B/C/D** (ou qualquer mudança de UI): criar **rascunho HTML+Tailwind** ([ui-draft-html.md](ui-draft-html.md)) **antes do gate**, renderizar o PNG (`pnpm ui-draft:render`) e embutir no plano; ASCII no plano fica opcional. Classe **A** / sem UI: sem rascunho. 4. **Dados (intenção)** ou `Dados: N/A`. -5. **Posicionamento:** `P0..P3`, `depends`, appetite, janela eleitoral se relevante, `serializes` se tocar recurso compartilhado (ex. migrations) — sem detalhar a migration. +5. **Posicionamento:** `P0..P3`, `depends`, appetite, janela eleitoral se relevante, `serializes` se tocar recurso compartilhado — nomeie o registro exato (ex. `serializes: 'docs/plans/'`, `serializes: 'docs/changelog/'` — migrations **não** serializam, AGENT-OPS). Sem detalhar a migration. 6. **Plano de intenção** em `docs/plans/.md` via [intention-template.md](intention-template.md), com campo **Rascunho UI** preenchido (path do `.html` + PNGs embutidos, ou `N/A`). Self-score ≥4/5 ([shaping.md](shaping.md)). ### O que é proibido no plano de intenção @@ -150,7 +150,7 @@ Sem `--plan`: nasce `ready` (use `--blocked` só se quiser não-claimável sem p 2. Atualize `Issue: #N` (e status) no plano local. 3. Commit + **`pnpm push`** + PR **Ready** `--base main` com **`Related #N`** (nunca `Closes #N` em PR só de `docs/plans/` — `plans-only-closes`). -4. Auto-merge (`gh pr merge --auto --merge`); espere o merge em `main`. +4. Auto-merge (`gh pr merge --auto --rebase`); espere o merge em `main`. 5. **Promote** com o script (idempotente se já `ready`; só Issues `blocked` + link `docs/plans/`, sem gates humanos `needs:*` nem `in-progress`/`done`/`in-prod`): ```bash diff --git a/.agents/skills/work-issue/execution-pipeline.md b/.agents/skills/work-issue/execution-pipeline.md index 0c47707d..5602c4f2 100644 --- a/.agents/skills/work-issue/execution-pipeline.md +++ b/.agents/skills/work-issue/execution-pipeline.md @@ -29,11 +29,16 @@ entrega. ## Fechar em main 1. Branch do ator (ver deltas) — nunca crie branch nova fora dela. -2. **`pnpm push -u origin HEAD`** -3. PR **Ready** (nunca draft): `gh pr create --base main` com `Closes #` -4. `gh pr merge --auto --merge` imediatamente -5. `gh pr checks --watch --required` (`checks`; ignore Vercel Git) -6. CI flipa `done`/`in-prod` no merge. Comente na Issue o desfecho em uma linha. +2. **Changelog da entrega (OPS44):** escreva `docs/changelog/-.md` + (ex. `2026-08-13-ops44.md`) — uma entrada curta no formato do agregado — + e rode `pnpm changelog:build` para regenerar `docs/CHANGELOG-AGENTS.md` + (insert-only: entradas históricas nunca mudam; o diff será só a entrada + nova + o agregado). Rode `pnpm changelog:check` para confirmar. +3. **`pnpm push -u origin HEAD`** +4. PR **Ready** (nunca draft): `gh pr create --base main` com `Closes #` +5. `gh pr merge --auto --rebase` imediatamente +6. `gh pr checks --watch --required` (`checks`; ignore Vercel Git) +7. CI flipa `done`/`in-prod` no merge. Comente na Issue o desfecho em uma linha. ## Deltas por ator diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ce37a226..569151a4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,7 +11,8 @@ Closes # F[docs/changelog/<data>-<id>.md] + F --> B[pnpm changelog:build] + B --> A[CHANGELOG-AGENTS.md agregado] + F --> P[PR] + A --> P + P --> G[CI docs-guards: append-only + marcadores] + G --> M[gh pr merge --auto --rebase] +``` + +**Opções consideradas:** A (arquivos por entrega + script agregador + guards de CI + rebase) | B (lock/fila de escrita por arquivo) | C (guard de marcadores só na suíte unit existente) | D (parser de diff para o guard append-only) | E (agregador em TS com DB) +**Recomendação:** **A** — ataca a causa (anchor único) no formato, como decidiu o gate; guards baratos no CI; rebase transfere o conflito ao autor. +**Rejeitadas:** B (rabbit hole da intenção — resolve no formato, não no merge); C (PR docs-only pula a suíte unit por `code_mode: none` — o incidente OPS41 era exatamente um PR de docs; guard precisa de job próprio sempre-executado); D (algoritmo de diff reordena hunk em movimentos de linha — falso positivo; comparação multiset de blobs é determinística e honesta); E (nada de DB — mjs é o padrão dos scripts de guard do repo, precedente `plansOnlyClosesGuard`). + +### Componentes / mudanças + +- **`scripts/lib/changelog.mjs`** (novo, puro, unit-testado): três superfícies — + 1. `listChangelogEntries(files)`: filtra `docs/changelog/-.md` (`^\d{4}-\d{2}-\d{2}-[a-z0-9-]+\.md$`), ordena data desc → id asc; `.md` fora do padrão → erro fail-closed; + 2. `buildChangelog(entries, header)`: header (linhas até o primeiro `---` do agregado atual) + `\n\n` + entradas (`\n\n`-separadas) + newline final; + 3. `assertChangelogAppendOnly({ oldContent, newContent, changelogDiff })`: **multiset** — toda linha do agregado antigo presente no novo com contagem ≥ (`docs/CHANGELOG-AGENTS.md`) e `docs/changelog/*` com `A`-only no `--name-status`; escape documentado: PR body com `changelog-rewrite: ` (padrão D8); retorna `{ ok } | { ok:false, message }`. +- **`scripts/lib/conflictMarkers.mjs`** (novo, puro): regex do OPS41 **extraída** do unit spec (`<<<<<<<`/`>>>>>>>` tolerante a indentação + forma corrompida `^\s*(> ){7}`) + `findConflictMarkerLines(content)` — o spec passa a importar daqui (don't twin; o scan repo-wide da suíte unit permanece). +- **CLIs** (padrão `check-plans-only-pr-closes.mjs` — wrapper fino, git plumbing): `scripts/build-changelog.mjs` (`pnpm changelog:build` — escreve o agregado; verifica `git ls-files` do changelog dir? não — só gera), `scripts/check-changelog-append-only.mjs` (blobs via `git show :` / `HEAD:`, PR body via `PR_BODY`/`gh pr view`), `scripts/check-docs-conflict-markers.mjs` (`git diff --name-only merge-base...HEAD -- docs/` → `git show HEAD:` → scan). +- **`.github/workflows/ci-pr.yml`**: job novo `docs-guards` (cheap, sempre roda, sem Postgres — espelha `plans-only-closes`) rodando os dois checks; entra no `needs` do rollup `checks`. +- **Merge por rebase** (mudança mecânica em 7 pontos): `execution-pipeline.md:34`, `scripts/lib/agent-pool-prompt.mjs:41`, `plan-issue/SKILL.md:153`, `.agents/rules/agent-pr-workflow.mdc` (32/54/87), **`agent-pr-ready-automerge.yml:37`** (safety net — sem isso ele re-arma `--merge` por cima do `--rebase` em PRs `cursor/*`), `docs/AGENT-OPS.md` "Contrato de PR" item 3. +- **`serializes` documentado**: `plan-issue/SKILL.md` (linha 97 — nomear os registries C2: `tests/unit/codebaseConventions.unit.spec.ts`, `src/utilities/ai/tools/index.ts` + `systemPrompt.ts`, `tests/e2e/fixtures/campaignE2EFixtures.ts`, `AGENTS.md`, `.agents/shell/worktree.sh`) e `docs/AGENT-OPS.md` (seção nova "Registries compartilhados"). Sem mudança no pool (só `migrations` é predica vel; intenção pede visibilidade, não serialização do pool). +- **Docs**: `docs/AGENT-OPS.md` (seção "Changelog": escrever `docs/changelog/-.md` → `pnpm changelog:build` → guard + escape `changelog-rewrite:`), `AGENTS.md` bullet "Histórico de entregas" (uma linha), header do agregado (o parágrafo "Itens novos …" descreve o fluxo novo), `execution-pipeline.md` (passo de registro), `.github/pull_request_template.md:14` (referência ao fluxo novo). +- **Entrega própria (dogfooding)**: `docs/changelog/2026-08-13-ops44.md` + agregado regenerado; o header do agregado muda nesta entrega → **escape `changelog-rewrite:` no body do PR** (primeiro uso real do escape, testado ao vivo). +- **Migration:** nenhuma. **Access/Consent:** nenhum. **UI:** Impeccable A. + +### Dados → forma + +- N/A (sem dados de produto). Forma dos artefatos: arquivo por entrega (imune a conflito por construção — mesmo padrão que já funciona em `docs/plans/*`) + agregado gerado (leitura estabelecida mantida — decisão A do gate). + +## Fases verificáveis + +1. **Libs puras + unit tests** — `changelog.mjs` (build/sort/fail-closed + append-only multiset: adição ok, remoção falha, linha movida ok, duplicata, escape) e `conflictMarkers.mjs` (casos do spec OPS41 movidos). `pnpm test:unit`. +2. **CLIs + CI** — 3 scripts; `pnpm changelog:build` gera o agregado idêntico ao atual (verificação: `git diff` vazio com o estado atual); job `docs-guards` no ci-pr.yml + rollup. Verificação local dos checks com git. +3. **Processo/merge** — rebase nos 7 pontos (grep pós: zero `--auto --merge` fora de histórico de workflows/skills — migrations/frozen não contam); docs (AGENT-OPS, AGENTS.md, PR template, header do agregado, execution-pipeline). +4. **Entrega própria** — `docs/changelog/2026-08-13-ops44.md`, regenerar, commit, PR com `changelog-rewrite:` no body. +5. **Gates** — `pnpm gate:fast`, `pnpm check:cycles`, `pnpm exec knip`, `format:check`; build não é afetado (sem `src/`), mas rodar `pnpm build` contra DB local por paridade. + +## Rabbit holes / Não escopo (engenharia) + +- Validar data do filename contra o `**Recently resolved (data):**` da entrada — v1 sem cross-validation (nota no doc do formato). +- Guard de presença de entradas históricas por id (D8 processo manual) — não automatizar. +- Serialização do pool para registries C2 — só documentação nesta entrega. +- Migrar entradas históricas para `docs/changelog/` — fora de escopo (intenção). + +## Riscos e mitigação + +- **Settings do repo: "Rebase and merge" precisa estar habilitado** para o auto-merge rebase; verificação read-only via `gh api repos/fsolla/teqo --jq .allow_rebase_merge` antes de fechar; se desligado → habilitar via API (ou pedir ao humano) como parte da entrega. +- **PRs `cursor/*` já armadas com `--merge`**: janela até o deploy da mudança — pequena; o safety-net re-arma com o método do workflow (a partir desta entrega, rebase). +- **Falso positivo do guard com reordenação**: eliminado pela semântica multiset (linha movida continua presente). +- **Duas entregas paralelas regeneram o agregado**: rebase conflita no agregado → autor regenera com `pnpm changelog:build` (resolução natural, diff conhecido — exatamente o fluxo desejado); guard valida o resultado. +- **Prettier/format:check sobre o agregado gerado**: entradas existentes já passam; gerador preserva o formato (proseWrap preserve). + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (formato novo + agregado legível + 2 guards + rebase + `serializes` doc) +- [x] Invariantes AGENTS/engineering-standards (sem `src/` de app; scripts mjs no padrão dos guards existentes; zero DB; zero UI) +- [x] Testes de domínio previstos: unit das libs puras (builder + append-only multiset + marcadores); guards exercitados localmente contra diffs sintéticos diff --git a/package.json b/package.json index 01757eb0..2944f465 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "gate:push": "pnpm gate:ci", "push": "node scripts/git-push.mjs", "check:cycles": "madge --circular --extensions ts,tsx --ts-config tsconfig.json src", + "changelog:build": "node scripts/build-changelog.mjs", + "changelog:check": "node scripts/build-changelog.mjs --check", "generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap", "generate:types": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:types", "migrate": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate", diff --git a/scripts/build-changelog.mjs b/scripts/build-changelog.mjs new file mode 100644 index 00000000..c829107e --- /dev/null +++ b/scripts/build-changelog.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +/** + * build-changelog — regenerate docs/CHANGELOG-AGENTS.md from the per-delivery + * entries in docs/changelog/ (OPS44). Deliveries write one + * `-.md` file and run `pnpm changelog:build`; the aggregate keeps + * the single readable "Recently resolved" history. Insert-only: historical + * blocks are never touched. + * + * Usage: node scripts/build-changelog.mjs [--check] + * --check verify the aggregate is up to date without writing (CI use) + */ +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { + buildChangelog, + CHANGELOG_AGGREGATE, + CHANGELOG_DIR, + listChangelogEntries, +} from './lib/changelog.mjs' + +const repoRoot = process.cwd() +const checkOnly = process.argv.includes('--check') + +function die(message) { + console.error(`[build-changelog] ${message}`) + process.exit(1) +} + +const aggregatePath = join(repoRoot, CHANGELOG_AGGREGATE) +const changelogDir = join(repoRoot, CHANGELOG_DIR) + +let aggregateContent +try { + aggregateContent = readFileSync(aggregatePath, 'utf8') +} catch { + die(`não consegui ler ${CHANGELOG_AGGREGATE}`) +} + +let files +try { + files = readdirSync(changelogDir) +} catch { + die(`não consegui ler ${CHANGELOG_DIR}/`) +} + +const { entries, errors } = listChangelogEntries(files.map((name) => `${CHANGELOG_DIR}/${name}`)) +if (errors.length > 0) { + die(`entradas inválidas em ${CHANGELOG_DIR}/:\n ${errors.join('\n ')}`) +} + +const bodies = entries.map(({ path }) => { + try { + return readFileSync(join(repoRoot, path), 'utf8') + } catch { + die(`não consegui ler ${path}`) + } +}) + +const next = buildChangelog({ + entries: bodies.map((content) => ({ content })), + aggregateContent, +}) + +if (checkOnly) { + if (next !== aggregateContent) { + die(`${CHANGELOG_AGGREGATE} desatualizado — rode \`pnpm changelog:build\``) + } + console.log('[build-changelog] ok — aggregate up to date') + process.exit(0) +} + +writeFileSync(aggregatePath, next) +console.log(`[build-changelog] regenerado ${CHANGELOG_AGGREGATE} com ${entries.length} entrada(s)`) diff --git a/scripts/check-changelog-append-only.mjs b/scripts/check-changelog-append-only.mjs new file mode 100644 index 00000000..fc7c7e98 --- /dev/null +++ b/scripts/check-changelog-append-only.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +/** + * check-changelog-append-only — CI guard (OPS44): the aggregated changelog + * must never lose an existing line, and docs/changelog/ entries are + * additions-only. Legitimate restorations (D8 pattern) and header rewrites + * escape via the documented `changelog-rewrite:` token in the PR body. + * + * Paths from git diff (merge-base…HEAD); body from PR_BODY env or `gh pr + * view` when PR_NUMBER is set. The escape is honored only when the diff + * actually touches the aggregate or docs/changelog/, and only as a + * standalone line (never the PR template's own checkbox text). + */ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { + assertChangelogAppendOnly, + CHANGELOG_AGGREGATE, + CHANGELOG_DIR, + CHANGELOG_REWRITE_ESCAPE_RE, +} from './lib/changelog.mjs' + +const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}` + +function die(message) { + console.error(`[check-changelog-append-only] ${message}`) + process.exit(1) +} + +function git(args) { + return execFileSync('git', args, { encoding: 'utf8' }).trim() +} + +let mergeBase +try { + mergeBase = git(['merge-base', 'HEAD', base]) +} catch { + die(`no merge-base with ${base} (shallow clone?)`) +} + +function listChangedPaths() { + const raw = execFileSync( + 'git', + ['diff', '--name-status', `${mergeBase}...HEAD`, '--', CHANGELOG_AGGREGATE, CHANGELOG_DIR], + { encoding: 'utf8' }, + ) + + return raw + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) +} + +function readPrBody() { + if (process.env.PR_BODY !== undefined) { + return process.env.PR_BODY + } + + const prNumber = process.env.PR_NUMBER ?? process.env.GITHUB_PR_NUMBER + if (!prNumber) return '' + + try { + const json = execFileSync('gh', ['pr', 'view', String(prNumber), '--json', 'body'], { + encoding: 'utf8', + }) + const parsed = JSON.parse(json) + return typeof parsed.body === 'string' ? parsed.body : '' + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + die(`failed to read PR #${prNumber} body via gh: ${detail}`) + } +} + +const diffLines = listChangedPaths() +if (diffLines.length === 0) { + console.log('[check-changelog-append-only] ok — no changelog diff') + process.exit(0) +} + +// R (rename): status + old path + new path — use the new path. +const changelogDiff = diffLines.map((line) => { + const [status, ...rest] = line.split('\t') + return { status, path: status.startsWith('R') ? rest[rest.length - 1] : rest.join('\t') } +}) + +const body = readPrBody() +if (CHANGELOG_REWRITE_ESCAPE_RE.test(body)) { + console.log('[check-changelog-append-only] ok — changelog-rewrite escape in PR body') + process.exit(0) +} + +function blob(ref, path) { + return execFileSync('git', ['show', `${ref}:${path}`], { encoding: 'utf8' }) +} + +const aggregateDiff = changelogDiff.filter(({ path }) => path === CHANGELOG_AGGREGATE) +const oldAggregate = aggregateDiff.length > 0 ? blob(mergeBase, CHANGELOG_AGGREGATE) : '' +const newAggregate = + aggregateDiff.length > 0 ? readFileSync(join(process.cwd(), CHANGELOG_AGGREGATE), 'utf8') : '' + +const result = assertChangelogAppendOnly({ + oldAggregate, + newAggregate, + changelogDiff: changelogDiff.filter(({ path }) => path.startsWith(`${CHANGELOG_DIR}/`)), +}) + +if (!result.ok) { + die(result.message) +} + +console.log('[check-changelog-append-only] ok — changelog is append-only') diff --git a/scripts/check-docs-conflict-markers.mjs b/scripts/check-docs-conflict-markers.mjs new file mode 100644 index 00000000..1042e9d3 --- /dev/null +++ b/scripts/check-docs-conflict-markers.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * check-docs-conflict-markers — CI guard (OPS44): a PR diff touching + * markdown/mdc files (docs/, AGENTS.md, .agents/, …) must not commit + * conflict markers. Extends the OPS41 repo-wide scan + * (tests/unit/codebaseConventions.unit.spec.ts, which shares the regex via + * scripts/lib/conflictMarkers.mjs) to docs-only PRs, where the unit suite + * is skipped (code_mode: none) — the exact class of the OPS41 incident + * (a marker committed to AGENTS.md at the repo root). + * + * Paths from git diff (merge-base…HEAD) filtered to markdown-ish files. + */ +import { execFileSync } from 'node:child_process' + +import { findConflictMarkerLines, markdownPathsOf } from './lib/conflictMarkers.mjs' + +const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}` + +function die(message) { + console.error(`[check-docs-conflict-markers] ${message}`) + process.exit(1) +} + +function git(args) { + return execFileSync('git', args, { encoding: 'utf8' }).trim() +} + +let mergeBase +try { + mergeBase = git(['merge-base', 'HEAD', base]) +} catch { + die(`no merge-base with ${base} (shallow clone?)`) +} + +const raw = execFileSync( + 'git', + ['diff', '--name-only', '--diff-filter=ACMR', `${mergeBase}...HEAD`], + { encoding: 'utf8' }, +) +const changedPaths = raw + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + +const scannedPaths = markdownPathsOf(changedPaths) +if (scannedPaths.length === 0) { + console.log('[check-docs-conflict-markers] ok — no markdown changes') + process.exit(0) +} + +const offenders = [] +for (const path of scannedPaths) { + let content + try { + content = git(['show', `HEAD:${path}`]) + } catch { + die(`failed to read HEAD:${path}`) + } + for (const { line } of findConflictMarkerLines(content)) { + offenders.push(`${path}:${line}`) + } +} + +if (offenders.length > 0) { + die(`marcadores de conflito em markdown:\n ${offenders.join('\n ')}`) +} + +console.log(`[check-docs-conflict-markers] ok — ${scannedPaths.length} markdown file(s) clean`) diff --git a/scripts/lib/agent-pool-prompt.mjs b/scripts/lib/agent-pool-prompt.mjs index 264382e8..dd3a3ab1 100644 --- a/scripts/lib/agent-pool-prompt.mjs +++ b/scripts/lib/agent-pool-prompt.mjs @@ -37,8 +37,8 @@ export const buildPoolWorkerPrompt = ({ issueNumber, issueTitle, issueId, planPa : '2. O body da Issue é a spec de intenção (não há plano linkado). Em Plan mode, escreva mesmo assim um `docs/plans/-impl.md` a partir do body.', '3. Execute o impl plan. Gates: `pnpm gate:fast` na iteração; entrega com `pnpm push` (não `git push` nu). Comandos bare, nunca piped.', '4. Rode `/simplify` completo; `capture-review-debts` em modo autônomo (só expensive_lock ≥4).', - `5. PR obrigatoriamente com \`gh pr create --base main\` e "Closes #${issueNumber}" no body (Ready, nunca draft).`, - '6. `gh pr merge --auto --merge ` e acompanhe `gh pr checks --watch --required` até o merge. Falha de CI no seu PR é sua (docs/AGENT-OPS.md — "Dono do PR, dono do CI"): corrija na mesma branch.', + `5. PR obrigatoriamente com \`gh pr create --base main\` e "Closes #${issueNumber}" no body (Ready, nunca draft). Escreva a entrada do changelog (\`docs/changelog/-.md\` + \`pnpm changelog:build\`) antes do push (OPS44).`, + '6. `gh pr merge --auto --rebase ` e acompanhe `gh pr checks --watch --required` até o merge. Falha de CI no seu PR é sua (docs/AGENT-OPS.md — "Dono do PR, dono do CI"): corrija na mesma branch.', '', '## Proibido', '', diff --git a/scripts/lib/changelog.mjs b/scripts/lib/changelog.mjs new file mode 100644 index 00000000..d37c370d --- /dev/null +++ b/scripts/lib/changelog.mjs @@ -0,0 +1,218 @@ +/** + * changelog — pure policy for the per-delivery changelog format (OPS44). + * + * Deliveries write `docs/changelog/-.md` (one entry per file, + * immune to merge conflicts by construction — same pattern as docs/plans/). + * `pnpm changelog:build` inserts those entries into the single readable + * docs/CHANGELOG-AGENTS.md in chronological position, keeping the + * "Recently resolved" reading intact. The build is insert-only: it never + * removes or rewrites existing blocks (historical entries stay as-is — + * OPS44 scope), so the aggregate is append-only by construction. CI guards + * that property (scripts/check-changelog-append-only.mjs) and that docs + * diffs never carry conflict markers + * (scripts/check-docs-conflict-markers.mjs). + */ + +export const CHANGELOG_DIR = 'docs/changelog' +export const CHANGELOG_AGGREGATE = 'docs/CHANGELOG-AGENTS.md' + +/** `2026-08-13-ops44.md` — date desc, then id asc. */ +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})\)/ + +/** + * `changelog-rewrite: ` — standalone-line escape in the PR body for + * legitimate aggregate loss (header rewrites, D8-style restorations). Kept + * in the lib so the CLI and unit tests share one spelling. The template's + * own checkbox text ("`changelog-rewrite: ` no body") must NOT + * match — hence the line anchor. + */ +export const CHANGELOG_REWRITE_ESCAPE_RE = /^\s*changelog-rewrite\s*:/im + +/** + * @param {string} filename - basename of a file inside docs/changelog/ + * @returns {{ date: string, id: string } | null} + */ +export function parseChangelogEntryName(filename) { + const match = ENTRY_FILE_RE.exec(filename) + if (!match) return null + const [, year, month, day, id] = match + const m = Number(month) + const d = Number(day) + if (m < 1 || m > 12 || d < 1 || d > 31) return null + const check = new Date(Date.UTC(Number(year), m - 1, d)) + if ( + check.getUTCFullYear() !== Number(year) || + check.getUTCMonth() !== m - 1 || + check.getUTCDate() !== d + ) { + return null + } + return { date: `${year}-${month}-${day}`, id } +} + +/** + * Filters and validates a list of paths inside docs/changelog/. + * Any .md file that does not match the entry pattern fails closed (the + * folder is dedicated; garbage would silently drop out of the aggregate). + * + * @param {string[]} paths - repo-relative paths under docs/changelog/ + * @returns {{ entries: { path: string, name: string, date: string, id: string }[], + * errors: string[] }} + */ +export function listChangelogEntries(paths) { + const entries = [] + const errors = [] + for (const path of paths) { + const name = path.split('/').pop() ?? '' + if (!name.endsWith('.md')) continue + const parsed = parseChangelogEntryName(name) + if (!parsed) { + errors.push(`${path} — não casa o padrão -.md`) + continue + } + entries.push({ path, name, ...parsed }) + } + entries.sort((a, b) => { + if (a.date !== b.date) return a.date < b.date ? 1 : -1 + return a.id.localeCompare(b.id) + }) + return { entries, errors } +} + +/** + * Splits the aggregate into its static header (everything up to and + * including the first `---` line) and the existing entry blocks (separated + * by blank lines, trailing whitespace trimmed). + * + * @param {string} aggregateContent - current docs/CHANGELOG-AGENTS.md + * @returns {{ header: string, blocks: string[] }} + */ +export function splitChangelogBody(aggregateContent) { + const lines = aggregateContent.split('\n') + const separatorIndex = lines.findIndex((line) => line === '---') + if (separatorIndex === -1) { + throw new Error('CHANGELOG-AGENTS.md sem separador "---" no cabeçalho') + } + const header = lines.slice(0, separatorIndex + 1).join('\n') + const body = lines + .slice(separatorIndex + 1) + .join('\n') + .replace(/^\n+/, '') + const blocks = body + .split('\n\n') + .map((block) => block.trimEnd()) + .filter(Boolean) + return { header, blocks } +} + +/** + * Builds the aggregate content by inserting entry contents into their + * chronological position (date comes from each entry's own + * "Recently resolved (date)" header, not from callers). Insert-only: + * existing blocks are never removed or rewritten; an entry whose exact + * text already exists is skipped (idempotent). Entries without a dated + * header go to the top. + * + * @param {{ entries: { content: string }[], + * aggregateContent: string }} input - entries sorted date desc; + * current aggregate (source of header/blocks) + * @returns {string} - full aggregate content (trailing newline) + */ +export function buildChangelog({ entries, aggregateContent }) { + const { header, blocks } = splitChangelogBody(aggregateContent) + + const nextBlocks = [...blocks] + for (const entry of entries) { + const text = entry.content.trimEnd() + if (nextBlocks.some((block) => block === text)) continue + + const entryDate = entryDateOf(text) + const insertAt = entryDate + ? nextBlocks.findIndex( + (block) => entryDateOf(block) !== null && entryDateOf(block) < entryDate, + ) + : 0 + nextBlocks.splice(insertAt === -1 ? nextBlocks.length : insertAt, 0, text) + } + + return [header, ...nextBlocks].join('\n\n') + '\n' +} + +/** + * @param {string} block - an entry block starting with the resolved header + * @returns {string | null} - entry date when the header matches + */ +export function entryDateOf(block) { + const match = ENTRY_BLOCK_RE.exec(block) + return match ? match[1] : null +} + +/** + * Append-only policy for the changelog (CI guard). + * + * The aggregate must never lose an existing line: every line of the old + * content must still appear in the new content with equal or greater + * count (multiset inclusion — honest under line moves/reordering, unlike + * a diff parser). Files under docs/changelog/ must be additions only + * (entries are immutable once committed; a deletion or modification is a + * silent data-loss path). + * + * Legitimate restorations (D8-style) and header rewrites escape via the + * documented `changelog-rewrite:` token in the PR body (see AGENT-OPS). + * + * @param {{ oldAggregate: string, newAggregate: string, + * changelogDiff: { status: string, path: string }[] }} input + * @returns {{ ok: true } | { ok: false, message: string }} + */ +export function assertChangelogAppendOnly({ oldAggregate, newAggregate, changelogDiff }) { + const missing = missingLines(oldAggregate, newAggregate) + if (missing.length > 0) { + return { + ok: false, + message: + `docs/CHANGELOG-AGENTS.md não é append-only: ${missing.length} linha(s) existente(s) ` + + `somem (ex.: ${JSON.stringify(missing[0])}). Restaurações legítimas: use ` + + '`changelog-rewrite: ` no body do PR (linha própria; definição em AGENT-OPS).', + } + } + + const touched = changelogDiff.filter((change) => change.status !== 'A') + if (touched.length > 0) { + return { + ok: false, + message: + `docs/changelog/ não é additions-only: ${touched + .map((change) => `${change.status} ${change.path}`) + .join(', ')}. Entradas são imutáveis; restaurações legítimas: use ` + + '`changelog-rewrite: ` no body do PR (linha própria; definição em AGENT-OPS).', + } + } + + return { ok: true } +} + +/** + * @param {string} oldContent + * @param {string} newContent + * @returns {string[]} - distinct old lines missing or under-counted in new + */ +export function missingLines(oldContent, newContent) { + const counts = new Map() + for (const line of newContent.split('\n')) { + counts.set(line, (counts.get(line) ?? 0) + 1) + } + + const missing = [] + for (const line of oldContent.split('\n')) { + const remaining = counts.get(line) ?? 0 + if (remaining <= 0) { + missing.push(line) + } else { + counts.set(line, remaining - 1) + } + } + return [...new Set(missing)] +} diff --git a/scripts/lib/conflictMarkers.mjs b/scripts/lib/conflictMarkers.mjs new file mode 100644 index 00000000..a35ffe34 --- /dev/null +++ b/scripts/lib/conflictMarkers.mjs @@ -0,0 +1,44 @@ +/** + * conflictMarkers — shared conflict-marker policy for docs diffs (OPS44). + * + * The regex started as the OPS41 guardrail inside + * tests/unit/codebaseConventions.unit.spec.ts (repo-wide scan of tracked + * files). OPS44 extracts it here so the unit spec and the CI docs guard + * (scripts/check-docs-conflict-markers.mjs) share one source — don't twin. + */ + +/** + * A real git conflict was committed to AGENTS.md twice: the OPS33 commit + * carried `<<<<<<< HEAD` plus a mangled `> > > > > > >` closer (the + * `=======` was deleted in the same bad resolution), and OPS37 inherited + * the state. The `(?:> ){7}` form is the exact corrupted closer that + * shipped; a genuine 7-deep nested blockquote would need a deliberate + * allowlist. + */ +export const CONFLICT_MARKER_RE = /^\s*<<<<<<<(?:\s|$)|^\s*>>>>>>>(?:\s|$)|^\s*(?:> ){7}/m + +/** + * @param {string} content - file content to scan + * @returns {{ line: number, text: string }[]} - 1-based offending lines + */ +export function findConflictMarkerLines(content) { + const offenders = [] + const lines = content.split('\n') + for (const [index, line] of lines.entries()) { + if (CONFLICT_MARKER_RE.test(line)) { + offenders.push({ line: index + 1, text: line }) + } + } + return offenders +} + +/** + * @param {string[]} paths - repo-relative changed paths + * @returns {string[]} - the markdown-ish paths (docs/, AGENTS.md, .agents/ …) + * + * The OPS41 incident file was AGENTS.md at the repo root, so the CI guard + * must not be limited to docs/: any changed markdown/mdc can carry markers. + */ +export function markdownPathsOf(paths) { + return paths.filter((path) => /\.(?:md|mdx|mdc)$/i.test(path)) +} diff --git a/tests/unit/agentPoolPrompt.unit.spec.ts b/tests/unit/agentPoolPrompt.unit.spec.ts index 4a6031a9..6d03ce99 100644 --- a/tests/unit/agentPoolPrompt.unit.spec.ts +++ b/tests/unit/agentPoolPrompt.unit.spec.ts @@ -25,10 +25,11 @@ describe('buildPoolWorkerPrompt', () => { expect(prompt).toContain('plano de intenção como contrato de engenharia') }) - it('pins the PR contract: base main, Closes #N, auto-merge, CI watch', () => { + it('pins the PR contract: base main, Closes #N, changelog entry, rebase auto-merge, CI watch', () => { expect(prompt).toContain('gh pr create --base main') expect(prompt).toContain('Closes #42') - expect(prompt).toContain('gh pr merge --auto --merge') + expect(prompt).toContain('changelog:build') + expect(prompt).toContain('gh pr merge --auto --rebase') expect(prompt).toContain('gh pr checks --watch') }) diff --git a/tests/unit/changelog.unit.spec.ts b/tests/unit/changelog.unit.spec.ts new file mode 100644 index 00000000..63746829 --- /dev/null +++ b/tests/unit/changelog.unit.spec.ts @@ -0,0 +1,289 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + assertChangelogAppendOnly, + buildChangelog, + CHANGELOG_REWRITE_ESCAPE_RE, + entryDateOf, + listChangelogEntries, + missingLines, + parseChangelogEntryName, + splitChangelogBody, +} from '../../scripts/lib/changelog.mjs' + +const HEADER = `# Changelog de contexto para agentes (fatias do AGENTS.md) + +Conteúdo movido do \`AGENTS.md\` em 2026-07-30. + +---` + +const ENTRY_2026_08_13 = `**Recently resolved (2026-08-13):** **OPS44** — teste. Intenção [x](plans/x.md); impl [x-impl](plans/x-impl.md).` +const ENTRY_2026_08_12 = `**Recently resolved (2026-08-12):** **B200** — teste.` + +describe('parseChangelogEntryName', () => { + it('parses the canonical date-id filename', () => { + expect(parseChangelogEntryName('2026-08-13-ops44.md')).toEqual({ + date: '2026-08-13', + id: 'ops44', + }) + }) + + it('accepts alphanumeric ids with dashes', () => { + expect(parseChangelogEntryName('2026-08-12-c123-teste.md')).toEqual({ + date: '2026-08-12', + id: 'c123-teste', + }) + }) + + it('rejects malformed names', () => { + expect(parseChangelogEntryName('ops44.md')).toBeNull() + expect(parseChangelogEntryName('2026-13-01-ops44.md')).toBeNull() + expect(parseChangelogEntryName('2026-08-13.md')).toBeNull() + expect(parseChangelogEntryName('2026-08-13-OPS44.md')).toBeNull() + expect(parseChangelogEntryName('README.md')).toBeNull() + }) + + it('rejects impossible calendar dates', () => { + expect(parseChangelogEntryName('2026-02-31-ops44.md')).toBeNull() + expect(parseChangelogEntryName('2026-04-31-ops44.md')).toBeNull() + }) +}) + +describe('listChangelogEntries', () => { + it('sorts by date desc then id asc', () => { + const { entries, errors } = listChangelogEntries([ + 'docs/changelog/2026-08-10-b197.md', + 'docs/changelog/2026-08-13-ops44.md', + 'docs/changelog/2026-08-12-a.md', + 'docs/changelog/2026-08-12-b.md', + ]) + expect(errors).toEqual([]) + expect(entries.map((entry) => entry.name)).toEqual([ + '2026-08-13-ops44.md', + '2026-08-12-a.md', + '2026-08-12-b.md', + '2026-08-10-b197.md', + ]) + }) + + it('fails closed on a .md that does not match the pattern', () => { + const { entries, errors } = listChangelogEntries([ + 'docs/changelog/2026-08-13-ops44.md', + 'docs/changelog/not-an-entry.md', + ]) + expect(entries).toHaveLength(1) + expect(errors).toEqual([ + 'docs/changelog/not-an-entry.md — não casa o padrão -.md', + ]) + }) + + it('ignores non-md files (e.g. stray assets)', () => { + const { entries, errors } = listChangelogEntries([ + 'docs/changelog/2026-08-13-ops44.md', + 'docs/changelog/foo.txt', + ]) + expect(errors).toEqual([]) + expect(entries).toHaveLength(1) + }) +}) + +describe('splitChangelogBody', () => { + const aggregate = `${HEADER}\n\n${ENTRY_2026_08_13}\n\n${ENTRY_2026_08_12}\n` + + it('splits the static header from the entry blocks', () => { + expect(splitChangelogBody(aggregate)).toEqual({ + header: HEADER, + blocks: [ENTRY_2026_08_13, ENTRY_2026_08_12], + }) + }) + + it('trims trailing whitespace from blocks', () => { + const { blocks } = splitChangelogBody(`${HEADER}\n\n${ENTRY_2026_08_13} \n`) + expect(blocks).toEqual([ENTRY_2026_08_13]) + }) + + it('throws when the separator is missing', () => { + expect(() => splitChangelogBody('sem separador')).toThrow('sem separador') + }) +}) + +describe('entryDateOf', () => { + it('extracts the date from a Recently resolved header', () => { + expect(entryDateOf(ENTRY_2026_08_13)).toBe('2026-08-13') + }) + + it('returns null for a block without the header', () => { + expect(entryDateOf('bloco sem header')).toBeNull() + }) +}) + +describe('buildChangelog', () => { + const aggregate = `${HEADER}\n\n${ENTRY_2026_08_12}\n` + + it('inserts a newer entry before an existing older block', () => { + const content = buildChangelog({ + entries: [{ content: ENTRY_2026_08_13 }], + aggregateContent: aggregate, + }) + expect(content).toBe(`${HEADER}\n\n${ENTRY_2026_08_13}\n\n${ENTRY_2026_08_12}\n`) + }) + + it('inserts an older entry after a newer existing block', () => { + const content = buildChangelog({ + entries: [{ content: ENTRY_2026_08_12 }], + aggregateContent: `${HEADER}\n\n${ENTRY_2026_08_13}\n`, + }) + expect(content).toBe(`${HEADER}\n\n${ENTRY_2026_08_13}\n\n${ENTRY_2026_08_12}\n`) + }) + + it('is idempotent — a present entry is skipped', () => { + const first = buildChangelog({ + entries: [{ content: ENTRY_2026_08_13 }], + aggregateContent: aggregate, + }) + const second = buildChangelog({ + entries: [{ content: ENTRY_2026_08_13 }], + aggregateContent: first, + }) + expect(second).toBe(first) + }) + + it('skips only on exact equality — a longer entry is not hidden by a prefix', () => { + const content = buildChangelog({ + entries: [{ content: `${ENTRY_2026_08_12} detalhes extras.` }, { content: ENTRY_2026_08_13 }], + aggregateContent: aggregate, + }) + expect(content).toBe( + `${HEADER}\n\n${ENTRY_2026_08_13}\n\n${ENTRY_2026_08_12}\n\n${ENTRY_2026_08_12} detalhes extras.\n`, + ) + }) + + it('is idempotent for entries with trailing blank lines (trimmed both sides)', () => { + const first = buildChangelog({ + entries: [{ content: `${ENTRY_2026_08_13}\n \n\n` }], + aggregateContent: aggregate, + }) + const second = buildChangelog({ + entries: [{ content: ENTRY_2026_08_13 }], + aggregateContent: first, + }) + expect(second).toBe(first) + }) + + it('inserts an entry without a date header at the top', () => { + const content = buildChangelog({ + entries: [{ content: 'bloco sem data' }], + aggregateContent: aggregate, + }) + expect(content).toBe(`${HEADER}\n\nbloco sem data\n\n${ENTRY_2026_08_12}\n`) + }) + + it('trims trailing newlines from entry contents', () => { + const content = buildChangelog({ + entries: [{ content: `${ENTRY_2026_08_13}\n\n` }], + aggregateContent: aggregate, + }) + expect(content).toBe(`${HEADER}\n\n${ENTRY_2026_08_13}\n\n${ENTRY_2026_08_12}\n`) + }) +}) + +describe('missingLines', () => { + it('returns [] for a pure addition', () => { + expect(missingLines('a\nb\n', 'x\na\nb\n')).toEqual([]) + }) + + it('flags a removed line', () => { + expect(missingLines('a\nb\n', 'a\n')).toEqual(['b']) + }) + + it('treats a moved line as present (multiset, not position)', () => { + expect(missingLines('a\nb\nc\n', 'c\na\nb\n')).toEqual([]) + }) + + it('flags a line whose count decreases (duplicate lost)', () => { + expect(missingLines('a\na\n', 'a\n')).toEqual(['a']) + }) + + it('returns distinct missing lines', () => { + expect(missingLines('a\nb\nb\n', 'a\n')).toEqual(['b']) + }) +}) + +describe('CHANGELOG_REWRITE_ESCAPE_RE', () => { + it('matches a standalone changelog-rewrite line in a PR body', () => { + expect( + CHANGELOG_REWRITE_ESCAPE_RE.test('## Notas\n\nchangelog-rewrite: header do agregado mudou'), + ).toBe(true) + expect(CHANGELOG_REWRITE_ESCAPE_RE.test('changelog-rewrite:restauração D8')).toBe(true) + }) + + it('never matches the PR template checkbox text (which cites the token)', () => { + expect( + CHANGELOG_REWRITE_ESCAPE_RE.test( + '- [ ] Se o changelog perdeu linhas de propósito: `changelog-rewrite: ` no body', + ), + ).toBe(false) + }) +}) + +describe('assertChangelogAppendOnly', () => { + it('passes on a pure addition with no changelog file touched', () => { + expect( + assertChangelogAppendOnly({ + oldAggregate: 'a\nb\n', + newAggregate: 'a\nb\nc\n', + changelogDiff: [{ status: 'A', path: 'docs/changelog/2026-08-13-ops44.md' }], + }), + ).toEqual({ ok: true }) + }) + + it('fails when an aggregate line disappears', () => { + const result = assertChangelogAppendOnly({ + oldAggregate: 'a\nb\n', + newAggregate: 'a\n', + changelogDiff: [], + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toContain('não é append-only') + expect(result.message).toContain('changelog-rewrite') + } + }) + + it('passes on an addition inside docs/changelog/', () => { + expect( + assertChangelogAppendOnly({ + oldAggregate: 'a\n', + newAggregate: 'a\nb\n', + changelogDiff: [{ status: 'A', path: 'docs/changelog/2026-08-13-ops44.md' }], + }), + ).toEqual({ ok: true }) + }) + + it('fails when a docs/changelog file is modified', () => { + const result = assertChangelogAppendOnly({ + oldAggregate: 'a\n', + newAggregate: 'a\nb\n', + changelogDiff: [{ status: 'M', path: 'docs/changelog/2026-08-13-ops44.md' }], + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toContain('additions-only') + expect(result.message).toContain('M docs/changelog/2026-08-13-ops44.md') + } + }) + + it('fails when a docs/changelog file is deleted', () => { + const result = assertChangelogAppendOnly({ + oldAggregate: 'a\n', + newAggregate: 'a\n', + changelogDiff: [{ status: 'D', path: 'docs/changelog/2026-08-12-b200.md' }], + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toContain('D docs/changelog/2026-08-12-b200.md') + } + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 306ad65a..8e4d8b9b 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -10,6 +10,8 @@ import { legacyFrameworkExportIgnores, } from '../../eslint-legacy-ignores.mjs' +import { CONFLICT_MARKER_RE } from '../../scripts/lib/conflictMarkers.mjs' + // Programmatic guards for conventions that used to be enforced only by prose // (codebase-map.mdc / docs/ARCHITECTURE.md) and drifted during Pass 2. @@ -755,9 +757,11 @@ describe('no conflict markers in committed files', () => { // `=======` was deleted in the same bad resolution), and OPS37 inherited // the state. The scan reads tracked paths from the index and greps the // working tree, so a bad resolution fails here before it is ever pushed. - // The `(?:> ){7}` form is the exact corrupted closer that shipped; a - // genuine 7-deep nested blockquote would need a deliberate allowlist. - const conflictMarker = /^\s*<<<<<<<(?:\s|$)|^\s*>>>>>>>(?:\s|$)|^\s*(?:> ){7}/m + // The regex lives in scripts/lib/conflictMarkers.mjs (OPS44 extraction) — + // the CI docs guard shares it. The `(?:> ){7}` form is the exact corrupted + // closer that shipped; a genuine 7-deep nested blockquote would need a + // deliberate allowlist. + const conflictMarker = CONFLICT_MARKER_RE it('keeps conflict markers out of tracked files', () => { const offenders: string[] = [] diff --git a/tests/unit/conflictMarkers.unit.spec.ts b/tests/unit/conflictMarkers.unit.spec.ts new file mode 100644 index 00000000..02732f19 --- /dev/null +++ b/tests/unit/conflictMarkers.unit.spec.ts @@ -0,0 +1,67 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + CONFLICT_MARKER_RE, + findConflictMarkerLines, + markdownPathsOf, +} from '../../scripts/lib/conflictMarkers.mjs' + +describe('conflictMarkers (shared with the OPS41 unit scan)', () => { + it('matches plain and indented open markers', () => { + expect(CONFLICT_MARKER_RE.test('<<<<<<< HEAD')).toBe(true) + expect(CONFLICT_MARKER_RE.test(' <<<<<<< HEAD')).toBe(true) + expect(CONFLICT_MARKER_RE.test('\t<<<<<<< branch')).toBe(true) + }) + + it('matches plain and indented close markers', () => { + expect(CONFLICT_MARKER_RE.test('>>>>>>> branch')).toBe(true) + expect(CONFLICT_MARKER_RE.test(' >>>>>>> branch')).toBe(true) + }) + + it('matches the corrupted closer form that shipped in OPS33', () => { + expect(CONFLICT_MARKER_RE.test('> > > > > > > 9e18bec8')).toBe(true) + expect(CONFLICT_MARKER_RE.test(' > > > > > > > 9e18bec8')).toBe(true) + }) + + it('does not match ordinary markdown lines', () => { + expect(CONFLICT_MARKER_RE.test('- item de lista')).toBe(false) + expect(CONFLICT_MARKER_RE.test('> citação simples')).toBe(false) + expect(CONFLICT_MARKER_RE.test('=======')).toBe(false) + }) + + it('reports 1-based offending lines', () => { + const offenders = findConflictMarkerLines('linha boa\n<<<<<<< HEAD\nlinha boa') + expect(offenders).toEqual([{ line: 2, text: '<<<<<<< HEAD' }]) + }) + + it('reports every offender in a file', () => { + const offenders = findConflictMarkerLines('<<<<<<< A\n>>>>>>> B\n') + expect(offenders.map((offender) => offender.line)).toEqual([1, 2]) + }) + + it('returns [] for clean content', () => { + expect(findConflictMarkerLines('markdown limpo\n')).toEqual([]) + }) +}) + +describe('markdownPathsOf', () => { + it('filters to markdown/mdc paths anywhere (incl. repo root — OPS41 file)', () => { + expect( + markdownPathsOf([ + 'docs/CHANGELOG-AGENTS.md', + 'src/lib/foo.ts', + 'docs/changelog/2026-08-13-ops44.md', + 'AGENTS.md', + '.agents/rules/agent-pr-workflow.mdc', + 'package.json', + ]), + ).toEqual([ + 'docs/CHANGELOG-AGENTS.md', + 'docs/changelog/2026-08-13-ops44.md', + 'AGENTS.md', + '.agents/rules/agent-pr-workflow.mdc', + ]) + }) +})