chore(lint): hold tests to their own max-lines ceiling (#1306)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-07-29 08:08:04 +02:00
1 parent 055170d188
commit 9b7776a901
16 files changed
+329 -120

No files matched your search

+1 -1
View File
@@ -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 -- <why> */`; 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 -- <why> */`; 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/<project>/**/*.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 <project> -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.
+32 -10
View File
@@ -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 -- <why> */`; 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/<project>/**/*.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
@@ -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';
@@ -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';
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Summary distributions and correlated phase derivation share one auditable schema mapping. */
import type {
CancellationBenchmarkManifest,
CancellationBenchmarkSummary,
@@ -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';
@@ -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';
@@ -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,
+6 -3
View File
@@ -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.
+20 -2
View File
@@ -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 },
],
},
},
+6
View File
@@ -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",
+108 -39
View File
@@ -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 -- <why> */\` 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}).`
);
}
+2 -59
View File
@@ -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 -- <why> */` 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',
];
+75
View File
@@ -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`
);
}
});
});
+44
View File
@@ -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',
];
+35
View File
@@ -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"]
}