diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index bd812aac..a91fac9c 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -1,14 +1,13 @@ -# PR gate: feature → stage (and stage → main promote PRs). -# Parallel jobs with `needs` only where a real dependency exists (Postgres for -# int/build/e2e). Build runs here (local service DB only) and NEVER in -# ci-stage.yml — `pnpm build` applies migrations to whatever DATABASE_URL it -# sees, so it is forbidden anywhere near stage/prod URLs. +# PR gate: feature → main (also stage until cutover). Two-phase cascade: +# Phase 1 (cheap, no Postgres): lint, format, typecheck, knip, cycles, unit +# Phase 2 (expensive, Postgres; needs phase 1 green/skipped): int, build, e2e +# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs). A +# phase-1 failure skips phase 2 via `needs` + explicit if. `migration-lock` +# stays parallel to phase 1 (cheap, independent). # -# Test selection (OPS5): unit/int run `vitest --changed origin/$GITHUB_BASE_REF` -# and e2e runs the manifest-selected specs; a diff touching a high-risk path -# (schema, lockfile, test harness, seed manifest — see -# scripts/lib/test-affected-core.mjs) falls back to the FULL suites. Push to -# stage/main always runs everything (ci-stage.yml / ci.yml). +# High-risk diffs (schema, lockfile, classifiers — see +# scripts/lib/test-affected-core.mjs) force full unit/int/e2e. Push to main +# always runs the full suite in ci.yml (no affected skips). name: CI (PR) on: @@ -25,6 +24,38 @@ env: NEXT_PUBLIC_SITE_URL: http://localhost:3000 jobs: + scope: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + code_mode: ${{ steps.classify.outputs.code_mode }} + build_mode: ${{ steps.classify.outputs.build_mode }} + test_mode: ${{ steps.classify.outputs.test_mode }} + e2e_mode: ${{ steps.classify.outputs.e2e_mode }} + e2e_specs: ${{ steps.classify.outputs.e2e_specs }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Classify PR blast radius + id: classify + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: | + result=$(node scripts/ci-scope.mjs) + echo "scope: $(echo "$result" | jq -c '{code:.code.mode,build:.build.mode,test:.test.mode,e2e:.e2e.mode,reason:{code:.code.reason,build:.build.reason,test:.test.reason,e2e:.e2e.reason}}')" + echo "code_mode=$(echo "$result" | jq -r .code.mode)" >> "$GITHUB_OUTPUT" + echo "build_mode=$(echo "$result" | jq -r .build.mode)" >> "$GITHUB_OUTPUT" + echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT" + echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT" + echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT" + lint: runs-on: ubuntu-latest timeout-minutes: 10 @@ -36,6 +67,8 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile + - name: Check test file locations + run: node scripts/check-test-locations.mjs - run: pnpm lint format: @@ -54,6 +87,8 @@ jobs: typecheck: runs-on: ubuntu-latest timeout-minutes: 10 + needs: [scope] + if: needs.scope.outputs.code_mode != 'none' steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -67,6 +102,8 @@ jobs: knip: runs-on: ubuntu-latest timeout-minutes: 10 + needs: [scope] + if: needs.scope.outputs.code_mode != 'none' steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -80,6 +117,8 @@ jobs: cycles: runs-on: ubuntu-latest timeout-minutes: 10 + needs: [scope] + if: needs.scope.outputs.code_mode != 'none' steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -93,6 +132,8 @@ jobs: unit: runs-on: ubuntu-latest timeout-minutes: 15 + needs: [scope] + if: needs.scope.outputs.test_mode != 'none' steps: - uses: actions/checkout@v4 with: @@ -104,33 +145,30 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - - name: Compute affected test scope - id: scope - env: - GITHUB_BASE_REF: ${{ github.base_ref }} - run: | - result=$(node scripts/test-affected.mjs) - echo "scope: $(echo "$result" | jq -r .reason)" - echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT" - - name: Unit tests (full suite) - if: steps.scope.outputs.mode == 'full' + if: needs.scope.outputs.test_mode == 'full' run: pnpm test:unit - name: Unit tests (changed since PR base) - if: steps.scope.outputs.mode == 'changed' + if: needs.scope.outputs.test_mode == 'changed' run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests - - name: Unit tests skipped - if: steps.scope.outputs.mode == 'none' - run: echo "No src/ or tests/ changes in this PR — nothing to run." - int: runs-on: ubuntu-latest timeout-minutes: 25 + needs: [scope, lint, format, typecheck, knip, cycles, unit] + if: | + always() + && !cancelled() + && needs.scope.outputs.test_mode != 'none' + && needs.lint.result == 'success' + && needs.format.result == 'success' + && (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped') + && (needs.knip.result == 'success' || needs.knip.result == 'skipped') + && (needs.cycles.result == 'success' || needs.cycles.result == 'skipped') + && (needs.unit.result == 'success' || needs.unit.result == 'skipped') services: postgres: - # Matches production Neon major (17) and docker-compose.yml. image: postgres:17-alpine env: POSTGRES_USER: teqo @@ -153,39 +191,31 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - - - name: Compute affected test scope - id: scope - env: - GITHUB_BASE_REF: ${{ github.base_ref }} - run: | - result=$(node scripts/test-affected.mjs) - echo "scope: $(echo "$result" | jq -r .reason)" - echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT" - - - name: Migrate test database - if: steps.scope.outputs.mode != 'none' - run: pnpm migrate - - - name: Seed minimal test database - if: steps.scope.outputs.mode != 'none' - run: pnpm db:seed:minimal + - run: pnpm migrate + - run: pnpm db:seed:minimal - name: Integration tests (full suite) - if: steps.scope.outputs.mode == 'full' + if: needs.scope.outputs.test_mode == 'full' run: pnpm test:int - name: Integration tests (changed since PR base) - if: steps.scope.outputs.mode == 'changed' + if: needs.scope.outputs.test_mode == 'changed' run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests - - name: Integration tests skipped - if: steps.scope.outputs.mode == 'none' - run: echo "No src/ or tests/ changes in this PR — nothing to run." - build: runs-on: ubuntu-latest timeout-minutes: 25 + needs: [scope, lint, format, typecheck, knip, cycles, unit] + if: | + always() + && !cancelled() + && needs.scope.outputs.build_mode != 'none' + && needs.lint.result == 'success' + && needs.format.result == 'success' + && (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped') + && (needs.knip.result == 'success' || needs.knip.result == 'skipped') + && (needs.cycles.result == 'success' || needs.cycles.result == 'skipped') + && (needs.unit.result == 'success' || needs.unit.result == 'skipped') services: postgres: image: postgres:17-alpine @@ -208,13 +238,23 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Build (against the local service database) run: pnpm build e2e: runs-on: ubuntu-latest timeout-minutes: 30 + needs: [scope, lint, format, typecheck, knip, cycles, unit] + if: | + always() + && !cancelled() + && needs.scope.outputs.e2e_mode != 'none' + && needs.lint.result == 'success' + && needs.format.result == 'success' + && (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped') + && (needs.knip.result == 'success' || needs.knip.result == 'skipped') + && (needs.cycles.result == 'success' || needs.cycles.result == 'skipped') + && (needs.unit.result == 'success' || needs.unit.result == 'skipped') services: postgres: image: postgres:17-alpine @@ -239,91 +279,31 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Install Playwright chromium run: pnpm exec playwright install --with-deps chromium - - - name: Compute affected e2e scope - id: scope - env: - GITHUB_BASE_REF: ${{ github.base_ref }} - run: | - result=$(node scripts/e2e-affected.mjs) - echo "scope: $(echo "$result" | jq -r .reason)" - echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT" - echo "specs=$(echo "$result" | jq -r '[.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT" - - - name: Migrate test database - if: steps.scope.outputs.mode != 'none' - run: pnpm migrate - - - name: Seed minimal test database - if: steps.scope.outputs.mode != 'none' - run: pnpm db:seed:minimal - - # CI e2e runs against the production build (see playwright.config.ts - # webServer.command); the dist dir must match the webServer env. + - run: pnpm migrate + - run: pnpm db:seed:minimal - name: Build for e2e - if: steps.scope.outputs.mode != 'none' run: pnpm build env: NEXT_DIST_DIR: .next/e2e - name: E2E tests (full suite) - if: steps.scope.outputs.mode == 'full' + if: needs.scope.outputs.e2e_mode == 'full' run: pnpm test:e2e - name: E2E tests (manifest-selected specs) - if: steps.scope.outputs.mode == 'selected' - run: pnpm test:e2e -- ${{ steps.scope.outputs.specs }} - - - name: E2E tests skipped - if: steps.scope.outputs.mode == 'none' - run: echo "No e2e-relevant changes in this PR — nothing to run." - - # Branch protection on `stage`/`main` still requires the legacy context name - # `checks` from the pre-parallel monolithic job. This rollup satisfies that - # contract without re-serializing the suite. - checks: - name: checks - runs-on: ubuntu-latest - needs: - - lint - - format - - typecheck - - knip - - cycles - - unit - - int - - build - - e2e - - migration-lock - if: always() - steps: - - name: All parallel checks green - env: - NEEDS: ${{ toJSON(needs) }} - run: | - set -euo pipefail - echo "$NEEDS" | jq -e ' - to_entries - | all(.value.result == "success" or .value.result == "skipped") - ' + if: needs.scope.outputs.e2e_mode == 'selected' + run: pnpm test:e2e -- ${{ needs.scope.outputs.e2e_specs }} migration-lock: - # At most ONE open PR may touch schema (migrations or generated types). - # Two in flight guarantees a broken rebase and a poisoned drizzle snapshot - # chain; the second PR must wait or rebase. runs-on: ubuntu-latest timeout-minutes: 5 permissions: - # gh pr list against the default GITHUB_TOKEN. contents: read pull-requests: read steps: - # gh expands --json files via git, so it needs the repo checked out. - uses: actions/checkout@v4 - - name: Count open PRs touching schema files env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -337,3 +317,30 @@ jobs: echo "::error::More than one open PR touches src/migrations/ or payload-types.ts. Merge or close the other first." exit 1 fi + + checks: + name: checks + runs-on: ubuntu-latest + needs: + - scope + - lint + - format + - typecheck + - knip + - cycles + - unit + - int + - build + - e2e + - migration-lock + if: always() + steps: + - name: All cascade checks green + env: + NEEDS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + echo "$NEEDS" | jq -e ' + to_entries + | all(.value.result == "success" or .value.result == "skipped") + ' diff --git a/scripts/check-test-locations.mjs b/scripts/check-test-locations.mjs new file mode 100644 index 00000000..a4023c60 --- /dev/null +++ b/scripts/check-test-locations.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * check-test-locations — always-on guard against misplaced *.spec|*.test files + * that the affected classifier would miss (false-green skip). Walks the repo + * (ignoring node_modules, .git, build artifacts) and fails if any spec/test + * file lives outside the three canonical trees. + * + * Pure predicates live in scripts/lib/test-affected-core.mjs; this CLI is the + * fs walk used by the lint job and by ciSkipInvariants. + */ +import { readdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { findMisplacedSpecPaths } from './lib/test-affected-core.mjs' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const IGNORE_DIRS = new Set([ + '.git', + '.next', + '.turbo', + 'node_modules', + 'coverage', + 'dist', + 'build', + 'playwright-report', + 'test-results', + '.playwright-cli', + '.aider.tags.cache.v4', + 'data', +]) + +function walk(dir, out = []) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return out + } + for (const entry of entries) { + if (entry.name.startsWith('.') && entry.name !== '.env.test') { + if (entry.isDirectory() && IGNORE_DIRS.has(entry.name)) continue + if (entry.isDirectory() && entry.name !== '.github' && entry.name !== '.cursor') continue + } + if (IGNORE_DIRS.has(entry.name)) continue + const absolute = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(absolute, out) + continue + } + if (entry.isFile()) { + out.push(path.relative(repoRoot, absolute).split(path.sep).join('/')) + } + } + return out +} + +export function collectRepoRelativeFiles(root = repoRoot) { + return walk(root) +} + +export function checkTestLocations(root = repoRoot) { + const files = collectRepoRelativeFiles(root) + return findMisplacedSpecPaths(files) +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + +if (isMain) { + const misplaced = checkTestLocations() + if (misplaced.length > 0) { + console.error( + `[check-test-locations] misplaced spec/test files (must live under tests/{unit,int,e2e} with the canonical suffix):\n ${misplaced.join('\n ')}`, + ) + process.exit(1) + } + console.log('[check-test-locations] ok — all specs in canonical trees') +} diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs new file mode 100644 index 00000000..79cb5c4a --- /dev/null +++ b/scripts/ci-scope.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * ci-scope — single classifier for PR job-level skips. Prints JSON: + * { base, code, build, test, e2e } where each of code/build/test/e2e is the + * classify* / selectE2eSpecs result. Used by the `scope` job in ci-pr.yml. + * + * Base ref: $GITHUB_BASE_REF (PR target) or origin/main locally. + */ +import { execFileSync } from 'node:child_process' + +import { E2E_AFFECTED_MANIFEST } from './lib/e2e-affected-manifest.mjs' +import { + classifyBuildScope, + classifyStaticScope, + classifyTestScope, + selectE2eSpecs, +} from './lib/test-affected-core.mjs' + +const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}` + +let mergeBase +try { + mergeBase = execFileSync('git', ['merge-base', 'HEAD', base], { encoding: 'utf8' }).trim() +} catch { + const full = { + mode: 'full', + reason: `no merge-base with ${base} (shallow clone?)`, + } + console.log( + JSON.stringify({ + base, + code: { mode: 'code', reason: full.reason }, + build: { mode: 'build', reason: full.reason }, + test: { ...full }, + e2e: { mode: 'full', specs: [], reason: full.reason, unmapped: [] }, + }), + ) + process.exit(0) +} + +const raw = execFileSync('git', ['diff', '--name-status', `${mergeBase}...HEAD`], { + encoding: 'utf8', +}) +const files = raw + .split('\n') + .filter(Boolean) + .map((line) => { + const [status, path] = line.split('\t') + return { status, path } + }) + +const e2e = selectE2eSpecs(files, E2E_AFFECTED_MANIFEST) +if (e2e.unmapped.length > 0) { + console.error( + `[ci-scope] src/ paths with no e2e manifest mapping:\n ${e2e.unmapped.join('\n ')}`, + ) +} + +console.log( + JSON.stringify({ + base, + code: classifyStaticScope(files), + build: classifyBuildScope(files), + test: classifyTestScope(files), + e2e, + }), +) diff --git a/scripts/e2e-affected.mjs b/scripts/e2e-affected.mjs index 89829f89..f53d0bbf 100644 --- a/scripts/e2e-affected.mjs +++ b/scripts/e2e-affected.mjs @@ -16,7 +16,7 @@ import { execFileSync } from 'node:child_process' import { E2E_AFFECTED_MANIFEST } from './lib/e2e-affected-manifest.mjs' import { selectE2eSpecs } from './lib/test-affected-core.mjs' -const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}` +const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}` let mergeBase try { diff --git a/scripts/gate-ci.mjs b/scripts/gate-ci.mjs index f59a3294..5e867818 100644 --- a/scripts/gate-ci.mjs +++ b/scripts/gate-ci.mjs @@ -1,12 +1,11 @@ #!/usr/bin/env node /** - * Local mirror of `.github/workflows/ci-pr.yml` (serial): static checks always; - * unit/int/e2e follow the same blast-radius selection as CI (`test-affected.mjs`, - * `e2e-affected.mjs` vs `origin/stage`). Build always runs. `migration-lock` is - * GitHub-only. + * Local mirror of `.github/workflows/ci-pr.yml` (serial): phase-1 cheap checks, + * then phase-2 expensive (int/build/e2e). Skips align with `scripts/ci-scope.mjs` + * (`origin/main` by default). `migration-lock` is GitHub-only. * - * Preflight checks only `teqo_test` (ci-pr never touches the dev DB). Escape: - * `git push --no-verify`. + * Preflight checks only `teqo_test` (ci-pr never touches the dev DB). Escape + * during pipeline cutover: `git push --no-verify` (documented in AGENT-OPS). */ import { execFileSync, spawnSync } from 'node:child_process' import { readFileSync } from 'node:fs' @@ -43,7 +42,7 @@ const readJsonScript = (script) => { encoding: 'utf8', env: process.env, }) - return JSON.parse(raw.trim()) + return JSON.parse(raw.trim().split('\n').filter(Boolean).at(-1)) } const run = (label, command, args, env = process.env) => { @@ -60,56 +59,68 @@ const run = (label, command, args, env = process.env) => { } const main = async () => { - const testScope = readJsonScript('scripts/test-affected.mjs') - const e2eScope = readJsonScript('scripts/e2e-affected.mjs') + const scope = readJsonScript('scripts/ci-scope.mjs') - console.log(`[gate:ci] vitest: ${testScope.mode} — ${testScope.reason} (base ${testScope.base})`) - console.log(`[gate:ci] e2e: ${e2eScope.mode} — ${e2eScope.reason} (base ${e2eScope.base})`) - if (e2eScope.unmapped?.length > 0) { - console.error(`[gate:ci] e2e unmapped src/ paths:\n ${e2eScope.unmapped.join('\n ')}`) - } - - console.log('\n[gate:ci] ▶ preflight (teqo_test)') - const dbOk = await diagnoseDatabaseTarget({ - label: 'test', - databaseUrl: dbEnv.DATABASE_URL, - }) - if (!dbOk) { - console.error('\n[gate:ci] ✗ preflight (teqo_test) failed — run `pnpm db:start`') - process.exit(1) + console.log( + `[gate:ci] scope base=${scope.base} code=${scope.code.mode} build=${scope.build.mode} test=${scope.test.mode} e2e=${scope.e2e.mode}`, + ) + if (scope.e2e.unmapped?.length > 0) { + console.error(`[gate:ci] e2e unmapped src/ paths:\n ${scope.e2e.unmapped.join('\n ')}`) } + // --- Phase 1 (cheap) --- + run('check-test-locations', 'node', ['scripts/check-test-locations.mjs']) run('lint', 'pnpm', ['lint']) run('format:check', 'pnpm', ['format:check']) - run('typecheck', 'pnpm', ['typecheck']) - run('knip', 'pnpm', ['exec', 'knip']) - run('check:cycles', 'pnpm', ['check:cycles']) - if (testScope.mode === 'full') { + if (scope.code.mode === 'none') { + console.log('\n[gate:ci] ⊘ typecheck/knip/cycles skipped (no code surface)') + } else { + run('typecheck', 'pnpm', ['typecheck']) + run('knip', 'pnpm', ['exec', 'knip']) + run('check:cycles', 'pnpm', ['check:cycles']) + } + + if (scope.test.mode === 'full') { run('test:unit (full)', 'pnpm', ['test:unit']) - } else if (testScope.mode === 'changed') { + } else if (scope.test.mode === 'changed') { run('test:unit (changed)', 'pnpm', [ 'test:unit', '--', '--changed', - testScope.base, + scope.base, '--passWithNoTests', ]) } else { console.log('\n[gate:ci] ⊘ test:unit skipped (no src/tests blast radius)') } - if (testScope.mode !== 'none') { + // --- Phase 2 (expensive) — only when phase 1 would have been green --- + const needsDb = + scope.test.mode !== 'none' || scope.build.mode !== 'none' || scope.e2e.mode !== 'none' + if (needsDb) { + console.log('\n[gate:ci] ▶ preflight (teqo_test)') + const dbOk = await diagnoseDatabaseTarget({ + label: 'test', + databaseUrl: dbEnv.DATABASE_URL, + }) + if (!dbOk) { + console.error('\n[gate:ci] ✗ preflight (teqo_test) failed — run `pnpm db:start`') + process.exit(1) + } + } + + if (scope.test.mode !== 'none') { run('migrate', 'pnpm', ['migrate'], dbEnv) run('db:seed:minimal', 'pnpm', ['db:seed:minimal'], dbEnv) - if (testScope.mode === 'full') { + if (scope.test.mode === 'full') { run('test:int (full)', 'pnpm', ['test:int']) } else { run('test:int (changed)', 'pnpm', [ 'test:int', '--', '--changed', - testScope.base, + scope.base, '--passWithNoTests', ]) } @@ -117,9 +128,13 @@ const main = async () => { console.log('\n[gate:ci] ⊘ test:int skipped (no src/tests blast radius)') } - run('build', 'pnpm', ['build'], dbEnv) + if (scope.build.mode === 'none') { + console.log('\n[gate:ci] ⊘ build skipped (no build surface)') + } else { + run('build', 'pnpm', ['build'], dbEnv) + } - if (e2eScope.mode === 'none') { + if (scope.e2e.mode === 'none') { console.log('\n[gate:ci] ⊘ e2e skipped (no e2e blast radius)') } else { run('playwright install chromium', 'pnpm', ['exec', 'playwright', 'install', 'chromium']) @@ -128,12 +143,12 @@ const main = async () => { run('e2e build', 'pnpm', ['build'], { ...dbEnv, NEXT_DIST_DIR: '.next/e2e' }) const e2eEnv = { ...dbEnv, E2E_PROD: '1', CI: '1', NEXT_DIST_DIR: '.next/e2e' } - if (e2eScope.mode === 'full') { + if (scope.e2e.mode === 'full') { run('test:e2e (full)', 'pnpm', ['test:e2e'], e2eEnv) } else { - const specPaths = e2eScope.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`) + const specPaths = scope.e2e.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`) run( - `test:e2e (selected: ${e2eScope.specs.join(', ')})`, + `test:e2e (selected: ${scope.e2e.specs.join(', ')})`, 'pnpm', ['test:e2e', '--', ...specPaths], e2eEnv, @@ -141,7 +156,7 @@ const main = async () => { } } - console.log('\n[gate:ci] ✓ all checks passed (ci-pr mirror, affected scope)') + console.log('\n[gate:ci] ✓ all checks passed (ci-pr mirror, cascade + affected scope)') } await main() diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 9b277020..4b5779dc 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -56,7 +56,11 @@ export const E2E_AFFECTED_MANIFEST = [ specs: ['campaignLeaderships'], }, { - prefixes: [`${CAMPAIGN_APP}/territorios`, 'src/utilities/territory'], + prefixes: [ + `${CAMPAIGN_APP}/territorios`, + 'src/components/campaign/tour', + 'src/utilities/territory', + ], specs: ['campaignTerritories'], }, { @@ -83,4 +87,29 @@ export const E2E_AFFECTED_MANIFEST = [ ], specs: ['campaign-pwa', 'campaignWizardChrome'], }, + // Domains without a dedicated e2e family still wake campaign home smoke so + // the affected classifier cannot return mode=none on an unmapped domain dir. + { + prefixes: [ + 'src/components/campaign/advisor', + 'src/components/campaign/demand', + 'src/components/campaign/invite', + 'src/components/campaign/organization', + 'src/components/campaign/stateDeputy', + 'src/components/campaign/suggestion', + 'src/components/campaign/supporter', + 'src/components/campaign/votePledge', + 'src/components/campaign/auth', + `${CAMPAIGN_APP}/acoes`, + `${CAMPAIGN_APP}/apoiadores`, + `${CAMPAIGN_APP}/assessores`, + `${CAMPAIGN_APP}/contatos`, + `${CAMPAIGN_APP}/demandas`, + `${CAMPAIGN_APP}/dobradinhas`, + `${CAMPAIGN_APP}/organizacoes`, + `${CAMPAIGN_APP}/perfil`, + `${CAMPAIGN_APP}/quadro`, + ], + specs: ['campaignHomeActions'], + }, ] diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 294a77e9..7b7c7391 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -1,13 +1,15 @@ /** * 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 and + * Unit-pinned in tests/unit/testAffected.unit.spec.ts, + * tests/unit/ciSkipInvariants.unit.spec.ts, and * tests/unit/e2eAffectedManifest.unit.spec.ts. */ /** - * Paths whose blast radius is the whole app: schema, test harness, lockfile. - * Any diff touching them runs the FULL suites — selection is unsafe there. + * 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', @@ -20,6 +22,15 @@ export const HIGH_RISK_EXACT = new Set([ 'src/payload.config.ts', 'scripts/seed-minimal.mjs', 'scripts/lib/seed-minimal-manifest.mjs', + 'scripts/lib/test-affected-core.mjs', + 'scripts/lib/e2e-affected-manifest.mjs', + 'scripts/test-affected.mjs', + 'scripts/e2e-affected.mjs', + 'scripts/ci-scope.mjs', + 'scripts/check-test-locations.mjs', + 'scripts/gate-ci.mjs', + 'tests/unit/ciSkipInvariants.unit.spec.ts', + 'tests/unit/testAffected.unit.spec.ts', '.env.test', ]) @@ -31,18 +42,105 @@ export const HIGH_RISK_PREFIXES = [ 'tests/e2e/fixtures/', ] -function isHighRisk(path) { +/** 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', +]) + +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 (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 }} - * full — high-risk path or new src file (nothing imports it yet, so - * --changed cannot find it; silent green otherwise); - * changed — vitest --changed is meaningful; - * none — no src/tests touched (docs-only PR); skip the jobs. */ export function classifyTestScope(files) { if (files.some(({ path }) => isHighRisk(path))) { @@ -57,6 +155,31 @@ export function classifyTestScope(files) { 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 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$/ /** @@ -65,9 +188,6 @@ const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/ * @param {{ prefixes: string[], specs: string[] }[]} manifest prefix → spec names. * @returns {{ mode: 'full' | 'selected' | 'none', specs: string[], reason: string, * unmapped: string[] }} - * `specs` are spec NAMES (no path, no .e2e.spec.ts). `unmapped` lists src/ - * files with no manifest prefix — logged in CI, never a failure (e2e is a - * thin layer over int; the gap is for a human to judge). */ export function selectE2eSpecs(files, manifest) { if (files.some(({ path }) => isHighRisk(path))) { @@ -113,3 +233,48 @@ export function selectE2eSpecs(files, manifest) { unmapped, } } + +/** + * 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() } +} diff --git a/scripts/test-affected.mjs b/scripts/test-affected.mjs index 3221ea5f..f545657a 100644 --- a/scripts/test-affected.mjs +++ b/scripts/test-affected.mjs @@ -7,14 +7,14 @@ * mode=changed → run `vitest --changed ` * mode=none → nothing to run (docs-only diff); CI skips the step * - * Base ref: $GITHUB_BASE_REF (PR target) or origin/stage locally. No DB, no + * Base ref: $GITHUB_BASE_REF (PR target) or origin/main locally. No DB, no * Payload — pure git + the pure core in scripts/lib/test-affected-core.mjs. */ import { execFileSync } from 'node:child_process' import { classifyTestScope } from './lib/test-affected-core.mjs' -const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}` +const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}` let mergeBase try { diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts new file mode 100644 index 00000000..4c26b5ca --- /dev/null +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -0,0 +1,97 @@ +// @vitest-environment node + +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { E2E_AFFECTED_MANIFEST } from '../../scripts/lib/e2e-affected-manifest.mjs' +import { + CANONICAL_E2E_SPEC_SUFFIX, + CANONICAL_E2E_TEST_DIR, + CANONICAL_INT_INCLUDE, + CANONICAL_UNIT_INCLUDE, + findMisplacedSpecPaths, + findUncoveredE2eDomainPrefixes, + HIGH_RISK_EXACT, + isCanonicalSpecPath, + isMisplacedSpecPath, +} from '../../scripts/lib/test-affected-core.mjs' +import { checkTestLocations } from '../../scripts/check-test-locations.mjs' + +const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..') + +describe('ciSkipInvariants', () => { + it('rejects misplaced *.spec|*.test files outside the three canonical trees', () => { + expect(isCanonicalSpecPath('tests/unit/foo.unit.spec.ts')).toBe(true) + expect(isCanonicalSpecPath('tests/unit/foo.unit.spec.tsx')).toBe(true) + expect(isCanonicalSpecPath('tests/int/foo.int.spec.ts')).toBe(true) + expect(isCanonicalSpecPath('tests/e2e/foo.e2e.spec.ts')).toBe(true) + + for (const path of [ + 'src/utilities/foo.spec.ts', + 'scripts/bar.test.ts', + 'tests/unit/wrong.spec.ts', + 'tests/int/wrong.spec.ts', + 'tests/e2e/nested/foo.e2e.spec.ts', + 'foo.test.tsx', + ]) { + expect(isMisplacedSpecPath(path), path).toBe(true) + } + + expect(findMisplacedSpecPaths(['tests/unit/ok.unit.spec.ts', 'src/x.spec.ts'])).toEqual([ + 'src/x.spec.ts', + ]) + }) + + it('repo walk finds no misplaced specs (same check as the lint job)', () => { + expect(checkTestLocations(repoRoot)).toEqual([]) + }) + + it('vitest/playwright harness globs match the canonical constants', () => { + const unitConfig = readFileSync(join(repoRoot, 'vitest.unit.config.mts'), 'utf8') + const intConfig = readFileSync(join(repoRoot, 'vitest.config.mts'), 'utf8') + const playwrightConfig = readFileSync(join(repoRoot, 'playwright.config.ts'), 'utf8') + + expect(unitConfig).toContain(`'${CANONICAL_UNIT_INCLUDE}'`) + expect(intConfig).toContain(`'${CANONICAL_INT_INCLUDE}'`) + expect(playwrightConfig).toContain(`'./${CANONICAL_E2E_TEST_DIR}'`) + expect(CANONICAL_E2E_SPEC_SUFFIX).toBe('.e2e.spec.ts') + }) + + it('skip classifiers and invariant specs are high-risk (force full suite)', () => { + for (const path of [ + 'scripts/lib/test-affected-core.mjs', + 'scripts/ci-scope.mjs', + 'scripts/check-test-locations.mjs', + 'scripts/test-affected.mjs', + 'scripts/e2e-affected.mjs', + 'tests/unit/ciSkipInvariants.unit.spec.ts', + 'tests/unit/testAffected.unit.spec.ts', + ]) { + expect(HIGH_RISK_EXACT.has(path), path).toBe(true) + } + }) + + it('every campaign domain dir is covered by the e2e affected manifest', () => { + const componentDirs = readdirSync(join(repoRoot, 'src/components/campaign'), { + withFileTypes: true, + }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + + const appRouteDirs = readdirSync(join(repoRoot, 'src/app/(campaign)/campanha/(app)'), { + withFileTypes: true, + }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + + const { missing } = findUncoveredE2eDomainPrefixes( + componentDirs, + appRouteDirs, + E2E_AFFECTED_MANIFEST, + ) + expect(missing, `unmapped domains:\n${missing.join('\n')}`).toEqual([]) + }) +}) diff --git a/tests/unit/testAffected.unit.spec.ts b/tests/unit/testAffected.unit.spec.ts index 499f110d..e1fa5cf3 100644 --- a/tests/unit/testAffected.unit.spec.ts +++ b/tests/unit/testAffected.unit.spec.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest' import { + classifyBuildScope, + classifyStaticScope, classifyTestScope, HIGH_RISK_EXACT, HIGH_RISK_PREFIXES, @@ -19,7 +21,9 @@ describe('classifyTestScope (OPS5)', () => { 'pnpm-lock.yaml', 'src/collections/Post.ts', 'scripts/lib/seed-minimal-manifest.mjs', + 'scripts/lib/test-affected-core.mjs', 'tests/helpers/campaignFixtures.ts', + 'tests/unit/ciSkipInvariants.unit.spec.ts', ]) { expect(classifyTestScope([changed(path)]).mode, path).toBe('full') } @@ -49,6 +53,32 @@ describe('classifyTestScope (OPS5)', () => { }) }) +describe('classifyStaticScope', () => { + it('runs for src/tests and type/graph config diffs', () => { + expect(classifyStaticScope([changed('src/lib/foo.ts')]).mode).toBe('code') + 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') + }) + + it('skips for docs-only diffs', () => { + expect(classifyStaticScope([changed('docs/AGENT-OPS.md')]).mode).toBe('none') + }) +}) + +describe('classifyBuildScope', () => { + it('runs for build surface diffs', () => { + expect(classifyBuildScope([changed('src/app/(frontend)/page.tsx')]).mode).toBe('build') + expect(classifyBuildScope([changed('public/favicon.ico')]).mode).toBe('build') + expect(classifyBuildScope([changed('next.config.mjs')]).mode).toBe('build') + expect(classifyBuildScope([changed('package.json')]).mode).toBe('build') + }) + + it('skips for docs-only diffs', () => { + expect(classifyBuildScope([changed('docs/AGENT-OPS.md')]).mode).toBe('none') + }) +}) + describe('selectE2eSpecs (OPS5)', () => { const manifest = [ { prefixes: ['src/components/campaign/municipality'], specs: ['campaignMunicipalities'] },