Files
iptvnator/tools/eslint/generate-max-lines-baseline.mjs
4grayandClaude Opus 5 9b7776a901 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>
2026-07-29 08:08:04 +02:00

181 lines
6.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Regenerates tools/eslint/max-lines-baseline.mjs: the list of TypeScript
* files that already exceed the max-lines limit enforced in eslint.config.mjs.
* Baselined files are exempt from the rule; the list should only shrink.
*
* 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
* be split (for example a function serialized into another process).
*
* 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 { fileURLToPath, pathToFileURL } from 'node:url';
import tsParser from '@typescript-eslint/parser';
import {
MAX_LINES_OPTIONS,
MAX_LINES_PROD,
MAX_LINES_TEST,
TEST_FILE_GLOBS,
} from './max-lines-config.mjs';
// 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()) {
if (!skipDirs.has(entry.name)) {
collectTsFiles(path.join(dir, entry.name), results);
}
} else if (
(entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) &&
!entry.name.endsWith('.d.ts')
) {
results.push(path.join(dir, entry.name));
}
}
return results;
}
/**
* True when the file already suppresses max-lines with a file-wide
* `/* eslint-disable *\/` comment. Those files are deliberately exempt with a
* reviewed justification, so baselining them too would be redundant — and
* would wrongly imply they merely predate the rule.
*/
function hasInlineMaxLinesDisable(content) {
// Only a file-wide `eslint-disable` block comment can suppress max-lines;
// the rule reports at the line where the limit is exceeded, so
// `eslint-disable-next-line` cannot apply to it.
const disableComments = content.matchAll(
/\/\*\s*eslint-disable\s*([^*]*?)\*\//g
);
for (const [, ruleList = ''] of disableComments) {
// `/* eslint-disable */` with no rules disables everything.
const rules = ruleList.split('--')[0].trim();
if (rules === '') {
return true;
}
if (rules.split(',').some((rule) => rule.trim() === 'max-lines')) {
return true;
}
}
return false;
}
/** Repo-relative POSIX path — what the flat-config globs are matched against. */
function toRelativePosix(filePath) {
return path.relative(workspaceRoot, filePath).split(path.sep).join('/');
}
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`;
}
export const BASELINE_PATH = path.join(
workspaceRoot,
'tools/eslint/max-lines-baseline.mjs'
);
// 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}).`
);
}