mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
055170d188
commit
9b7776a901
16 files changed
+329
-120
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}).`
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user