From 2e0cc2c03ec1465f20e0a99f15f0e7cb8e943135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 13:43:29 -0300 Subject: [PATCH 1/6] chore(ci): parallel PR jobs, affected tests, push gate, owner-of-red-CI policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci-pr.yml: split the serial checks job into parallel lint/format/ typecheck/knip/cycles + unit/int (vitest --changed vs PR base via scripts/test-affected.mjs, full fallback on high-risk paths) + e2e (manifest path->spec via scripts/e2e-affected.mjs, blocking) + build; migration-lock gains checkout + pull-requests:read (fixes the failures that blocked PR #50) - ci.yml (main): full safety net — seed:minimal + int full + build + e2e - package.json: gate:fast / gate:push; .husky/pre-push runs gate:push - docs: "Dono do PR, dono do CI" policy in AGENT-OPS + work-issue + engineering-standards; changelog entry; plans for OPS3-OPS7 --- .cursor/rules/engineering-standards.mdc | 2 +- .cursor/skills/work-issue/SKILL.md | 4 +- .github/workflows/ci-pr.yml | 262 +++++++++++++++++--- .github/workflows/ci.yml | 127 +++++++--- .husky/pre-push | 4 +- docs/AGENT-OPS.md | 20 +- docs/CHANGELOG-AGENTS.md | 2 + docs/TESTING.md | 2 +- docs/plans/ci-paralela-pr.md | 90 +++++++ docs/plans/desbloquear-pr50-ci.md | 77 ++++++ docs/plans/gate-push-local.md | 73 ++++++ docs/plans/politica-ci-dono-do-pr.md | 75 ++++++ docs/plans/testes-afetados-pr.md | 94 +++++++ package.json | 2 + scripts/e2e-affected.mjs | 53 ++++ scripts/lib/e2e-affected-manifest.mjs | 85 +++++++ scripts/lib/test-affected-core.mjs | 115 +++++++++ scripts/test-affected.mjs | 39 +++ tests/unit/e2eAffectedManifest.unit.spec.ts | 45 ++++ tests/unit/testAffected.unit.spec.ts | 87 +++++++ 20 files changed, 1177 insertions(+), 81 deletions(-) create mode 100644 docs/plans/ci-paralela-pr.md create mode 100644 docs/plans/desbloquear-pr50-ci.md create mode 100644 docs/plans/gate-push-local.md create mode 100644 docs/plans/politica-ci-dono-do-pr.md create mode 100644 docs/plans/testes-afetados-pr.md create mode 100644 scripts/e2e-affected.mjs create mode 100644 scripts/lib/e2e-affected-manifest.mjs create mode 100644 scripts/lib/test-affected-core.mjs create mode 100644 scripts/test-affected.mjs create mode 100644 tests/unit/e2eAffectedManifest.unit.spec.ts create mode 100644 tests/unit/testAffected.unit.spec.ts diff --git a/.cursor/rules/engineering-standards.mdc b/.cursor/rules/engineering-standards.mdc index b4cc365f..677ed1be 100644 --- a/.cursor/rules/engineering-standards.mdc +++ b/.cursor/rules/engineering-standards.mdc @@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM 1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive. 2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment. -3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (antes de cada push): `pnpm lint`, `pnpm exec tsc --noEmit`, `pnpm test:unit` (+ `pnpm format:check` e `pnpm check:cycles` antes de commitar — o pre-push hook cobre os três primeiros). Se a entrega tocou schema: também `pnpm migrate && pnpm db:seed:minimal && pnpm test:int`. **Gate completo = CI do PR** (`ci-pr.yml`: os anteriores + knip + int + build, contra o DB mínimo). O handoff humano (promote `stage→main`) exige CI stage + ci-pr green — não o build local. +3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (antes de cada push): `pnpm gate:fast` (lint + typecheck + test:unit); o pre-push hook roda `pnpm gate:push` (fast + format:check + check:cycles). Fonte única: scripts `gate:*` em `package.json`; escape para WIP: `git push --no-verify`. Se a entrega tocou schema: também `pnpm migrate && pnpm db:seed:minimal && pnpm test:int`. **Gate completo = CI do PR** (`ci-pr.yml`: jobs paralelos — estático + unit/int afetados pelo diff + e2e por manifesto + build, contra o DB mínimo). O handoff humano (promote `stage→main`) exige CI stage + ci-pr green — não o build local. **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`). 4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w ` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3). ## Type honesty diff --git a/.cursor/skills/work-issue/SKILL.md b/.cursor/skills/work-issue/SKILL.md index 43647710..1ff41e8c 100644 --- a/.cursor/skills/work-issue/SKILL.md +++ b/.cursor/skills/work-issue/SKILL.md @@ -59,7 +59,7 @@ Ordem fixa, fases pequenas e verificáveis, respeitando o appetite do plano: 1. **Schema e server** — migrations (`pnpm migrate:create`, seguir `payload-migrations`), collections, utilities, server actions, testes de domínio. Guardrails do repo: Local API com `user` → `overrideAccess: false`; escrita multi-collection → transação com `req: { transactionID }`; pessoa → join com `Contact`; opt-in/PII → `Consent` por chave estável falhando fechado. 2. **UI via /impeccable** (classes B/C/D — a classe está no plano; se A, declare "Impeccable: N/A" e siga só engenharia): shape conforme a classe → craft → critique → (harden/optimize **só sob gatilho**) → polish. Paleta = tokens `data-theme='campaign'`; reusar `src/components/ui` e shells existentes; shape obrigatório em C **para** para confirmação do brief antes do craft. -3. **Gates de engenharia** — fast gate local: `pnpm lint`, `pnpm exec tsc --noEmit`, `pnpm test:unit`, `pnpm format:check`, `pnpm check:cycles` (+ `pnpm migrate && pnpm db:seed:minimal && pnpm test:int` se tocou schema). Scan Aikido dos arquivos editados. Comandos bare, nunca piped. +3. **Gates de engenharia** — fast gate local: `pnpm gate:fast` (lint + typecheck + test:unit) e, antes do push, `pnpm gate:push` (fast + format:check + check:cycles — o pre-push hook roda exatamente isso) (+ `pnpm migrate && pnpm db:seed:minimal && pnpm test:int` se tocou schema). Scan Aikido dos arquivos editados. Comandos bare, nunca piped. Tracer bullet: se a Issue for grande, a primeira fatia vertical real (schema mínimo → uma action → uma superfície UI) vem cedo. @@ -73,7 +73,7 @@ Rode `/simplify` sobre o diff da sessão. Follow-ups maiores que o cleanup → ` 2. Branch `agent/-` (worktrees do Cursor são donos da criação; commits lógicos). 3. `gh pr create --base stage` com `Closes #` no body. 4. `gh pr merge --auto --merge `. -5. **Acompanhe os checks até o merge** (`gh pr checks --watch`): falha no ci-pr → corrige na mesma branch e reempurra (o auto-merge dispara de novo quando verde). +5. **Acompanhe os checks até o merge** (`gh pr checks --watch`): falha no ci-pr → corrige na mesma branch e reempurra (o auto-merge dispara de novo quando verde). **Qualquer falha é tua** — infra do workflow, teste pré-existente ou regressão da feature; "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`; exceção de blast radius: migration/access/Consent → para e escala). 6. O flip `in-progress → done` é **determinístico no CI** (workflow `issue-done-on-stage-merge.yml` lê o `Closes #N` no merge em stage). O agente espera o merge apenas para verificar e **consolidar pontas soltas**: débitos não registrados, doc da sessão pendente (notebook do projeto + `docs/plans/.md` com Status/Atualizado em), comentário de fechamento na Issue com o que entrou. Se o agente falhar depois do merge, o status já está correto. ## Resumo final ao usuário diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 239b750c..9a00b6b4 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -1,8 +1,14 @@ # PR gate: feature → stage (and stage → main promote PRs). -# Full gate against the MINIMAL synthetic database: service Postgres → migrate → -# db:seed:minimal → test:int → build. Build runs here (local service DB only) and -# NEVER in ci-stage.yml — `pnpm build` applies migrations to whatever DATABASE_URL -# it sees, so it is forbidden anywhere near stage/prod URLs. +# Parallel jobs with `needs` only where a real dependency exists (Postgres for +# int/build/e2e). Build runs here (local service DB only) and NEVER in +# ci-stage.yml — `pnpm build` applies migrations to whatever DATABASE_URL it +# sees, so it is forbidden anywhere near stage/prod URLs. +# +# Test selection (OPS5): unit/int run `vitest --changed origin/$GITHUB_BASE_REF` +# and e2e runs the manifest-selected specs; a diff touching a high-risk path +# (schema, lockfile, test harness, seed manifest — see +# scripts/lib/test-affected-core.mjs) falls back to the FULL suites. Push to +# stage/main always runs everything (ci-stage.yml / ci.yml). name: CI (PR) on: @@ -13,10 +19,115 @@ concurrency: group: ci-pr-${{ github.event.pull_request.number }} cancel-in-progress: true +env: + DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test + PAYLOAD_SECRET: test-only-secret-not-used-in-production + NEXT_PUBLIC_SITE_URL: http://localhost:3000 + jobs: - checks: + lint: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + + format: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm format:check + + typecheck: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + + knip: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec knip + + cycles: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check:cycles + + unit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Compute affected test scope + id: scope + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: | + result=$(node scripts/test-affected.mjs) + echo "scope: $(echo "$result" | jq -r .reason)" + echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT" + + - name: Unit tests (full suite) + if: steps.scope.outputs.mode == 'full' + run: pnpm test:unit + + - name: Unit tests (changed since PR base) + if: steps.scope.outputs.mode == 'changed' + run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests + + - name: Unit tests skipped + if: steps.scope.outputs.mode == 'none' + run: echo "No src/ or tests/ changes in this PR — nothing to run." + + int: + runs-on: ubuntu-latest + timeout-minutes: 25 services: postgres: # Matches production Neon major (17) and docker-compose.yml. @@ -32,55 +143,136 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 12 - env: - # Tests load .env.test themselves (database teqo_test on localhost:5432). - # These are only for the migrate/seed/build steps, which read the process env. - DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test - PAYLOAD_SECRET: test-only-secret-not-used-in-production - NEXT_PUBLIC_SITE_URL: http://localhost:3000 steps: - uses: actions/checkout@v4 - + with: + fetch-depth: 0 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm + - run: pnpm install --frozen-lockfile - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint (zero warnings) - run: pnpm lint - - - name: Format check (prettier) - run: pnpm format:check - - - name: Typecheck - run: pnpm typecheck - - - name: Dead code (knip) - run: pnpm exec knip - - - name: Circular dependencies (madge) - run: pnpm check:cycles - - - name: Unit tests - run: pnpm test:unit + - name: Compute affected test scope + id: scope + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: | + result=$(node scripts/test-affected.mjs) + echo "scope: $(echo "$result" | jq -r .reason)" + echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT" - name: Migrate test database + if: steps.scope.outputs.mode != 'none' run: pnpm migrate - name: Seed minimal test database + if: steps.scope.outputs.mode != 'none' run: pnpm db:seed:minimal - - name: Integration tests + - name: Integration tests (full suite) + if: steps.scope.outputs.mode == 'full' run: pnpm test:int + - name: Integration tests (changed since PR base) + if: steps.scope.outputs.mode == 'changed' + run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests + + - name: Integration tests skipped + if: steps.scope.outputs.mode == 'none' + run: echo "No src/ or tests/ changes in this PR — nothing to run." + + build: + runs-on: ubuntu-latest + timeout-minutes: 25 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: teqo + POSTGRES_PASSWORD: teqo + POSTGRES_DB: teqo_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U teqo" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build (against the local service database) run: pnpm build + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: teqo + POSTGRES_PASSWORD: teqo + POSTGRES_DB: teqo_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U teqo" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Install Playwright chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Compute affected e2e scope + id: scope + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: | + result=$(node scripts/e2e-affected.mjs) + echo "scope: $(echo "$result" | jq -r .reason)" + echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT" + echo "specs=$(echo "$result" | jq -r '[.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT" + + - name: Migrate test database + if: steps.scope.outputs.mode != 'none' + run: pnpm migrate + + - name: Seed minimal test database + if: steps.scope.outputs.mode != 'none' + run: pnpm db:seed:minimal + + - name: E2E tests (full suite) + if: steps.scope.outputs.mode == 'full' + run: pnpm test:e2e + + - name: E2E tests (manifest-selected specs) + if: steps.scope.outputs.mode == 'selected' + run: pnpm test:e2e -- ${{ steps.scope.outputs.specs }} + + - name: E2E tests skipped + if: steps.scope.outputs.mode == 'none' + run: echo "No e2e-relevant changes in this PR — nothing to run." + migration-lock: # At most ONE open PR may touch schema (migrations or generated types). # Two in flight guarantees a broken rebase and a poisoned drizzle snapshot diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f99cc0f7..9a041c6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,10 @@ # Continuous integration on GitHub Actions (GitHub is the only home). -# Post-merge gate on `main` (promote from stage already passed ci-pr on the PR). -# PRs run in ci-pr.yml (with build + migration-lock); merges to `stage` run in -# ci-stage.yml (stage Neon snapshot, never a build). +# Post-merge gate on `main` — the FULL safety net, always: the promote PR +# already passed ci-pr (possibly with affected-test selection), so this run +# re-verifies everything against the minimal synthetic database, including +# build and full e2e. +# PRs run in ci-pr.yml; merges to `stage` run in ci-stage.yml (stage Neon +# snapshot smoke — never a build there). name: CI on: @@ -12,10 +15,35 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +env: + # Tests load .env.test themselves (database teqo_test on localhost:5432). + # These are only for the migrate/seed/build steps, which read the process env. + DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test + PAYLOAD_SECRET: test-only-secret-not-used-in-production + NEXT_PUBLIC_SITE_URL: http://localhost:3000 + jobs: - checks: + static: runs-on: ubuntu-latest timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm format:check + - run: pnpm typecheck + - run: pnpm exec knip + - run: pnpm check:cycles + - run: pnpm test:unit + + int: + runs-on: ubuntu-latest + timeout-minutes: 25 services: postgres: # Matches production Neon major (17) and docker-compose.yml. @@ -31,45 +59,78 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 12 - env: - # Tests load .env.test themselves (database teqo_test on localhost:5432). - # These are only for the migrate step, which reads the process env. - DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test - PAYLOAD_SECRET: test-only-secret-not-used-in-production - NEXT_PUBLIC_SITE_URL: http://localhost:3000 steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm migrate + - run: pnpm db:seed:minimal + - run: pnpm test:int - - name: Install dependencies - run: pnpm install --frozen-lockfile + build: + runs-on: ubuntu-latest + timeout-minutes: 25 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: teqo + POSTGRES_PASSWORD: teqo + POSTGRES_DB: teqo_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U teqo" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile - - name: Lint (zero warnings) - run: pnpm lint + - name: Build (against the local service database) + run: pnpm build - - name: Format check (prettier) - run: pnpm format:check + e2e: + runs-on: ubuntu-latest + timeout-minutes: 40 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: teqo + POSTGRES_PASSWORD: teqo + POSTGRES_DB: teqo_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U teqo" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile - - name: Typecheck - run: pnpm typecheck + - name: Install Playwright chromium + run: pnpm exec playwright install --with-deps chromium - - name: Dead code (knip) - run: pnpm exec knip + - run: pnpm migrate + - run: pnpm db:seed:minimal - - name: Circular dependencies (madge) - run: pnpm check:cycles - - - name: Unit tests - run: pnpm test:unit - - - name: Migrate test database - run: pnpm migrate - - - name: Integration tests - run: pnpm test:int + - name: E2E tests (full suite) + run: pnpm test:e2e diff --git a/.husky/pre-push b/.husky/pre-push index 0e825e66..3461751c 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1,3 @@ -pnpm lint && pnpm format:check && pnpm typecheck +# Fast gate before push (source of truth: package.json `gate:push`). +# Full gate = CI do PR (docs/AGENT-OPS.md). Escape hatch para WIP: `git push --no-verify`. +pnpm gate:push diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 4adab044..455547aa 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -21,7 +21,12 @@ stage → CI stage green → (humano) pnpm agent:promote --i-am-human → main **Skills do fluxo (desde 2026-07-30):** `plan-issue` (ideia → plano completo + gate de confirmação + Issue via `agent:register --model`) → `work-issue` (claim → verificação de modelo → freshness audit → execução → /simplify → PR `--base stage` → acompanha CI até o merge) → `project-status` (overview/fila/mermaid/bloqueios/consolidação, read-only). `docs/roadmap.md` = **legado congelado** (stub); a fonte canônica são as GitHub Issues. -- **Agente faz sozinho:** `pnpm agent:claim` (pega a próxima Issue `ready` desbloqueada por `prio:P0..P3`, lock otimista ready→in-progress) → implementa → fast gate → `gh pr create --base stage` com `Closes #N` → acompanha `gh pr checks --watch` até o merge (falha → corrige na mesma branch). O flip `in-progress→done` é determinístico no CI (workflow `issue-done-on-stage-merge.yml`). +- **Agente faz sozinho:** `pnpm agent:claim` (pega a próxima Issue `ready` desbloqueada por `prio:P0..P3`, lock otimista ready→in-progress) → implementa → `pnpm gate:fast` → `gh pr create --base stage` com `Closes #N` → acompanha `gh pr checks --watch` até o merge (falha → corrige na mesma branch). O flip `in-progress→done` é determinístico no CI (workflow `issue-done-on-stage-merge.yml`). + +### Dono do PR, dono do CI (desde 2026-07-30 — incidente PR #50) + +**Done = PR mergeado em `stage` com CI verde.** Falha de CI no teu PR — infra do workflow, teste pré-existente ou regressão da feature — é tua até o merge. "Fora do escopo da feature" não é motivo para parar: corrige na mesma branch. Triagem das três classes: (a) **infra do workflow** (job mal configurado, step faltando) → fix no workflow no mesmo PR; (b) **teste pré-existente quebrado ou frágil** → repair no mesmo PR, no padrão dos specs irmãos; (c) **regressão da feature** → fix normal. Exceção de blast radius: se o fix exigir migration, access control ou Consent, para e escala com Opções — esses nunca se corrigem "de passagem". Flaky não se resolve com retry: repair, ou quarentena justificada no PR + débito registrado (`capture-review-debts`). + - **Só humano:** `pnpm agent:promote --i-am-human` (PR `stage→main` + merge com CI stage + ci-pr green; recusa se `main` divergiu de `stage` — merge main em stage antes), `pnpm db:refresh:stage` (refresh semanal do snapshot), editar envs Vercel/Neon, rodar `pnpm build` contra qualquer banco remoto. - Se `main` divergir de `stage` (hotfix): merge/rebase de `main` em `stage` + CI stage green de novo, só depois promote. - **Engineering-audit é solitário:** quando roda, o paralelismo pausa (nenhum outro agente no repo) e o agente do audit executa as remediações P0/P1 na mesma sessão; inclui o harvest `kind:agent-miss` → guardrails. @@ -49,14 +54,13 @@ Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:fe ## CI por alvo -| Workflow | Trigger | Banco | Passos | -| -------------- | ---------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ci-pr.yml` | PR → `stage`/`main` | service Postgres 17 (mínimo) | lint, format, typecheck, knip, cycles, unit → migrate → **seed:minimal** → int → **build** + `migration-lock` | -| `ci-stage.yml` | push em `stage` | `STAGE_DATABASE_URL` (Environment `stage`) | **só** `migrate` + smoke int (subset curado — a suíte completa é lenta demais contra Neon remoto e fica no ci-pr) — **NUNCA `pnpm build`** (build migra o banco que vê), nunca seed:minimal | -| `ci.yml` | push em `main` | service Postgres | gate pós-promote | -| E2E | fora do caminho crítico (nightly/label futura) | — | não bloqueia autonomia | +| Workflow | Trigger | Banco | Passos | +| -------------- | ------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ci-pr.yml` | PR → `stage`/`main` | service Postgres 17 (mínimo) | jobs paralelos: `lint`/`format`/`typecheck`/`knip`/`cycles` · `unit`/`int` (**afetados pelo diff**: `vitest --changed` vs base do PR; fallback full em paths de risco — `scripts/test-affected.mjs`) · `e2e` (manifesto path→spec — `scripts/e2e-affected.mjs`, bloqueante) · `build` + `migration-lock` | +| `ci-stage.yml` | push em `stage` | `STAGE_DATABASE_URL` (Environment `stage`) | **só** `migrate` + smoke int (subset curado — a suíte completa é lenta demais contra Neon remoto, estourou timeout 2× em 2026-07-30; a full fica no ci-pr/ci.yml) — **NUNCA `pnpm build`** (build migra o banco que vê), nunca seed:minimal | +| `ci.yml` | push em `main` | service Postgres | **gate completo sempre** (rede de segurança pós-promote): estático + unit + int full (com seed:minimal) + build + e2e full | -Fast gate local do agente antes do push: `pnpm lint && pnpm exec tsc --noEmit && pnpm test:unit` (+ `pnpm format:check` e `pnpm check:cycles` antes de commitar; int/build = CI). +Fast gate local do agente antes do push: `pnpm gate:fast` (lint + typecheck + test:unit); o hook `.husky/pre-push` roda `pnpm gate:push` (fast + format:check + check:cycles). Fonte única: scripts `gate:*` em `package.json`. knip fica fora do hook (grafo incompleto — ledger P3; CI o roda bloqueante). Escape para WIP: `git push --no-verify` (o CI repete tudo). int/build/e2e = CI. ## Stage DB — runbook de refresh (semanal, humano) diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index ec05a818..6f98399e 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -13,6 +13,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. 3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas. 4. **Campaign LGPD consent texts are not yet configured in production.** Invites and self-service leadership flows fail closed until an admin creates `Consent.key = 'lideranca-autopreenchimento'` with counsel-approved copy. The C2 supporter registry (merged to `main`, production-blocked) additionally requires `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'` (vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (see deploy checklist). +**Recently resolved (2026-07-30):** **CI/dev workflow + desbloqueio PR #50** — o PR #50 (B53) ficou BLOCKED por duas causas de infra empilhadas no job `migration-lock` (sem `actions/checkout` → `gh` sem `.git`; depois `GraphQL: Resource not accessible by integration` → `GITHUB_TOKEN` sem `pull-requests: read`) e por um spec int frágil ao `db:seed:minimal` (`homeSearchLeaderships` assertava conjunto global com nome fixo `'Lider'`, que casa word-start com `Seed Liderança Um` do seed). O incidente gerou: (a) política **"Dono do PR, dono do CI"** (`docs/AGENT-OPS.md`) — done = PR mergeado em stage com CI verde, falha fora da feature também é do dono; (b) `ci-pr.yml` reescrito em **jobs paralelos**; (c) **testes afetados pelo diff** — `scripts/test-affected.mjs` (`vitest --changed` vs base do PR) e `scripts/e2e-affected.mjs` + manifesto `scripts/lib/e2e-affected-manifest.mjs`, fallback full em paths de risco (núcleo puro em `scripts/lib/test-affected-core.mjs`, pins unit); e2e entra bloqueante no PR; (d) `ci.yml` (main) virou rede de segurança completa (seed:minimal + build + e2e full); stage segue smoke; (e) fast gate DRY: scripts `gate:fast`/`gate:push`, `.husky/pre-push` roda `gate:push` (knip fora, escape `--no-verify`). + **Recently resolved (2026-07-18):** the `/campanha` nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration `20260718_010733_consolidate_campaign_schema`. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration `20260718_190559_territorio_multi_municipio_bairro`), TSE 2022 election baseline collections + `pnpm db:seed:tse` (migration `20260718_195854_add_election_results`), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable `/campanha` PWA. Still on 2026-07-18, **A2** shipped: static Bahia municipality↔TSE zone map (`src/lib/bahiaTseZones.ts` from TSE 2024 `detalhe_votacao_munzona` BA) plus opt-in territory/zone suggestion chips in the nucleus form (`territorySuggestions`, `NucleusTerritoryAndZonesFields`) — no migration, no forced equality on save. Also the same day: **C2** Cadastro nominal de apoiadores (`supporter` join `Contact`↔campanha with optional `nucleus`, migration `20260718_222656_add_supporter` with `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent keys `apoiador-cadastro` / `apoiador-intencao-voto` resolved via a generalized `campaignConsent.ts` that fails closed, `/campanha/apoiadores` vertical with list+KPIs/detail/CSV-import wizard; phone required in v1, `lideranca` has no access to the area; production use with real data still waits on Consent keys + legal sign-off). Also the same day: C3 Planos de Ação (`actionPlan` + `/campanha/planos`, migration `20260718_222832_add_action_plan`, blocos "Próximos eventos") — **renamed to `activity` / `/campanha/atividades` by C13 on 2026-07-25**; the old names survive only in frozen migration history. Also the same day: **B2** map foundation — static TopoJSON for Bahia municipalities + identity territories, `bahiaMunicipalityCodes`, helpers in `bahiaGeometries`, re-runnable `pnpm build:geometries` (no migration, no UI; Leaflet is B3). Post-`/simplify` scale follow-ups for that delivery are registered as **B5** (`docs/plans/escala-dry-pos-b2.md`). Earlier (2026-07-15): local Postgres via `docker-compose.yml` (Postgres 17, `teqo_test`); Payload migrations baselined; dev/test database guards; `post`/`tag` news routes deployed (see "Posts & Tags" below). **Recently resolved (2026-07-18 evening / 2026-07-19):** **A4** Baseline no produto + Gap vs 2022 shipped and merged to `main` — `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` aggregate TSE 2022 by nucleus geography; `computeGapVs2022` + `NucleusInsights` on the overview tab; `NucleusElectoralBaseline` card (candidate / president / governor via `BASELINE_TICKET_2022`); "Baseline 2022" card on the filtered nuclei-list overview. No migration (reads existing A3 collections). A `/simplify` pass applied targeted cleanup (role-generic ticket config, per-city `zoneNumber in […]`, shared geography helpers). Scale/DRY debits larger than cleanup (aggregate federal ranking on detail instead of loading all nominal rows, filter by TSE `cityCode`, Alert `confirmed` + `Progress` reuse) are registered as **A7** (`docs/plans/escala-dry-pos-a4.md`). diff --git a/docs/TESTING.md b/docs/TESTING.md index 24f80a1d..b72a2e3d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -8,7 +8,7 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse - **Integration** (`tests/int/**/*.int.spec.ts`, 48 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned. - **E2E** (`tests/e2e`, Playwright, 7 specs): smoke-level; most campaign behavior is pinned at the int layer. -CI (`.github/workflows/ci-pr.yml`) runs lint (zero warnings), typecheck, knip, unit, migrate, **`db:seed:minimal`**, int and build against a Postgres 17 service. +CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings), format, typecheck, knip, cycles, unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths), e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking) and build — against a Postgres 17 service with **`db:seed:minimal`**. Push to `main` (`ci.yml`) always runs the full net including e2e. ## Minimal test database (`pnpm db:seed:minimal`) diff --git a/docs/plans/ci-paralela-pr.md b/docs/plans/ci-paralela-pr.md new file mode 100644 index 00000000..73772d88 --- /dev/null +++ b/docs/plans/ci-paralela-pr.md @@ -0,0 +1,90 @@ +# CI paralela no PR — quebrar o job único do ci-pr em jobs com needs reais + +Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano) +Atualizado em: 2026-07-30 +Issue: — +Priority: P1 +Model: composer-2.5 +Impeccable: A — N/A (sem superfície UI) +Appetite: ~1 dia eng; 1 workflow reescrito + matriz documentada em AGENT-OPS +Responsável: — + +## Dados → decisão → apresentação + +Dados: N/A — infra de CI. A métrica de sucesso é wall-clock do workflow (ver critérios de aceite), lida no próprio GitHub Actions. + +## Contexto + +`.github/workflows/ci-pr.yml` roda **tudo em série num único job `checks`**: lint → format → tsc → knip → madge → unit → migrate → seed → int → build (último run do PR #50: 9m37s até falhar no int — e a falha só aparece no minuto ~9, depois de todos os gates baratos passarem). Cada falha de lint custa um ciclo completo de feedback. `.github/workflows/ci.yml` (main) replica o mesmo job único sem `seed:minimal`, sem `build` e sem e2e; `ci-stage.yml` roda só `migrate` + smoke int contra Neon (subset curado — a suíte completa estourou 30 min duas vezes em 2026-07-30 por latência, comentário no próprio workflow). + +Decisão de produto (2026-07-30, brief do lote CI): jobs paralelizáveis com `needs` só onde há dependência real; push em `stage` e `main` roda suíte **completa** como rede de segurança. + +## Objetivos + +- `ci-pr.yml` vira N jobs paralelos; feedback de lint/format/typecheck em < ~3 min. +- Dependências reais explícitas: só `int` e `build` precisam do Postgres service; `build` pode `needs: [int]` (ou rodar contra o próprio service — decidir na implementação por tempo medido). +- Matriz **PR vs push stage vs push main** documentada em `docs/AGENT-OPS.md` (tabela "CI por alvo"). +- Invariantes preservados: `pnpm build` nunca perto de `STAGE_DATABASE_URL`; comandos bare; `migration-lock` intacto (com o fix de checkout do OPS3). + +## Decisões travadas + +- **Stage continua smoke (migrate + subset curado), não suíte completa.** A suíte int completa contra Neon remoto estourou o timeout duas vezes (registrado no comentário de `ci-stage.yml:22-30`); a rede de segurança completa fica no ci-pr (DB mínimo) e no ci.yml (main). **Rejeitado:** full int no stage (latência ~120 ms/query — inviável, já medido); dropar o smoke do stage (é a única verificação contra snapshot real de prod). +- **`ci.yml` (main) ganha `db:seed:minimal` + `build` + int completo, espelhando o ci-pr.** Hoje main não roda seed/build/e2e; como o promote PR já passou no ci-pr, o ci.yml é rede de segurança contra drift de merge — barato (service DB local). **Rejeitado:** manter main minimal (a tensão do brief fica sem resolução); build contra qualquer URL remota (proibido). +- **E2E entra no ci-pr como job afetado por manifesto (OPS5), bloqueante; stage não roda e2e; main roda full e2e.** Detalhes e fallback no plano OPS5. **Rejeitado:** e2e fora do caminho crítico para sempre (o "fora do caminho crítico" do AGENT-OPS era placeholder "nightly/label futura" — este lote é a futura); full e2e em todo PR (custo de boot do dev server por PR sem relação com o diff). +- **Uma estrutura de jobs compartilhada via YAML anchors/reusable steps, sem reusable-workflow separado.** **Rejeitado:** extrair `.github/workflows/_reusable.yml` (cerimônia para 2 consumidores; revisitado quando o 3º workflow precisar da mesma cadeia). + +## Questões em aberto + +- **`build` precisa de `needs: [int]`?** **Opções:** A) build paralelo ao int (cada um com seu service Postgres) | B) build só após int verde. **Recomendação:** A — build não depende do resultado do int; paralelo corta o caminho crítico. Se medir contenção de runner, cair para B é mudança de uma linha. +- **Cache de pnpm/Next entre jobs.** **Opções:** A) cada job instala (cache pnpm já existe) | B) artefato de `node_modules` compartilhado. **Recomendação:** A — upload/download de artefato costuma perder para o cache pnpm; medir no primeiro run. + +## Abordagem proposta + +```mermaid +flowchart LR + subgraph pr [ci-pr.yml] + lint[lint] --- fmt[format] --- tsc[typecheck] --- knip --- madge[cycles] --- unit + intjob["int (service PG: migrate+seed)"] --- buildjob["build (service PG)"] + mlock[migration-lock] + end + subgraph main [ci.yml] + full[gate completo + seed + build + e2e full] + end + subgraph stage [ci-stage.yml] + smoke[migrate + smoke int subset] + end +``` + +Componentes: + +- **`.github/workflows/ci-pr.yml`** — quebrar `checks` em jobs: `lint`, `format`, `typecheck`, `knip`, `cycles`, `unit` (sem service), `int` (com service Postgres 17 + migrate + seed:minimal), `build` (com service), `migration-lock` (inalterado salvo fix OPS3). Sem `needs` entre os baratos. +- **`.github/workflows/ci.yml`** — adicionar steps `db:seed:minimal` e `build`; preparar para receber e2e full (OPS5). +- **`docs/AGENT-OPS.md`** — atualizar tabela "CI por alvo" com a matriz final (incl. e2e por alvo) e o tempo-alvo de feedback. +- Sem migration, sem código de app. + +## Dependências + +- **OPS3** (migration-lock funcionando) — dura, senão o CI fica vermelho por infra no PR desta entrega. +- OPS5 (affected tests) depende desta — a seleção vira flags nos jobs `int`/`e2e` aqui criados. + +## Não escopo + +- Manifesto e2e / `--changed` do vitest → OPS5. +- Hooks locais (`gate:fast`/`gate:push`) → OPS6. Docs de política de agente → OPS7. +- Nightly e2e full agendado → Adiado (abaixo). + +## Rabbit holes + +- **Reusable workflows / composite actions.** Cerimônia YAML sem 3º consumidor. **Mitigação:** decisão travada acima; gatilho de revisitação registrado. +- **Shard da suíte int em N workers.** O gargalo medido é serialização, não CPU de um job; sharding adiciona setup de DB por shard. **Mitigação:** adiado com gatilho (int > 15 min no job único por 2 semanas). + +## Adiado com gatilho + +- **Nightly e2e full + shard de int.** Revisitar quando: job `int` > 15 min sustentado ou e2e full no ci.yml > 25 min. + +## Referências + +- `.github/workflows/ci-pr.yml` (job único atual), `ci.yml`, `ci-stage.yml` (comentário do subset) +- `docs/AGENT-OPS.md` — tabela "CI por alvo" e "Contrato de PR" +- AGENTS.md / `.cursor/rules/engineering-standards.mdc` — gate em duas velocidades, comandos bare +- Run 30558184687 — baseline de wall-clock (9m37s serial até a falha) diff --git a/docs/plans/desbloquear-pr50-ci.md b/docs/plans/desbloquear-pr50-ci.md new file mode 100644 index 00000000..50a043b9 --- /dev/null +++ b/docs/plans/desbloquear-pr50-ci.md @@ -0,0 +1,77 @@ +# Desbloquear PR #50 — fix migration-lock + spec homeSearchLeaderships seed-proof + +Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano) +Atualizado em: 2026-07-30 +Issue: — +Priority: P0 +Model: kimi-k2.7-code +Impeccable: A — N/A (sem superfície UI) +Appetite: ~0,5 dia eng; 1 workflow step + 1 repair de spec int + watch de CI +Responsável: — + +## Dados → decisão → apresentação + +Dados: N/A — repair de CI/teste, sem superfície de dados. + +## Contexto + +O PR [#50](https://github.com/fsolla/teqo/pull/50) (`agent/B53-busca-global-demandas`, Issue #7, B53) está `MERGEABLE` mas `BLOCKED` com dois checks vermelhos (run [30558184687](https://github.com/fsolla/teqo/actions/runs/30558184687)): + +1. **`migration-lock` FAIL** — duas causas de infra empilhadas no job em `.github/workflows/ci-pr.yml`: (a) `gh pr list --json number,files` **sem `actions/checkout@v4`** → `failed to run git: fatal: not a git repository`; (b) com o checkout, `GraphQL: Resource not accessible by integration (repository.pullRequests)` → o `GITHUB_TOKEN` padrão precisa de `permissions: pull-requests: read` explícita. Todo PR falhava esse job. +2. **`checks` FAIL** — `tests/int/homeSearchLeaderships.int.spec.ts:87`: `expected [ 1, 168, 169 ] to deeply equal [ 168, 169 ]`. O spec consulta `'Lider'` e asserta o conjunto global exato; em ci-pr, `pnpm db:seed:minimal` roda antes do int e semeia `Seed Liderança Um` (`scripts/lib/seed-minimal-manifest.mjs:103`) — word-start `Liderança` casa `Lider`. Em `ci.yml` (main) não há `seed:minimal`, então o defeito só aparece no ci-pr: **todo PR futuro contra `stage` está bloqueado**, não só o #50. O spec de Demandas do próprio PR (`tests/int/homeSearchDemands.int.spec.ts:76`) já usa o padrão correto: token único `fixtures.value('scope-demanda')`. + +Decisão de produto (2026-07-30, brief do lote CI): done = **PR #50 mergeado em `stage` com CI verde**, não "feature implementada". + +## Objetivos + +- `gh pr checks 50` todo verde e merge automático em `stage` efetivado. +- `migration-lock` volta a exercer a regra real (≤1 PR tocando schema) em vez de falhar por infra. +- `homeSearchLeaderships.int.spec.ts` fica seed-proof: passa com e sem `db:seed:minimal` no banco. +- Sem migration, sem schema, sem mudança de comportamento de produto. + +## Decisões travadas + +- **Fix do spec no padrão token-único, não filtro-por-ids nem exclusão do seed.** O padrão `fixtures.value(...)` já existe no spec irmão (demandas) e mantém a asserção de conjunto exato (que pega regressão de escopo). **Rejeitado:** filtrar o resultado aos ids criados pela fixture (enfraquece a asserção de escopo do coordinator); remover as lideranças do `seed-minimal-manifest` (o contrato do DB mínimo — pinado por `tests/unit/seedMinimalManifest.unit.spec.ts` — existe para int specs que dependem de seed; quebraria outros consumidores). +- **Fix do `migration-lock` = `actions/checkout@v4` + `permissions: { contents: read, pull-requests: read }` no job.** As duas causas foram confirmadas em runs consecutivos (log do run 30561456683: GraphQL permission error após o checkout). **Rejeitado:** token dedicado/PAT (GITHUB_TOKEN com grant explícito é o padrão e não adiciona secret). +- **Fix vai na branch do próprio PR #50** (`agent/B53-busca-global-demandas`), não em branch separada: a política "CI vermelho = seu problema" (OPS7) começa valendo aqui. **Rejeitado:** PR separado de infra primeiro (duplo round-trip de CI; o #50 já é o PR afetado e está `MERGEABLE`). + +## Questões em aberto + +- **O spec `homeSearchStateDeputies`/`homeSearchAdvisors` etc. têm o mesmo padrão frágil?** **Opções:** A) auditar e corrigir todos neste PR | B) corrigir só o que falha e registrar o resto como débito. **Recomendação:** A se o audit mostrar o mesmo padrão de nome fixo + assert global (grep por `toEqual([` em `homeSearch*.int.spec.ts`) — custo marginal dentro do appetite, evita o próximo bloqueio; senão B. + +## Abordagem proposta + +Componentes: + +- **`.github/workflows/ci-pr.yml`** — job `migration-lock`: inserir `- uses: actions/checkout@v4` como primeiro step. +- **`tests/int/homeSearchLeaderships.int.spec.ts`** — trocar nomes fixos (`Lider Cairu`, `Lider Feira`, `Zeca Pagodinho`, `Maria Silva`) por tokens únicos via `fixtures.value(...)` e usar o token como query (espelhando `homeSearchDemands.int.spec.ts:76-94`); manter asserts de conjunto exato. +- **Audit grep** — `rg "toEqual\(\[" tests/int/homeSearch*.int.spec.ts` para a questão em aberto acima. + +Sem migration, sem collection, sem server action. + +## Dependências + +Nenhuma de outro plano. Reusa `installCampaignFixtures` (`tests/helpers/campaignFixtures.ts`) e o padrão `fixtures.value` já existente. + +## Não escopo + +- Refatoração estrutural do ci-pr (jobs paralelos) → OPS4. +- Affected tests / manifesto e2e → OPS5. +- Pre-push hooks → OPS6. Política de agente em docs → OPS7 (texto novo; a **prática** vale neste PR). + +## Rabbit holes + +- **"Já que toco o workflow, paralelizo logo".** Explode OPS4 dentro do P0 e atrasa o desbloqueio. **Mitigação:** diff do workflow limitado ao step de checkout. +- **Investigar por que `ci-stage` smoke não pegou isso.** O smoke roda contra Neon com subset curado; a falha é do ambiente mínimo. **Mitigação:** registrar observação no plano OPS4, não investigar aqui. + +## Adiado com gatilho + +Nenhum neste item. + +## Referências + +- GitHub Issue #7 (B53), PR #50, run 30558184687 (logs das duas falhas) +- `.github/workflows/ci-pr.yml` — job `migration-lock` +- `tests/int/homeSearchLeaderships.int.spec.ts` + `tests/int/homeSearchDemands.int.spec.ts` (padrão-alvo) +- `scripts/lib/seed-minimal-manifest.mjs` — `MINIMAL_LEADERSHIPS` +- `.cursor/skills/work-issue/SKILL.md` — Passo 6 (acompanhar checks até o merge) diff --git a/docs/plans/gate-push-local.md b/docs/plans/gate-push-local.md new file mode 100644 index 00000000..e5fc4f18 --- /dev/null +++ b/docs/plans/gate-push-local.md @@ -0,0 +1,73 @@ +# Gate local DRY — gate:fast / gate:push + pre-push completo + +Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano) +Atualizado em: 2026-07-30 +Issue: — +Priority: P2 +Model: cursor-grok-4.5-high +Impeccable: A — N/A (sem superfície UI) +Appetite: ~0,5 dia eng; 2 scripts npm + 1 hook + docs +Responsável: — + +## Dados → decisão → apresentação + +Dados: N/A — chore de DX. + +## Contexto + +`.husky/pre-push` é uma linha: `pnpm lint && pnpm format:check && pnpm typecheck`. Mas o fast gate exigido por `.cursor/rules/engineering-standards.mdc` (desde 2026-07-30) e por `work-issue` Passo 4.3 é **lint + tsc + test:unit + format:check + check:cycles**. Resultado: agentes e humanos pusham verde no hook e quebram no CI por unit/cycles que nunca rodaram localmente. Não existe pre-commit hook. + +Decisão de produto (2026-07-30, brief do lote CI): scripts DRY (`gate:fast`, `gate:push`), divisão pre-commit vs pre-push explícita, escape hatch documentado. + +## Objetivos + +- `.husky/pre-push` roda o fast gate completo via script npm único (DRY com a documentação). +- `package.json`: `gate:fast` (lint + typecheck + test:unit) e `gate:push` (gate:fast + format:check + check:cycles). +- Escape hatch documentado (`git push --no-verify`), com aviso de que CI repete tudo. +- Docs (`engineering-standards.mdc`, `AGENT-OPS.md`) citam os scripts, não a lista de comandos — uma fonte só. + +## Decisões travadas + +- **knip fora do pre-push.** O grafo do knip é incompleto (não carrega `payload.config.ts`, ledger P3) e é lento para um hook; CI já o roda como erro bloqueante. **Rejeitado:** knip no hook (custo por push + falsos positivos já fichados); knip no `gate:fast` documentado como obrigatório (mesma razão). +- **Sem pre-commit hook.** A divisão fica: nada no commit (commits intermediários de WIP são legítimos), tudo no push. **Rejeitado:** lint-staged no commit (adiciona toolchain e atrito sem pegar classe de erro que o push não pegue segundos depois). +- **Escape hatch = `--no-verify` nativo, documentado; sem variável mágica própria.** **Rejeitado:** `SKIP_GATE=1` (mais uma convenção para manter; `--no-verify` é o padrão git que todo agente/humano já conhece). +- **Hook chama `pnpm gate:push`, nunca inline de comandos.** Uma fonte de verdade editável em `package.json`. **Rejeitado:** manter a linha de shell no hook (foi o que causou o drift atual). + +## Questões em aberto + +- **`pnpm format` auto-fix no hook em vez de `format:check`?** **Opções:** A) check falha e o autor roda `pnpm format` | B) hook formata e aborta para re-add. **Recomendação:** A — hook que edita a worktree surpreende; o erro do check já diz o remédio. + +## Abordagem proposta + +Componentes: + +- **`package.json`** — scripts `gate:fast` e `gate:push` (comandos bare encadeados com `&&`, nunca piped — invariante do repo). +- **`.husky/pre-push`** — `pnpm gate:push`. +- **`.cursor/rules/engineering-standards.mdc`** — item 3 do "After every change" passa a citar `pnpm gate:fast` / `pnpm gate:push`. +- **`docs/AGENT-OPS.md`** — linha do fast gate local atualizada para os scripts. +- **`.cursor/skills/work-issue/SKILL.md`** — Passo 4.3/6.1 cita `pnpm gate:push`. + +Sem migration, sem código de app. + +## Dependências + +Nenhuma de outro plano (independente; pode rodar em paralelo com OPS3/OPS4). Conflito potencial de merge com OPS7 em `AGENT-OPS.md`/`engineering-standards.mdc` — serializar na fila ou aceitar rebase trivial. + +## Não escopo + +- Mudar o que o CI roda → OPS4/OPS5. Política "CI vermelho = seu problema" → OPS7. +- Husky v9 deprecations / migração de tooling de hooks. + +## Rabbit holes + +- **"Já que toco hooks, adiciono lint-staged/commitlint".** Toolchain nova para problema que não temos. **Mitigação:** decisão travada; commitlint só se 3 mensagens fora-do-padrão quebrarem automação (nenhuma hoje). + +## Adiado com gatilho + +Nenhum neste item. + +## Referências + +- `.husky/pre-push` (linha atual), `package.json` (scripts de gate existentes) +- `.cursor/rules/engineering-standards.mdc` — "Gate em duas velocidades" +- `docs/AGENT-OPS.md` — fast gate local; `.cursor/skills/work-issue/SKILL.md` — Passo 4.3 diff --git a/docs/plans/politica-ci-dono-do-pr.md b/docs/plans/politica-ci-dono-do-pr.md new file mode 100644 index 00000000..300badcf --- /dev/null +++ b/docs/plans/politica-ci-dono-do-pr.md @@ -0,0 +1,75 @@ +# Política de agente — CI vermelho é do dono do PR até o merge + +Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano) +Atualizado em: 2026-07-30 +Issue: — +Priority: P2 +Model: cursor-grok-4.5-high +Impeccable: A — N/A (sem superfície UI) +Appetite: ~0,5 dia; docs em 3 arquivos +Responsável: — + +## Dados → decisão → apresentação + +Dados: N/A — política de fluxo em docs. + +## Contexto + +Incidente (2026-07-30, PR #50): o agente em `work-issue` desistiu ao ver (a) `migration-lock` falhando por defeito de infra (job sem `checkout`) e (b) um teste int falhando fora do escopo nominal da feature (`homeSearchLeaderships`, confundido com e2e). A feature estava pronta; o PR ficou `BLOCKED` e ninguém era dono. Lacuna: nem `work-issue` nem `engineering-standards` dizem quem conserta CI quebrado "fora da feature". + +Decisão de produto (2026-07-30, brief do lote CI): **done = PR mergeado em `stage` com CI verde** — corrigir qualquer falha de CI necessária ao merge, mesmo fora da feature. + +## Objetivos + +- `work-issue`, `engineering-standards.mdc` e `docs/AGENT-OPS.md` declaram a política com o mesmo texto-núcleo (uma fonte, duas citações). +- Regra de triagem da falha de CI documentada: infra do workflow / teste pré-existente / regressão da feature — as três são do dono do PR; o que muda é o fix. +- Registro do incidente #50 como precedente em `docs/CHANGELOG-AGENTS.md` (uma entrada curta, formato do arquivo). + +## Decisões travadas + +- **Dono do PR conserta, sem exceção por escopo.** Racional: com agentes paralelos, "não é minha feature" vira deadlock (o #50 ficou parado por isso). O autor já tem branch, contexto e CI loop aberto. **Rejeitado:** abrir Issue separada e seguir (o PR apodrece BLOCKED — exatamente o incidente); esperar humano (derrota a autonomia do paradigma AGENT-OPS). +- **Limite explícito: o fix colateral segue as mesmas regras de blast radius** — migration/access/Consent continuam proibidos de "consertar de passagem" sem plano; nesses casos o agente para e escala (Opções documentadas). **Rejeitado:** carta branca para qualquer fix (contradiz as invariantes de segurança do AGENTS.md). +- **Uma fonte de verdade (AGENT-OPS), citações nos outros dois** — evita o drift que o pre-push sofreu (OPS6). **Rejeitado:** repetir o parágrafo inteiro nos três arquivos. + +## Questões em aberto + +- **Teste flaky (não determinístico) conta como "sua responsabilidade"?** **Opções:** A) sim, repair ou quarentena com justificativa no PR | B) retry até passar. **Recomendação:** A — retry cega o CI para flakes reais; quarentena exige comentário + débito registrado via `capture-review-debts`. + +## Abordagem proposta + +Componentes (texto, não código): + +- **`docs/AGENT-OPS.md`** — novo bloco na seção "Fluxo": "Dono do PR, dono do CI" (definição de done, triagem das 3 classes de falha, limite de blast radius, flaky→quarentena+débito). +- **`.cursor/skills/work-issue/SKILL.md`** — Passo 6.5 reforçado: falha no ci-pr → corrige na mesma branch **qualquer que seja a origem**, citando AGENT-OPS; "fora do escopo" não é critério de parada. +- **`.cursor/rules/engineering-standards.mdc`** — linha no "After every change": CI vermelho no seu PR é seu, mesmo pré-existente. +- **`docs/CHANGELOG-AGENTS.md`** — entrada do incidente PR #50 → esta política. + +Texto-núcleo proposto (núcleo único citado nos três): + +> Done = PR mergeado em `stage` com CI verde. Falha de CI no teu PR — infra do workflow, teste pré-existente ou regressão da feature — é tua até o merge. "Fora do escopo da feature" não é motivo para parar: corrige na mesma branch. Exceção de blast radius: se o fix exigir migration, access control ou Consent, para e escala com Opções — esses nunca se corrigem "de passagem". Flaky não se resolve com retry: repair ou quarentena justificada + débito registrado. + +Sem migration, sem código de app. + +## Dependências + +Nenhuma dura. Sinérgica com OPS3 (que aplica a política na prática) e OPS6 (mesma família de arquivos — serializar para evitar conflito trivial). + +## Não escopo + +- Mudança de gates/hooks → OPS6. Refatoração de workflows → OPS4/OPS5. +- `agent:file-miss` do incidente: o incidente vira precedente documentado aqui; se o humano quiser também como `kind:agent-miss` para o harvest OPS2 (#43), registrar lá. + +## Rabbit holes + +- **Reescrever work-issue inteira "de passagem".** A skill está estável; o delta é uma política. **Mitigação:** diff limitado ao Passo 6 + citação. + +## Adiado com gatilho + +Nenhum neste item. + +## Referências + +- Incidente: PR #50, run 30558184687 (migration-lock sem checkout; int fora da feature) +- `docs/AGENT-OPS.md` — seção "Fluxo"; `.cursor/skills/work-issue/SKILL.md` — Passo 6 +- `.cursor/rules/engineering-standards.mdc` — "After every change" +- Issue #43 (OPS2) — harvest agent-miss → guardrails diff --git a/docs/plans/testes-afetados-pr.md b/docs/plans/testes-afetados-pr.md new file mode 100644 index 00000000..41a5e3af --- /dev/null +++ b/docs/plans/testes-afetados-pr.md @@ -0,0 +1,94 @@ +# Testes afetados pelo diff no PR — vitest --changed + manifesto path→spec e2e + +Status: entregue (2026-07-30 — executado em sessão única, fora do fluxo agent:register, a pedido do humano) +Atualizado em: 2026-07-30 +Issue: — +Priority: P1 +Model: composer-2.5 +Impeccable: A — N/A (sem superfície UI) +Appetite: ~1–2 dias eng; script de seleção + manifesto + wiring nos jobs do OPS4 + pins +Responsável: — + +## Dados → decisão → apresentação + +Dados: N/A — infra de CI. + +## Contexto + +Com o ci-pr paralelizado (OPS4), o próximo corte é rodar **só o que o diff toca** em PRs de feature branch: hoje um PR de copy roda 523 testes int + 522 unit + build. Vitest suporta `--changed` contra uma base git; Playwright `--only-changed` **não** cobre mudanças em `src/` (specs e2e não importam o app), então a seleção e2e precisa de um **manifesto path → spec** curado no repo. Push em `stage`/`main` sempre roda suíte completa (decisão OPS4). + +Decisão de produto (2026-07-30, brief do lote CI): fallback para suíte completa quando o diff tocar paths de alto risco — migrations, lockfile, configs de teste, seed manifest, collections/globals. + +## Objetivos + +- Job `unit`/`int` no ci-pr roda `vitest --changed origin/$GITHUB_BASE_REF` (fallback full nos paths de risco). +- Job `e2e` no ci-pr roda specs mapeadas pelo manifesto; fallback full idem. +- `scripts/e2e-affected.mjs` (ou equivalente) imprime a lista de specs e o motivo (decisão auditável no log do CI). +- Pins: unit test do manifesto (path→spec existe, spec existe no disco) + comportamento de fallback. + +## Decisões travadas + +- **Manifesto e2e = dados no repo, não inferência por import.** e2e não importa `src/`; a única fonte de verdade é uma tabela curada. **Rejeitado:** heurística por nome de spec (frágil — `campaignZoneMap` ≠ pasta `map/`); trace estático Playwright→app (não existe aresta de import). +- **Fallback full em paths de alto risco** (lista congelada no script): `src/migrations/`, `src/payload-types.ts`, `pnpm-lock.yaml`, `package.json`, `vitest*.config.*`, `playwright.config.ts`, `scripts/lib/seed-minimal-manifest.mjs`, `scripts/seed-minimal.mjs`, `src/collections/`, `src/globals/`, `src/payload.config.ts`, `tests/helpers/`, `tests/e2e/fixtures/`, `.env*`. **Rejeitado:** fallback por "mais de N arquivos" (tamanho ≠ risco); lista aberta por glob no workflow (regra fora de teste unitário). +- **Seleção só no ci-pr; `ci.yml`/`ci-stage.yml` sempre full.** Rede de segurança por alvo (decisão OPS4). **Rejeitado:** affected também no main (derrota o propósito do gate pós-merge). +- **`--changed` com fallback para full quando vitest retorna conjunto vazio mas o diff toca `src/`** (ex.: arquivo novo sem spec importando). **Rejeitado:** aceitar conjunto vazio como "nada a rodar" (falso verde em código novo sem teste ligado). + +## Questões em aberto + +- **e2e bloqueante no PR desde o dia 1?** **Decidido no gate (humano, 2026-07-30): bloqueante já.** A alternativa de 2 semanas em `continue-on-error` para calibrar o manifesto foi rejeitada pelo humano ("já resolve logo nessa sessão"). O manifesto fica visível nos logs (`unmapped`) para calibração contínua. + +## Abordagem proposta + +```mermaid +flowchart LR + diff["git diff origin/base...HEAD"] --> script["scripts/e2e-affected.mjs + scripts/test-affected.mjs"] + script -->|paths de risco| full[suites completas] + script -->|paths mapeados| sel["specs selecionadas + vitest --changed"] + sel --> jobs["jobs unit/int/e2e do ci-pr"] + full --> jobs +``` + +Componentes: + +- **`scripts/test-affected.mjs`** — computa diff vs `GITHUB_BASE_REF` (default `origin/stage`), aplica a lista de risco, emite `{ mode: 'full' | 'changed' }` + args vitest. Consumido pelos jobs `unit` e `int`. +- **`scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`** — tabela prefixo→specs. Esboço inicial (17 specs em `tests/e2e/`): + - `src/app/(campaign)/campanha/(app)/municipios`, `src/components/campaign/municipality`, `src/utilities/municipality/` → `campaignMunicipalities`, `campaignZoneMap`, `campaignNearestMunicipality`, `campaignSavedFilters`, `campaignColumnPicker` + - `…/atividades`, `components/campaign/activity` → `campaignActivity` + - `…/liderancas`, `components/campaign/leadership` → `campaignLeaderships` + - `…/territorios`, `components/campaign/territory` → `campaignTerritories` + - `campanha/login`, `utilities/campaignAuth`, `utilities/webauthn/` → `campaignAuth`, `campaignWebAuthn` + - `…/conceitos` → `campaignConcepts`; dashboard/home → `campaignHomeActions` + - `components/campaign/shell`, PWA → `campaign-pwa`, `campaignWizardChrome` + - `src/app/(payload)` → `admin`; `src/app/(frontend)` → `frontend` + - qualquer outro `src/` não mapeado → log explícito "sem spec mapeada" (modo e2e: nenhuma; o gap fica visível no log para o humano julgar — decisão: não falhar, e2e é camada fina sobre int). +- **`.github/workflows/ci-pr.yml`** — jobs `unit`/`int`/`e2e` (do OPS4) consomem os scripts; step de diff com `fetch-depth: 0` no checkout. +- **Tests** — `tests/unit/testAffected.unit.spec.ts` + `tests/unit/e2eAffectedManifest.unit.spec.ts` (manifesto aponta para specs existentes; paths de risco disparam full). + +Sem migration, sem código de app. + +## Dependências + +- **OPS4** (jobs paralelos existem para receber a seleção) — dura. +- Reusa: padrão `scripts/lib/` (B5 F2), `tests/helpers/assertTestDatabase.ts` não é tocado. + +## Não escopo + +- Seleção de unit por manifesto (vitest `--changed` resolve por import graph real). +- Cache de resultados de teste entre runs (Turborepo-style) — sem evidência de necessidade. +- Nightly full e2e → OPS4 (adiado lá). + +## Rabbit holes + +- **Manifesto vira segundo grafo de dependências.** Se alguém "só completar" granularidade por arquivo, vira manutenção morta. **Mitigação:** granularidade de prefixo de domínio; pin de unit test só na existência, não na completude. +- **`vitest --changed` com worktrees/forks sem base fetchada.** `fetch-depth: 0` no checkout; fallback full se o merge-base não resolver (log + exit code do script). + +## Adiado com gatilho + +- **Completude do manifesto enforced (falhar CI quando `src/` novo não mapeia).** Revisitar quando: 3º fallback full causado por prefixo ausente. + +## Referências + +- `tests/e2e/` (17 specs + fixtures), `playwright.config.ts`, `vitest.unit.config.mts`, `vitest.config.mts` +- `.github/workflows/ci-pr.yml` (jobs do OPS4) +- `docs/AGENT-OPS.md` — linha E2E da tabela "CI por alvo" (atualizar neste item) +- Vitest `--changed`: https://vitest.dev/guide/cli.html#changed diff --git a/package.json b/package.json index 7d53b7d7..2e9635ad 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev", "format": "prettier --write .", "format:check": "prettier --check .", + "gate:fast": "pnpm lint && pnpm typecheck && pnpm test:unit", + "gate:push": "pnpm gate:fast && pnpm format:check && pnpm check:cycles", "check:cycles": "madge --circular --extensions ts,tsx --ts-config tsconfig.json src", "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", diff --git a/scripts/e2e-affected.mjs b/scripts/e2e-affected.mjs new file mode 100644 index 00000000..89829f89 --- /dev/null +++ b/scripts/e2e-affected.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/** + * e2e-affected (OPS5) — decides the Playwright scope for a PR diff via the + * curated manifest and prints JSON on stdout: + * { mode, specs, reason, unmapped, base }. + * + * mode=full → run the whole e2e suite + * mode=selected → run tests/e2e/.e2e.spec.ts for each name in `specs` + * mode=none → skip e2e for this diff + * + * `unmapped` src/ files go to stderr as a warning — never a failure (e2e is a + * thin layer over int; a human judges whether the manifest needs the prefix). + */ +import { execFileSync } from 'node:child_process' + +import { E2E_AFFECTED_MANIFEST } from './lib/e2e-affected-manifest.mjs' +import { selectE2eSpecs } from './lib/test-affected-core.mjs' + +const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}` + +let mergeBase +try { + mergeBase = execFileSync('git', ['merge-base', 'HEAD', base], { encoding: 'utf8' }).trim() +} catch { + const result = { + mode: 'full', + specs: [], + reason: `no merge-base with ${base} (shallow clone?)`, + unmapped: [], + base, + } + console.log(JSON.stringify(result)) + process.exit(0) +} + +const raw = execFileSync('git', ['diff', '--name-status', `${mergeBase}...HEAD`], { + encoding: 'utf8', +}) +const files = raw + .split('\n') + .filter(Boolean) + .map((line) => { + const [status, path] = line.split('\t') + return { status, path } + }) + +const result = { ...selectE2eSpecs(files, E2E_AFFECTED_MANIFEST), base } +if (result.unmapped.length > 0) { + console.error( + `[e2e-affected] src/ paths with no manifest mapping:\n ${result.unmapped.join('\n ')}`, + ) +} +console.log(JSON.stringify(result)) diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs new file mode 100644 index 00000000..459b3ffe --- /dev/null +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -0,0 +1,85 @@ +/** + * e2e manifest (OPS5): source path prefix → Playwright specs exercising it. + * e2e specs never import app code, so this curated table is the only source + * of truth. Granularity is the DOMAIN prefix on purpose — finer grains rot. + * Spec names exist as tests/e2e/.e2e.spec.ts (pinned by unit test). + * Prefixes are repo-relative POSIX paths; matching is startsWith. + */ +const CAMPAIGN_APP = 'src/app/(campaign)/campanha/(app)' + +export const E2E_AFFECTED_MANIFEST = [ + { + prefixes: ['src/app/(payload)'], + specs: ['admin'], + }, + { + prefixes: ['src/app/(frontend)'], + specs: ['frontend'], + }, + { + prefixes: ['src/app/(campaign)/campanha/login', 'src/utilities/campaignAuth'], + specs: ['campaignAuth'], + }, + { + prefixes: ['src/app/(campaign)/campanha/webauthn', 'src/utilities/webauthn'], + specs: ['campaignWebAuthn'], + }, + { + prefixes: [ + `${CAMPAIGN_APP}/municipios`, + 'src/components/campaign/municipality', + 'src/components/campaign/map', + 'src/utilities/municipality', + ], + specs: [ + 'campaignMunicipalities', + 'campaignZoneMap', + 'campaignNearestMunicipality', + 'campaignSavedFilters', + 'campaignColumnPicker', + ], + }, + { + prefixes: [ + `${CAMPAIGN_APP}/atividades`, + 'src/components/campaign/activity', + 'src/utilities/activity', + ], + specs: ['campaignActivity'], + }, + { + prefixes: [ + `${CAMPAIGN_APP}/liderancas`, + 'src/components/campaign/leadership', + 'src/utilities/leadership', + ], + specs: ['campaignLeaderships'], + }, + { + prefixes: [`${CAMPAIGN_APP}/territorios`, 'src/utilities/territory'], + specs: ['campaignTerritories'], + }, + { + prefixes: [`${CAMPAIGN_APP}/conceitos`], + specs: ['campaignConcepts'], + }, + { + prefixes: [ + `${CAMPAIGN_APP}/page.tsx`, + `${CAMPAIGN_APP}/home-search`, + 'src/components/campaign/dashboard', + 'src/utilities/homeSearch', + 'src/utilities/campaignDashboardData', + 'src/lib/campaignHomeSearchHits', + ], + specs: ['campaignHomeActions'], + }, + { + prefixes: [ + 'src/components/campaign/shell', + `${CAMPAIGN_APP}/layout.tsx`, + 'src/utilities/campaignPwa', + ], + specs: ['campaign-pwa', 'campaignWizardChrome'], + }, +] diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs new file mode 100644 index 00000000..294a77e9 --- /dev/null +++ b/scripts/lib/test-affected-core.mjs @@ -0,0 +1,115 @@ +/** + * Pure selection logic for affected-test CI jobs (OPS5). No git, no fs, no + * process — the CLI wrappers feed it the changed file list and it decides. + * Unit-pinned in tests/unit/testAffected.unit.spec.ts and + * tests/unit/e2eAffectedManifest.unit.spec.ts. + */ + +/** + * Paths whose blast radius is the whole app: schema, test harness, lockfile. + * Any diff touching them runs the FULL suites — selection is unsafe there. + */ +export const HIGH_RISK_EXACT = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'playwright.config.ts', + 'vitest.config.mts', + 'vitest.unit.config.mts', + 'vitest.setup.ts', + 'src/payload-types.ts', + 'src/payload.config.ts', + 'scripts/seed-minimal.mjs', + 'scripts/lib/seed-minimal-manifest.mjs', + '.env.test', +]) + +export const HIGH_RISK_PREFIXES = [ + 'src/migrations/', + 'src/collections/', + 'src/globals/', + 'tests/helpers/', + 'tests/e2e/fixtures/', +] + +function isHighRisk(path) { + return HIGH_RISK_EXACT.has(path) || HIGH_RISK_PREFIXES.some((prefix) => path.startsWith(prefix)) +} + +/** + * Decide vitest scope for a PR diff. + * @param {{ path: string, status: string }[]} files git diff --name-status rows. + * @returns {{ mode: 'full' | 'changed' | 'none', reason: string }} + * full — high-risk path or new src file (nothing imports it yet, so + * --changed cannot find it; silent green otherwise); + * changed — vitest --changed is meaningful; + * none — no src/tests touched (docs-only PR); skip the jobs. + */ +export function classifyTestScope(files) { + if (files.some(({ path }) => isHighRisk(path))) { + return { mode: 'full', reason: 'diff touches a high-risk path (schema/lockfile/test harness)' } + } + if (files.some(({ path, status }) => status === 'A' && path.startsWith('src/'))) { + return { mode: 'full', reason: 'new src/ file — vitest --changed cannot trace tests to it yet' } + } + if (files.some(({ path }) => path.startsWith('src/') || path.startsWith('tests/'))) { + return { mode: 'changed', reason: 'src/tests diff without high-risk paths' } + } + return { mode: 'none', reason: 'no src/ or tests/ changes' } +} + +const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/ + +/** + * Decide the Playwright scope for a PR diff. + * @param {{ path: string, status: string }[]} files + * @param {{ prefixes: string[], specs: string[] }[]} manifest prefix → spec names. + * @returns {{ mode: 'full' | 'selected' | 'none', specs: string[], reason: string, + * unmapped: string[] }} + * `specs` are spec NAMES (no path, no .e2e.spec.ts). `unmapped` lists src/ + * files with no manifest prefix — logged in CI, never a failure (e2e is a + * thin layer over int; the gap is for a human to judge). + */ +export function selectE2eSpecs(files, manifest) { + if (files.some(({ path }) => isHighRisk(path))) { + return { + mode: 'full', + specs: [], + reason: 'diff touches a high-risk path (schema/lockfile/test harness)', + unmapped: [], + } + } + const specs = new Set() + const unmapped = [] + for (const { path } of files) { + if (E2E_SPEC_PATTERN.test(path)) { + specs.add(path.slice('tests/e2e/'.length, -'.e2e.spec.ts'.length)) + continue + } + if (!path.startsWith('src/')) continue + const matches = manifest.filter((entry) => + entry.prefixes.some((prefix) => path.startsWith(prefix)), + ) + if (matches.length === 0) { + unmapped.push(path) + continue + } + for (const match of matches) for (const spec of match.specs) specs.add(spec) + } + if (specs.size === 0) { + return { + mode: 'none', + specs: [], + reason: + unmapped.length > 0 + ? 'src/ changes with no e2e manifest mapping (see unmapped list)' + : 'no e2e-relevant changes', + unmapped, + } + } + return { + mode: 'selected', + specs: [...specs].sort(), + reason: `${specs.size} spec(s) selected via manifest and changed specs`, + unmapped, + } +} diff --git a/scripts/test-affected.mjs b/scripts/test-affected.mjs new file mode 100644 index 00000000..3221ea5f --- /dev/null +++ b/scripts/test-affected.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * test-affected (OPS5) — decides the vitest scope for a PR diff and prints it + * as JSON on stdout: { mode, reason, base }. + * + * mode=full → run the whole suite (high-risk path / new src file) + * mode=changed → run `vitest --changed ` + * mode=none → nothing to run (docs-only diff); CI skips the step + * + * Base ref: $GITHUB_BASE_REF (PR target) or origin/stage locally. No DB, no + * Payload — pure git + the pure core in scripts/lib/test-affected-core.mjs. + */ +import { execFileSync } from 'node:child_process' + +import { classifyTestScope } from './lib/test-affected-core.mjs' + +const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}` + +let mergeBase +try { + mergeBase = execFileSync('git', ['merge-base', 'HEAD', base], { encoding: 'utf8' }).trim() +} catch { + const result = { mode: 'full', reason: `no merge-base with ${base} (shallow clone?)`, base } + console.log(JSON.stringify(result)) + process.exit(0) +} + +const raw = execFileSync('git', ['diff', '--name-status', `${mergeBase}...HEAD`], { + encoding: 'utf8', +}) +const files = raw + .split('\n') + .filter(Boolean) + .map((line) => { + const [status, path] = line.split('\t') + return { status, path } + }) + +console.log(JSON.stringify({ ...classifyTestScope(files), base })) diff --git a/tests/unit/e2eAffectedManifest.unit.spec.ts b/tests/unit/e2eAffectedManifest.unit.spec.ts new file mode 100644 index 00000000..8a024333 --- /dev/null +++ b/tests/unit/e2eAffectedManifest.unit.spec.ts @@ -0,0 +1,45 @@ +// @vitest-environment node + +import { existsSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { E2E_AFFECTED_MANIFEST } from '../../scripts/lib/e2e-affected-manifest.mjs' + +const E2E_DIR = join(__dirname, '../e2e') + +function specNamesOnDisk() { + return readdirSync(E2E_DIR) + .filter((file) => file.endsWith('.e2e.spec.ts')) + .map((file) => file.slice(0, -'.e2e.spec.ts'.length)) +} + +describe('E2E_AFFECTED_MANIFEST (OPS5)', () => { + it('only references specs that exist on disk', () => { + const onDisk = new Set(specNamesOnDisk()) + for (const entry of E2E_AFFECTED_MANIFEST) { + for (const spec of entry.specs) { + expect(onDisk.has(spec), `${spec} (prefixes: ${entry.prefixes.join(', ')})`).toBe(true) + expect(existsSync(join(E2E_DIR, `${spec}.e2e.spec.ts`))).toBe(true) + } + } + }) + + it('keeps prefixes repo-relative and trailing-slash-clean', () => { + for (const entry of E2E_AFFECTED_MANIFEST) { + expect(entry.prefixes.length).toBeGreaterThan(0) + for (const prefix of entry.prefixes) { + expect(prefix.startsWith('src/')).toBe(true) + expect(prefix.endsWith('/'), `${prefix} — drop the trailing slash`).toBe(false) + } + } + }) + + it('covers every on-disk spec family that has a clear domain prefix', () => { + // Smoke/infrastructure specs are allowed to be unmapped (they run in full + // mode); this pins the reverse direction: no manifest entry is dead. + const mapped = new Set(E2E_AFFECTED_MANIFEST.flatMap((entry) => entry.specs)) + expect(mapped.size).toBeGreaterThanOrEqual(10) + }) +}) diff --git a/tests/unit/testAffected.unit.spec.ts b/tests/unit/testAffected.unit.spec.ts new file mode 100644 index 00000000..499f110d --- /dev/null +++ b/tests/unit/testAffected.unit.spec.ts @@ -0,0 +1,87 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + classifyTestScope, + HIGH_RISK_EXACT, + HIGH_RISK_PREFIXES, + selectE2eSpecs, +} from '../../scripts/lib/test-affected-core.mjs' + +const changed = (path: string) => ({ path, status: 'M' }) + +describe('classifyTestScope (OPS5)', () => { + it('runs the full suite when a high-risk path changes', () => { + for (const path of [ + 'src/migrations/20260730_000000_x.ts', + 'src/payload-types.ts', + 'pnpm-lock.yaml', + 'src/collections/Post.ts', + 'scripts/lib/seed-minimal-manifest.mjs', + 'tests/helpers/campaignFixtures.ts', + ]) { + expect(classifyTestScope([changed(path)]).mode, path).toBe('full') + } + }) + + it('runs the full suite when a new src file appears', () => { + expect(classifyTestScope([{ path: 'src/utilities/newThing.ts', status: 'A' }]).mode).toBe( + 'full', + ) + }) + + it('selects vitest --changed for a plain src/tests diff', () => { + expect(classifyTestScope([changed('src/utilities/posts.ts')]).mode).toBe('changed') + expect(classifyTestScope([changed('tests/int/api.int.spec.ts')]).mode).toBe('changed') + }) + + it('skips tests for a docs-only diff', () => { + expect(classifyTestScope([changed('docs/AGENT-OPS.md'), changed('README.md')]).mode).toBe( + 'none', + ) + }) + + it('high-risk lists cover the harness files the CI relies on', () => { + expect(HIGH_RISK_EXACT.has('vitest.config.mts')).toBe(true) + expect(HIGH_RISK_EXACT.has('playwright.config.ts')).toBe(true) + expect(HIGH_RISK_PREFIXES).toContain('tests/e2e/fixtures/') + }) +}) + +describe('selectE2eSpecs (OPS5)', () => { + const manifest = [ + { prefixes: ['src/components/campaign/municipality'], specs: ['campaignMunicipalities'] }, + { prefixes: ['src/app/(payload)'], specs: ['admin'] }, + ] + + it('maps src prefixes to specs via the manifest', () => { + const result = selectE2eSpecs( + [changed('src/components/campaign/municipality/MunicipalityTable.tsx')], + manifest, + ) + expect(result).toMatchObject({ mode: 'selected', specs: ['campaignMunicipalities'] }) + }) + + it('runs a changed e2e spec directly', () => { + const result = selectE2eSpecs([changed('tests/e2e/campaignAuth.e2e.spec.ts')], manifest) + expect(result.mode).toBe('selected') + expect(result.specs).toContain('campaignAuth') + }) + + it('runs the full suite on high-risk paths', () => { + expect(selectE2eSpecs([changed('tests/e2e/fixtures/auth.ts')], manifest).mode).toBe('full') + expect(selectE2eSpecs([changed('src/migrations/x.ts')], manifest).mode).toBe('full') + }) + + it('reports unmapped src paths without failing and skips the run', () => { + const result = selectE2eSpecs([changed('src/utilities/brandNewModule.ts')], manifest) + expect(result.mode).toBe('none') + expect(result.unmapped).toEqual(['src/utilities/brandNewModule.ts']) + }) + + it('skips e2e for diffs with no src/e2e changes', () => { + const result = selectE2eSpecs([changed('docs/AGENT-OPS.md')], manifest) + expect(result).toMatchObject({ mode: 'none', specs: [], unmapped: [] }) + }) +}) From 322794b1eeaafa8f85e411d898a39b78ab0ad424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 14:20:14 -0300 Subject: [PATCH 2/6] fix(e2e-ci): serve the production build in CI instead of next dev The first full e2e run on the parallel pipeline hit the 30-min timeout: dev-mode cold webpack compiles per route, a __webpack_modules__ TypeError, and dev-server memory-threshold restarts. CI now builds (NEXT_DIST_DIR matches the webServer env) and playwright webServer runs `pnpm start`; local spec development keeps `pnpm dev`. --- .github/workflows/ci-pr.yml | 8 ++++++++ .github/workflows/ci.yml | 5 +++++ playwright.config.ts | 9 ++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 9a00b6b4..e3bdaf49 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -261,6 +261,14 @@ jobs: if: steps.scope.outputs.mode != 'none' run: pnpm db:seed:minimal + # CI e2e runs against the production build (see playwright.config.ts + # webServer.command); the dist dir must match the webServer env. + - name: Build for e2e + if: steps.scope.outputs.mode != 'none' + run: pnpm build + env: + NEXT_DIST_DIR: .next/e2e + - name: E2E tests (full suite) if: steps.scope.outputs.mode == 'full' run: pnpm test:e2e diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a041c6a..2510cb79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,5 +132,10 @@ jobs: - run: pnpm migrate - run: pnpm db:seed:minimal + - name: Build for e2e + run: pnpm build + env: + NEXT_DIST_DIR: .next/e2e + - name: E2E tests (full suite) run: pnpm test:e2e diff --git a/playwright.config.ts b/playwright.config.ts index a9efe28d..38559d44 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -90,7 +90,14 @@ export default defineConfig({ }, ], webServer: { - command: 'pnpm dev', + /* + * CI serves the PRODUCTION build (`pnpm start` after a `pnpm build` step in + * the e2e job): dev-mode cold webpack compiles per route plus dev-server + * memory restarts made the full suite blow the 30-min job timeout on CI + * (measured 2026-07-30 — first full e2e run on the parallel pipeline). + * Locally `pnpm dev` stays: no build wait and hot reload while writing specs. + */ + command: process.env.CI ? 'pnpm start' : 'pnpm dev', /* * Never reuse a dev server a developer may already have running against the * production database — always start a fresh one bound to the test DB. From 6a20e98047fda980b94b26904d4bcdb8cd6e0935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 16:40:56 -0300 Subject: [PATCH 3/6] fix(campanha): share one Drawer across municipalities mobile cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the prod-build React #130 on /campanha/municipios: the md:hidden mobile-cards tree still hydrated ~25 rows x ~5 sheet controls (~125 eager Drawer/DrawerCloseButton instances) even on wide viewports. Filtered ?q=... routes failed more often only because they exercise the list with filters active — the search param itself was not the bug. CampaignListSheetProvider hosts a single bottom Drawer for the whole mobile section; CampaignCellEditOverlay sheet cells portal their body/footer into it while keeping per-cell open state and autosave scaffolds. Footers with DrawerCloseButton are only created once a sheet actually opens. Desktop popover path is unchanged; per-cell Drawer remains the fallback for surfaces without the provider. Also: DrawerCloseButton (function render prop, avoids base-ui element render in prod bundles) replaces the DrawerClose render={