test: a size and complexity ratchet; coverage reported on both sides

The "class size" item from the quality checklist, measured:
- A shipped file over 800 lines may not grow past its line count today
  (13 frontend, 11 backend). No other file may cross 800.
- A function over the complexity limit may not rise, and no new one may
  appear. The limit is eslint `complexity` 20 on the frontend (64 functions
  over it today) and ruff C901 10 on the backend (32).
- Each is a per-file ceiling. Shrinking is free; an entry back under the
  limit must be deleted, so the lists only shrink.

C901 is measured in a test rather than switched on in ruff.toml: ruff has no
per-function baseline, and a noqa would let a function grow without limit.

Coverage (owner decision D4) is printed with no threshold:
`vitest --coverage` (v8) gives 80% of lines, and the pytest lane ends with
the total for app/, 78%. The bar stays "every regression test seen red".
.coverage is ignored by git and kept out of the image's build context.
This commit is contained in:
2026-10-03 17:18:53 +02:00
parent da554df7c7
commit 751e1ae5ed
10 changed files with 504 additions and 1 deletions
+1
View File
@@ -4,6 +4,7 @@
*.env.local
**/__pycache__/
**/*.pyc
**/.coverage
.venv/
venv/
# Docker patterns are anchored at the context root with NO implicit `**/` (unlike .gitignore, where
+1
View File
@@ -9,6 +9,7 @@ __pycache__/
venv/
.mypy_cache/
.pytest_cache/
.coverage
.ruff_cache/
# Node / frontend
+6 -1
View File
@@ -35,4 +35,9 @@ services:
volumes:
- ./:/app
working_dir: /app
command: python -m pytest
# The suite, then the coverage total (E32 D4: reported, no threshold). pytest's exit code is the
# lane's — the report is printed either way and never decides it.
command:
- sh
- -c
- 'python -m pytest --cov=app --cov-report= ; rc=$$?; echo ">> backend coverage (app/) $$(python -m coverage report --format=total)% of lines"; exit $$rc'
+3
View File
@@ -7,7 +7,10 @@
# basedpyright: the backend type-check lane (E27 S3). Pinned like everything else here — a type
# checker whose version floats silently changes what "clean" means, and the ratchet baseline it
# writes is keyed to the diagnostics THIS version produces.
# pytest-cov: the coverage REPORT the gate prints (E32 D4) — a number to read, deliberately with no
# threshold; "every regression test seen red" is the bar, not a percentage.
basedpyright==1.40.1
pytest==8.4.2
pytest-cov==7.1.0
ruff==0.15.21
httpx2==2.13.0
+139
View File
@@ -0,0 +1,139 @@
"""The size / complexity RATCHET for the backend (E32, owner 2026-10-03 — "class size" from the quality
checklist, measured rather than argued). The frontend's twin is src/lib/sourceSize.contract.test.ts.
Two limits, each with today's offenders as a CEILING:
* a module over MAX_LINES lines — a listed one may not grow past its line count; any other module
may not cross the limit;
* a function over ruff's C901 limit (cyclomatic complexity, MAX_COMPLEXITY) — per module, the
offenders' values highest-first may not rise, and no new offender may appear.
Shrinking is free; a module or function that drops UNDER the limit must be taken off its list (red
until it is), so the lists only get shorter. Raising a ceiling is a decision for the owner, with the
reason in docs/gates.md.
C901 is measured here rather than switched on in ruff.toml because ruff has no per-function
baseline: on, it would need a `noqa` on each of today's offenders, and a `noqa` lets the function
grow without limit. This keeps the ceiling on the number.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parent.parent
MAX_LINES = 800
MAX_COMPLEXITY = 10 # ruff's C901 default (mccabe max-complexity)
BIG_MODULES: dict[str, int] = {
"app/downloads/edit.py": 833,
"app/downloads/pipeline.py": 870,
"app/logs.py": 839,
"app/models.py": 1607,
"app/plex/stream.py": 1128,
"app/plex/subtitles.py": 1092,
"app/plex/sync.py": 841,
"app/routes/admin.py": 864,
"app/routes/auth.py": 1353,
"app/routes/downloads.py": 1237,
"app/sync/playlists.py": 843,
}
COMPLEX: dict[str, list[int]] = {
"app/bodylimit.py": [11],
"app/discover/hero.py": [14],
"app/discover/pins.py": [12],
"app/downloads/edit.py": [16, 15],
"app/downloads/formats.py": [11],
"app/downloads/gc.py": [15, 11],
"app/downloads/og.py": [12, 11],
"app/downloads/pipeline.py": [14, 12],
"app/feedquery.py": [28, 14],
"app/plex/query.py": [13],
"app/plex/stream.py": [14],
"app/plex/subtitles.py": [12],
"app/plex/sync.py": [13],
"app/plex/tmdb.py": [11],
"app/plex/watch_sync.py": [12],
"app/routes/auth.py": [19],
"app/routes/debug_sessions.py": [12],
"app/routes/downloads.py": [11],
"app/routes/plex/browse.py": [25, 18, 13],
"app/routes/plex/detail.py": [13],
"app/routes/search.py": [15],
"app/sync/playlists.py": [19],
"app/sync/runner.py": [13],
"app/sync/tagsuggest.py": [14],
"app/worker.py": [18],
}
def _line_counts() -> dict[str, int]:
return {
p.relative_to(BACKEND).as_posix(): len(p.read_text(encoding="utf-8").split("\n"))
for p in sorted((BACKEND / "app").rglob("*.py"))
}
def _complexity() -> dict[str, list[int]]:
out = subprocess.run(
[
sys.executable,
"-m",
"ruff",
"check",
"--config",
str(BACKEND / "ruff.toml"),
"--select",
"C901",
"--output-format",
"json",
"--exit-zero",
str(BACKEND / "app"),
],
capture_output=True,
text=True,
check=True,
).stdout
found: dict[str, list[int]] = {}
for m in json.loads(out):
value = re.search(r"\((\d+) > \d+\)", m["message"])
assert value, m["message"]
rel = Path(m["filename"]).resolve().relative_to(BACKEND).as_posix()
found.setdefault(rel, []).append(int(value.group(1)))
return {f: sorted(v, reverse=True) for f, v in found.items()}
def test_no_module_crosses_the_line_limit_and_a_listed_one_does_not_grow():
over = [
f"{f}: {n} lines (ceiling {BIG_MODULES.get(f, MAX_LINES)})"
for f, n in _line_counts().items()
if n > BIG_MODULES.get(f, MAX_LINES)
]
assert over == []
def test_lists_no_module_that_is_back_under_the_limit():
lines = _line_counts()
assert [f for f in BIG_MODULES if lines.get(f, 0) <= MAX_LINES] == []
def test_no_function_crosses_the_complexity_limit_and_a_listed_one_does_not_rise():
worse = []
for f, values in _complexity().items():
ceiling = COMPLEX.get(f, [])
for i, v in enumerate(values):
cap = ceiling[i] if i < len(ceiling) else MAX_COMPLEXITY
if v > cap:
worse.append(f"{f}: #{i + 1} is {v} (ceiling {cap})")
assert worse == []
def test_lists_no_offender_that_is_back_under_the_limit():
actual = _complexity()
stale = [
f"{f}: listed {len(v)}, now {len(actual.get(f, []))}"
for f, v in COMPLEX.items()
if len(actual.get(f, [])) < len(v)
]
assert stale == []
+148
View File
@@ -33,6 +33,7 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "4.1.11",
"@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
@@ -380,6 +381,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -3313,6 +3324,37 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.11",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/eslint-plugin": {
"version": "1.6.27",
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.27.tgz",
@@ -3562,6 +3604,25 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz",
"integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -4679,6 +4740,13 @@
"node": ">=18"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -4826,6 +4894,45 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -5353,6 +5460,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz",
"integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -49,6 +49,7 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "4.1.11",
"@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
complexityOffenders,
lineCounts,
MAX_COMPLEXITY,
MAX_LINES,
shippedFiles,
} from "./sourceSize.testkit";
// The size / complexity RATCHET for the shipped frontend (E32, owner 2026-10-03 — "class size" from
// the quality checklist, measured rather than argued). Two limits, each with today's offenders as a
// CEILING:
// • a file over 800 lines — the listed ones may not grow past their line count; any other
// file may not cross the limit;
// • a function over complexity 20 (eslint `complexity`, cyclomatic) — per file, the
// offenders' values highest-first may not rise, and no new offender may appear.
// Shrinking is free; a file or function that drops UNDER the limit must be taken off its list (red
// until it is), so the lists only get shorter. Raising a ceiling is a decision for the owner, with
// the reason in docs/gates.md. JSX branches count toward complexity, so a component scores high by
// construction — the limit catches the outliers, it does not grade style.
const BIG_FILES: Record<string, number> = {
"components/AdminUsers.tsx": 993,
"components/Channels.tsx": 904,
"components/Feed.tsx": 934,
"components/Home.tsx": 1136,
"components/IssueComposer.tsx": 861,
"components/PlayerModal.tsx": 1142,
"components/Playlists.tsx": 1069,
"components/PlexPlayer.tsx": 1984,
"components/PlexPlaylistView.tsx": 830,
"components/plexViews.tsx": 941,
"components/SettingsPanel.tsx": 885,
"components/Welcome.tsx": 944,
"lib/plexPlayerHooks.ts": 922,
};
const COMPLEX: Record<string, number[]> = {
"App.tsx": [28],
"components/AccountPanel.tsx": [29],
"components/AdminLogs.tsx": [35],
"components/auditColumns.tsx": [24],
"components/ChannelPage.tsx": [91],
"components/Channels.tsx": [53],
"components/ConfigPanel.tsx": [28, 24],
"components/DownloadCenter.tsx": [36],
"components/downloadRow.tsx": [25],
"components/Feed.tsx": [64],
"components/Home.tsx": [34, 32, 21],
"components/IssueComposer.tsx": [34],
"components/KeyGate.tsx": [22],
"components/NotificationsPanel.tsx": [28],
"components/OnboardingWizard.tsx": [23],
"components/PersonalViewsModal.tsx": [24],
"components/PlayerModal.tsx": [99],
"components/Playlists.tsx": [66],
"components/PlexBrowse.tsx": [45],
"components/plexCards.tsx": [58],
"components/PlexCollectionView.tsx": [41],
"components/PlexDiscover.tsx": [21],
"components/PlexInfo.tsx": [65],
"components/PlexPlayer.tsx": [103, 22],
"components/PlexPlaylistView.tsx": [22],
"components/PlexSidebar.tsx": [32],
"components/PlexStatsOverlay.tsx": [29],
"components/plexViews.tsx": [50, 30, 21],
"components/Scheduler.tsx": [21, 21],
"components/SetupWizard.tsx": [21],
"components/SyncStatus.tsx": [22],
"components/ui/AnchoredPopover.tsx": [22],
"components/ui/anchoredPopoverHooks.ts": [30],
"components/ui/Combobox.tsx": [22],
"components/ui/DataTable.tsx": [42],
"components/ui/PanelGroup.tsx": [23],
"components/ui/SidePanel.tsx": [45],
"components/ui/SortControl.tsx": [23],
"components/usePlaylistActions.tsx": [25],
"components/usePlexActiveFilters.ts": [29],
"components/useVideoActions.tsx": [25],
"components/VideoCard.tsx": [39],
"components/VideoEditor.tsx": [50],
"components/WatchPage.tsx": [30],
"components/Welcome.tsx": [39, 24],
"lib/api/core.ts": [48, 22],
"lib/api/plex.ts": [26],
"lib/diagnostics.ts": [22],
"lib/keymap.ts": [28],
"lib/logHooks.ts": [21],
"lib/plexDetail.ts": [24],
"lib/plexPlayerHooks.ts": [30],
"lib/urlState.ts": [26, 24],
"lib/useActiveFilters.ts": [28],
};
describe("file size", () => {
const lines = lineCounts(shippedFiles());
it(`no file crosses ${MAX_LINES} lines, and a listed one does not grow`, () => {
const over = Object.entries(lines)
.filter(([f, n]) => n > (BIG_FILES[f] ?? MAX_LINES))
.map(([f, n]) => `${f}: ${n} lines (ceiling ${BIG_FILES[f] ?? MAX_LINES})`);
expect(over).toEqual([]);
});
it("lists no file that is back under the limit (delete it)", () => {
const back = Object.keys(BIG_FILES).filter((f) => (lines[f] ?? 0) <= MAX_LINES);
expect(back).toEqual([]);
});
});
describe("function complexity", async () => {
const actual = await complexityOffenders();
it(`no function crosses ${MAX_COMPLEXITY}, and a listed one does not rise`, () => {
const worse: string[] = [];
for (const [file, values] of Object.entries(actual)) {
const ceiling = COMPLEX[file] ?? [];
values.forEach((v, i) => {
if (v > (ceiling[i] ?? MAX_COMPLEXITY))
worse.push(`${file}: #${i + 1} is ${v} (ceiling ${ceiling[i] ?? MAX_COMPLEXITY})`);
});
}
expect(worse).toEqual([]);
});
it("lists no offender that is back under the limit (shorten the entry)", () => {
const stale = Object.entries(COMPLEX)
.filter(([file, values]) => (actual[file]?.length ?? 0) < values.length)
.map(
([file, values]) => `${file}: listed ${values.length}, now ${actual[file]?.length ?? 0}`,
);
expect(stale).toEqual([]);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import tseslint from "typescript-eslint";
// Measures the shipped frontend source for the size/complexity ratchet (E32): lines per file, and the
// cyclomatic complexity of every function over the limit. Used by sourceSize.contract.test.ts.
export const MAX_LINES = 800;
export const MAX_COMPLEXITY = 20;
const SRC = fileURLToPath(new URL("../", import.meta.url));
/** src-relative paths of everything the build ships (no tests, no test kits). */
export function shippedFiles(): string[] {
return (readdirSync(SRC, { recursive: true }) as string[])
.map((p) => p.split("\\").join("/"))
.filter(
(p) => /\.(ts|tsx)$/.test(p) && !/\.(test|testkit)\.tsx?$/.test(p) && !p.endsWith(".d.ts"),
)
.sort();
}
export function lineCounts(files: string[]): Record<string, number> {
const out: Record<string, number> = {};
for (const f of files) out[f] = readFileSync(SRC + f, "utf8").split("\n").length;
return out;
}
/** file → the complexities over MAX_COMPLEXITY, highest first. A bare rule run (no type info, no
* other rules), so it costs a couple of seconds, not the full lint. */
export async function complexityOffenders(): Promise<Record<string, number[]>> {
const eslint = new ESLint({
cwd: fileURLToPath(new URL("../../", import.meta.url)),
overrideConfigFile: true,
overrideConfig: [
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["**/*.test.*", "**/*.testkit.*"],
languageOptions: {
parser: tseslint.parser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
rules: { complexity: ["error", MAX_COMPLEXITY] },
},
],
});
const out: Record<string, number[]> = {};
for (const r of await eslint.lintFiles(["src/**/*.{ts,tsx}"])) {
const values = r.messages
.filter((m) => m.ruleId === "complexity")
.map((m) => Number(/complexity of (\d+)/.exec(m.message)?.[1]))
.sort((a, b) => b - a);
if (values.length)
out[
r.filePath
.split("\\")
.join("/")
.replace(/^.*\/frontend\/src\//, "")
] = values;
}
return out;
}
+8
View File
@@ -19,5 +19,13 @@ export default defineConfig({
// collected, and the gate still prints green.
include: ["src/**/*.test.{ts,tsx}"],
environment: "node",
// E32 D4: a coverage REPORT, read and not enforced — the gate's `vitest --coverage` prints the
// summary; no threshold, because "every regression test seen red" is the bar, not a percentage.
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.test.{ts,tsx}", "src/**/*.testkit.ts", "src/**/*.d.ts"],
reporter: ["text-summary"],
},
},
});