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

+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"]
}