/** * Pure selection logic for affected-test CI jobs (OPS5). No git, no fs, no * process — the CLI wrappers feed it the changed file list and it decides. * Unit-pinned in tests/unit/testAffected.unit.spec.ts, * tests/unit/ciSkipInvariants.unit.spec.ts, and * tests/unit/e2eAffectedManifest.unit.spec.ts. * * OPS86: e2e selection is never silently zero when code changed — high-risk * diffs run the curated cross-section (`curated`), risk-area files without a * manifest entry fail closed (`unmapped-risk`), and any other unmapped src/ * file wakes the home smoke. */ import { E2E_CURATED_SPECS, E2E_RISK_PREFIXES, 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/build-chart-from-data.mjs', 'scripts/check-test-locations.mjs', 'scripts/cityReportSnapshot.mjs', 'scripts/institutionSnapshot.mjs', 'scripts/themeSnapshot.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/archiveCatalogPlan.mjs', 'scripts/lib/archiveVisionApi.mjs', 'scripts/lib/auto-unblock.mjs', 'scripts/lib/briefingContent.mjs', 'scripts/lib/briefingRender.mjs', 'scripts/lib/buildPdf.mjs', 'scripts/lib/camaraFetch.mjs', 'scripts/lib/camaraSpeeches.mjs', 'scripts/lib/campaignKitAssets.mjs', 'scripts/lib/changelog.mjs', 'scripts/lib/chartData.mjs', 'scripts/lib/chartPrimitives.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/dossieBlocks.mjs', 'scripts/lib/dossieBulletin.mjs', 'scripts/lib/dossieBulletinRender.mjs', 'scripts/lib/dossieCamara.mjs', 'scripts/lib/dossieCareer.mjs', 'scripts/lib/dossieHealthData.mjs', 'scripts/lib/dossiePack.mjs', 'scripts/lib/dossieRender.mjs', 'scripts/lib/dossieResearch.mjs', 'scripts/lib/dossieUnit.mjs', 'scripts/lib/e2e-affected-manifest.mjs', 'scripts/lib/faceEnrollPlan.mjs', 'scripts/lib/faceIndexPlan.mjs', 'scripts/lib/flickrApi.mjs', 'scripts/lib/flickrPlan.mjs', 'scripts/lib/github-api.mjs', 'scripts/lib/github-branch-protection.mjs', 'scripts/lib/github-pr-flow.mjs', 'scripts/lib/graficosInstagramRender.mjs', 'scripts/lib/imageFill.mjs', 'scripts/lib/imageResize.mjs', 'scripts/lib/issues-panel.mjs', 'scripts/lib/markdown-ansi.mjs', 'scripts/lib/mediaBinaries.mjs', 'scripts/lib/mediaEndpoint.mjs', 'scripts/lib/plansOnlyClosesGuard.mjs', 'scripts/lib/playwright-e2e-args.mjs', 'scripts/lib/portalTransparenciaEmendas.mjs', 'scripts/lib/readOnlyExtract.mjs', 'scripts/lib/reel-ingest.mjs', 'scripts/lib/reelFfmpeg.mjs', 'scripts/lib/reelPackage.mjs', 'scripts/lib/reelScript.mjs', 'scripts/lib/reelShotList.mjs', 'scripts/lib/reelTimeline.mjs', 'scripts/lib/reelTts.mjs', 'scripts/lib/reportText.mjs', 'scripts/lib/seed-minimal-manifest.mjs', 'scripts/lib/sollaCeuciSalvadorMetrics.mjs', 'scripts/lib/speechIndex.mjs', 'scripts/lib/staging-test-account.mjs', 'scripts/lib/test-affected-core.mjs', 'scripts/lib/testing-audit-metrics-core.mjs', 'scripts/lib/webSpeeches.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 * is unsafe there. */ export const HIGH_RISK_EXACT = new Set([ 'package.json', 'pnpm-lock.yaml', 'playwright.config.ts', 'vitest.config.mts', 'vitest.unit.config.mts', 'vitest.setup.ts', 'src/payload-types.ts', 'src/payload.config.ts', 'scripts/seed-minimal.mjs', 'scripts/lib/seed-minimal-manifest.mjs', 'scripts/worktree.mjs', 'scripts/agent-session.mjs', 'scripts/lib/agent-session.mjs', 'scripts/lib/worktree.mjs', 'scripts/lib/test-affected-core.mjs', 'scripts/lib/e2e-affected-manifest.mjs', 'scripts/test-affected.mjs', 'scripts/e2e-affected.mjs', 'scripts/run-e2e-affected.mjs', 'scripts/vitest-changed-or-full.mjs', 'scripts/ci-scope.mjs', 'scripts/db-reset.mjs', 'scripts/lib/cli.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', '.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 = [ 'src/migrations/', 'src/collections/', 'src/globals/', 'tests/helpers/', 'tests/e2e/fixtures/', ] /** Canonical vitest/playwright globs — keep in sync with config files. */ export const CANONICAL_UNIT_INCLUDE = 'tests/unit/**/*.unit.spec.{ts,tsx}' export const CANONICAL_INT_INCLUDE = 'tests/int/**/*.int.spec.ts' export const CANONICAL_E2E_TEST_DIR = 'tests/e2e' export const CANONICAL_E2E_SPEC_SUFFIX = '.e2e.spec.ts' const CODE_CONFIG_EXACT = new Set([ 'package.json', 'pnpm-lock.yaml', 'tsconfig.json', 'tsconfig.tsbuildinfo', 'eslint.config.mjs', 'eslint.config.js', 'knip.json', 'knip.ts', 'knip.config.ts', '.eslintrc.cjs', '.eslintrc.js', ]) const CODE_CONFIG_PREFIXES = ['tsconfig'] const BUILD_SURFACE_EXACT = new Set([ 'package.json', 'pnpm-lock.yaml', 'next.config.js', 'next.config.mjs', 'next.config.ts', 'src/payload.config.ts', 'src/payload-types.ts', 'tsconfig.json', // OPS88: a change to the reset script must exercise it — build_mode='build' // arms the Reset step in ci-pr.yml (and the mirror in gate-ci.mjs). 'scripts/db-reset.mjs', ]) const BUILD_SURFACE_PREFIXES = [ 'src/', 'public/', 'src/migrations/', 'src/collections/', 'src/globals/', 'tsconfig', ] const SPEC_FILE_RE = /\.(?:spec|test)\.(?:ts|tsx)$/ const UNIT_SPEC_RE = /^tests\/unit\/.+\.unit\.spec\.(?:ts|tsx)$/ const INT_SPEC_RE = /^tests\/int\/.+\.int\.spec\.ts$/ const E2E_SPEC_RE = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/ /** Cross-cutting dirs under campaign components (no dedicated e2e family). */ export const E2E_MANIFEST_DOMAIN_EXEMPT = new Set(['shared']) export function isHighRisk(path) { return HIGH_RISK_EXACT.has(path) || HIGH_RISK_PREFIXES.some((prefix) => path.startsWith(prefix)) } export function isTestPath(path) { return path.startsWith('tests/') } export function isSrcPath(path) { return path.startsWith('src/') } /** * Paths that affect typecheck / knip / madge (and therefore those PR jobs). */ 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 } if (path.startsWith('eslint') || path.includes('eslint.config')) return true if (path.startsWith('knip')) return true return false } /** * Paths that affect `pnpm build` (Next + Payload surface). */ export function isBuildPath(path) { if (BUILD_SURFACE_EXACT.has(path)) return true if (isSrcPath(path) || path.startsWith('public/')) return true if (path.startsWith('next.config')) return true if (BUILD_SURFACE_PREFIXES.some((prefix) => path.startsWith(prefix))) return true return false } export function isCanonicalSpecPath(path) { return UNIT_SPEC_RE.test(path) || INT_SPEC_RE.test(path) || E2E_SPEC_RE.test(path) } export function isMisplacedSpecPath(path) { return SPEC_FILE_RE.test(path) && !isCanonicalSpecPath(path) } /** * Decide vitest scope for a PR diff. * @param {{ path: string, status: string }[]} files git diff --name-status rows. * @returns {{ mode: 'full' | 'changed' | 'none', reason: string }} */ export function classifyTestScope(files) { if (files.some(({ path }) => isHighRisk(path))) { return { mode: 'full', reason: 'diff touches a high-risk path (schema/lockfile/test harness)' } } if (files.some(({ path, status }) => status === 'A' && path.startsWith('src/'))) { return { mode: 'full', reason: 'new src/ file — vitest --changed cannot trace tests to it yet' } } if (files.some(({ path }) => path.startsWith('src/') || path.startsWith('tests/'))) { return { mode: 'changed', reason: 'src/tests diff without high-risk paths' } } return { mode: 'none', reason: 'no src/ or tests/ changes' } } /** * Decide whether typecheck / knip / cycles should run. * @returns {{ mode: 'code' | 'none', reason: string }} */ export function classifyStaticScope(files) { if (files.some(({ path }) => isCodePath(path))) { return { mode: 'code', reason: 'diff touches src/tests/scripts or type/graph config' } } return { mode: 'none', reason: 'no code/type/graph surface changes' } } /** * Decide whether the PR build job should run. * @returns {{ mode: 'build' | 'none', reason: string }} */ export function classifyBuildScope(files) { if (files.some(({ path }) => isHighRisk(path) && isBuildPath(path))) { return { mode: 'build', reason: 'diff touches a high-risk build surface path' } } if (files.some(({ path }) => isBuildPath(path))) { return { mode: 'build', reason: 'diff touches build surface' } } return { mode: 'none', reason: 'no build surface changes' } } 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, 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). 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 } /** * Decide the Playwright scope for a PR diff. * @param {{ path: string, status: string }[]} files * @param {{ prefixes: string[], specs: string[] }[]} manifest prefix → spec names. * @returns {{ mode: 'curated' | 'selected' | 'unmapped-risk' | 'none', * specs: string[], reason: string, unmapped: string[] }} * * Modes (OPS86): * - `curated` — high-risk diff: run `E2E_CURATED_SPECS`, never zero (the * 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. * - `selected` — manifest-selected specs; an unmapped non-risk src/ file * still wakes the home smoke so code never ships on zero e2e. * - `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' + (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() 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). if (status === 'D' && E2E_SPEC_PATTERN.test(path)) continue if (E2E_SPEC_PATTERN.test(path)) { specs.add(path.slice('tests/e2e/'.length, -'.e2e.spec.ts'.length)) continue } if (!path.startsWith('src/')) continue const matches = manifest.filter((entry) => entry.prefixes.some((prefix) => path.startsWith(prefix)), ) if (matches.length === 0) continue // already in unmappedSrc for (const match of matches) for (const spec of match.specs) specs.add(spec) } // 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: unmappedSrc, } } if (specs.size === 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: unmappedSrc, } } return { mode: 'none', specs: [], reason: 'no e2e-relevant changes', unmapped: [], } } // The `setup` spec is dev-mode-only: the setup project is dropped under // CI/prod (playwright.config.ts), so a selection containing ONLY it would // run `playwright test -- tests/e2e/setup.e2e.spec.ts` against zero projects // and fail with "No tests found" (OPS39 — first setup-only PR hit it). In a // mixed set the spec is harmless (it just matches no project), so only the // 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 (unmappedSrc.length > 0) { return { mode: 'selected', specs: [E2E_SMOKE_FALLBACK_SPEC], reason: 'setup-only spec plus unmapped src/ changes — running the home smoke', unmapped: unmappedSrc, } } return { mode: 'none', specs: [], reason: 'setup spec is dev-mode-only; prod-mode e2e cannot run it', unmapped: unmappedSrc, } } return { mode: 'selected', specs: [...specs].sort(), reason: `${specs.size} spec(s) selected via manifest and changed specs`, unmapped: unmappedSrc, } } /** * Walk a list of repo-relative paths and return misplaced spec/test files. * @param {string[]} paths * @returns {string[]} */ export function findMisplacedSpecPaths(paths) { return paths.filter(isMisplacedSpecPath).sort() } /** * Domain folders that must appear in the e2e affected manifest (or be exempt). * @param {string[]} componentDirs immediate children of src/components/campaign * @param {string[]} appRouteDirs immediate children of src/app/(campaign)/campanha/(app) * @param {{ prefixes: string[] }[]} manifest * @param {Set} [exempt] * @returns {{ missing: string[], covered: string[] }} */ export function findUncoveredE2eDomainPrefixes( componentDirs, appRouteDirs, manifest, exempt = E2E_MANIFEST_DOMAIN_EXEMPT, ) { const prefixes = manifest.flatMap((entry) => entry.prefixes) const required = [] for (const dir of componentDirs) { if (exempt.has(dir)) continue required.push(`src/components/campaign/${dir}`) } for (const dir of appRouteDirs) { if (dir.includes('.')) continue // files like page.tsx / error.tsx required.push(`src/app/(campaign)/campanha/(app)/${dir}`) } const missing = [] const covered = [] for (const path of required) { const hit = prefixes.some( (prefix) => prefix === path || prefix.startsWith(`${path}/`) || path.startsWith(prefix), ) if (hit) covered.push(path) else missing.push(path) } return { missing: missing.sort(), covered: covered.sort() } }