From e14b8ae8d9a011e99e4f3e5ffdd691664fe6b5f6 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:06:45 +0200 Subject: [PATCH] feat(ci): enforce lint, guard coverage policy, add max-lines rule (#1117) * fix(lint): resolve module-boundary and prefer-inject errors Retag workspace-shell-util as type:data-access to match its injectable services that depend on @iptvnator/services, and convert RemoteControlService to inject(HttpClient). Co-Authored-By: Claude Fable 5 * feat(lint): enforce max-lines 400 with generated baseline Add a max-lines ESLint error (hard cap 400 raw lines per TypeScript file) per the repo file-size rule. The 134 pre-existing offenders are baselined in tools/eslint/max-lines-baseline.mjs, regenerable via generate-max-lines-baseline.mjs; the list should only shrink. Co-Authored-By: Claude Fable 5 * 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 * docs: document CI lint enforcement and coverage policy guard Co-Authored-By: Claude Fable 5 * fix(ci): address bot review feedback on policy guard and baseline generator - Drive Tier B/C validation from each policy entry's validationCommand (falling back to nx test), skipping projects with an e2e target since the E2E workflow already runs them (Codex). - Fail when a Tier A entry has no test target (Greptile, adapted: checking all entries against test targets would false-positive on the intentionally spec-less e2e/mock-server tiers). - Guard against missing JSON array in nx show projects output (Greptile). - Scan .tsx files in the max-lines baseline generator to match the ESLint rule's file patterns (Greptile). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/ci.yml | 33 ++++ CLAUDE.md | 14 +- docs/architecture/nx-workspace-boundaries.md | 13 ++ docs/architecture/validation-map.md | 26 +++- eslint.config.mjs | 18 +++ .../remote-control/remote-control.service.ts | 5 +- libs/workspace/shell/util/project.json | 2 +- package.json | 7 +- tools/coverage/check-coverage-policy.mjs | 141 ++++++++++++++++++ tools/eslint/generate-max-lines-baseline.mjs | 68 +++++++++ tools/eslint/max-lines-baseline.mjs | 140 +++++++++++++++++ 11 files changed, 455 insertions(+), 12 deletions(-) create mode 100644 tools/coverage/check-coverage-policy.mjs create mode 100644 tools/eslint/generate-max-lines-baseline.mjs create mode 100644 tools/eslint/max-lines-baseline.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a73d3f6f..bf4da6b24 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,12 @@ jobs: CI: true NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + - name: Run Tier B/C validation commands + run: node tools/coverage/check-coverage-policy.mjs --run-non-tier-a + env: + CI: true + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + - name: Upload unit coverage artifact if: always() uses: actions/upload-artifact@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 48fda5eeb..b1a866a22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,13 +198,21 @@ Before finishing behavior changes or bug fixes, follow `Regression Prevention An ### Linting ```bash -# Lint frontend -nx lint web +# Lint all projects (what CI enforces on every PR) +pnpm run lint -# Lint backend +# Lint a single project +nx lint web nx lint electron-backend ``` +CI runs lint for every project (`.github/workflows/ci.yml`). This enforces the +Nx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint +rule (hard maximum 400 lines per TypeScript file). Pre-existing oversized files +are baselined in `tools/eslint/max-lines-baseline.mjs`; regenerate the baseline +with `node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. +Never add new files to the baseline. + ### Documentation And Wiki Export ```bash diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 4207660c8..44a43772c 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -82,3 +82,16 @@ playback, or EPG data belongs in `libs/portal/shared/data-access`, not `libs/portal/shared/util`. That keeps pure collection helpers importable by Xtream/Stalker data-access libraries while allowing shared UI to use provider-specific collection services without creating cycles. + +Note: `workspace-shell-util` (`libs/workspace/shell/util`) is tagged +`type:data-access` despite its path. It exports injectable services such as +`WorkspaceStartupPreferencesService` that depend on `@iptvnator/services`, and +it must stay eagerly importable from `apps/web/src/app/app.routes.ts` without +pulling the lazy-loaded workspace shell feature bundle into the initial chunk. + +## CI Enforcement + +The `Lint` job in `.github/workflows/ci.yml` runs +`pnpm nx run-many --target=lint --all` on every PR, so +`@nx/enforce-module-boundaries` violations, legacy bare-alias imports, and +`max-lines` violations fail CI. Run `pnpm run lint` locally before pushing. diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 7df36a6de..387bfcea7 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -17,16 +17,38 @@ pnpm nx show projects --withTarget e2e | ---------------------------------- | ----------------------------------- | | Angular renderer entry points | `pnpm run typecheck:web` | | Electron main process entry points | `pnpm run typecheck:backend` | -| Curated CI unit suite | `pnpm run test:unit:ci` | +| Full unit suite (all projects) | `pnpm run test:unit:ci` | | EPG data access | `pnpm nx test epg-data-access` | | Workspace shell utilities | `pnpm nx test workspace-shell-util` | | Shared SQLite schema/connection | `pnpm nx test database` | | Packaging metadata | `pnpm nx test packaging` | +## Lint + +```bash +pnpm run lint # nx run-many --target=lint --all +pnpm nx lint # single project +``` + +The CI workflow (`.github/workflows/ci.yml`) runs lint for every project on +each PR. This enforces `@nx/enforce-module-boundaries` (scope/domain/type tag +constraints), the legacy bare-alias ban, and the `max-lines` file-size rule +(hard maximum 400 lines per TypeScript file). Files that predate the +`max-lines` rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after +splitting a baselined file below the limit, regenerate the list with +`node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to +the baseline. + ## Coverage Tiers Use `tools/coverage/coverage-policy.json` as the source of truth for coverage -ownership. +ownership. Every project with a `test` target must be classified in a tier; +`pnpm run coverage:policy:check` (part of `coverage:ci`) fails CI when a new +project is missing from the policy, a listed project no longer exists, or a +Tier A entry has no test target. CI runs Tier A with coverage (uploaded to +Codecov) and each Tier B/C project's `validationCommand` (falling back to +`nx test`) without coverage; projects with an `e2e` target are skipped there +because the E2E workflow already runs them on every PR. | Tier | Rule | Validation | | --- | --- | --- | diff --git a/eslint.config.mjs b/eslint.config.mjs index 41093fbd1..57bef608a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,4 +1,5 @@ import nx from '@nx/eslint-plugin'; +import { maxLinesBaseline } from './tools/eslint/max-lines-baseline.mjs'; const legacyBareAliases = [ 'components', @@ -246,6 +247,23 @@ export default [ '@angular-eslint/template/interactive-supports-focus': 'off', }, }, + { + // CLAUDE.md file-size rule: keep TypeScript files under 300 lines, + // hard maximum 400. Files that predate the rule are baselined below. + files: ['**/*.ts', '**/*.tsx'], + rules: { + 'max-lines': [ + 'error', + { max: 400, skipBlankLines: false, skipComments: false }, + ], + }, + }, + { + files: maxLinesBaseline, + rules: { + 'max-lines': 'off', + }, + }, { files: [ 'apps/web/src/app/app.component.ts', diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts index 79c057901..27ccf4f0c 100644 --- a/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; @@ -25,8 +25,7 @@ export interface RemoteControlStatus { }) export class RemoteControlService { private readonly apiBaseUrl = '/api/remote-control'; - - constructor(private http: HttpClient) {} + private readonly http = inject(HttpClient); /** * Changes to the next channel diff --git a/libs/workspace/shell/util/project.json b/libs/workspace/shell/util/project.json index 09e65ab5d..61a84736b 100644 --- a/libs/workspace/shell/util/project.json +++ b/libs/workspace/shell/util/project.json @@ -4,7 +4,7 @@ "sourceRoot": "libs/workspace/shell/util/src", "prefix": "lib", "projectType": "library", - "tags": ["scope:workspace", "domain:workspace", "type:util"], + "tags": ["scope:workspace", "domain:workspace", "type:data-access"], "targets": { "test": { "executor": "@nx/jest:jest", 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..3fe6e5bf0 --- /dev/null +++ b/tools/coverage/check-coverage-policy.mjs @@ -0,0 +1,141 @@ +#!/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 projects, stale + * entries, or Tier A entries without a test target. + * node tools/coverage/check-coverage-policy.mjs --run-non-tier-a + * Run the validation command for each Tier B/C project. Uses the + * entry's `validationCommand` when present, falling back to + * `pnpm nx test `. Projects with an `e2e` target are skipped + * because the E2E workflow already runs them on every PR. + */ + +import { execFileSync, spawnSync } 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' } + ); + const start = output.indexOf('['); + if (start === -1) { + throw new Error( + `nx show projects --json produced no JSON array.\nRaw output:\n${output}` + ); + } + return new Set(JSON.parse(output.slice(start))); +} + +const testProjects = showProjects(['--withTarget', 'test']); +const allProjects = showProjects(); +const e2eProjects = showProjects(['--withTarget', 'e2e']); + +function runNonTierA() { + let failed = false; + + for (const project of [...tiers.tierB, ...tiers.tierC]) { + if (e2eProjects.has(project.name)) { + console.log( + `==> Skipping ${project.name}: covered by the E2E workflow.` + ); + continue; + } + + const command = + project.validationCommand ?? + (testProjects.has(project.name) + ? `pnpm nx test ${project.name}` + : null); + if (!command) { + console.log( + `==> Skipping ${project.name}: no validation command or test target.` + ); + continue; + } + + console.log(`\n==> Validating ${project.name}: ${command}`); + const result = spawnSync(command, { + cwd: workspaceRoot, + shell: true, + stdio: 'inherit', + }); + if (result.status !== 0) { + failed = true; + } + } + + process.exit(failed ? 1 : 0); +} + +if (process.argv.includes('--run-non-tier-a')) { + runNonTierA(); +} + +const classified = new Map( + Object.entries(tiers).flatMap(([tier, projects]) => + projects.map((project) => [project.name, tier]) + ) +); + +const unclassified = [...testProjects].filter((name) => !classified.has(name)); +const stale = [...classified.keys()].filter((name) => !allProjects.has(name)); +const tierAWithoutTest = tiers.tierA + .map((project) => project.name) + .filter((name) => !testProjects.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 do not exist in the workspace:\n' + + stale.map((name) => ` - ${name}`).join('\n') + + '\n\nRemove them from tools/coverage/coverage-policy.json.' + ); + process.exit(1); +} + +if (tierAWithoutTest.length > 0) { + console.error( + 'Tier A projects must have a test target, but these do not:\n' + + tierAWithoutTest.map((name) => ` - ${name}`).join('\n') + + '\n\nRestore the test target or move the project to Tier B/C in ' + + '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).` +); diff --git a/tools/eslint/generate-max-lines-baseline.mjs b/tools/eslint/generate-max-lines-baseline.mjs new file mode 100644 index 000000000..a92204f12 --- /dev/null +++ b/tools/eslint/generate-max-lines-baseline.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +/** + * Regenerates tools/eslint/max-lines-baseline.mjs: the list of TypeScript + * files that already exceed the max-lines limit enforced in eslint.config.mjs. + * Baselined files are exempt from the rule; the list should only shrink. + * + * Usage: node tools/eslint/generate-max-lines-baseline.mjs + */ + +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +export const MAX_LINES = 400; + +const workspaceRoot = process.cwd(); +const scanRoots = ['apps', 'libs', 'tools']; +const skipDirs = new Set(['node_modules', 'dist', '.nx', 'coverage']); + +function collectTsFiles(dir, results) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + collectTsFiles(path.join(dir, entry.name), results); + } + } else if ( + (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) && + !entry.name.endsWith('.d.ts') + ) { + results.push(path.join(dir, entry.name)); + } + } + return results; +} + +function countLines(filePath) { + const content = readFileSync(filePath, 'utf8'); + if (content.length === 0) { + return 0; + } + const lines = content.split('\n').length; + return content.endsWith('\n') ? lines - 1 : lines; +} + +const offenders = scanRoots + .flatMap((root) => collectTsFiles(path.join(workspaceRoot, root), [])) + .map((filePath) => ({ + file: path.relative(workspaceRoot, filePath).split(path.sep).join('/'), + lines: countLines(filePath), + })) + .filter(({ lines }) => lines > MAX_LINES) + .sort((a, b) => a.file.localeCompare(b.file)); + +const banner = `// Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand. +// TypeScript files that predate the max-lines (${MAX_LINES}) ESLint rule. +// This list should only shrink: split a file below the limit, rerun the +// generator, and commit the result. Never add new files here. +`; + +const body = offenders.map(({ file }) => ` '${file}',`).join('\n'); + +writeFileSync( + path.join(workspaceRoot, 'tools/eslint/max-lines-baseline.mjs'), + `${banner}export const maxLinesBaseline = [\n${body}\n];\n` +); + +console.log(`Baselined ${offenders.length} files over ${MAX_LINES} lines.`); diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs new file mode 100644 index 000000000..2e79ddc2c --- /dev/null +++ b/tools/eslint/max-lines-baseline.mjs @@ -0,0 +1,140 @@ +// Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand. +// TypeScript files that predate the max-lines (400) ESLint rule. +// This list should only shrink: split a file below the limit, rerun the +// generator, and commit the result. Never add new files here. +export const maxLinesBaseline = [ + 'apps/electron-backend-e2e/src/catalog-sorting.e2e.ts', + 'apps/electron-backend-e2e/src/category-management.e2e.ts', + 'apps/electron-backend-e2e/src/electron-test-fixtures.ts', + 'apps/electron-backend-e2e/src/favorites.e2e.ts', + 'apps/electron-backend-e2e/src/playlist-switcher.e2e.ts', + 'apps/electron-backend-e2e/src/portal-mock-fixtures.ts', + 'apps/electron-backend-e2e/src/recent.e2e.ts', + 'apps/electron-backend-e2e/src/search.e2e.ts', + 'apps/electron-backend-e2e/src/settings.e2e.ts', + 'apps/electron-backend-e2e/src/sources.e2e.ts', + 'apps/electron-backend/src/app/api/main.preload.spec-data.ts', + 'apps/electron-backend/src/app/api/main.preload.ts', + 'apps/electron-backend/src/app/app.ts', + 'apps/electron-backend/src/app/database/operations/content.operations.spec.ts', + 'apps/electron-backend/src/app/database/operations/content.operations.ts', + 'apps/electron-backend/src/app/database/operations/playlist.operations.ts', + 'apps/electron-backend/src/app/events/epg-query.service.spec.ts', + 'apps/electron-backend/src/app/events/epg-query.service.ts', + 'apps/electron-backend/src/app/events/epg-worker.service.ts', + 'apps/electron-backend/src/app/events/epg.events.spec.ts', + 'apps/electron-backend/src/app/events/mpv-session.service.ts', + 'apps/electron-backend/src/app/events/player.events.spec.ts', + 'apps/electron-backend/src/app/events/playlist.events.spec.ts', + 'apps/electron-backend/src/app/events/vlc-session.service.ts', + 'apps/electron-backend/src/app/services/app-update.service.spec.ts', + 'apps/electron-backend/src/app/services/app-update.service.ts', + 'apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts', + 'apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts', + 'apps/electron-backend/src/app/services/embedded-mpv-native.service.ts', + 'apps/electron-backend/src/app/util/validated-axios.spec.ts', + 'apps/electron-backend/src/app/workers/database.worker.ts', + 'apps/electron-backend/src/app/workers/epg-parser.worker.ts', + 'apps/stalker-mock-server/src/app/data-generator.ts', + 'apps/web-backend/src/app/web-backend-app.spec.ts', + 'apps/web-backend/src/app/web-backend-app.ts', + 'apps/web-e2e/src/stalker.e2e.ts', + 'apps/web-e2e/src/xtream.e2e.ts', + 'apps/web/src/app/services/electron.service.ts', + 'apps/web/src/app/services/pwa.service.ts', + 'apps/web/src/app/settings/settings.component.spec.ts', + 'apps/web/src/app/settings/settings.component.ts', + 'apps/xtream-mock-server/src/app/generators/marketing.generator.ts', + 'libs/epg/data-access/src/lib/epg.service.spec.ts', + 'libs/epg/data-access/src/lib/epg.service.ts', + 'libs/m3u-state/src/lib/effects.ts', + 'libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts', + 'libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts', + 'libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts', + 'libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts', + 'libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts', + 'libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts', + 'libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts', + 'libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts', + 'libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts', + 'libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts', + 'libs/playlist/shared/util/src/lib/playlist-context.facade.ts', + 'libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts', + 'libs/portal/downloads/feature/src/lib/downloads.component.ts', + 'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts', + 'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts', + 'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts', + 'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts', + 'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts', + 'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts', + 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts', + 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts', + 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts', + 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts', + 'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts', + 'libs/portal/stalker/data-access/src/lib/stalker-session.service.ts', + 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts', + 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts', + 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts', + 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts', + 'libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts', + 'libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts', + 'libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts', + 'libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts', + 'libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts', + 'libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts', + 'libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts', + 'libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts', + 'libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts', + 'libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts', + 'libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts', + 'libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts', + 'libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts', + 'libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts', + 'libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts', + 'libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts', + 'libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts', + 'libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts', + 'libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts', + 'libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts', + 'libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts', + 'libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts', + 'libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts', + 'libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts', + 'libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts', + 'libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts', + 'libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts', + 'libs/services/src/lib/database-electron.service.ts', + 'libs/services/src/lib/downloads.service.ts', + 'libs/services/src/lib/playlist-backup.service.ts', + 'libs/services/src/lib/playlists.service.spec.ts', + 'libs/services/src/lib/playlists.service.ts', + 'libs/services/src/lib/runtime-capabilities.service.spec.ts', + 'libs/shared/database/src/lib/connection.spec.ts', + 'libs/shared/database/src/lib/connection.ts', + 'libs/shared/interfaces/src/lib/electron-api.interface.ts', + 'libs/shared/m3u-utils/src/lib/playlist.utils.ts', + 'libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts', + 'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts', + 'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts', + 'libs/ui/components/src/lib/season-container/season-container.component.ts', + 'libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts', + 'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts', + 'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts', + 'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts', + 'libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts', + 'libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts', + 'libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts', + 'libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts', + 'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts', + 'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts', + 'libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts', + 'libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts', + 'libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts', + 'libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts', + 'libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts', + 'libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts', + 'libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts', + 'tools/release/capture-v020-screenshots.ts', + 'tools/release/generate-marketing-artwork.ts', +];