teqo-backup/docs/plans/escala-dry-pos-scripts-blast-radius-impl.md
Francisco Solla 6641e7e993 chore(OPS119++): blast radius de scripts/ — risk map das libs + static scope (Closes #1077)
Fecha o resíduo da classe S6 da OPS119+ no resto de scripts/. F1: SCRIPTS_SPEC_PINNED (42 módulos scripts/** alcançáveis de specs unit+int) espalhado em HIGH_RISK_EXACT, sincronizado por invariante de igualdade que anda o grafo de imports dos specs (missing = lib nova pinada; stale = rename). F2: isCodePath cobre scripts/ (classifyStaticScope = code). Sem migration/access/Consent/UI. gate:fast verde.
2026-09-16 12:12:37 -03:00

268 lines
16 KiB
Markdown

# Impl: OPS119++ — blast radius de scripts/: risk map das libs restantes + static scope
Status: em execução
Atualizado em: 2026-09-16
Gate: aprovado pelo humano (superset unit+int, 42 paths — confirmado 2026-09-16)
Issue: #1077
Intencao: docs/plans/escala-dry-pos-scripts-blast-radius.md
Appetite restante: herdado (~1h eng) - F1 ~35min + F2 ~15min + gates ~10min, sem corte
Impeccable: A - N/A (CLI/CI, sem superficie de produto) - no designer dispatch
## Leitura da intencao
- **Outcome:** fechar a classe S6 remanescente da OPS119+ (#1071) para o resto de
`scripts/`. F1: qualquer modulo `scripts/**` alcancavel por um spec (transitivo)
deve ser high-risk, fail-closed - um diff so-lib nao pode mais classificar
`classifyTestScope` como `none` e deixar de rodar a unit que cobre o modulo
(ex.: `scripts/lib/worktree-env.mjs`, pinada por `tests/unit/worktree.unit.spec.ts:16`
e importada por `scripts/lib/auto-unblock.mjs:10`, hoje classifica `none`). F2:
`isCodePath` (`scripts/lib/test-affected-core.mjs:134`) deve cobrir `scripts/`
para que `classifyStaticScope` devolva `code` e destrave typecheck/knip/cycles
num diff so-scripts.
- **O que NAO negociar:** risco e integridade de guard (blast radius fail-closed),
nao refactor; Impeccable A (CLI/CI); appetite ~1h; sem migration, sem
schema/Consent/access, sem URL publica/API, sem UI; a alternativa rejeitada na
intencao-mae (prefixo `scripts/lib/` inteiro em `HIGH_RISK_EXACT`) nao pode
voltar. Pins so chamam `.has()` - `HIGH_RISK_EXACT` continua sendo um `Set`
exportado; nenhum pin afirma que um path de `scripts/` NAO e high-risk.
- **O que reavaliar:** (1) "invariante derivado" NAO pode derivar em tempo de
import - `test-affected-core.mjs` e documentado puro (`:2-6`: "No git, no fs,
no process"), entao a lista segue literal e o que "deriva" e um **teste de
invariante fail-closed** que anda o grafo de imports dos specs e exige o
fechamento contido em `HIGH_RISK_EXACT`; (2) "spec unit" literal (41 paths) vs
todas as arvores de spec (`tests/unit` + `tests/int`, 42 paths - so acrescenta
`scripts/cityReportSnapshot.mjs`, importado por `tests/int/cityReportSnapshot.int.spec.ts:16`);
(3) "append direto" vs grupo nomeado nao-exportado (`SCRIPTS_SPEC_PINNED`) -
o grupo documenta o bloco e evita knip dead-export.
## Abordagem recomendada
```mermaid
flowchart LR
spec[specs tests/unit + tests/int] -->|walker fs+regex| graph[grafo de imports]
graph -->|closure restrita a scripts/**| closure[scripts reachable]
closure -->|assert subset| HRE[HIGH_RISK_EXACT]
HRE --> classifyTestScope
classifyTestScope -->|full| full[unit+int full / e2e curated]
isCodePath -->|startsWith scripts/| classifyStaticScope
classifyStaticScope -->|code| static[typecheck+knip+cycles]
pin1[testAffected.unit.spec] -->|classify full| isCodePath
pin2[ciSkipInvariants.unit.spec] -->|invariante do grafo| closure
```
**Opcoes consideradas:** A | B | C
**Recomendacao:** A - manter `HIGH_RISK_EXACT` literal e adicionar um **teste de
invariante fail-closed** (`tests/unit/ciSkipInvariants.unit.spec.ts`) que anda o
grafo de imports dos specs via `fs`+regex (test-only), calcula o fechamento
transitivo restrito a `scripts/**` e afere `closure subset HIGH_RISK_EXACT`,
falhando com a lista de faltantes. Porque `test-affected-core.mjs` e puro por
contrato (nao pode ler o repo em tempo de import), o "invariante derivado" so
existe como teste; o teste **e** o que mata a classe na raiz - uma lib nova
pinada por spec sem entrada no mapa quebra o invariante no PR que a adiciona, em
vez de abrir o buraco em silencio. Escopo: **superset** de todas as arvores de
spec (42, inclui `scripts/cityReportSnapshot.mjs` do int), divergencia deliberada
do "spec unit" literal da intencao (41) - mesmo walker, +1 entrada, fecha a mesma
classe S6 tambem para int; confirmar no gate.
**Rejeitadas:** (B) estender o allowlist literal apenas com os arquivos
conhecidos **sem** o teste de invariante - e literalmente a opcao rejeitada na
intencao-mae: reabre o buraco a cada lib nova; (C) prefixo `scripts/lib/`
inteiro - engole as ~40 libs `scripts/lib/*.mjs` sem harness e forca `full` em
diffs triviais, rejeitado na intencao; (D) modulo de dados gerado/commitado com o
closure - mesma indirecao extra, mesmo teste de sincronia necessario, sem ganho
sobre a lista literal + invariante.
### Decisoes de engenharia
- **Forma do invariante: derivacao em import vs teste fail-closed.** Opcoes: A)
teste que anda o grafo e afere subset; B) exportar o closure do modulo puro; C)
manter literal sem teste. Recomendacao: A - e a unica forma compativel com o
contrato "pure, no fs" do dono (`test-affected-core.mjs:2-6`); o custo de CI e
zero (roda na suite unit normal). Rejeitadas: B porque o modulo puro nao pode
ler o repo e um valor derivado de dado externo exigiria injecao/codigo gerado
(opcao D acima); C porque repete a opcao B da intencao (reabre o buraco).
- **Escopo do walker: so unit vs unit+int.** Opcoes: A) so `tests/unit` (41,
palavra literal da intencao); B) todas as arvores (`tests/unit` + `tests/int`,
42). Recomendacao: A superset B - o espec importador `tests/int/cityReportSnapshot.int.spec.ts:16`
adiciona exatamente um path (`scripts/cityReportSnapshot.mjs`); mesmo walker,
fecha a mesma classe para int. Rejeitadas: A sozinho deixaria um modulo
int-pinado de fora por fidelidade literal ao texto, sem ganho de seguranca;
divergencia registrada para o gate.
- **Shape da lista: append direto vs grupo nomeado nao-exportado.** Opcoes: A)
acrescentar os 33 paths soltos no fim de `HIGH_RISK_EXACT`; B) criar
`const SCRIPTS_SPEC_PINNED = [ ... ]` **nao exportado**, documentando o bloco, e
espalhar (`...SCRIPTS_SPEC_PINNED`) dentro do `new Set([...])`. Recomendacao: B
- o bloco de ~40 scripts fica legivel/documentado como "modulos scripts/
pinados por spec" e nao vira export novo (evita dead-export do knip). O grupo
**nao** e exportado; o que os pins consomem continua sendo `HIGH_RISK_EXACT`.
Rejeitadas: A reduz a legibilidade do mapa mas e funcionalmente identica; nao
ha razao para carregar 42 entradas soltas. Gatilho de revisao: se um pin
precisar do subconjunto puro, exportar com pin proprio.
- **F2: `path.startsWith('scripts/')` vs lista exata de scripts.** Opcoes: A)
prefixo `scripts/` em `isCodePath`; B) enumerar os scripts relevantes.
Recomendacao: A - `isCodePath` ja opera por prefixo/categoria (`src/`, `tests/`,
`eslint*`, `knip*`); todo `.mjs` de `scripts/` e codigo de ferramenta e afeta
typecheck/knip/cycles, entao a categoria e a granularidade certa. Rejeitadas: B
por manutencao (nova lib so-scripts voltaria a ficar de fora) e por contrariar
o padrao do predicado; `classifyBuildScope` fica intocado (`isBuildPath` nao
inclui `scripts/`).
- **Divergencia unit vs unit+int.** Registrada acima; decisao do gate, nao do
agente. Se o gate exigir fidelidade literal, cai-se para o subconjunto de 41
removendo 1 path do grupo - reversao barata e mecanica.
### Componentes / mudancas
- **`HIGH_RISK_EXACT` / `SCRIPTS_SPEC_PINNED`** (`scripts/lib/test-affected-core.mjs:25-56`):
novo `const SCRIPTS_SPEC_PINNED` nao-exportado com os modulos `scripts/**`
alcancaveis por specs (8 ja cobertos + 33 faltantes + `cityReportSnapshot.mjs`);
espalhado no `new Set([...])`. Os 33 faltantes (`scripts/lib/...` salvo
indicacao): `agent-forgejo.mjs`, `agent-plan-lifecycle.mjs`,
`agent-pool-cursor.mjs`, `agent-pool-eligibility.mjs`, `agent-pool-models.mjs`,
`agent-pool-prompt.mjs`, `agent-pool-state.mjs`, `ansi.mjs`, `auto-unblock.mjs`,
`camaraSpeeches.mjs`, `changelog.mjs`, `cityReportBlocks.mjs`,
`cityReportDatabase.mjs`, `cityReportFormat.mjs`, `cityReportRender.mjs`,
`cityReportResearch.mjs`, `cityReportTerritory.mjs`, `conflictMarkers.mjs`,
`db-start.mjs`, `deploy-trigger.mjs`, `github-api.mjs`,
`github-branch-protection.mjs`, `github-pr-flow.mjs`, `imageFill.mjs`,
`imageResize.mjs`, `issues-panel.mjs`, `markdown-ansi.mjs`,
`playwright-e2e-args.mjs`, `portalTransparenciaEmendas.mjs`,
`sollaCeuciSalvadorMetrics.mjs`, `testing-audit-metrics-core.mjs`,
`worktree-env.mjs`, `wpArticles.mjs`; mais `scripts/cityReportSnapshot.mjs` (int).
Nao-reachable confirmados e deixados fora: `scripts/lib/agent-pool-forgejo.mjs`,
`camaraFetch.mjs`, `topology.mjs`.
- **`isCodePath`** (`scripts/lib/test-affected-core.mjs:134-143`): acrescentar
`if (path.startsWith('scripts/')) return true`; `classifyStaticScope` passa a
devolver `code` para diff so-scripts. `classifyBuildScope`/`isBuildPath`
intocados.
- **Pins:** `tests/unit/testAffected.unit.spec.ts` (loop `:22-35` + caso
`classifyStaticScope`), `tests/unit/ciSkipInvariants.unit.spec.ts` (novo `it`
do invariante do grafo + pin comportamental de `classifyTestScope` para
`scripts/lib/worktree-env.mjs`, e pin de `isCodePath('scripts/...')`).
- **Changelog:** `docs/changelog/2026-09-16-ops119plusplus.md` (follow-up nomeado
pela entrada `2026-09-16-ops119plus.md`).
- **Migration:** sem migration.
- **Access / Consent:** n/a.
- **UI:** Impeccable A - N/A; no designer dispatch.
### Dados -> forma (se aplicavel)
N/A - sem superficie de produto; o "dado" e a lista de paths, cuja forma
(grupo nomeado nao-exportado + `Set` exportado) foi decidida acima.
## Fases verificaveis
1. **F1 - risk map (~35min).** Introduzir `SCRIPTS_SPEC_PINNED` em
`scripts/lib/test-affected-core.mjs` com os 42 paths (41 + `cityReportSnapshot.mjs`),
espalhar em `HIGH_RISK_EXACT`; novo `it` em
`tests/unit/ciSkipInvariants.unit.spec.ts` que anda `tests/unit/**` + `tests/int/**`,
extrai imports estaticos relativos (`from './x.mjs'`/`from '../...'`), calcula o
fechamento transitivo restrito a `scripts/**` e afere
`closure.filter(p => !HIGH_RISK_EXACT.has(p))` vazio (falha listando os
faltantes); acrescentar o mesmo caso comportamental
`classifyTestScope([{ path: 'scripts/lib/worktree-env.mjs', status: 'M' }]).mode === 'full'`.
Verificacao: invariante verde com o mapa novo; e removendo mentalmente uma
entrada o teste falha (provado no PR). `pnpm test:unit` verde.
2. **F2 - static scope (~15min).** Extender `isCodePath` com
`path.startsWith('scripts/')`; pins `isCodePath('scripts/lib/cli.mjs') === true` e
`classifyStaticScope([{ path: 'scripts/lib/cli.mjs', status: 'M' }]).mode === 'code'`
em `ciSkipInvariants`/`testAffected`. Verificacao: `pnpm test:unit` verde;
`classifyBuildScope` segue `none` num diff so-scripts (pin de nao-regressao se
barato).
3. **Gates (~10min).** `pnpm gate:fast` (lint + typecheck + unit); `pnpm push`.
Sem migration e sem e2e novo (Impeccable A, sem superficie de produto).
## Rabbit holes / Nao escopo (engenharia)
- Prefixo `scripts/lib/` inteiro em `HIGH_RISK_EXACT`: rejeitado na intencao
(engoliria libs sem harness e forca `full` em diffs triviais). O que entra e
exatamente o fechamento alcancavel por specs, nao o diretorio.
- Invariante de existencia em disco repo-wide sobre toda a lista
`HIGH_RISK_EXACT`/`HIGH_RISK_PREFIXES`: named follow-up da OPS119+, fora do
appetite; aqui so o invariante de cobertura do grafo.
- Walker com parsing real de AST/aliases/dynamic import: regex de `import`
estaticos e suficiente para o contrato (specs importam libs relativas); se um
dia houver alias/dynamic import, o invariante falha fail-closed (nao ve a
aresta -> ainda pode sub-reportar; ver Riscos).
- Tocar `classifyBuildScope`/`isBuildPath` para `scripts/`: fora - `build_mode`
segue `none`, nao e o achado.
- Guard `isMain` em scripts, S1/S2/S3, prefill upstream, UI/Consent/access:
fora, conforme a intencao.
## Riscos e mitigacao
- **Walker sub-reporta (falso verde).** Regex que perde uma aresta de import
(alias, dynamic import, re-export) encurta o closure. Mitigacao: o `it` inclui
um **self-check** que assere arestas conhecidas, **direta**
(`scripts/lib/worktree-env.mjs`) e **transitiva** (`scripts/lib/cityReportTerritory.mjs`
so alcancavel via outro modulo `scripts/`); a travessia segue toda aresta
relativa (nao so as que caem em `scripts/`), entao um import via
`tests/helpers`/`src/` tambem entra. Se um alias aparecer, o self-check quebra
primeiro.
- **Custo de CI sobe em diff so-scripts.** Adicionar ao mapa faz um diff
so-`scripts/` rodar **unit+int full** e e2e **`curated`** (6 specs congeladas,
OPS86); `build_mode` segue `none` (`isBuildPath` nao inclui `scripts/`).
Precedente: OPS119+ ja aceitou isso para as 3 libs de launch. Mitigacao:
esperado e desejado (fail-closed); registrar no changelog; nao ampliar alem do
fechamento alcancavel por specs.
- **`HIGH_RISK_EXACT` estoura/degrada leitura.** ~42 entradas + grupo nomeado.
Mitigacao: `SCRIPTS_SPEC_PINNED` documentado e **exportado** (consumido pelo
invariante; knip limpo), com o overlap de 8 paths explicado no doc do grupo.
- **Entrada high-risk morta por rename.** Mitigacao: o invariante e de
**igualdade** — o half `stale` (`SCRIPTS_SPEC_PINNED` fora do closure) falha
numa entrada que nao e mais alcancavel. Existencia em disco repo-wide sobre
todo o `HIGH_RISK_EXACT` segue debito diferido (abaixo).
## Debitos diferidos (triagem /simplify)
Triagem autonoma dos achados dos 2 revisores; nenhum vira Issue nova (nenhum
`expensive_lock` score >=4 — a integridade do guard ja e fail-closed).
- **A (score 2, cheap_polish) — `STATIC_IMPORT_RE` sem boundary**: casa
`from './x.mjs'` em comentario/string. Direcao fail-closed (so adiciona
pin/aresta inexistente), nunca falso verde. Gatilho: primeira falha do
invariante por import relativo comentado/em string.
- **D (score 3, defer_trigger) — closure cobre unit+int, nao e2e**: um
setup/spec e2e que importe um modulo `scripts/**` fora do mapa nao seria
pinado (hoje o unico, `seed-minimal-manifest.mjs`, ja e high-risk). Gatilho:
primeiro import de `scripts/**` por um spec/setup e2e fora do mapa.
- **E (score 3, defer_trigger) — existencia em disco repo-wide** sobre todo o
`HIGH_RISK_EXACT`/`HIGH_RISK_PREFIXES` (alem do grupo scripts, que o `stale`
ja cobre). Ja nomeado na intencao-mae. Gatilho: primeira entrada high-risk
morta fora do grupo scripts.
- **B/C descartados**: overlap de 8 paths com o literal (intencional — o grupo e
o mapa exato da igualdade) e custo de CI maior em diff so-scripts (aceito,
fail-closed, precedente OPS119+).
## Aceite de engenharia
- [x] Aceite de produto da intencao coberto: diff so-`scripts/lib/<lib>` pinada
por spec classifica `full` (F1) e diff so-scripts classifica `code` em
`classifyStaticScope` (F2).
- [x] Invariantes AGENTS/engineering-standards: identificadores em ingles, prosa
pt-BR; nenhuma migration; nenhum access/Consent/UI/URL publica tocado;
`HIGH_RISK_EXACT` segue `Set` exportado.
- [x] Testes de dominio previstos: `tests/unit/testAffected.unit.spec.ts` e
`tests/unit/ciSkipInvariants.unit.spec.ts` - todos unit, sem harness novo.
- [x] `pnpm gate:fast` verde; `pnpm push` no fluxo normal de PR.
## Self-score decision-quality
1. Decisoes caras tem rejeitadas? **5/5** - forma do invariante, escopo
unit-vs-unit+int, shape da lista e F2 prefixo-vs-lista registraram
Opcoes/Recomendacao/Rejeitadas; as rejeitadas herdadas da intencao-mae
(prefixo `scripts/lib/`) ficam explicitas.
2. Cabe no appetite (~1h)? **5/5** - F1 ~35min (grupo + invariante + 2 pins),
F2 ~15min (1 predicado + pins), gates ~10min; sem migration, sem e2e novo,
sem UI.
3. Rabbit holes nomeados? **5/5** - prefixo `scripts/lib/` inteiro, existencia
repo-wide, AST/dynamic import, `classifyBuildScope`, S1/S2/S3, guard
`isMain`, UI/Consent.
4. Depth check reusa shells/helpers? **4/5** - reusa `HIGH_RISK_EXACT`,
`isCodePath`/`classifyStaticScope` e os specs donos que ja importam o modulo;
nao cria modulo paralelo nem derived-data commitado; o walker e local ao
spec-to-be que ja tem `fs`.
5. Intencao (aceite de produto) intacta? **5/5** - a engenharia nao reescreveu o
outcome; o invariante fail-closed e a unica forma compativel com o contrato
pure do dono e cobre superset com divergencia registrada.
**Score: 24/25 - aprovado; gate confirmou o superset unit+int (42 paths) em
2026-09-16.**