From 9c5cd7830bdbcd506753ee5624261b93e9a2d36f Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 24 Aug 2026 14:21:33 -0300 Subject: [PATCH] simplify(OPS86): gate fail-fast no unmapped-risk, smoke fallback no manifesto, pins de sync do wrapper, docs AGENT-OPS (Related #846) --- .github/workflows/ci-pr.yml | 4 +- docs/AGENT-OPS.md | 16 +++--- docs/plans/escala-dry-pos-ops86.md | 60 +++++++++++++++++++++ scripts/ci-scope.mjs | 2 +- scripts/e2e-affected.mjs | 8 +-- scripts/gate-ci.mjs | 13 ++++- scripts/lib/e2e-affected-manifest.mjs | 12 ++++- scripts/lib/test-affected-core.mjs | 9 ++-- scripts/run-e2e-affected.mjs | 18 ++++--- scripts/vitest-changed-or-full.mjs | 12 ++++- tests/unit/ciSkipInvariants.unit.spec.ts | 17 ++++++ tests/unit/e2eAffectedManifest.unit.spec.ts | 7 +++ tests/unit/testAffected.unit.spec.ts | 6 ++- 13 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 docs/plans/escala-dry-pos-ops86.md diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 9ad2c0c1..871e6d66 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -121,11 +121,11 @@ jobs: - name: Fail on unmapped risk paths if: steps.scope.outputs.e2e_mode == 'unmapped-risk' run: | - echo "::error::Risk-area files touched by this PR have no e2e manifest mapping:" + echo "::error::Risk-area files touched by this PR have no e2e manifest mapping (fail-closed). Unmapped files:" for file in ${{ steps.scope.outputs.e2e_unmapped }}; do echo "::error::- $file" done - echo "::error::Add an entry in scripts/lib/e2e-affected-manifest.mjs (E2E_AFFECTED_MANIFEST) covering these paths." + echo "::error::Add entries in scripts/lib/e2e-affected-manifest.mjs (E2E_AFFECTED_MANIFEST) covering the risk-area paths." exit 1 # Content guards are PR-scoped and cheap — run them before the suite so diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 64927742..db933a6b 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -74,14 +74,14 @@ Arquivos de "lista" que N agentes escrevem em paralelo (plans, migrações, cata ## CI por alvo (GitHub Actions, OPS71) -| Workflow | Trigger | Banco | Passos | -| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `ci-pr.yml` | PR → `main` (same-repo) | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** (`selected`, nunca `full` — OPS72) em 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host); fork-PR excluída no `if:` do job (repo público — não queima minutes) | -| `deploy.yml` | **`workflow_dispatch` manual** | 2× service Postgres 17 | **Dois jobs:** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→build→e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh ` **localmente** no homeserver (sem SSH; idempotente; HEAD guard; flock; migrator→migrate→runner→rollout→smoke) | -| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no GitHub** (token `GITHUB_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) | -| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no GitHub** (OPS18; soft-skip; mesmo token) | -| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip | -| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool | +| Workflow | Trigger | Banco | Passos | +| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ci-pr.yml` | PR → `main` (same-repo) | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** — `selected` ou, em high-risk, o conjunto `curated` (nunca `full` — OPS72/OPS86); área de risco sem mapping falha `unmapped-risk`; unit/int `changed` com seleção vazia cai em full (`vitest-changed-or-full.mjs`) — em 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host); fork-PR excluída no `if:` do job (repo público — não queima minutes) | +| `deploy.yml` | **`workflow_dispatch` manual** | 2× service Postgres 17 | **Dois jobs:** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→build→e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh ` **localmente** no homeserver (sem SSH; idempotente; HEAD guard; flock; migrator→migrate→runner→rollout→smoke) | +| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no GitHub** (token `GITHUB_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) | +| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no GitHub** (OPS18; soft-skip; mesmo token) | +| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip | +| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool | `agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do dispatch manual roda a suíte full. diff --git a/docs/plans/escala-dry-pos-ops86.md b/docs/plans/escala-dry-pos-ops86.md new file mode 100644 index 00000000..336880fc --- /dev/null +++ b/docs/plans/escala-dry-pos-ops86.md @@ -0,0 +1,60 @@ +# OPS86+: curated não engole unmapped-risk sem rastro + +Status: rascunho +Atualizado em: 2026-08-24 +Issue: #846 +Intenção: docs/plans/ops86-blast-radius-testes-prs.md (débito pós-entrega) +Priority: P2 +Impeccable: A — N/A sem UI +Appetite: ~2h eng + +## Origem + +Triagem do simplify da entrega OPS86 (#832): o cheque high-risk de +`selectE2eSpecs` (`scripts/lib/test-affected-core.mjs`) precede a computação +de `unmappedRisk`. Um diff que toca uma migração (high-risk) **e** um arquivo +novo de área de risco (ex. `src/utilities/access/novo.ts`) cai em `curated` +com `unmapped: []` — o arquivo de risco desaparece sem rastro no `reason`. +Segurança mantida (o conjunto curado inclui as specs de RBAC/visibilidade), +mas o fail-closed é bypassado sem pista de diagnóstico exatamente no caso que +o OPS86 existe para caçar. + +## Fase única + +1. **Núcleo + pin** — reordenar `selectE2eSpecs` para computar os unmapped de + área de risco **antes** do early-return de high-risk, e incluí-los no + `reason` do curated (ex.: "curated + risk files without mapping: …"), sem + mudar o mode (`curated` continua vencendo — a curadoria cobre as + superfícies de risco). Alternativa rejeitada: fazer `unmapped-risk` vencer + o high-risk — piora o falso negativo (a spec nova de RBAC poderia não + existir e o curated é a rede mais forte). Pins: `testAffected.unit.spec.ts` + ganha caso "high-risk + risco não-mapeado → curated com unmapped no + reason"; `ciSkipInvariants`/`e2eAffectedManifest` intactos. + Verificação: `pnpm test:unit` (pins), `pnpm gate:fast`, classificador + exercitado à mão no caso combinado. + +## Já resolvido no simplify/critique (não reabrir) + +- gate-ci falha em `unmapped-risk` (espelho fail-fast do CI); pin do wrapper + espelhando `package.json`; `E2E_SMOKE_FALLBACK_SPEC` movido para o + manifesto; labels risk-aware no `ci-scope`/`e2e-affected`; wording do passo + de falha no `ci-pr.yml`; switch no `run-e2e-affected`; `AGENT-OPS.md` + atualizado. + +## Explicitamente fora (defer/descartes desta triagem) + +- **Descartado:** pin do ci-pr.yml casa 1 ocorrência de `selected || curated` + (S6) — remover `curated` de um passo isolado quebra loud no CI (sem + browser/build); pin redundante aceitável. +- **Descartado:** replicação da lista curada/risco em docs (S10) — o pin de + teste é o guard real; changelog é registro histórico por convenção. +- **Descartado:** 10 specs órfãs full-only (G1) — gap já registrado no + changelog da entrega; dono natural é o OPS87 (#833). +- **Defer + gatilho:** `startsWith('src/utilities/ai')` sem trailing slash + over-seleciona um futuro `src/utilities/ai*` (S7) — direção segura + (fail-closed); gatilho: surgir o 1º módulo irmão `src/utilities/ai*` fora + de `ai/`. +- **Defer + gatilho:** parse manual de argv no `vitest-changed-or-full.mjs` + diverge do `scripts/lib/cli.mjs` (3º estilo de parsing no repo) (S9) — + DRY com 1 call site de 2 flags; gatilho: 3ª flag ou 2º script duplicando o + loop. diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index 4a4f70ff..ccfdfc05 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -61,7 +61,7 @@ if (e2e.unmapped.length > 0) { const label = e2e.mode === 'unmapped-risk' ? '[ci-scope] RISK-AREA paths with no e2e manifest mapping (CI will fail closed):' - : '[ci-scope] src/ paths with no e2e manifest mapping (running the home smoke):' + : '[ci-scope] src/ paths with no e2e manifest mapping (selection may not cover them):' console.error(`${label}\n ${e2e.unmapped.join('\n ')}`) } diff --git a/scripts/e2e-affected.mjs b/scripts/e2e-affected.mjs index 32a1602e..2a867668 100644 --- a/scripts/e2e-affected.mjs +++ b/scripts/e2e-affected.mjs @@ -49,8 +49,10 @@ const files = raw const result = { ...selectE2eSpecs(files, E2E_AFFECTED_MANIFEST), base } if (result.unmapped.length > 0) { - console.error( - `[e2e-affected] src/ paths with no manifest mapping:\n ${result.unmapped.join('\n ')}`, - ) + const label = + result.mode === 'unmapped-risk' + ? '[e2e-affected] RISK-AREA paths with no manifest mapping (CI fails closed; running curated locally):' + : '[e2e-affected] src/ paths with no manifest mapping (the selection may not cover them):' + console.error(`${label}\n ${result.unmapped.join('\n ')}`) } console.log(JSON.stringify(result)) diff --git a/scripts/gate-ci.mjs b/scripts/gate-ci.mjs index 442dfabc..625665a2 100644 --- a/scripts/gate-ci.mjs +++ b/scripts/gate-ci.mjs @@ -84,6 +84,15 @@ const main = async () => { `[gate:ci] scope base=${scope.base} code=${scope.code.mode} build=${scope.build.mode} test=${scope.test.mode} e2e=${scope.e2e.mode} (e2e not in this gate — OPS72; local affected is a skill step)`, ) + // Fail-fast mirror of the CI fail-closed step: risk-area files without a + // manifest entry fail the job — catch it before running the whole suite. + if (scope.e2e.mode === 'unmapped-risk') { + console.error( + `\n[gate:ci] ✗ e2e: RISK-AREA files without a manifest mapping — CI fails closed on this, so the local mirror fails too:\n ${scope.e2e.unmapped.join('\n ')}\n Add an e2e manifest entry (scripts/lib/e2e-affected-manifest.mjs) covering them.`, + ) + process.exit(1) + } + // --- Phase 1 (cheap) --- run('check-test-locations', 'node', ['scripts/check-test-locations.mjs']) run('lint', 'pnpm', ['lint']) @@ -163,9 +172,9 @@ const main = async () => { console.log( `\n[gate:ci] ▶ e2e: diff is high-risk — CI runs the curated cross-section (${scope.e2e.specs.join(', ')}) — not in this gate (OPS72); run \`pnpm test:e2e:affected\` locally (runs full); deploy verify runs full before publishing`, ) - } else if (scope.e2e.mode === 'unmapped-risk') { + } else { console.log( - `\n[gate:ci] ✗ e2e: RISK-AREA files without a manifest mapping — CI WILL FAIL on:\n ${scope.e2e.unmapped.join('\n ')}\n Add an e2e manifest entry for them before pushing.`, + `\n[gate:ci] ⚠ e2e: unexpected mode "${scope.e2e.mode}" — review scripts/ci-scope.mjs (contract drift?)`, ) } diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 86546e85..5cf3d53c 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -44,6 +44,14 @@ export const E2E_RISK_PREFIXES = [ 'src/utilities/ai', ] +/** + * Generic e2e smoke for unmapped non-risk src/ files (OPS86 fallback): the + * home spec renders the campaign shell (login + home) with console-error + * fail-fast. Lives here (not in the core) so the manifest entries below can + * reference the same constant without a cycle. + */ +export const E2E_SMOKE_FALLBACK_SPEC = 'campaignHomeActions' + export const E2E_AFFECTED_MANIFEST = [ { prefixes: ['src/app/(payload)'], @@ -201,7 +209,7 @@ export const E2E_AFFECTED_MANIFEST = [ // Web Push client — the opt-in toast mounts on the campaign shell, so a // push diff wakes the home smoke (console-error fail-fast covers it). prefixes: ['src/utilities/campaignPushClient.ts'], - specs: ['campaignHomeActions'], + specs: [E2E_SMOKE_FALLBACK_SPEC], }, { prefixes: ['src/app/(campaign)/campanha/agenda/ical', 'src/utilities/calendarFeed'], @@ -244,6 +252,6 @@ export const E2E_AFFECTED_MANIFEST = [ `${CAMPAIGN_APP}/perfil`, `${CAMPAIGN_APP}/quadro`, ], - specs: ['campaignHomeActions'], + specs: [E2E_SMOKE_FALLBACK_SPEC], }, ] diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 4716417e..22c2b96e 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -11,10 +11,11 @@ * file wakes the home smoke. */ -import { E2E_CURATED_SPECS, E2E_RISK_PREFIXES } from './e2e-affected-manifest.mjs' - -/** Generic e2e smoke for unmapped non-risk src/ files (OPS86 fallback). */ -export const E2E_SMOKE_FALLBACK_SPEC = 'campaignHomeActions' +import { + E2E_CURATED_SPECS, + E2E_RISK_PREFIXES, + E2E_SMOKE_FALLBACK_SPEC, +} from './e2e-affected-manifest.mjs' /** * Paths whose blast radius is the whole app: schema, test harness, lockfile, diff --git a/scripts/run-e2e-affected.mjs b/scripts/run-e2e-affected.mjs index ff2c1006..1ddefa61 100644 --- a/scripts/run-e2e-affected.mjs +++ b/scripts/run-e2e-affected.mjs @@ -78,12 +78,18 @@ if (process.env.E2E_PROD === '1') { } const curatedSpecPaths = E2E_CURATED_SPECS.map((name) => `tests/e2e/${name}.e2e.spec.ts`) -const scopeSpecPaths = - scope.mode === 'selected' - ? scope.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`) - : scope.mode === 'unmapped-risk' - ? curatedSpecPaths - : [] // curated/full → run the whole suite locally +let scopeSpecPaths +switch (scope.mode) { + case 'selected': + scopeSpecPaths = scope.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`) + break + case 'unmapped-risk': + scopeSpecPaths = curatedSpecPaths + break + default: + // curated / full → run the whole suite locally (OPS72 local contract). + scopeSpecPaths = [] +} const playwrightArgs = buildPlaywrightE2eArgs({ scopeSpecPaths, passthroughArgs, diff --git a/scripts/vitest-changed-or-full.mjs b/scripts/vitest-changed-or-full.mjs index 85b74360..b6c16286 100644 --- a/scripts/vitest-changed-or-full.mjs +++ b/scripts/vitest-changed-or-full.mjs @@ -12,7 +12,7 @@ * Exit code is the test run's — the wrapper never turns red into green. * * The env/config pairs mirror the `test:unit` / `test:int` package.json - * scripts; keep them in sync (both are pinned by ciSkipInvariants). + * scripts; the sync is pinned by ciSkipInvariants.unit.spec.ts. */ import { spawnSync } from 'node:child_process' import path from 'node:path' @@ -58,7 +58,15 @@ if (listResult.status !== 0) { console.error(`[vitest-changed-or-full] ✗ vitest list failed (exit ${listResult.status ?? 1})`) process.exit(listResult.status ?? 1) } -const changedSpecs = JSON.parse(listResult.stdout) +let changedSpecs +try { + changedSpecs = JSON.parse(listResult.stdout) +} catch { + console.error( + `[vitest-changed-or-full] ✗ vitest list emitted non-JSON on stdout:\n${listResult.stdout.slice(0, 500)}`, + ) + process.exit(1) +} const run = (label, argv, env = process.env) => { console.log(`\n[vitest-changed-or-full] ▶ ${args.suite} ${label}`) diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index 46d16327..59c19993 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -152,4 +152,21 @@ describe('ciSkipInvariants', () => { // The PR never runs e2e full. expect(ciPr).not.toContain("e2e_mode == 'full'") }) + + it('vitest-changed-or-full mirrors the package.json test scripts (config + unit DB guard)', () => { + // The wrapper re-declares the vitest config paths and the unit + // invalid-DATABASE_URL guard from the `test:unit`/`test:int` scripts; a + // drift changes what `--changed` detects and is silent. Pin the sync. + const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) + const wrapper = readFileSync(join(repoRoot, 'scripts/vitest-changed-or-full.mjs'), 'utf8') + + expect(pkg.scripts['test:unit']).toContain('./vitest.unit.config.mts') + expect(pkg.scripts['test:int']).toContain('./vitest.config.mts') + expect(wrapper).toContain('./vitest.unit.config.mts') + expect(wrapper).toContain('./vitest.config.mts') + + const invalidUnitDbUrl = 'postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect' + expect(pkg.scripts['test:unit']).toContain(invalidUnitDbUrl) + expect(wrapper).toContain(invalidUnitDbUrl) + }) }) diff --git a/tests/unit/e2eAffectedManifest.unit.spec.ts b/tests/unit/e2eAffectedManifest.unit.spec.ts index 0cd9ea99..b4653ae9 100644 --- a/tests/unit/e2eAffectedManifest.unit.spec.ts +++ b/tests/unit/e2eAffectedManifest.unit.spec.ts @@ -9,6 +9,7 @@ import { E2E_AFFECTED_MANIFEST, E2E_CURATED_SPECS, E2E_RISK_PREFIXES, + E2E_SMOKE_FALLBACK_SPEC, } from '../../scripts/lib/e2e-affected-manifest.mjs' const E2E_DIR = join(__dirname, '../e2e') @@ -72,4 +73,10 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => { expect(mappedPrefixes.has(prefix), prefix).toBe(true) } }) + + it('the unmapped fallback smoke spec exists on disk (OPS86 never-zero)', () => { + // A rename of the smoke spec would turn the fallback into a CI + // "No tests found" failure. + expect(new Set(specNamesOnDisk()).has(E2E_SMOKE_FALLBACK_SPEC)).toBe(true) + }) }) diff --git a/tests/unit/testAffected.unit.spec.ts b/tests/unit/testAffected.unit.spec.ts index bcb3ca61..e61ea0f9 100644 --- a/tests/unit/testAffected.unit.spec.ts +++ b/tests/unit/testAffected.unit.spec.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from 'vitest' -import { E2E_CURATED_SPECS } from '../../scripts/lib/e2e-affected-manifest.mjs' +import { + E2E_CURATED_SPECS, + E2E_SMOKE_FALLBACK_SPEC, +} from '../../scripts/lib/e2e-affected-manifest.mjs' import { classifyBuildScope, classifyStaticScope, classifyTestScope, - E2E_SMOKE_FALLBACK_SPEC, HIGH_RISK_EXACT, HIGH_RISK_PREFIXES, selectE2eSpecs,