mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
OPS44: changelog por entrega + guards de docs + merge por rebase
This commit is contained in:
parent
85e5aa81e1
commit
9471c8f0aa
23 changed files with 1028 additions and 28 deletions
|
|
@ -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 <url|number>` (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 <url|number>` (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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<slug>.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
|
||||
|
|
|
|||
|
|
@ -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 #<N>`
|
||||
4. `gh pr merge --auto --merge` imediatamente
|
||||
5. `gh pr checks <PR> --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/<data>-<id>.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 #<N>`
|
||||
5. `gh pr merge --auto --rebase` imediatamente
|
||||
6. `gh pr checks <PR> --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
|
||||
|
||||
|
|
|
|||
3
.github/pull_request_template.md
vendored
3
.github/pull_request_template.md
vendored
|
|
@ -11,7 +11,8 @@ Closes #<!-- número da Issue (claim) — omita em PRs que alteram só docs/plan
|
|||
- [ ] Órfãos da mudança deletados na mesma entrega (knip em CI confirma)
|
||||
- [ ] **Se tocou schema** (migration, collection, campo, Consent key, dado de boot): `db:seed:minimal` atualizado **neste PR** (`scripts/lib/seed-minimal-manifest.mjs` + pin unit) — senão a label `needs:migration` está errada
|
||||
- [ ] Nenhum `DATABASE_URL`/`ALLOW_REMOTE_DB` de prod em script ou workflow
|
||||
- [ ] Se muda o que o agente precisa saber sempre: `AGENTS.md`/kernels atualizados; entregas registram UMA entrada curta em `docs/CHANGELOG-AGENTS.md` (nunca no AGENTS.md)
|
||||
- [ ] Se muda o que o agente precisa saber sempre: `AGENTS.md`/kernels atualizados; entregas registram UMA entrada em `docs/changelog/<data>-<id>.md` + `pnpm changelog:build` (OPS44 — nunca editam `docs/CHANGELOG-AGENTS.md` na mão)
|
||||
- [ ] Se o changelog perdeu linhas de propósito (restauração/header): `changelog-rewrite: <motivo>` no body deste PR
|
||||
|
||||
## Notas para review
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Safety net: agent PRs (cursor/* → main) must be Ready + auto-merge.
|
||||
# Covers Cloud ManagePullRequest defaulting to draft and agents that forget
|
||||
# `gh pr merge --auto --merge`. No skill opt-out (engineering-audit included).
|
||||
# `gh pr merge --auto --rebase`. No skill opt-out (engineering-audit included).
|
||||
name: Agent PR Ready + auto-merge
|
||||
|
||||
on:
|
||||
|
|
@ -34,11 +34,11 @@ jobs:
|
|||
echo "PR #$PR_NUMBER already Ready"
|
||||
fi
|
||||
# Arm auto-merge (idempotent when already enabled; fails only on hard blocks).
|
||||
if gh pr merge --auto --merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"; then
|
||||
if gh pr merge --auto --rebase "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"; then
|
||||
echo "Auto-merge armed for PR #$PR_NUMBER"
|
||||
else
|
||||
# Already merged / already armed / race — succeed only if request exists or merged.
|
||||
armed="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json autoMergeRequest,state --jq '(.autoMergeRequest != null) or (.state == "MERGED")')"
|
||||
echo "gh pr merge --auto exited non-zero; armed_or_merged=$armed"
|
||||
echo "gh pr merge --auto --rebase exited non-zero; armed_or_merged=$armed"
|
||||
[ "$armed" = "true" ]
|
||||
fi
|
||||
|
|
|
|||
23
.github/workflows/ci-pr.yml
vendored
23
.github/workflows/ci-pr.yml
vendored
|
|
@ -353,6 +353,28 @@ jobs:
|
|||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
docs-guards:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Changelog is append-only
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: node scripts/check-changelog-append-only.mjs
|
||||
- name: Aggregate is up to date with docs/changelog
|
||||
run: node scripts/build-changelog.mjs --check
|
||||
- name: No conflict markers in markdown diffs
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: node scripts/check-docs-conflict-markers.mjs
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
||||
plans-only-closes:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
|
@ -383,6 +405,7 @@ jobs:
|
|||
- int
|
||||
- build
|
||||
- e2e
|
||||
- docs-guards
|
||||
- plans-only-closes
|
||||
if: always()
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ These are product/architecture constraints — not a ban on editing existing cod
|
|||
|
||||
## Histórico de entregas (movido)
|
||||
|
||||
"Known Gaps" e os blocos "Recently resolved" saíram deste arquivo em 2026-07-30: vivem em [`docs/CHANGELOG-AGENTS.md`](docs/CHANGELOG-AGENTS.md). Novas entregas registram UMA entrada curta lá, não aqui. A referência genérica de Payload (config/collections/fields/hooks/queries/components) foi para [`docs/PAYLOAD-REFERENCE.md`](docs/PAYLOAD-REFERENCE.md) — consulta sob demanda, não always-on. Operação do paradigma de agentes paralelos (claim→PR→main→deploy gated): [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md).
|
||||
"Known Gaps" e os blocos "Recently resolved" saíram deste arquivo em 2026-07-30: vivem em [`docs/CHANGELOG-AGENTS.md`](docs/CHANGELOG-AGENTS.md). Novas entregas registram UMA entrada curta em `docs/changelog/<data>-<id>.md` e rodam `pnpm changelog:build` (agregado insert-only, OPS44) — nunca editam `docs/CHANGELOG-AGENTS.md` na mão. A referência genérica de Payload (config/collections/fields/hooks/queries/components) foi para [`docs/PAYLOAD-REFERENCE.md`](docs/PAYLOAD-REFERENCE.md) — consulta sob demanda, não always-on. Operação do paradigma de agentes paralelos (claim→PR→main→deploy gated): [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md).
|
||||
|
||||
## Posts & Tags (news / publications)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ main → ci.yml full suite → vercel deploy --prod (se verde) → requeue se HE
|
|||
|
||||
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = GitHub Issues.
|
||||
|
||||
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **`pnpm push`** → PR **Ready** (nunca draft) com `Closes #N` → `gh pr merge --auto --merge` → `gh pr checks --watch --required`. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false`, depois armar auto-merge via `gh pr merge --auto --merge` (o default draft da tool **não** vale neste repo).
|
||||
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **`pnpm push`** → PR **Ready** (nunca draft) com `Closes #N` → `gh pr merge --auto --rebase` → `gh pr checks --watch --required`. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false`, depois armar auto-merge via `gh pr merge --auto --rebase` (o default draft da tool **não** vale neste repo).
|
||||
- **Só humano:** secrets Vercel (`VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`), `POOL_GITHUB_TOKEN`, `pnpm configure:branch-protection`, editar envs Neon/Vercel.
|
||||
|
||||
### Dono do PR, dono do CI
|
||||
|
|
@ -56,7 +56,16 @@ Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
|
|||
|
||||
1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`).
|
||||
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR e na main; rebase antes de `migrate:create` continua obrigatório.
|
||||
3. Ready + auto-merge: `gh pr create --base main` → `gh pr merge --auto --merge`; `gh pr checks --watch --required` (Vercel Git não é gate).
|
||||
3. Ready + auto-merge: `gh pr create --base main` → `gh pr merge --auto --rebase`; `gh pr checks --watch --required` (Vercel Git não é gate). **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo que o CI guarda contra conflitos latentes.
|
||||
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). A própria troca `--merge` → `--rebase` deste doc usou o escape — header do agregado mudou.
|
||||
|
||||
## Registries compartilhados (serialização por arquivo, não por PR)
|
||||
|
||||
Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migrações, catalog) conflitam por construção: cada escrita reescreve o arquivo inteiro. Regras:
|
||||
|
||||
- **Cada entrega escreve UM arquivo por registro** (`docs/changelog/<data>-<id>.md`, `docs/plans/<slug>.md`, migration com nome único, etc.) — o arquivo comum é **derivado** (build/CI) ou **só-leitura por PR** (guard).
|
||||
- **`serializes` no plano de intenção** nomeia o registro exato que a Issue toca (ex. `serializes: 'docs/plans/'`), para o executor agendar com clareza — é dica de ordem, não lock (rebase continua obrigatório).
|
||||
- O rebase (Contrato de PR item 3) é o mecanismo real de conciliação: conflitos de append resolvem com `git checkout --ours/--theirs` por lado.
|
||||
|
||||
## CI por alvo
|
||||
|
||||
|
|
@ -66,7 +75,7 @@ Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
|
|||
| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho → `deploy` (`vercel deploy --prod`) → `requeue` se HEAD andou |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `Closes`/`Fixes` → `done` + `in-prod` |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip) |
|
||||
| `agent-pr-ready-automerge.yml` | PR `cursor/*` → `main` (open/sync/…) | — | Draft→Ready + `gh pr merge --auto --merge` (safety net; audit incluso) |
|
||||
| `agent-pr-ready-automerge.yml` | PR `cursor/*` → `main` (open/sync/…) | — | Draft→Ready + `gh pr merge --auto --rebase` (safety net; audit incluso) |
|
||||
| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) |
|
||||
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `styfle/cancel-workflow-action@0.13.1` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
# Changelog de contexto para agentes (fatias do AGENTS.md)
|
||||
|
||||
Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agentes paralelos): "Known Gaps" e todos os blocos "Recently resolved". Histórico — leia só quando precisar do contexto de uma entrega passada; o sempre-presente fica no `AGENTS.md`. Itens novos de "Recently resolved" passam a ser registrados aqui (uma entrada curta), não no AGENTS.md.
|
||||
Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agentes paralelos): "Known Gaps" e todos os blocos "Recently resolved". Histórico — leia só quando precisar do contexto de uma entrega passada; o sempre-presente fica no `AGENTS.md`. **Desde OPS44 (2026-08-13)** cada entrega registra UMA entrada em `docs/changelog/<data>-<id>.md` e o agregado é regenerado por `pnpm changelog:build` (insert-only — este arquivo nunca é editado na mão; guard de CI `docs-guards` e escape `changelog-rewrite:` documentados em AGENT-OPS).
|
||||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-13):** **OPS44** (Issue #713) — redução de conflitos de merge em arquivos compartilhados. **Changelog por entrega:** cada entrega escreve `docs/changelog/<data>-<id>.md` (uma entrada, imune a conflito por construção) e `pnpm changelog:build` regenera o agregado `docs/CHANGELOG-AGENTS.md` de forma **insert-only** (nunca remove/reescreve entradas; histórico não migrado) — libs puras `scripts/lib/changelog.mjs` (parse/sort fail-closed, split do header, build idempotente, `assertChangelogAppendOnly` multiset) e `scripts/lib/conflictMarkers.mjs` (regex compartilhada com o guard OPS41); CLIs `scripts/build-changelog.mjs` (com `--check`), `scripts/check-changelog-append-only.mjs` e `scripts/check-docs-conflict-markers.mjs`; job **`docs-guards`** no `ci-pr.yml` (append-only do agregado + additions-only em `docs/changelog/` + zero marcadores de conflito em diffs de `docs/` — cobre PRs docs-only onde a suíte unit não roda, a classe do incidente OPS41); escape documentado `changelog-rewrite: <motivo>` no PR body (padrão D8; usado pela própria entrega — header do agregado mudou). **Merge por rebase em 7 pontos** (`--auto --rebase` em vez de `--auto --merge`: execution-pipeline, agent-pool-prompt, plan-issue, agent-pr-workflow ×3, agent-pr-ready-automerge, engineering-audit; AGENT-OPS contrato item 3) — sem isso o safety-net re-arma `--merge` por cima; `allow_rebase_merge: true` verificado no repo. **`serializes` nomeia o registro** (ex. `serializes: 'docs/plans/'`). Docs: AGENT-OPS (Contrato item 4 + seção "Registries compartilhados"), AGENTS.md bullet, PR template (2 checkboxes), header do agregado, execution-pipeline (passo de registro). Sem migration/access/Consent/UI/schema.
|
||||
|
||||
**Recently resolved (2026-08-13):** **OPS44** (Issue #713) — redução de conflitos de merge em arquivos compartilhados. **Changelog por entrega:** cada entrega escreve `docs/changelog/<data>-<id>.md` (uma entrada, imune a conflito por construção) e `pnpm changelog:build` regenera o agregado `docs/CHANGELOG-AGENTS.md` de forma **insert-only** (nunca remove/reescreve entradas; histórico não migrado) — libs puras `scripts/lib/changelog.mjs` (parse/sort fail-closed, split do header, build idempotente, `assertChangelogAppendOnly` multiset) e `scripts/lib/conflictMarkers.mjs` (regex compartilhada com o guard OPS41); CLIs `scripts/build-changelog.mjs` (com `--check`), `scripts/check-changelog-append-only.mjs` e `scripts/check-docs-conflict-markers.mjs`; job **`docs-guards`** no `ci-pr.yml` (append-only do agregado + additions-only em `docs/changelog/` + zero marcadores de conflito em diffs de `docs/` — cobre PRs docs-only onde a suíte unit não roda, a classe do incidente OPS41); escape documentado `changelog-rewrite: <motivo>` no PR body (linha própria; definição canônica em AGENT-OPS, precedente de restauração D8; usado pela própria entrega — header do agregado mudou). **Merge por rebase em 7 pontos** (`--auto --rebase` em vez de `--auto --merge`: execution-pipeline, agent-pool-prompt, plan-issue, agent-pr-workflow ×3, agent-pr-ready-automerge, engineering-audit; AGENT-OPS contrato item 3) — sem isso o safety-net re-arma `--merge` por cima; `allow_rebase_merge: true` verificado no repo. **`serializes` nomeia o registro** (ex. `serializes: 'docs/plans/'`). Docs: AGENT-OPS (Contrato item 4 + seção "Registries compartilhados"), AGENTS.md bullet, PR template (2 checkboxes), header do agregado, execution-pipeline (passo de registro). Sem migration/access/Consent/UI/schema.
|
||||
|
||||
**Recently resolved (2026-08-12):** **B200** (Issue #704) — encaixes dos cards mobile de `/campanha/municipios` (pós-B196): o card lê como um bloco só. **Barra (chassis omnibox):** form `py-1` → `pt-1 pb-0` + campo `min-h-8` → `min-h-10` + X `size-8` → `size-9` → barra 41 → **45px** (aceite "≈48" do plano — o draft gateado manda: min-h-10 sem voltar ao campo alto do B184); **a causa real do vão entre barra e primeiro card não era o pb do form, e sim o `gap-8` da coluna do shell** (32px — medido ao vivo: form.bottom 100.67 vs card.top 132.67) → `max-md:-mb-8` no `campaignListOmniboxFormClassName` cancela exatamente esse gap (primeiro card colado; demais seções do shell mantêm o ritmo; desktop `md:mb-0` intacto). **Card:** article `gap-2.5` → `gap-2`; nome/território `gap-0` + `leading-tight` no território (vão ≈0–2px, e2e ≤2); chips `gap-y-2` → `gap-y-1.5`; grupos de relação `gap-1.5` → `gap-1`. **Avatares (decisão cara):** o modo `overlapRow` troca as células `flex-1 justify-center` + `overflow-hidden` (origem do "um em cada canto" com 2 e das pontas cortadas com 5+) por **pilha à esquerda com sobreposição medida** — função pura `relationAvatarOverlapPx(count, width)` (`src/lib/relationAvatarOverlap.ts`, unit-testada: min 8px garantido até com 2 avatares, fit exato `28+(n−1)(28−overlap) ≤ W` para qualquer n/viewport, fallback no mínimo para width não-finita) + `ResizeObserver` no grupo (`'use client'` no stack; modo default desktop `-space-x-2`/cap 3 intacto; `overflow-hidden` permanece como guarda de último recurso, nunca ativa no fit). **E2E:** B196 da barra (44–48px + primeiro card colado com limite duplo — nem vão nem card embaixo da barra) e de avatares reescritos (2 avatares levemente sobrepostos à esquerda — a asserção antiga "sem overlap" pinava o bug); novo describe B200 (barra 44–48 + colagem + eixo label/valor nos seis blocos + pilha: mesma linha, distância entre avatares 8–20px — sobreposição mínima E sliver visível, ancorada à esquerda, nada invade o vizinho via fronteira do grupo); **endurecimento anti-flake de medição** nas specs de geometria — wait `S:` settle + footer "1 município encontrado" (o settle sozinho passava com o card mid-stream num layout transiente de célula ~15px — medido via debug) + `expect.poll` 30s de layout único por leitura (o webServer dev sob workers paralelos recarrega página no meio do teste — medições em frames diferentes capturavam estados distintos). Sem migration/access/Consent/schema. Gates: unit 2085 (6 novos), int 748, e2e B196+B200 14/14 (2 repeats) + spec completa 33/34 (única falha B24 — reproduzida **sem** a mudança, flake de ambiente pré-existente; 5 falhas ambientais do 1º run sumiram), build OK, lint/format/cycles OK (knip: erro pré-existente ledgerado P3 do payload.config). Intenção [`municipios-mobile-ajustes-cards.md`](plans/municipios-mobile-ajustes-cards.md); impl [`municipios-mobile-ajustes-cards-impl.md`](plans/municipios-mobile-ajustes-cards-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-12):** **Remoção do `migration-lock`** (CI) — o check de `ci-pr.yml` que falhava quando >1 PR aberto tocava `src/migrations/`/`payload-types.ts` foi **removido** (deadlock de CI: com 2 PRs de schema abertos, AMBOS falhavam o check e nenhum mergeava sem bypass manual — 9 de 13 runs de CI falhos desde 11/08 foram por isso). A ameaça que ele serializava (cadeia de migrations com drift de snapshot) já é coberta pelo CI em todo PR e na main: `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation.int.spec.ts`, checksum do schema migrado vs config); o conflito real (index.ts/payload-types.ts) resolve no rebase. Removido: job `migration-lock` do `ci-pr.yml` + `needs` do rollup `checks` + contexto required da branch protection (`pnpm configure:branch-protection` agora aplica só `checks`); pool mantém a serialização de spawn de issues `needs:migration` (`countOpenSchemaPrs` em `agent-pool-github.mjs` — independe do CI). Docs/skills atualizados (AGENT-OPS contrato de PR, GUARDRAILS, engineering-standards, agent-pool, work-issue, agent-pr-workflow). Sem migration/access/Consent/UI/schema.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-13-ops44.md
Normal file
1
docs/changelog/2026-08-13-ops44.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Recently resolved (2026-08-13):** **OPS44** (Issue #713) — redução de conflitos de merge em arquivos compartilhados. **Changelog por entrega:** cada entrega escreve `docs/changelog/<data>-<id>.md` (uma entrada, imune a conflito por construção) e `pnpm changelog:build` regenera o agregado `docs/CHANGELOG-AGENTS.md` de forma **insert-only** (nunca remove/reescreve entradas; histórico não migrado) — libs puras `scripts/lib/changelog.mjs` (parse/sort fail-closed, split do header, build idempotente, `assertChangelogAppendOnly` multiset) e `scripts/lib/conflictMarkers.mjs` (regex compartilhada com o guard OPS41); CLIs `scripts/build-changelog.mjs` (com `--check`), `scripts/check-changelog-append-only.mjs` e `scripts/check-docs-conflict-markers.mjs`; job **`docs-guards`** no `ci-pr.yml` (append-only do agregado + additions-only em `docs/changelog/` + zero marcadores de conflito em diffs de `docs/` — cobre PRs docs-only onde a suíte unit não roda, a classe do incidente OPS41); escape documentado `changelog-rewrite: <motivo>` no PR body (linha própria; definição canônica em AGENT-OPS, precedente de restauração D8; usado pela própria entrega — header do agregado mudou). **Merge por rebase em 7 pontos** (`--auto --rebase` em vez de `--auto --merge`: execution-pipeline, agent-pool-prompt, plan-issue, agent-pr-workflow ×3, agent-pr-ready-automerge, engineering-audit; AGENT-OPS contrato item 3) — sem isso o safety-net re-arma `--merge` por cima; `allow_rebase_merge: true` verificado no repo. **`serializes` nomeia o registro** (ex. `serializes: 'docs/plans/'`). Docs: AGENT-OPS (Contrato item 4 + seção "Registries compartilhados"), AGENTS.md bullet, PR template (2 checkboxes), header do agregado, execution-pipeline (passo de registro). Sem migration/access/Consent/UI/schema.
|
||||
78
docs/plans/redesenhar-fluxo-reduzir-conflitos-impl.md
Normal file
78
docs/plans/redesenhar-fluxo-reduzir-conflitos-impl.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Impl: OPS44 — Reduzir conflitos de merge em arquivos compartilhados
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-13
|
||||
Issue: #713
|
||||
Intenção: docs/plans/redesenhar-fluxo-reduzir-conflitos.md
|
||||
Appetite restante: herdado (~1–2 dias eng; processo + guards, sem UI)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** cada entrega grava sua entrada de changelog em arquivo próprio (imune a conflito) e o agregado legível "Recently resolved" continua gerado por script; CI barra remoção de linhas existentes do agregado e barra marcadores de conflito em diffs de `docs/`; merges das PRs passam a ser por rebase; Issues que tocam registries compartilhados declaram `serializes`.
|
||||
- **O que NÃO negociar:** nada de migrar entradas históricas; `docs/plans/*` intocado; sem mapa de contenção commitado; sem UI; sem virar gerenciador de merge; a legibilidade do histórico "Recently resolved" permanece.
|
||||
- **O que reavaliar:** a hipótese de "guard de append-only" como parser de diff — parser de diff é ruidoso com linhas movidas; semântica honesta é inclusão de multiset sobre os blobs (abaixo). A hipótese de tocar só `ci-pr.yml` — o `agent-pr-ready-automerge.yml` re-arma `--merge` em PRs `cursor/*` e sobrescreveria o `--rebase`; precisa mudar junto. O gap que motiva o guard de marcadores: PR docs-only tem `code_mode: none` → a suíte unit (onde mora o guard OPS41) **não roda** — o guard precisa ser job dedicado de CI.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E[Entrega] --> 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/<data>-<id>.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: <motivo>` (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 <merge-base>:<path>` / `HEAD:<path>`, 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:<path>` → 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/<data>-<id>.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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
74
scripts/build-changelog.mjs
Normal file
74
scripts/build-changelog.mjs
Normal file
|
|
@ -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
|
||||
* `<date>-<id>.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)`)
|
||||
112
scripts/check-changelog-append-only.mjs
Normal file
112
scripts/check-changelog-append-only.mjs
Normal file
|
|
@ -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')
|
||||
68
scripts/check-docs-conflict-markers.mjs
Normal file
68
scripts/check-docs-conflict-markers.mjs
Normal file
|
|
@ -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`)
|
||||
|
|
@ -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/<id>-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 <PR>` e acompanhe `gh pr checks <PR> --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/<data>-<id>.md\` + \`pnpm changelog:build\`) antes do push (OPS44).`,
|
||||
'6. `gh pr merge --auto --rebase <PR>` e acompanhe `gh pr checks <PR> --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',
|
||||
'',
|
||||
|
|
|
|||
218
scripts/lib/changelog.mjs
Normal file
218
scripts/lib/changelog.mjs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
/**
|
||||
* changelog — pure policy for the per-delivery changelog format (OPS44).
|
||||
*
|
||||
* Deliveries write `docs/changelog/<date>-<id>.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: <motivo>` — 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: <motivo>` 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 <YYYY-MM-DD>-<id>.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: <motivo>` 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: <motivo>` 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)]
|
||||
}
|
||||
44
scripts/lib/conflictMarkers.mjs
Normal file
44
scripts/lib/conflictMarkers.mjs
Normal file
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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 <PR> --watch')
|
||||
})
|
||||
|
||||
|
|
|
|||
289
tests/unit/changelog.unit.spec.ts
Normal file
289
tests/unit/changelog.unit.spec.ts
Normal file
|
|
@ -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 <YYYY-MM-DD>-<id>.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: <motivo>` 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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[] = []
|
||||
|
|
|
|||
67
tests/unit/conflictMarkers.unit.spec.ts
Normal file
67
tests/unit/conflictMarkers.unit.spec.ts
Normal file
|
|
@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue