From 0948a56ba2f37dc9912da105cfb4c82ee9726075 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 2 Jul 2026 00:41:59 +0200 Subject: [PATCH] feat(ci): enforce lint on PRs and guard coverage policy drift - Add a Lint job to ci.yml running nx run-many -t lint --all, so module-boundary tags, legacy-alias bans, and max-lines gate merges. - Fix the root lint script (was linting only electron-backend). - Add tools/coverage/check-coverage-policy.mjs: fails CI when a project with a test target is missing from coverage-policy.json; wired into coverage:ci as coverage:policy:check. - Run Tier B/C unit tests in CI without coverage (list derived from the policy), so website/packaging/remote-control tests run on PRs. - Replace the hand-picked 16-project test:unit:ci list with --all. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 39 +++++++++++ package.json | 7 +- tools/coverage/check-coverage-policy.mjs | 85 ++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 tools/coverage/check-coverage-policy.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a73d3f6f..d076e5d9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,33 @@ on: workflow_dispatch: jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint all projects + run: pnpm nx run-many --target=lint --all --parallel=3 --output-style=static + env: + CI: true + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + unit-and-typecheck: name: Unit Tests and Typechecks runs-on: ubuntu-latest @@ -43,6 +70,18 @@ jobs: CI: true NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + - name: Run Tier B/C unit tests without coverage + run: | + projects="$(node tools/coverage/check-coverage-policy.mjs --list-non-tier-a)" + if [ -n "$projects" ]; then + pnpm nx run-many --target=test --projects "$projects" --parallel=3 --output-style=static + else + echo "No Tier B/C projects with a test target." + fi + env: + CI: true + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + - name: Upload unit coverage artifact if: always() uses: actions/upload-artifact@v4 diff --git a/package.json b/package.json index b342bcca3..3b250354d 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,12 @@ "test:frontend": "nx test web", "test:backend": "nx test electron-backend", "test:unit:all": "nx run-many --target=test --all --parallel=3", - "test:unit:ci": "nx run-many --target=test --projects=electron-backend,web,services,m3u-state,portal-stalker-data-access,portal-shared-util,playlist-shared-ui,portal-xtream-data-access,portal-xtream-feature,workspace-dashboard-data-access,components,@iptvnator/pipes,packaging,epg-data-access,workspace-shell-util,database --parallel=3 --output-style=static", + "test:unit:ci": "nx run-many --target=test --all --parallel=3 --output-style=static", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", "coverage:health": "node tools/coverage/coverage-health.mjs", - "coverage:ci": "pnpm run coverage:unit:ci && pnpm run coverage:merge && node tools/coverage/coverage-health.mjs --require-report", + "coverage:policy:check": "node tools/coverage/check-coverage-policy.mjs", + "coverage:ci": "pnpm run coverage:policy:check && pnpm run coverage:unit:ci && pnpm run coverage:merge && node tools/coverage/coverage-health.mjs --require-report", "coverage:e2e:summary": "node tools/coverage/e2e-semantic-summary.mjs", "coverage:e2e:v8:web": "node tools/coverage/e2e-v8-web.mjs", "typecheck:web": "tsc -p apps/web/tsconfig.app.json --noEmit", @@ -59,7 +60,7 @@ "release:artwork:manifest": "tsx tools/release/generate-marketing-artwork.ts --manifest", "release:artwork:generate": "tsx tools/release/generate-marketing-artwork.ts --generate", "release:artwork:validate": "tsx tools/release/generate-marketing-artwork.ts --validate", - "lint": "nx lint electron-backend", + "lint": "nx run-many --target=lint --all", "build": "nx build electron-backend", "wiki:export": "node tools/wiki/export-wiki-context.mjs", "test:wiki-export": "node --test tools/wiki/export-wiki-context.test.mjs" diff --git a/tools/coverage/check-coverage-policy.mjs b/tools/coverage/check-coverage-policy.mjs new file mode 100644 index 000000000..f33e29284 --- /dev/null +++ b/tools/coverage/check-coverage-policy.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +/** + * Guards tools/coverage/coverage-policy.json against drift. + * + * Every Nx project with a `test` target must be classified in a coverage + * tier (A, B, or C). This makes CI fail loudly when a new library is added + * without deciding how it is tested, instead of silently skipping it. + * + * Usage: + * node tools/coverage/check-coverage-policy.mjs + * Validate the policy. Exits non-zero on unclassified or stale entries. + * node tools/coverage/check-coverage-policy.mjs --list-non-tier-a + * Print a comma-separated list of Tier B/C projects that have a test + * target, for running them in CI without coverage. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const workspaceRoot = process.cwd(); +const policyPath = path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'); +const policy = JSON.parse(readFileSync(policyPath, 'utf8')); + +const tiers = { + tierA: policy.unitCoverage.tierA ?? [], + tierB: policy.unitCoverage.tierB ?? [], + tierC: policy.unitCoverage.tierC ?? [], +}; + +function showProjects(extraArgs = []) { + const output = execFileSync( + 'pnpm', + ['nx', 'show', 'projects', '--json', ...extraArgs], + { cwd: workspaceRoot, encoding: 'utf8' } + ); + return new Set(JSON.parse(output.slice(output.indexOf('[')))); +} + +const testProjects = showProjects(['--withTarget', 'test']); +const allProjects = showProjects(); + +const classified = new Map( + Object.entries(tiers).flatMap(([tier, projects]) => + projects.map((project) => [project.name, tier]) + ) +); + +if (process.argv.includes('--list-non-tier-a')) { + const nonTierA = [...tiers.tierB, ...tiers.tierC] + .map((project) => project.name) + .filter((name) => testProjects.has(name)); + process.stdout.write(nonTierA.join(',')); + process.exit(0); +} + +const unclassified = [...testProjects].filter((name) => !classified.has(name)); +const stale = [...classified.keys()].filter((name) => !allProjects.has(name)); + +if (unclassified.length > 0) { + console.error( + 'Coverage policy is missing projects that have a test target:\n' + + unclassified.map((name) => ` - ${name}`).join('\n') + + '\n\nAdd each project to a tier in tools/coverage/coverage-policy.json:' + + '\n tierA: runs in CI with coverage (Codecov)' + + '\n tierB/tierC: runs in CI without coverage' + ); + process.exit(1); +} + +if (stale.length > 0) { + console.error( + 'Coverage policy lists projects that no longer have a test target:\n' + + stale.map((name) => ` - ${name}`).join('\n') + + '\n\nRemove them from tools/coverage/coverage-policy.json.' + ); + process.exit(1); +} + +console.log( + `Coverage policy OK: ${testProjects.size} test projects classified ` + + `(${tiers.tierA.length} Tier A, ${tiers.tierB.length} Tier B, ${tiers.tierC.length} Tier C).` +);