diff --git a/docs/changelog/2026-09-16-ops119plusplus.md b/docs/changelog/2026-09-16-ops119plusplus.md new file mode 100644 index 00000000..335c3f85 --- /dev/null +++ b/docs/changelog/2026-09-16-ops119plusplus.md @@ -0,0 +1 @@ +**OPS119++ — blast radius de `scripts/`: risk map das libs restantes + static scope (2026-09-16):** fecha o resíduo da classe S6 que a OPS119+ (#1071) tinha tratado só para as libs de launch. **F1:** `HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs`) cobria só alguns dos `scripts/lib/*.mjs` unit-pinados — um diff que tocasse **apenas** uma lib como `scripts/lib/worktree-env.mjs` (pinada por `tests/unit/worktree.unit.spec.ts` e importada por `scripts/lib/auto-unblock.mjs`) classificava `classifyTestScope` como `none` e a suíte que a cobre nunca rodava. Novo grupo **exportado** `SCRIPTS_SPEC_PINNED` (42 caminhos `scripts/**` alcançáveis de specs unit+int, deduplicado no `Set`) espalhado em `HIGH_RISK_EXACT`, sincronizado por um invariante de **igualdade** em `tests/unit/ciSkipInvariants.unit.spec.ts` que recalcula o fechamento transitivo do grafo de imports dos specs (arestas relativas, inclusive via `tests/helpers`/`src/`): lib nova pinada falha como `missing`, rename/entrada morta falha como `stale` — o buraco não reabre em silêncio. **F2:** `isCodePath` passa a cobrir `scripts/`, então `classifyStaticScope` devolve `code` e typecheck/knip/cycles deixam de pular um diff só-scripts. `classifyBuildScope`/`build_mode` intocados. Pins de comportamento em `tests/unit/testAffected.unit.spec.ts` (`classifyTestScope` → `full`, `classifyStaticScope` → `code`, `classifyBuildScope` → `none`). Sem migration, sem access/Consent/UI/URL pública (Impeccable A). `pnpm gate:fast` verde (lint + typecheck + 3421 unit). Débitos diferidos da triagem `/simplify` (regex sem boundary; closure não cobre e2e; existência repo-wide) registrados no impl plan — nenhum vira Issue nova. Issue #1077. Impl plan: `docs/plans/escala-dry-pos-scripts-blast-radius-impl.md`. diff --git a/docs/plans/escala-dry-pos-scripts-blast-radius-impl.md b/docs/plans/escala-dry-pos-scripts-blast-radius-impl.md new file mode 100644 index 00000000..590089f3 --- /dev/null +++ b/docs/plans/escala-dry-pos-scripts-blast-radius-impl.md @@ -0,0 +1,268 @@ +# 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/` 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.** diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index de55ab21..fd68f981 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -17,6 +17,62 @@ import { E2E_SMOKE_FALLBACK_SPEC, } from './e2e-affected-manifest.mjs' +/** + * `scripts/**` modules reachable from a spec under `tests/` (transitive over + * relative imports). Each is pinned by the suite a lib-only diff would + * otherwise skip: without the entry `classifyTestScope` returns `none` and the + * covering spec never runs (S6, OPS119++). The list is literal because this + * module is pure (no fs); the invariant in + * tests/unit/ciSkipInvariants.unit.spec.ts recomputes this closure and requires + * exact equality, so a new spec-pinned module (missing) or a rename (stale) + * fails the suite until the map tracks it. Overlap with the explicit entries in + * HIGH_RISK_EXACT below is intentional — this group is the exact map. + */ +export const SCRIPTS_SPEC_PINNED = [ + 'scripts/check-test-locations.mjs', + 'scripts/cityReportSnapshot.mjs', + 'scripts/lib/agent-forgejo.mjs', + 'scripts/lib/agent-plan-lifecycle.mjs', + 'scripts/lib/agent-pool-cursor.mjs', + 'scripts/lib/agent-pool-eligibility.mjs', + 'scripts/lib/agent-pool-models.mjs', + 'scripts/lib/agent-pool-prompt.mjs', + 'scripts/lib/agent-pool-state.mjs', + 'scripts/lib/agent-session.mjs', + 'scripts/lib/ansi.mjs', + 'scripts/lib/auto-unblock.mjs', + 'scripts/lib/camaraSpeeches.mjs', + 'scripts/lib/changelog.mjs', + 'scripts/lib/cityReportBlocks.mjs', + 'scripts/lib/cityReportDatabase.mjs', + 'scripts/lib/cityReportFormat.mjs', + 'scripts/lib/cityReportRender.mjs', + 'scripts/lib/cityReportResearch.mjs', + 'scripts/lib/cityReportTerritory.mjs', + 'scripts/lib/cli.mjs', + 'scripts/lib/conflictMarkers.mjs', + 'scripts/lib/db-start.mjs', + 'scripts/lib/deploy-trigger.mjs', + 'scripts/lib/e2e-affected-manifest.mjs', + 'scripts/lib/github-api.mjs', + 'scripts/lib/github-branch-protection.mjs', + 'scripts/lib/github-pr-flow.mjs', + 'scripts/lib/imageFill.mjs', + 'scripts/lib/imageResize.mjs', + 'scripts/lib/issues-panel.mjs', + 'scripts/lib/markdown-ansi.mjs', + 'scripts/lib/plansOnlyClosesGuard.mjs', + 'scripts/lib/playwright-e2e-args.mjs', + 'scripts/lib/portalTransparenciaEmendas.mjs', + 'scripts/lib/seed-minimal-manifest.mjs', + 'scripts/lib/sollaCeuciSalvadorMetrics.mjs', + 'scripts/lib/test-affected-core.mjs', + 'scripts/lib/testing-audit-metrics-core.mjs', + 'scripts/lib/worktree-env.mjs', + 'scripts/lib/worktree.mjs', + 'scripts/lib/wpArticles.mjs', +] + /** * Paths whose blast radius is the whole app: schema, test harness, lockfile, * skip classifiers. Any diff touching them runs the FULL suites — selection @@ -53,6 +109,9 @@ export const HIGH_RISK_EXACT = new Set([ 'tests/unit/ciSkipInvariants.unit.spec.ts', 'tests/unit/testAffected.unit.spec.ts', '.env.test', + // OPS119++: spec-pinned scripts/** modules (some already explicit above; the + // Set dedupes — SCRIPTS_SPEC_PINNED is the exact map the invariant checks). + ...SCRIPTS_SPEC_PINNED, ]) export const HIGH_RISK_PREFIXES = [ @@ -133,6 +192,7 @@ export function isSrcPath(path) { */ export function isCodePath(path) { if (isSrcPath(path) || isTestPath(path)) return true + if (path.startsWith('scripts/')) return true if (CODE_CONFIG_EXACT.has(path)) return true if (CODE_CONFIG_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}.`))) { return true @@ -185,7 +245,7 @@ export function classifyTestScope(files) { */ export function classifyStaticScope(files) { if (files.some(({ path }) => isCodePath(path))) { - return { mode: 'code', reason: 'diff touches src/tests or type/graph config' } + return { mode: 'code', reason: 'diff touches src/tests/scripts or type/graph config' } } return { mode: 'none', reason: 'no code/type/graph surface changes' } } diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index 8eb04b26..0e62e063 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -1,12 +1,15 @@ // @vitest-environment node import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { checkTestLocations } from '../../scripts/check-test-locations.mjs' +import { + checkTestLocations, + collectRepoRelativeFiles, +} from '../../scripts/check-test-locations.mjs' import { E2E_AFFECTED_MANIFEST } from '../../scripts/lib/e2e-affected-manifest.mjs' import { CANONICAL_E2E_SPEC_SUFFIX, @@ -24,6 +27,7 @@ import { isMisplacedSpecPath, isSrcPath, isTestPath, + SCRIPTS_SPEC_PINNED, } from '../../scripts/lib/test-affected-core.mjs' const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..') @@ -35,11 +39,67 @@ const readWorkflow = (relativePath: string) => .filter((line) => !line.trimStart().startsWith('#')) .join('\n') +/** Unit/int spec files — the surface whose imports pin `scripts/**` modules. */ +const specFiles = (): string[] => + collectRepoRelativeFiles().filter( + (path) => + (path.startsWith('tests/unit/') && /\.unit\.spec\.tsx?$/.test(path)) || + (path.startsWith('tests/int/') && path.endsWith('.int.spec.ts')), + ) + +/** Relative-import edges only — bare/alias imports cannot reach scripts/. */ +const STATIC_IMPORT_RE = /(?:from|import)\s*(?:\(\s*)?['"](\.[^'"]+)['"]/g + +const resolveRelativeImport = (specifier: string, fromDir: string): string | null => { + for (const candidate of [ + specifier, + `${specifier}.mjs`, + `${specifier}.ts`, + `${specifier}.tsx`, + `${specifier}.js`, + ]) { + const full = join(fromDir, candidate) + if (existsSync(full)) return full + } + return null +} + +/** + * Transitive `scripts/**` closure from `roots` (repo-relative spec paths). + * Every relative edge is followed; only `scripts/**` nodes are collected, so a + * spec reaching a lib through `tests/helpers` or `src/` is still pinned. + */ +const scriptsClosure = (roots: string[]): Set => { + const closure = new Set() + const visited = new Set() + const queue = roots.map((path) => join(repoRoot, path)) + while (queue.length > 0) { + const file = queue.pop() + if (file === undefined || visited.has(file)) continue + visited.add(file) + let source: string + try { + source = readFileSync(file, 'utf8') + } catch { + continue + } + for (const match of source.matchAll(STATIC_IMPORT_RE)) { + const resolved = resolveRelativeImport(match[1], dirname(file)) + if (!resolved) continue + const relativePath = relative(repoRoot, resolved) + if (relativePath.startsWith('scripts/')) closure.add(relativePath) + if (!visited.has(resolved)) queue.push(resolved) + } + } + return closure +} + describe('ciSkipInvariants', () => { it('exports shared path predicates used by classifiers and the lint guard', () => { expect(isSrcPath('src/lib/x.ts')).toBe(true) expect(isTestPath('tests/unit/x.unit.spec.ts')).toBe(true) expect(isCodePath('tsconfig.json')).toBe(true) + expect(isCodePath('scripts/lib/cli.mjs')).toBe(true) expect(isBuildPath('public/favicon.ico')).toBe(true) expect(isHighRisk('src/migrations/x.ts')).toBe(true) expect(E2E_MANIFEST_DOMAIN_EXEMPT.has('shared')).toBe(true) @@ -129,6 +189,32 @@ describe('ciSkipInvariants', () => { } }) + it('every scripts/ module reachable from a spec is high-risk (OPS119++ blast radius)', () => { + // SCRIPTS_SPEC_PINNED is literal because test-affected-core is pure (no fs), + // so the map sync is enforced here: recompute the spec import-graph closure + // over scripts/** and require exact equality. Without the entry, a lib-only + // diff classifies `none` and the covering spec never runs (S6). + const closure = scriptsClosure(specFiles()) + // Self-check the walker sees real edges — direct and transitive — or the + // equality below would fail for the wrong reason. + expect(closure.has('scripts/lib/worktree-env.mjs')).toBe(true) + expect(closure.has('scripts/lib/cityReportTerritory.mjs')).toBe(true) + + const missing = [...closure].filter((path) => !SCRIPTS_SPEC_PINNED.includes(path)).sort() + const stale = SCRIPTS_SPEC_PINNED.filter((path) => !closure.has(path)).sort() + expect( + missing, + `spec-pinned scripts/ modules missing from SCRIPTS_SPEC_PINNED:\n${missing.join('\n')}`, + ).toEqual([]) + expect( + stale, + `SCRIPTS_SPEC_PINNED entries no longer reachable from a spec (rename/stale):\n${stale.join('\n')}`, + ).toEqual([]) + // The map must stay folded into the high-risk set — a spec-pinned module + // can never classify `none`. + expect(SCRIPTS_SPEC_PINNED.every((path) => HIGH_RISK_EXACT.has(path))).toBe(true) + }) + it('deploy starts on push to main with an active-run guard, dispatch kept (OPS104)', () => { // OPS104 supersedes the OPS71 manual-only pin: a push to main starts the // deploy run in the same workflow, and the preflight guard skips it when diff --git a/tests/unit/testAffected.unit.spec.ts b/tests/unit/testAffected.unit.spec.ts index cd01e7da..5d8ea22d 100644 --- a/tests/unit/testAffected.unit.spec.ts +++ b/tests/unit/testAffected.unit.spec.ts @@ -29,6 +29,7 @@ describe('classifyTestScope (OPS5)', () => { 'scripts/agent-session.mjs', 'scripts/lib/agent-session.mjs', 'scripts/lib/worktree.mjs', + 'scripts/lib/worktree-env.mjs', 'scripts/lib/test-affected-core.mjs', 'tests/helpers/campaignFixtures.ts', 'tests/unit/ciSkipInvariants.unit.spec.ts', @@ -67,6 +68,9 @@ describe('classifyStaticScope', () => { expect(classifyStaticScope([changed('tsconfig.json')]).mode).toBe('code') expect(classifyStaticScope([changed('package.json')]).mode).toBe('code') expect(classifyStaticScope([changed('eslint.config.mjs')]).mode).toBe('code') + // OPS119++: scripts/ is code — a scripts-only diff must not skip + // typecheck/knip/cycles. + expect(classifyStaticScope([changed('scripts/lib/cli.mjs')]).mode).toBe('code') }) it('skips for docs-only diffs', () => { @@ -85,6 +89,10 @@ describe('classifyBuildScope', () => { it('skips for docs-only diffs', () => { expect(classifyBuildScope([changed('docs/AGENT-OPS.md')]).mode).toBe('none') }) + + it('stays none for a scripts-only diff (scripts are not a build surface)', () => { + expect(classifyBuildScope([changed('scripts/lib/cli.mjs')]).mode).toBe('none') + }) }) describe('selectE2eSpecs (OPS5)', () => {