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:
commit
b809bf71dd
11 changed files with 276 additions and 17 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
.github/workflows/ci-pr.yml
vendored
17
.github/workflows/ci-pr.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
69
scripts/check-plans-only-pr-closes.mjs
Normal file
69
scripts/check-plans-only-pr-closes.mjs
Normal 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)')
|
||||
}
|
||||
71
scripts/lib/plansOnlyClosesGuard.mjs
Normal file
71
scripts/lib/plansOnlyClosesGuard.mjs
Normal 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.',
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
94
tests/unit/plansOnlyClosesGuard.unit.spec.ts
Normal file
94
tests/unit/plansOnlyClosesGuard.unit.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue