From 1ad886399475ffd01856b92468dba49292e53a60 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 11:25:35 +0000 Subject: [PATCH] =?UTF-8?q?OPS12=20=E2=80=94=20CI=20bloqueia=20Closes/Fixe?= =?UTF-8?q?s=20em=20PR=20s=C3=B3=20docs/plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lógica pura em plansOnlyClosesGuard.mjs + CLI check-plans-only-pr-closes - Job plans-only-closes em ci-pr.yml (rollup checks) - Unit tests com fixture do incidente #115 - plan-issue, agent-pr-workflow, PR template e GUARDRAILS atualizados Closes #116 Co-authored-by: Francisco Solla --- .cursor/rules/agent-pr-workflow.mdc | 1 + .cursor/skills/plan-issue/SKILL.md | 2 + .github/pull_request_template.md | 2 +- .github/workflows/ci-pr.yml | 17 ++++ docs/GUARDRAILS.md | 31 +++---- docs/plans/ci-proibir-closes-plans-only.md | 2 +- scripts/check-plans-only-pr-closes.mjs | 69 ++++++++++++++ scripts/lib/plansOnlyClosesGuard.mjs | 71 +++++++++++++++ scripts/lib/test-affected-core.mjs | 2 + tests/unit/ciSkipInvariants.unit.spec.ts | 2 + tests/unit/plansOnlyClosesGuard.unit.spec.ts | 94 ++++++++++++++++++++ 11 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 scripts/check-plans-only-pr-closes.mjs create mode 100644 scripts/lib/plansOnlyClosesGuard.mjs create mode 100644 tests/unit/plansOnlyClosesGuard.unit.spec.ts diff --git a/.cursor/rules/agent-pr-workflow.mdc b/.cursor/rules/agent-pr-workflow.mdc index cfdd1c7d..a8e5494b 100644 --- a/.cursor/rules/agent-pr-workflow.mdc +++ b/.cursor/rules/agent-pr-workflow.mdc @@ -22,6 +22,7 @@ EOF - **Never** `--draft`. PRs must be **Ready for review** on creation. - Base is always **`main`**. +- **Plans-only PRs** (diff só em `docs/plans/`): **nunca** `Closes #N` — use `Related #N`; CI falha com keywords de fechamento (`plans-only-closes` em `ci-pr.yml`). ### Cursor Cloud / `ManagePullRequest` diff --git a/.cursor/skills/plan-issue/SKILL.md b/.cursor/skills/plan-issue/SKILL.md index 8f704d33..59aba55c 100644 --- a/.cursor/skills/plan-issue/SKILL.md +++ b/.cursor/skills/plan-issue/SKILL.md @@ -88,6 +88,8 @@ pnpm agent:register -- --id --title "" --prio \ A Issue nasce `ready` (ou `blocked`) com frontmatter `id/depends/serializes/priority/model` + spec + link do plano. Atualize o cabeçalho do plano (`Issue: #N`). Commit dos planos (commits lógicos por item ou por lote). +**PR só de plano (`docs/plans/` exclusivamente):** nunca use `Closes #N` / `Fixes` / `Resolves` no body — use `Related #N`. O CI (`plans-only-closes`) bloqueia keywords de fechamento; a implementação fecha a Issue em outro PR (`work-issue`). + **NÃO faz:** editar `docs/roadmap.md` (legado congelado), implementar código, claim (`pnpm agent:claim` é de `work-issue`). ## Resumo final diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 820f2140..ce37a226 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,7 +2,7 @@ -Closes # +Closes # ## Contrato da entrega diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 15024065..24dc005d 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -331,6 +331,22 @@ jobs: if: failure() uses: ./.github/actions/cancel-workflow-on-failure + plans-only-closes: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Block Closes/Fixes/Resolves on plans-only PRs + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + PR_BODY: ${{ github.event.pull_request.body }} + run: node scripts/check-plans-only-pr-closes.mjs + - name: Cancel workflow on failure + if: failure() + uses: ./.github/actions/cancel-workflow-on-failure + migration-lock: runs-on: ubuntu-latest timeout-minutes: 5 @@ -368,6 +384,7 @@ jobs: - build - e2e - migration-lock + - plans-only-closes if: always() steps: - name: All cascade checks green diff --git a/docs/GUARDRAILS.md b/docs/GUARDRAILS.md index 443d6115..c0d5cb95 100644 --- a/docs/GUARDRAILS.md +++ b/docs/GUARDRAILS.md @@ -22,21 +22,22 @@ Ledger dos guardrails determinísticos colhidos de `kind:agent-miss` (fluxo `pnp ## Precedentes vivos (anteriores ao primeiro harvest) -| Guarda | Origem | Classe | Mecanismo | -| ------------------------------------------------------------------------ | ----------------- | ------ | ------------------------------------------------------------------------------------------------ | -| Vocabulário banido (Praça/Núcleo/Plano de Ação, accent-tolerant) | C13 / W4f / P3-K | 3 | `codebaseConventions.unit.spec.ts` | -| `server-only` em utilities acopladas (sweep payload/next/\*) | Pass 2 W2 / P3-K | 3 | `codebaseConventions.unit.spec.ts` | -| Escadas de formActions nos wrappers (`runCampaignFormAction`/`Redirect`) | W4d / C8 F4b | 3 | `codebaseConventions.unit.spec.ts` (filename `*FormActions.ts` — dodge `actions/*.ts` ledgerado) | -| Ratchet de bypass admin não documentado | P3-E | 3 | `codebaseConventions.unit.spec.ts` (por arquivo, só diminui) | -| Mensagens de recusa como constantes + correspondência throw↔safelist | P3-A | 3 | `codebaseConventions.unit.spec.ts:216-263` | -| `as never` banido | Pass 1 | 2 | ESLint `no-restricted-syntax` | -| `src/lib` não importa `utilities/` (valores) | Pass 2 W2 | 2 | ESLint `no-restricted-imports` | -| 0 ciclos de import | Pass 2 | 4 | madge (`pnpm check:cycles`) | -| Dead code (arquivos/deps/exports em erro) | Pass 2 W4b | 4 | knip em CI | -| `migration-lock` (≤1 PR aberto tocando schema) | agentes paralelos | 4 | job em `ci-pr.yml` | -| Seed mínimo obrigatório com migration (contrato manifest) | OPS fluxo PR | 3 + 5 | `tests/unit/seedMinimalManifest.unit.spec.ts` + job CI migrate→seed→int | -| Regex de template ancorada em e2e | P3-C | 3 | `codebaseConventions.unit.spec.ts` (ampliada para literais no Pass 4) | -| Consent fail-closed por chave estável | D3/Onda 0 | 5 | pins int `submitWhatsapp`/liderança/apoiador | +| Guarda | Origem | Classe | Mecanismo | +| ------------------------------------------------------------------------ | ------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| Vocabulário banido (Praça/Núcleo/Plano de Ação, accent-tolerant) | C13 / W4f / P3-K | 3 | `codebaseConventions.unit.spec.ts` | +| `server-only` em utilities acopladas (sweep payload/next/\*) | Pass 2 W2 / P3-K | 3 | `codebaseConventions.unit.spec.ts` | +| Escadas de formActions nos wrappers (`runCampaignFormAction`/`Redirect`) | W4d / C8 F4b | 3 | `codebaseConventions.unit.spec.ts` (filename `*FormActions.ts` — dodge `actions/*.ts` ledgerado) | +| Ratchet de bypass admin não documentado | P3-E | 3 | `codebaseConventions.unit.spec.ts` (por arquivo, só diminui) | +| Mensagens de recusa como constantes + correspondência throw↔safelist | P3-A | 3 | `codebaseConventions.unit.spec.ts:216-263` | +| `as never` banido | Pass 1 | 2 | ESLint `no-restricted-syntax` | +| `src/lib` não importa `utilities/` (valores) | Pass 2 W2 | 2 | ESLint `no-restricted-imports` | +| 0 ciclos de import | Pass 2 | 4 | madge (`pnpm check:cycles`) | +| Dead code (arquivos/deps/exports em erro) | Pass 2 W4b | 4 | knip em CI | +| `migration-lock` (≤1 PR aberto tocando schema) | agentes paralelos | 4 | job em `ci-pr.yml` | +| PR só `docs/plans/` sem `Closes`/`Fixes`/`Resolves` | [#116](https://github.com/fsolla/teqo/issues/116) OPS12 | 4 | `scripts/lib/plansOnlyClosesGuard.mjs` + `scripts/check-plans-only-pr-closes.mjs` + job `plans-only-closes` em `ci-pr.yml` | +| Seed mínimo obrigatório com migration (contrato manifest) | OPS fluxo PR | 3 + 5 | `tests/unit/seedMinimalManifest.unit.spec.ts` + job CI migrate→seed→int | +| Regex de template ancorada em e2e | P3-C | 3 | `codebaseConventions.unit.spec.ts` (ampliada para literais no Pass 4) | +| Consent fail-closed por chave estável | D3/Onda 0 | 5 | pins int `submitWhatsapp`/liderança/apoiador | ## Misses colhidas aguardando guarda (fila do harvest) diff --git a/docs/plans/ci-proibir-closes-plans-only.md b/docs/plans/ci-proibir-closes-plans-only.md index 03fbd72e..4c3d8a07 100644 --- a/docs/plans/ci-proibir-closes-plans-only.md +++ b/docs/plans/ci-proibir-closes-plans-only.md @@ -1,6 +1,6 @@ # CI — proibir `Closes`/`Fixes` em PR só `docs/plans` -Status: registrado +Status: entregue (OPS12 — PR em merge) Atualizado em: 2026-08-01 Issue: #116 (OPS12) Priority: P1 diff --git a/scripts/check-plans-only-pr-closes.mjs b/scripts/check-plans-only-pr-closes.mjs new file mode 100644 index 00000000..6773ac07 --- /dev/null +++ b/scripts/check-plans-only-pr-closes.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * check-plans-only-pr-closes — CI guard: plans-only PRs must not close Issues via body keywords. + * Paths from git diff (merge-base…HEAD); body from PR_BODY env or `gh pr view` when PR_NUMBER is set. + */ +import { execFileSync } from 'node:child_process' + +import { assertPlansOnlyPrAllowsBody } from './lib/plansOnlyClosesGuard.mjs' + +const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}` + +function die(message) { + console.error(`[check-plans-only-pr-closes] ${message}`) + process.exit(1) +} + +function listChangedPaths() { + let mergeBase + try { + mergeBase = execFileSync('git', ['merge-base', 'HEAD', base], { encoding: 'utf8' }).trim() + } catch { + die(`no merge-base with ${base} (shallow clone?)`) + } + + const raw = execFileSync('git', ['diff', '--name-only', `${mergeBase}...HEAD`], { + 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 paths = listChangedPaths() +const body = readPrBody() +const result = assertPlansOnlyPrAllowsBody({ paths, body }) + +if (!result.ok) { + die(result.message) +} + +if (paths.length > 0 && paths.every((path) => path.startsWith('docs/plans/'))) { + console.log('[check-plans-only-pr-closes] ok — plans-only diff without closing keywords') +} else { + console.log('[check-plans-only-pr-closes] ok — not a plans-only diff (skipped)') +} diff --git a/scripts/lib/plansOnlyClosesGuard.mjs b/scripts/lib/plansOnlyClosesGuard.mjs new file mode 100644 index 00000000..84caf957 --- /dev/null +++ b/scripts/lib/plansOnlyClosesGuard.mjs @@ -0,0 +1,71 @@ +/** + * plansOnlyClosesGuard — pure policy for PRs that touch only docs/plans/. + * Plans-only PRs must not carry GitHub issue-closing keywords (Closes/Fixes/Resolves). + */ + +const PLANS_ONLY_PREFIX = 'docs/plans/' + +/** + * @param {string} path - repo-relative path with forward slashes + */ +export function isUnderDocsPlans(path) { + return path === PLANS_ONLY_PREFIX.slice(0, -1) || path.startsWith(PLANS_ONLY_PREFIX) +} + +/** + * True when every changed path lives under docs/plans/ (non-empty diff). + * + * @param {string[]} paths + */ +export function isPlansOnlyDiff(paths) { + if (paths.length === 0) return false + return paths.every(isUnderDocsPlans) +} + +/** GitHub closing keywords + resolves family (case-insensitive). */ +const ISSUE_CLOSING_KEYWORD_RE = + /\b(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)\b/gi + +/** + * @param {string} body + * @returns {{ keyword: string, number: number }[]} + */ +export function findIssueClosingKeywords(body) { + if (!body) return [] + + const closers = [] + for (const match of body.matchAll(ISSUE_CLOSING_KEYWORD_RE)) { + closers.push({ keyword: match[1], number: Number(match[2]) }) + } + return closers +} + +/** + * @param {{ paths: string[], body: string | null | undefined }} input + * @returns {{ ok: true } | { ok: false, closers: { keyword: string, number: number }[], message: string }} + */ +export function assertPlansOnlyPrAllowsBody({ paths, body }) { + if (!isPlansOnlyDiff(paths)) { + return { ok: true } + } + + const closers = findIssueClosingKeywords(body ?? '') + if (closers.length === 0) { + return { ok: true } + } + + const unique = [ + ...new Map( + closers.map((closer) => [`${closer.keyword.toLowerCase()}:${closer.number}`, closer]), + ).values(), + ] + const listed = unique.map((closer) => `${closer.keyword} #${closer.number}`).join(', ') + + return { + ok: false, + closers: unique, + message: + `PR que altera apenas docs/plans/ não pode usar keywords de fechamento (${listed}). ` + + 'Use "Related #N" ou remova — a implementação fecha a Issue em outro PR.', + } +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 42eb9456..c8b4ff33 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -28,6 +28,8 @@ export const HIGH_RISK_EXACT = new Set([ 'scripts/e2e-affected.mjs', 'scripts/ci-scope.mjs', 'scripts/check-test-locations.mjs', + 'scripts/check-plans-only-pr-closes.mjs', + 'scripts/lib/plansOnlyClosesGuard.mjs', 'scripts/gate-ci.mjs', 'tests/unit/ciSkipInvariants.unit.spec.ts', 'tests/unit/testAffected.unit.spec.ts', diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index 4e26179a..8c150518 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -80,6 +80,8 @@ describe('ciSkipInvariants', () => { 'scripts/lib/test-affected-core.mjs', 'scripts/ci-scope.mjs', 'scripts/check-test-locations.mjs', + 'scripts/check-plans-only-pr-closes.mjs', + 'scripts/lib/plansOnlyClosesGuard.mjs', 'scripts/test-affected.mjs', 'scripts/e2e-affected.mjs', 'tests/unit/ciSkipInvariants.unit.spec.ts', diff --git a/tests/unit/plansOnlyClosesGuard.unit.spec.ts b/tests/unit/plansOnlyClosesGuard.unit.spec.ts new file mode 100644 index 00000000..864e232b --- /dev/null +++ b/tests/unit/plansOnlyClosesGuard.unit.spec.ts @@ -0,0 +1,94 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + assertPlansOnlyPrAllowsBody, + findIssueClosingKeywords, + isPlansOnlyDiff, + isUnderDocsPlans, +} from '../../scripts/lib/plansOnlyClosesGuard.mjs' + +/** Body shape from incident PR #115 (plan-only + Closes #114). */ +const INCIDENT_PR_115_BODY = `## O que é + +Plano B100 — bottom drawer peek nas ações de busca. + +Closes #114 + +## Notas + +Registro de plano; implementação em PR separado.` + +describe('plansOnlyClosesGuard', () => { + it('classifies docs/plans paths', () => { + expect(isUnderDocsPlans('docs/plans/foo.md')).toBe(true) + expect(isUnderDocsPlans('docs/plans/nested/bar.md')).toBe(true) + expect(isUnderDocsPlans('docs/plans')).toBe(true) + expect(isUnderDocsPlans('docs/GUARDRAILS.md')).toBe(false) + expect(isUnderDocsPlans('src/lib/foo.ts')).toBe(false) + }) + + it('detects plans-only diffs', () => { + expect(isPlansOnlyDiff(['docs/plans/bottom-drawer-peek-acoes-busca.md'])).toBe(true) + expect(isPlansOnlyDiff([])).toBe(false) + expect(isPlansOnlyDiff(['docs/plans/a.md', 'docs/plans/sub/b.md'])).toBe(true) + expect(isPlansOnlyDiff(['docs/plans/a.md', '.cursor/skills/plan-issue/SKILL.md'])).toBe(false) + }) + + it('finds closing keywords case-insensitively', () => { + expect(findIssueClosingKeywords('Closes #114')).toEqual([{ keyword: 'Closes', number: 114 }]) + expect(findIssueClosingKeywords('fixes #1 and RESOLVES #2')).toEqual([ + { keyword: 'fixes', number: 1 }, + { keyword: 'RESOLVES', number: 2 }, + ]) + expect(findIssueClosingKeywords('Related #114')).toEqual([]) + expect(findIssueClosingKeywords('')).toEqual([]) + }) + + it('fails plans-only + Closes (incident #115 fixture)', () => { + const result = assertPlansOnlyPrAllowsBody({ + paths: ['docs/plans/bottom-drawer-peek-acoes-busca.md'], + body: INCIDENT_PR_115_BODY, + }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.closers).toEqual([{ keyword: 'Closes', number: 114 }]) + expect(result.message).toContain('docs/plans/') + expect(result.message).toContain('Related #N') + } + }) + + it('allows plans-only + Related', () => { + expect( + assertPlansOnlyPrAllowsBody({ + paths: ['docs/plans/foo.md'], + body: '## O que é\n\nRelated #114', + }), + ).toEqual({ ok: true }) + }) + + it('allows mixed diff + Closes', () => { + expect( + assertPlansOnlyPrAllowsBody({ + paths: ['docs/plans/foo.md', 'scripts/lib/plansOnlyClosesGuard.mjs'], + body: 'Closes #116', + }), + ).toEqual({ ok: true }) + }) + + it('allows empty paths (skip)', () => { + expect(assertPlansOnlyPrAllowsBody({ paths: [], body: 'Closes #1' })).toEqual({ ok: true }) + }) + + it('rejects Fixes and Resolves on plans-only PRs', () => { + for (const body of ['Fixes #42', 'Resolved #7']) { + const result = assertPlansOnlyPrAllowsBody({ + paths: ['docs/plans/x.md'], + body, + }) + expect(result.ok, body).toBe(false) + } + }) +})