diff --git a/docs/changelog/2026-08-24-o0-ops86.md b/docs/changelog/2026-08-24-o0-ops86.md new file mode 100644 index 00000000..9b695fdc --- /dev/null +++ b/docs/changelog/2026-08-24-o0-ops86.md @@ -0,0 +1 @@ +**O0+ (OPS86+): curated não engole unmapped-risk sem rastro (2026-08-24):** `selectE2eSpecs` (scripts/lib/test-affected-core.mjs) retornava `unmapped: []` no early-return de high-risk antes de computar os unmapped de área de risco — um diff com migração + arquivo de risco sem manifesto perdia o rastro sem diagnóstico. Fix: hoist `collectUnmappedSrc` (fonte única de unmapped, semântica espelho da walk) antes do check high-risk; `unmappedRisk` (filtro `E2E_RISK_PREFIXES`) é exposto no `unmapped` do curated e no `reason` (`; curated + risk files without mapping: ...`). Mode continua `curated` (a curadoria cobre as superfícies de risco). Pin novo em `testAffected.unit.spec.ts`: "high-risk + risco não-mapeado → curated com unmapped no reason". Manifesto production intocado (todo risk prefix tem entry — o caso real só ocorre em drift). Consumidores (`ci-scope`/`e2e-affected`/`run-e2e-affected`/`gate-ci`/`ci-pr.yml`) intocados: `unmapped` populado em curated só soma diagnóstico (stderr + reason), não altera gating. Verificação: `test:unit` 2456 verdes, `gate:fast` verde, classificador exercitado à mão (produção: unmapped vazio; drift: unmapped + risk note). diff --git a/docs/plans/escala-dry-pos-ops86-impl.md b/docs/plans/escala-dry-pos-ops86-impl.md new file mode 100644 index 00000000..26ba3e46 --- /dev/null +++ b/docs/plans/escala-dry-pos-ops86-impl.md @@ -0,0 +1,222 @@ +# Impl: OPS86+: curated não engole unmapped-risk sem rastro + +Status: rascunho +Atualizado em: 2026-08-24 +Issue: #846 +Intenção: docs/plans/escala-dry-pos-ops86.md +Appetite restante: herdado (~2h) + +## Leitura da intenção + +- **Outcome:** um diff high-risk **combinado** com arquivo de área de risco sem + entry no manifesto deixa rastro: `selectE2eSpecs` computa os unmapped de + área de risco **antes** do early-return do curated e os expõe no `unmapped` e + no `reason` do resultado — sem mudar o mode (`curated` continua vencendo). +- **O que NÃO negociar:** mode `curated` continua vencendo o `unmapped-risk` + (a curadoria cobre as superfícies de risco; a rede forte não troca por falso + negativo); pins `ciSkipInvariants`/`e2eAffectedManifest` intactos; nenhum + consumidor muda de semântica (`unmapped` populado em modo curated só soma + diagnóstico — ninguém falha com ele). +- **O que reavaliar:** + 1. O que o `unmapped` do resultado curated deve conter: só os arquivos de + risco (filtro `E2E_RISK_PREFIXES`) ou a lista hoisted completa. Ver D1. + 2. Forma do hoist: helper puro dedicado vs pre-pass inline vs duplicar o + loop. Ver D2. + 3. Wording do `reason`. Ver D3. + +## Abordagem recomendada + +```mermaid +flowchart LR + F[files + manifest] --> H[collectUnmappedSrc
walk hoisted, semântica do loop] + H --> R[unmappedRisk
filtro E2E_RISK_PREFIXES] + R --> C{high-risk?} + C -- sim --> CUR[curated + reason suffix
unmapped: unmappedRisk] + C -- não --> M[loop: só coleta specs
continue via Set de unmappedSrc] + M --> FR{unmappedRisk?} + FR -- sim --> UR[unmapped-risk fail-closed
idêntico ao de hoje] + FR -- não --> SEL[selected / smoke / none
idêntico ao de hoje] +``` + +**Opções consideradas:** + +- **D1 — conteúdo do `unmapped` no curated:** A) só `unmappedRisk` + (recomendada); B) a lista hoisted completa. + - A mantém o pin existente (`testAffected.unit.spec.ts:123-130`: high-risk + `src/migrations/x.ts` → `unmapped toEqual([])`) — `src/migrations/` é + high-risk, é unmapped no manifesto de fixture, mas **não** é prefixo de + risco; se o curated devolvesse a lista completa, esse pin quebraria. + - A preserva a semântica OPS86 de `unmapped` = "conjunto descoberto + relevante ao fail-closed" (o mesmo conjunto que o `reason` nomeia). + - A evita ruído de stderr: com a lista completa, todo PR de migration + printaria `[ci-scope] src/ paths with no e2e manifest mapping (selection +may not cover them): src/migrations/...` num run curated que **cobre** + aquele arquivo por design (label enganosa; o manifesto de produção não + mapeia `src/migrations/` de propósito). + - B rejeitada: quebra o pin, dilui a semântica e cria ruído em todo diff de + schema/lockfile. +- **D2 — forma do hoist:** A) helper puro `collectUnmappedSrc(files, manifest)` + module-local com a walk completa, e o loop principal **consome** a lista + hoisted (via `Set` no `continue`, sem re-coletar `unmapped`) — recomendada; + B) helper + loop mantém a coleta própria (duas cópias da walk); + C) primitiva compartilhada `hasManifestMatch(path, manifest)` com o loop + mantendo a walk própria. + - A é a única com UMA fonte de verdade da semântica de walk (skip de e2e + spec deletada, só `src/`, `startsWith` nos prefixes do manifesto) — o + `unmapped.push` do loop (:242-244) desaparece, o `continue` vira + `unmappedSet.has(path)`, e o `manifest.filter` recomputa os matches só para + arquivos mapeados (custo trivial: dezenas de arquivos × ~230 prefixes). + - B rejeitada: reintroduz exatamente a duplicação que a escala denuncia — + se as cópias divergirem (ex. uma esquece o guard `status === 'D'`), o bug + ressurge sem teste que o pegue. + - C rejeitada: compartilha só o predicado; a walk (quais arquivos contam) + continua duplicada; A é estritamente mais limpa. + - Sub-decisão: helper **não exportado** — um único call site, sem novo + surface público; o comportamento é pinado por `selectE2eSpecs`, que é o + contrato real. +- **D3 — wording do reason:** sufixo `; curated + risk files without mapping: +` apensado ao texto atual do curated quando `unmappedRisk` não + vazio. Texto-base intocado; sufixo greppável e fiel ao exemplo da intenção. + Rejeitada: fundir os arquivos na frase existente (sentence longa, menos + greppável). Não há pin do reason do curated (único pin de reason no arquivo: + `:166` `toContain('dev-mode-only')`, branch setup-only), então o wording é + contrato-fraco — mas o pin novo casa o substring, então fica semi-pinado. + +**Recomendação:** D1-A + D2-A + D3 — helper puro hoisted, curated devolve só +`unmappedRisk` com reason-suffix, loop consome a lista hoisted. Nenhum outro +branch muda de comportamento (`unmapped-risk`/smoke/selected/none idênticos: +as listas são as mesmas por construção — a walk do helper espelha exatamente +:230-247). + +### Componentes / mudanças + +- **`scripts/lib/test-affected-core.mjs`** — único arquivo de produto: + - Novo helper module-local acima de `selectE2eSpecs`: + ```mjs + function collectUnmappedSrc(files, manifest) { + const unmapped = [] + for (const { path, status } of files) { + if (status === 'D' && E2E_SPEC_PATTERN.test(path)) continue + if (E2E_SPEC_PATTERN.test(path)) continue + if (!path.startsWith('src/')) continue + if (manifest.some((entry) => entry.prefixes.some((prefix) => path.startsWith(prefix)))) + continue + unmapped.push(path) + } + return unmapped + } + ``` + - `selectE2eSpecs` (:218): hoist logo no topo — + `const unmappedSrc = collectUnmappedSrc(files, manifest)` e + `const unmappedRisk = unmappedSrc.filter((path) => E2E_RISK_PREFIXES.some((prefix) => path.startsWith(prefix)))`. + - Early-return high-risk (:219-227): devolve `unmapped: unmappedRisk` e + `reason` com o sufixo (D3) quando `unmappedRisk.length > 0`. + - Loop principal (:230-247): `const unmapped = unmappedSrc`; `unmapped.push` + (:242-244) vira `continue` via `unmappedSet.has(path)` (Set construído da + lista hoisted); a coleta de specs continua igual. + - Filtro `unmappedRisk` de :248-250 e branch fail-closed :251-258: usam o + `unmappedRisk` hoisted (mesma lista, uma cópia do filtro). + - Branches smoke/setup-only/none/selected: intocados (a variável `unmapped` + já é a lista hoisted; o `unmapped: []` do branch `none` (:273) fica como + está — inalcançável com unmapped não vazio, diff mínimo). +- **`tests/unit/testAffected.unit.spec.ts`** — pin novo, junto ao bloco curated + (:123-130), usando o manifesto de fixture (:88-91, que não tem entry de + risco — o caso combinado cai limpo): + ```ts + it('high-risk + risk file without mapping → curated com unmapped no reason', () => { + const result = selectE2eSpecs( + [changed('src/migrations/x.ts'), changed('src/utilities/access/brandNewPolicy.ts')], + manifest, + ) + expect(result.mode).toBe('curated') + expect(result.specs).toEqual(E2E_CURATED_SPECS) + expect(result.unmapped).toEqual(['src/utilities/access/brandNewPolicy.ts']) + expect(result.reason).toContain('curated + risk files without mapping') + }) + ``` + (`src/migrations/x.ts` = high-risk via prefixo; `src/utilities/access/...` = + prefixo de risco sem match no manifesto de fixture → unmapped; curated vence.) +- **Consumidores** (`ci-scope.mjs:59-66`, `e2e-affected.mjs:50-57`, + `run-e2e-affected.mjs:50-61`, `gate-ci.mjs:89-94`): **intocados** — nenhum + falha com `unmapped` populado em curated; o rastro aparece como stderr-warning + (label não-risco, correta para o caso) e no `reason` impresso pelo + `run-e2e-affected`. +- **Manifesto** (`scripts/lib/e2e-affected-manifest.mjs`), `ci-pr.yml`, + `deploy.yml`: intocados. Em produção todo prefixo de risco tem entry (pin + `e2eAffectedManifest.unit.spec.ts:67-75`), então o caso real só ocorre em + drift de manifesto — o pin unitário é o guard. +- **Migration/schema/UI/Access/Consent:** nenhum. + +## Fases verificáveis + +1. **Núcleo:** helper + reordenação em `selectE2eSpecs` (D1-A/D2-A/D3) no + `test-affected-core.mjs`. Rodar `pnpm test:unit` — pins existentes verdes + (curated :123-130, unmapped-risk :139-156, deleted-e2e :107-113, reason + setup-only :166) + exercitar o caso combinado à mão: + `node -e` importando `selectE2eSpecs` com files sintéticos + manifesto de + fixture (espelho do pin novo) e conferindo `mode`/`unmapped`/`reason`. +2. **Pin:** caso "high-risk + risco não-mapeado → curated com unmapped no + reason" em `testAffected.unit.spec.ts`. `pnpm test:unit` verde. +3. **Gates:** `pnpm gate:fast` (lint + typecheck + test:unit); opcional + `pnpm gate:ci` (espelho full local, incl. int/build); changelog: + `docs/changelog/2026-08-24-ops86-curated-unmapped-rastro.md` (entrada curta, + append-only); push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- `classifyTestScope`/`classifyStaticScope`/`classifyBuildScope`, + `findUncoveredE2eDomainPrefixes` — intocados. +- Resultados sintéticos sem merge-base (`ci-scope.mjs:42`, `e2e-affected.mjs:32`) + — sem files a classificar, nada a hoist. +- Wording do label de warning do `ci-scope`/`e2e-affected` em modo curated + ("selection may not cover them") — cosmético, deixa quieto: o warning agora + acerta quando há unmapped de risco de verdade (drift) e nunca aparece nos + diffs high-risk normais. +- `seed-posts.mjs` unmapped (taxonomia) — fora do caminho e2e. +- `e2eShardConfig` (removido no OPS62) — não existe mais. +- `deploy.yml` verify (suíte full) — intocado. +- S7 (prefixo `src/utilities/ai` sem trailing slash over-seleciona futuro + `src/utilities/ai*`) e S9 (argv do `vitest-changed-or-full.mjs`) — defers com + gatilho do plano de intenção, mantidos. +- Não adicionar warn próprio do curated nos consumidores (o `reason` já carrega + o rastro e o stderr genérico cobre). + +## Riscos e mitigação + +- **Quebrar o pin curated existente (`:128` `unmapped toEqual([])`)**: só + acontece se a entrega devolver a lista completa em vez de `unmappedRisk` + (D1-B). Mitigação: D1-A justificada no plano + o próprio pin :123-130 roda + em `src/migrations/x.ts` (high-risk + unmapped, não-risco) — se o D1-B + vazar, o CI falha loud. +- **Helper divergir da semântica do loop** (ex. esquecer o guard de e2e spec + deletada): com D2-A não há segunda cópia para divergir — o loop consome a + lista hoisted; os pins existentes de deleted-e2e (`:107-113`) e deleted-src + (`:115-121`) exercitam a walk em modos não-curated. +- **Diff auto-referencial**: `test-affected-core.mjs` e + `testAffected.unit.spec.ts` estão em `HIGH_RISK_EXACT` — o PR desta entrega + roda o próprio classificador no CI (curated) e `gate:ci` roda unit full. + Mitigação: `pnpm test:unit` + `pnpm gate:fast` locais antes do push. +- **Custo da walk sempre executada** (hoje o early-return pula a walk em + high-risk): O(files × prefixes do manifesto), dezenas × ~230 — desprezível; + nenhum consumidor mede esse caminho. +- **Wording do reason sem pin**: o pin novo casa o substring + `curated + risk files without mapping`; texto-base do curated preservado + (nenhum teste depende dele, mas estabilidade evita ruído de diff). + +## Aceite de engenharia + +- [ ] `collectUnmappedSrc` module-local espelha a walk do loop (skip de e2e + spec deletada, só `src/`, `startsWith` nos prefixes) e é a única fonte de + `unmapped` em `selectE2eSpecs`. +- [ ] Early-return high-risk devolve `unmapped: unmappedRisk` (só prefixos de + risco — pin :123-130 verde) e `reason` com sufixo + `; curated + risk files without mapping: ` só quando não vazio. +- [ ] Branches `unmapped-risk`/smoke/setup-only/none/selected com listas + idênticas ao comportamento atual (pins :107-161 verdes). +- [ ] Pin novo "high-risk + risco não-mapeado → curated com unmapped no + reason" em `testAffected.unit.spec.ts` (mode/specs/unmapped/reason). +- [ ] `ciSkipInvariants`/`e2eAffectedManifest` e consumidores + (`ci-scope`/`e2e-affected`/`run-e2e-affected`/`gate-ci`/`ci-pr.yml`) + intactos. +- [ ] `pnpm test:unit` e `pnpm gate:fast` verdes; changelog entry curta + append-only; `pnpm push`. diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 22c2b96e..f4ce6634 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -198,6 +198,29 @@ export function classifyBuildScope(files) { const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/ +/** + * Walk files and collect src/ paths that have no manifest match. + * This is the single source of truth for unmapped paths in selectE2eSpecs — the + * loop below consumes this list (via Set) instead of re-collecting. + * Mirrors loop semantics: skip deleted e2e spec files, only src/ paths, startsWith. + * @param {{ path: string, status: string }[]} files + * @param {{ prefixes: string[], specs: string[] }[]} manifest + * @returns {string[]} + */ +function collectUnmappedSrc(files, manifest) { + const unmapped = [] + for (const { path } of files) { + // Skip e2e spec files (deleted or not) — they cannot select src/ domains. + if (E2E_SPEC_PATTERN.test(path)) continue + if (!path.startsWith('src/')) continue + if (manifest.some((entry) => entry.prefixes.some((prefix) => path.startsWith(prefix)))) { + continue + } + unmapped.push(path) + } + return unmapped +} + /** * Decide the Playwright scope for a PR diff. * @param {{ path: string, status: string }[]} files @@ -207,7 +230,9 @@ const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/ * * Modes (OPS86): * - `curated` — high-risk diff: run `E2E_CURATED_SPECS`, never zero (the - * full suite stays in the deploy verify). + * full suite stays in the deploy verify). If risk-area files without a + * manifest entry exist, they are included in `unmapped` and appended to + * `reason` (OPS86+: diagnostic visibility even when curated wins). * - `unmapped-risk` — a risk-area file (`E2E_RISK_PREFIXES`) matched no * manifest entry: the CI fails closed listing the files instead of going * green with zero e2e. @@ -216,17 +241,28 @@ const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/ * - `none` — no e2e-relevant changes (docs-only). */ export function selectE2eSpecs(files, manifest) { + // OPS86+: compute unmapped src/ files before any early-return so risk-area + // files without a manifest entry surface in the curated result (mode stays + // curated — the curated cross-section covers risk surfaces). + const unmappedSrc = collectUnmappedSrc(files, manifest) + const unmappedRisk = unmappedSrc.filter((path) => + E2E_RISK_PREFIXES.some((prefix) => path.startsWith(prefix)), + ) if (files.some(({ path }) => isHighRisk(path))) { return { mode: 'curated', specs: [...E2E_CURATED_SPECS], reason: - 'diff touches a high-risk path (schema/lockfile/test harness) — curated e2e cross-section; full suite stays in the deploy verify', - unmapped: [], + 'diff touches a high-risk path (schema/lockfile/test harness) — curated e2e cross-section; full suite stays in the deploy verify' + + (unmappedRisk.length > 0 + ? ` — curated + risk files without mapping: ${unmappedRisk.join(', ')}` + : ''), + unmapped: unmappedRisk, } } + // Main loop: collect specs. The hoisted unmappedSrc list is the single source + // of truth for unmapped; unmapped files are skipped via `continue`. const specs = new Set() - const unmapped = [] for (const { path, status } of files) { // A deleted spec file cannot run — selecting it would make CI fail with // "No tests found" (the stale-manifest variant of a spec rename). @@ -239,31 +275,26 @@ export function selectE2eSpecs(files, manifest) { const matches = manifest.filter((entry) => entry.prefixes.some((prefix) => path.startsWith(prefix)), ) - if (matches.length === 0) { - unmapped.push(path) - continue - } + if (matches.length === 0) continue // already in unmappedSrc for (const match of matches) for (const spec of match.specs) specs.add(spec) } - const unmappedRisk = unmapped.filter((path) => - E2E_RISK_PREFIXES.some((prefix) => path.startsWith(prefix)), - ) + // unmappedRisk is already computed above; reuse for the fail-closed branch. if (unmappedRisk.length > 0) { return { mode: 'unmapped-risk', specs: [], reason: 'risk-area files have no e2e manifest mapping — add an entry (fail-closed)', - unmapped, + unmapped: unmappedSrc, } } if (specs.size === 0) { - if (unmapped.length > 0) { + if (unmappedSrc.length > 0) { return { mode: 'selected', specs: [E2E_SMOKE_FALLBACK_SPEC], reason: 'src/ changes with no e2e manifest mapping — running the home smoke (never zero e2e)', - unmapped, + unmapped: unmappedSrc, } } return { @@ -281,26 +312,26 @@ export function selectE2eSpecs(files, manifest) { // setup-only case drops out — and an unmapped src/ file still wakes the // smoke instead (OPS86: never zero e2e on touched code). if (specs.size === 1 && specs.has('setup')) { - if (unmapped.length > 0) { + if (unmappedSrc.length > 0) { return { mode: 'selected', specs: [E2E_SMOKE_FALLBACK_SPEC], reason: 'setup-only spec plus unmapped src/ changes — running the home smoke', - unmapped, + unmapped: unmappedSrc, } } return { mode: 'none', specs: [], reason: 'setup spec is dev-mode-only; prod-mode e2e cannot run it', - unmapped, + unmapped: unmappedSrc, } } return { mode: 'selected', specs: [...specs].sort(), reason: `${specs.size} spec(s) selected via manifest and changed specs`, - unmapped, + unmapped: unmappedSrc, } } diff --git a/tests/unit/testAffected.unit.spec.ts b/tests/unit/testAffected.unit.spec.ts index e61ea0f9..01631420 100644 --- a/tests/unit/testAffected.unit.spec.ts +++ b/tests/unit/testAffected.unit.spec.ts @@ -129,6 +129,17 @@ describe('selectE2eSpecs (OPS5)', () => { } }) + it('reports unmapped risk-area files even when a high-risk path wins (OPS86+)', () => { + const result = selectE2eSpecs( + [changed('src/migrations/x.ts'), changed('src/utilities/access/brandNewPolicy.ts')], + manifest, + ) + expect(result.mode).toBe('curated') + expect(result.specs).toEqual(E2E_CURATED_SPECS) + expect(result.unmapped).toEqual(['src/utilities/access/brandNewPolicy.ts']) + expect(result.reason).toContain('curated + risk files without mapping') + }) + it('wakes the home smoke for unmapped non-risk src files (never zero, OPS86)', () => { const result = selectE2eSpecs([changed('src/utilities/brandNewModule.ts')], manifest) expect(result.mode).toBe('selected')