From 9b7776a901ea0b62386a8eaa2afa23a79a5254da Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:08:04 +0200 Subject: [PATCH] chore(lint): hold tests to their own max-lines ceiling (#1306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(lint): hold tests to their own max-lines ceiling The flat 400-line cap treated a spec like a component. A spec is a flat list of independent cases, so hitting the cap there produces arbitrary `-2.spec.ts` splits and hides coverage instead of surfacing design debt — 65 of the 138 files over the limit were tests. Production code keeps 400. Tests (`**/*.spec.ts`, `**/*.e2e.ts`, and everything under `apps/*-e2e/**`) get 1200. Blank lines and comments no longer count, so a docblock can't be the reason a file must be split. Both limits now live in tools/eslint/max-lines-config.mjs, imported by eslint.config.mjs and the baseline generator alike. The generator decides who belongs on the list by running ESLint's own max-lines rule instead of counting lines itself — a private reimplementation would disagree with the rule the moment either side changed (a `//` inside a template literal is enough) and yield a baseline that turns CI red while looking correct. The baseline drops 126 -> 68 entries with nothing added, and six now-dead `eslint-disable max-lines` directives are removed. A new eslint-tools test asserts the committed baseline still matches what the generator produces, so a stale entry or a forgotten regeneration fails CI. Co-Authored-By: Claude Opus 5 * chore(lint): classify eslint-tools in the coverage policy A project with a `test` target must be assigned a coverage tier, so adding eslint-tools broke `coverage:policy:check` before the unit suite even ran. Tier B alongside packaging and release-tools: these are Node tests over lint tooling, and a coverage percentage across a generated list would not mean anything. CI runs Tier B/C through its own `--run-non-tier-a` step, so the baseline-consistency test executes there rather than being skipped. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- AGENTS.md | 2 +- CLAUDE.md | 42 +++-- .../database-worker-post-gc-probe.spec.ts | 1 - .../m3u-import-report.test-helpers.ts | 1 - .../m3u-refresh-cancellation-report.ts | 1 - .../m3u-refresh-cancellation.benchmark.ts | 1 - .../m3u-refresh-renderer-capture.ts | 1 - .../src/performance/xtream-renderer-probe.ts | 1 - docs/architecture/validation-map.md | 9 +- eslint.config.mjs | 22 ++- tools/coverage/coverage-policy.json | 6 + tools/eslint/generate-max-lines-baseline.mjs | 147 +++++++++++++----- tools/eslint/max-lines-baseline.mjs | 61 +------- tools/eslint/max-lines-baseline.test.mjs | 75 +++++++++ tools/eslint/max-lines-config.mjs | 44 ++++++ tools/eslint/project.json | 35 +++++ 16 files changed, 329 insertions(+), 120 deletions(-) create mode 100644 tools/eslint/max-lines-baseline.test.mjs create mode 100644 tools/eslint/max-lines-config.mjs create mode 100644 tools/eslint/project.json diff --git a/AGENTS.md b/AGENTS.md index 3d24958a1..4fa10bf23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ This file provides guidance to coding agents working in this repository. - Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. -- ESLint enforces `max-lines` on TypeScript files (target under 300, hard maximum 400). Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. +- ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 — a long spec signals coverage, not the design debt the production limit catches. Blank lines and comments are not counted, so a docblock never forces a split. Limits live in `tools/eslint/max-lines-config.mjs`, imported by both `eslint.config.mjs` and the generator so the rule and the baseline cannot drift. Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs` (it runs ESLint's own rule rather than counting lines itself). Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused. - Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find -name '*.ts' | wc -l`. - Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an external agent does not support skills, treat those files as concise ownership docs. diff --git a/CLAUDE.md b/CLAUDE.md index c9c62a2af..f37f92450 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,14 +219,32 @@ nx lint electron-backend CI lints affected projects on PRs (`nx affected`) and every project on master pushes (`.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 — the list must only shrink. A new file -that genuinely cannot be split (for example a function serialized into another -process) instead carries its own file-wide +rule. The limits and their rationale live in one place, +`tools/eslint/max-lines-config.mjs`, which both `eslint.config.mjs` and the +baseline generator import so the enforced rule and the generated list cannot +drift: + +- **Production TypeScript: hard maximum 400 lines.** +- **Tests: 1200.** `**/*.spec.ts`, `**/*.e2e.ts` and everything under + `apps/*-e2e/**` — a spec is a flat list of independent cases, so splitting one + at the production limit yields arbitrary `-2.spec.ts` files, and length there + signals coverage rather than the design debt the production limit catches. +- **Blank lines and comments are not counted** (`skipBlankLines`, + `skipComments`), so a docblock is never the reason a file must be split. + +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. The +generator decides who belongs on the list by running ESLint's own `max-lines` +rule, not by counting lines itself — a private reimplementation would silently +disagree with the rule and produce a baseline that turns CI red while looking +correct. Never add new files to the baseline — the list must only shrink. A new +file that genuinely cannot be split (for example a function serialized into +another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so -a justified exemption never lands in the baseline. +a justified exemption never lands in the baseline. If such a directive later +becomes unnecessary, ESLint reports it as an unused disable directive — remove +it rather than leaving a stale justification behind. Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX @@ -490,7 +508,11 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation. **TypeScript File Size Rule**: -Keep TypeScript files under **300 lines**. Hard maximum is **350–400 lines**. +Keep production TypeScript files under **300 lines**. Hard maximum is +**350–400 lines**, and CI enforces the 400. Blank lines and comments do not +count toward it, so documenting a file never costs you headroom. Tests +(`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 instead — the +guidance below is about production code. - When creating new files, design them to stay within this limit from the start. - When adding a feature to an existing file that would push it past 350 lines, **refactor first**: extract helpers, sub-services, or feature modules before adding the new code. @@ -672,12 +694,12 @@ with its own `mimeType`. Electron Builder derives all three platform registrations from it: macOS `CFBundleDocumentTypes` (which is what makes `open-file` fire from Finder), the NSIS registry entries, and, on Linux, the desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for -deb/rpm/pacman. Two traps: it assigns the derived `MimeType` *after* spreading +deb/rpm/pacman. Two traps: it assigns the derived `MimeType` _after_ spreading `linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and must not be used; and it appends `%U` to `Exec`, so Linux file managers hand over percent-encoded `file://` URIs rather than paths — `createPlaylistOpenRequest` decodes them before the extension check. `%U` is -also the *plural* exec code, so a multi-file selection arrives as one launch +also the _plural_ exec code, so a multi-file selection arrives as one launch with one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all of them and `enqueueAll` queues the batch, because stopping at the first match would silently drop the rest of the selection. Adding an exec code to diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts index 7ddf4e4cf..39802115d 100644 --- a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts @@ -1,5 +1,4 @@ /* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ -/* eslint-disable max-lines -- The one-shot transport contract keeps all terminal-path fixtures together. */ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import test from 'node:test'; diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts index e00b7e637..8bce4efe2 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- The deterministic fixture keeps the full production-shaped import sequence visible. */ import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; import type { M3uImportBenchmarkManifest } from './m3u-import-summary'; diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts index f95d075e6..3a296d51e 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Summary distributions and correlated phase derivation share one auditable schema mapping. */ import type { CancellationBenchmarkManifest, CancellationBenchmarkSummary, diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index 09b7f1abf..bcb95d21d 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Benchmark lifecycle, local fixture server, and artifact safeguards stay auditable in one entry point. */ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { once } from 'node:events'; diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts index 2c574b3aa..e61c1e6c9 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Renderer CDP and injected-page lifecycle are kept together so raw artifact boundaries stay auditable. */ import { createWriteStream } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts index a402b593e..ea06941c6 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Playwright serializes this self-contained renderer probe; external helpers would be unavailable in page context. */ import type { Page } from '@playwright/test'; import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY, diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index c41c042e9..c22d3d008 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -34,9 +34,12 @@ The CI workflow (`.github/workflows/ci.yml`) lints affected projects on PRs (`nx affected`) and every project on master pushes. 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 +(hard maximum 400 lines for production TypeScript, 1200 for tests; blank lines +and comments are not counted). The limits live in +`tools/eslint/max-lines-config.mjs`, which both `eslint.config.mjs` and the +generator import. 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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 57bef608a..7b78ea47c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,11 @@ import nx from '@nx/eslint-plugin'; import { maxLinesBaseline } from './tools/eslint/max-lines-baseline.mjs'; +import { + MAX_LINES_OPTIONS, + MAX_LINES_PROD, + MAX_LINES_TEST, + TEST_FILE_GLOBS, +} from './tools/eslint/max-lines-config.mjs'; const legacyBareAliases = [ 'components', @@ -248,13 +254,25 @@ export default [ }, }, { - // CLAUDE.md file-size rule: keep TypeScript files under 300 lines, + // CLAUDE.md file-size rule for production code: target under 300 lines, // hard maximum 400. Files that predate the rule are baselined below. files: ['**/*.ts', '**/*.tsx'], + ignores: TEST_FILE_GLOBS, rules: { 'max-lines': [ 'error', - { max: 400, skipBlankLines: false, skipComments: false }, + { max: MAX_LINES_PROD, ...MAX_LINES_OPTIONS }, + ], + }, + }, + { + // Tests get a much higher ceiling — see tools/eslint/max-lines-config.mjs + // for why a long spec is not the same signal as a long component. + files: TEST_FILE_GLOBS, + rules: { + 'max-lines': [ + 'error', + { max: MAX_LINES_TEST, ...MAX_LINES_OPTIONS }, ], }, }, diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index 5c3db575a..ebd6f52eb 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -277,6 +277,12 @@ "validationCommand": "pnpm nx test release-tools", "reason": "Node tests validate release-note parsing, rendering and gate policy; the scripts are release tooling, not shipped source." }, + { + "name": "eslint-tools", + "root": "tools/eslint", + "validationCommand": "pnpm nx test eslint-tools", + "reason": "Node tests assert the committed max-lines baseline still matches what the generator produces; the scripts are lint tooling, not shipped source, and percentage coverage over a generated list would not mean anything." + }, { "name": "shared-marketing-fixtures", "root": "libs/shared/marketing-fixtures", diff --git a/tools/eslint/generate-max-lines-baseline.mjs b/tools/eslint/generate-max-lines-baseline.mjs index 2fe912693..9be939139 100644 --- a/tools/eslint/generate-max-lines-baseline.mjs +++ b/tools/eslint/generate-max-lines-baseline.mjs @@ -5,6 +5,13 @@ * 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. * + * The offender check runs ESLint's own `max-lines` rule through `Linter`, + * against a config mirroring the real one (production vs test ceiling, shared + * options), rather than counting lines here. Reimplementing the count would + * silently disagree with the rule the moment either side changed — a `//` + * inside a template literal is enough — and a baseline that disagrees with the + * rule turns CI red while looking correct. + * * Files that carry their own file-wide `eslint-disable max-lines` comment are * skipped: they are already exempt with a written justification next to the * code, which is the preferred escape hatch for a file that genuinely cannot @@ -13,16 +20,60 @@ * Usage: node tools/eslint/generate-max-lines-baseline.mjs */ +import { Linter } from 'eslint'; import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; -import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import tsParser from '@typescript-eslint/parser'; -export const MAX_LINES = 400; +import { + MAX_LINES_OPTIONS, + MAX_LINES_PROD, + MAX_LINES_TEST, + TEST_FILE_GLOBS, +} from './max-lines-config.mjs'; -const workspaceRoot = process.cwd(); +// Resolved from this file rather than cwd, so importing the module from a test +// (or running the script from a subdirectory) scans the same tree either way. +const workspaceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); const scanRoots = ['apps', 'libs', 'tools']; const skipDirs = new Set(['node_modules', 'dist', '.nx', 'coverage']); +/** + * Mirrors the two max-lines blocks in eslint.config.mjs. The baseline block is + * deliberately absent — an already-baselined file must still report here, or + * the list could never be re-derived. + */ +const lintConfig = [ + { + files: ['**/*.ts', '**/*.tsx'], + ignores: TEST_FILE_GLOBS, + languageOptions: { parser: tsParser }, + rules: { + 'max-lines': [ + 'error', + { max: MAX_LINES_PROD, ...MAX_LINES_OPTIONS }, + ], + }, + }, + { + files: TEST_FILE_GLOBS, + languageOptions: { parser: tsParser }, + rules: { + 'max-lines': [ + 'error', + { max: MAX_LINES_TEST, ...MAX_LINES_OPTIONS }, + ], + }, + }, +]; + +const linter = new Linter(); + function collectTsFiles(dir, results) { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { @@ -39,14 +90,6 @@ function collectTsFiles(dir, results) { return results; } -function countLines(content) { - if (content.length === 0) { - return 0; - } - const lines = content.split('\n').length; - return content.endsWith('\n') ? lines - 1 : lines; -} - /** * True when the file already suppresses max-lines with a file-wide * `/* eslint-disable *\/` comment. Those files are deliberately exempt with a @@ -66,46 +109,72 @@ function hasInlineMaxLinesDisable(content) { if (rules === '') { return true; } - if ( - rules - .split(',') - .some((rule) => rule.trim() === 'max-lines') - ) { + if (rules.split(',').some((rule) => rule.trim() === 'max-lines')) { return true; } } return false; } -const offenders = scanRoots - .flatMap((root) => collectTsFiles(path.join(workspaceRoot, root), [])) - .map((filePath) => { - const content = readFileSync(filePath, 'utf8'); - return { - file: path - .relative(workspaceRoot, filePath) - .split(path.sep) - .join('/'), - lines: countLines(content), - disabled: hasInlineMaxLinesDisable(content), - }; - }) - .filter(({ lines, disabled }) => lines > MAX_LINES && !disabled) - .sort((a, b) => a.file.localeCompare(b.file)); +/** Repo-relative POSIX path — what the flat-config globs are matched against. */ +function toRelativePosix(filePath) { + return path.relative(workspaceRoot, filePath).split(path.sep).join('/'); +} -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. +function exceedsLimit(content, relativePath) { + const messages = linter.verify(content, lintConfig, relativePath); + return messages.some((message) => message.ruleId === 'max-lines'); +} + +/** + * The repo-relative paths that currently exceed their bucket's limit, sorted. + * Exported so a test can assert the committed baseline still matches, which is + * what keeps stale entries and forgotten regenerations out of the list. + */ +export function computeOffenders() { + return scanRoots + .flatMap((root) => collectTsFiles(path.join(workspaceRoot, root), [])) + .map((filePath) => { + const content = readFileSync(filePath, 'utf8'); + return { file: toRelativePosix(filePath), content }; + }) + .filter( + ({ content, file }) => + !hasInlineMaxLinesDisable(content) && + exceedsLimit(content, file) + ) + .map(({ file }) => file) + .sort((a, b) => a.localeCompare(b)); +} + +/** The exact file contents for a given offender list. */ +export function renderBaseline(offenders) { + const banner = `// Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand. +// TypeScript files that predate the max-lines ESLint rule (${MAX_LINES_PROD} for +// production code, ${MAX_LINES_TEST} for tests; blank lines and comments are not counted). // This list should only shrink: split a file below the limit, rerun the // generator, and commit the result. Never add new files here. // A new file that genuinely cannot be split takes a file-wide // \`/* eslint-disable max-lines -- */\` instead; the generator skips those. `; + const body = offenders.map((file) => ` '${file}',`).join('\n'); + return `${banner}export const maxLinesBaseline = [\n${body}\n];\n`; +} -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` +export const BASELINE_PATH = path.join( + workspaceRoot, + 'tools/eslint/max-lines-baseline.mjs' ); -console.log(`Baselined ${offenders.length} files over ${MAX_LINES} lines.`); +// Only write when run as a script — importing this module must not mutate the +// baseline it is used to verify. +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const offenders = computeOffenders(); + writeFileSync(BASELINE_PATH, renderBaseline(offenders)); + console.log( + `Baselined ${offenders.length} files (prod > ${MAX_LINES_PROD}, test > ${MAX_LINES_TEST}).` + ); +} diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 8ac72bd1d..6082a75a7 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -1,134 +1,77 @@ // Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand. -// TypeScript files that predate the max-lines (400) ESLint rule. +// TypeScript files that predate the max-lines ESLint rule (400 for +// production code, 1200 for tests; blank lines and comments are not counted). // This list should only shrink: split a file below the limit, rerun the // generator, and commit the result. Never add new files here. // A new file that genuinely cannot be split takes a file-wide // `/* eslint-disable max-lines -- */` instead; the generator skips those. 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/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/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/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/generate-marketing-artwork.ts', ]; diff --git a/tools/eslint/max-lines-baseline.test.mjs b/tools/eslint/max-lines-baseline.test.mjs new file mode 100644 index 000000000..d70ac72ec --- /dev/null +++ b/tools/eslint/max-lines-baseline.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; + +import { maxLinesBaseline } from './max-lines-baseline.mjs'; +import { + BASELINE_PATH, + computeOffenders, + renderBaseline, +} from './generate-max-lines-baseline.mjs'; +import { + MAX_LINES_OPTIONS, + MAX_LINES_PROD, + MAX_LINES_TEST, + TEST_FILE_GLOBS, +} from './max-lines-config.mjs'; + +describe('max-lines baseline', () => { + it('matches what the generator produces', () => { + // The whole point of the baseline is that it lists files which really do + // exceed the limit. A stale entry silently exempts a file that was + // already split, and a missing entry turns CI red on an unrelated PR. + assert.equal( + readFileSync(BASELINE_PATH, 'utf8'), + renderBaseline(computeOffenders()), + 'Baseline is out of date — run: node tools/eslint/generate-max-lines-baseline.mjs' + ); + }); + + it('never lists a file twice', () => { + assert.equal( + new Set(maxLinesBaseline).size, + maxLinesBaseline.length, + 'baseline contains duplicate entries' + ); + }); + + it('stays sorted so regenerating produces a reviewable diff', () => { + assert.deepEqual( + maxLinesBaseline, + [...maxLinesBaseline].sort((a, b) => a.localeCompare(b)) + ); + }); +}); + +describe('max-lines config', () => { + it('gives tests more headroom than production code', () => { + // A spec is a flat list of independent cases; holding it to the + // production ceiling produces arbitrary `-2.spec.ts` splits. + assert.ok( + MAX_LINES_TEST > MAX_LINES_PROD, + `expected test ceiling (${MAX_LINES_TEST}) above production (${MAX_LINES_PROD})` + ); + }); + + it('does not count comments or blank lines', () => { + // This repo mandates documentation, so a docblock must never be the + // reason a file has to be split. + assert.equal(MAX_LINES_OPTIONS.skipComments, true); + assert.equal(MAX_LINES_OPTIONS.skipBlankLines, true); + }); + + it('routes specs, e2e specs and e2e apps to the test ceiling', () => { + for (const glob of [ + '**/*.spec.ts', + '**/*.e2e.ts', + 'apps/*-e2e/**/*.ts', + ]) { + assert.ok( + TEST_FILE_GLOBS.includes(glob), + `expected ${glob} among the test globs` + ); + } + }); +}); diff --git a/tools/eslint/max-lines-config.mjs b/tools/eslint/max-lines-config.mjs new file mode 100644 index 000000000..30c7d05a6 --- /dev/null +++ b/tools/eslint/max-lines-config.mjs @@ -0,0 +1,44 @@ +/** + * Single source of truth for the max-lines rule. + * + * Both eslint.config.mjs (which enforces the rule) and + * generate-max-lines-baseline.mjs (which lists the files that predate it) import + * from here, so the enforced limit and the generated baseline can never drift + * apart. + */ + +/** + * Production TypeScript. CLAUDE.md asks for under 300 lines; this is the hard + * ceiling CI enforces. + */ +export const MAX_LINES_PROD = 400; + +/** + * Tests and test infrastructure. A spec is a flat list of independent cases: + * splitting one at the production limit yields arbitrary `-2.spec.ts` files and + * makes coverage harder to find, and a long spec signals thorough coverage + * rather than the design debt the production limit is meant to catch. The + * ceiling is still bounded so a runaway fixture gets noticed. + */ +export const MAX_LINES_TEST = 1200; + +/** + * Comments and blank lines are not counted. This repo mandates documentation + * (see "Documentation After Changes" in CLAUDE.md), so a docblock must never be + * the reason a file has to be split. + */ +export const MAX_LINES_OPTIONS = { + skipBlankLines: true, + skipComments: true, +}; + +/** + * Specs, E2E specs, and everything inside the E2E apps — the latter covers + * fixtures, harnesses and performance capture helpers, which are test + * infrastructure even though they carry no `.spec`/`.e2e` suffix. + */ +export const TEST_FILE_GLOBS = [ + '**/*.spec.ts', + '**/*.e2e.ts', + 'apps/*-e2e/**/*.ts', +]; diff --git a/tools/eslint/project.json b/tools/eslint/project.json new file mode 100644 index 000000000..2872e4c3c --- /dev/null +++ b/tools/eslint/project.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "eslint-tools", + "projectType": "library", + "sourceRoot": "tools/eslint", + "targets": { + "test": { + "executor": "nx:run-commands", + "cache": true, + "inputs": [ + "{workspaceRoot}/tools/eslint/max-lines-config.mjs", + "{workspaceRoot}/tools/eslint/max-lines-baseline.mjs", + "{workspaceRoot}/tools/eslint/generate-max-lines-baseline.mjs", + "{workspaceRoot}/tools/eslint/max-lines-baseline.test.mjs", + "{workspaceRoot}/apps/**/*.ts", + "{workspaceRoot}/libs/**/*.ts", + "{workspaceRoot}/tools/**/*.ts" + ], + "options": { + "command": "node --test tools/eslint/max-lines-baseline.test.mjs", + "cwd": "{workspaceRoot}" + } + }, + "lint": { + "inputs": [ + "default", + "{workspaceRoot}/eslint.config.mjs", + "{workspaceRoot}/tools/eslint-rules/**/*", + "{workspaceRoot}/tools/eslint/**/*" + ], + "command": "eslint \"tools/eslint/*.mjs\"" + } + }, + "tags": ["scope:tools", "domain:lint", "type:tool"] +}