Merge pull request #118 from fsolla/cursor/pool-i116-2df83301-bbe5

OPS12 — CI bloqueia Closes/Fixes em PR só docs/plans
This commit is contained in:
Francisco Solla 2026-08-01 08:39:47 -03:00 committed by GitHub
commit b809bf71dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 276 additions and 17 deletions

View file

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

View file

@ -88,6 +88,8 @@ pnpm agent:register -- --id <ID> --title "<título>" --prio <P0..P3> \
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

View file

@ -2,7 +2,7 @@
<!-- 2–4 linhas: o que muda e por quê. Link do plano se houver. -->
Closes #<!-- número da Issue (claim) -->
Closes #<!-- número da Issue (claim) — omita em PRs que alteram só docs/plans/; use Related #N -->
## Contrato da entrega

View file

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

View file

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

View file

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

View file

@ -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)')
}

View file

@ -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.',
}
}

View file

@ -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',

View file

@ -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',

View file

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