diff --git a/backend/tests/test_source_size.py b/backend/tests/test_source_size.py deleted file mode 100644 index f83a658e..00000000 --- a/backend/tests/test_source_size.py +++ /dev/null @@ -1,144 +0,0 @@ -"""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) — a listed one, by its - NAME (`module::function`), may not rise; no other function may cross the limit. (Ranked per - module until the E32 review: one function could grow by what another in the same module lost.) - A rename reads as a new offender plus a stale entry: move its row. -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": 1525, - "app/plex/stream.py": 1128, - "app/plex/subtitles.py": 1092, - "app/routes/auth.py": 1273, - "app/routes/downloads.py": 1232, -} - -COMPLEX: dict[str, int] = { - "app/bodylimit.py::__call__": 11, - "app/discover/hero.py::pick": 14, - "app/discover/pins.py::_shelf_card": 12, - "app/downloads/edit.py::normalize_edit_spec": 16, - "app/downloads/edit.py::run_ffmpeg": 15, - "app/downloads/formats.py::build_ydl_opts": 11, - "app/downloads/gc.py::_sweep_orphan_files": 15, - "app/downloads/gc.py::run_download_gc": 11, - "app/downloads/og.py::_jpeg_size": 12, - "app/downloads/og.py::_watch_tags": 11, - "app/downloads/pipeline.py::_download": 14, - "app/downloads/pipeline.py::_process_edit": 12, - "app/feedquery.py::feed_summary": 14, - "app/feedquery.py::filtered_query": 28, - "app/plex/query.py::meta_filter_conds": 13, - "app/plex/stream.py::start_session": 14, - "app/plex/subtitles.py::_ffmpeg_extract": 12, - "app/plex/sync.py::sync": 13, - "app/plex/tmdb.py::parse_tmdb": 11, - "app/plex/watch_sync.py::run_plex_watch_sync": 12, - "app/routes/auth.py::callback": 19, - "app/routes/debug_sessions.py::send_issue": 12, - "app/routes/downloads.py::resolve_source": 11, - "app/routes/plex/browse.py::facets": 25, - "app/routes/plex/browse.py::per_table": 13, - "app/routes/plex/browse.py::unified_library": 18, - "app/routes/plex/detail.py::item_detail": 13, - "app/routes/search.py::search_youtube": 15, - "app/sync/playlists.py::merge_playlist": 19, - "app/sync/runner.py::run_rss_poll": 13, - "app/sync/tagsuggest.py::compute_tag_suggestions": 14, - "app/worker.py::_run_supervised": 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, int]: - """`module::function` -> its complexity; a name taken twice in a module gets ` 2`, ` 3` in - source order.""" - 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, int] = {} - for m in sorted(json.loads(out), key=lambda m: (m["filename"], m["location"]["row"])): - value = re.search(r"\((\d+) > \d+\)", m["message"]) - name = re.search(r"`([^`]+)`", m["message"]) - assert value and name, m["message"] - rel = Path(m["filename"]).resolve().relative_to(BACKEND).as_posix() - key, n = f"{rel}::{name.group(1)}", 2 - while key in found: - key, n = f"{rel}::{name.group(1)} {n}", n + 1 - found[key] = int(value.group(1)) - return found - - -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 = [ - f"{fn}: {v} (ceiling {COMPLEX.get(fn, MAX_COMPLEXITY)})" - for fn, v in _complexity().items() - if v > COMPLEX.get(fn, MAX_COMPLEXITY) - ] - assert worse == [] - - -def test_lists_no_function_that_is_back_under_the_limit_or_gone(): - actual = _complexity() - assert [fn for fn in COMPLEX if fn not in actual] == [] diff --git a/docs/development.md b/docs/development.md index e23464b1..5356de25 100644 --- a/docs/development.md +++ b/docs/development.md @@ -98,7 +98,7 @@ npm run typecheck # tsc x3 (app / node / e2e) npm run lint # eslint (typed rules included; warnings fail) npm run format:check # prettier npm run knip # unused files/exports/deps — and unused by what SHIPS (--production) -npm run test # vitest (incl. the compiler, size/complexity and gate-register contracts) +npm run test # vitest (incl. the compiler and gate-register contracts) npx vite build # the production bundle (devOnlyGuard fails it on dev-only code) npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above ``` diff --git a/docs/gates.md b/docs/gates.md index 096f327a..f90ae3e7 100644 --- a/docs/gates.md +++ b/docs/gates.md @@ -87,7 +87,7 @@ project checks needs a row. | Scope | Narrowed | Why | Measured | | --- | --- | --- | --- | | backend + scripts | `B008` — narrowed: `fastapi.Depends`, `fastapi.Query` declared immutable | FastAPI's dependency injection IS a call in a default argument, on every route. Every other call in a default still fails. | 474 sites, all of them FastAPI | -| backend/app | `C901` — not in ruff; measured by `tests/test_source_size.py` | Ruff has no per-function baseline, so turning it on would need a `noqa` per offender, and a `noqa` lets a function grow without limit. The test holds each offender, by name, at its current value. Tests and scripts are outside it: see the size / complexity rows under Lanes. | 32 functions over 10 | +| backend/app | `C901` — not in ruff; reported by `scripts/audit_metrics.py` | Ruff has no per-function baseline, so turning it on would need a `noqa` per offender, and a `noqa` lets a function grow without limit. Complexity is a number an epic reads before and after (E35), not a gate: the ratchet that held it caught no defect in E32–E34, went red 8 times on its own ceilings and drew 5 review findings about itself. | 32 functions over 10 (E34) | ## basedpyright (backend) @@ -121,8 +121,6 @@ project checks needs a row. | CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) | | npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 | | React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) | -| size / complexity | today's offenders as ceilings | `src/lib/sourceSize.contract.test.ts`, `backend/tests/test_source_size.py`: listed files and functions may not grow, nothing new may cross the limit, an entry back under it must be deleted. A function is held by its name, not its rank in the file. | files >800 lines: 13 FE / 9 BE; functions over the limit: 64 FE / 32 BE (E32 review) | -| size / complexity | shipped code only — not tests, test kits, e2e or `scripts/` | The ratchet guards what runs in production. A test file is long because it is a list of cases, and a test kit is test code; splitting either moves lines without making anything easier to change. `scripts/` is release and seed tooling, not in the image. | outside it: tests >800 lines 2 FE / 5 BE; functions over the limit 2 FE test code (`gates.contract.test.ts` `eslintNarrowings` 23, E32 review round 3; `compilerSkips.testkit.ts` 25), 1 BE test (11), 1 script (`e2e_seed.py` `main` 13) | ## Accepted findings diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index b2366411..ef2e43f2 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -1505,7 +1505,6 @@ "src/lib/saveLanguage.dom.test.ts", "src/lib/savedViews.test.ts", "src/lib/sortableA11y.contract.test.ts", - "src/lib/sourceSize.contract.test.ts", "src/lib/staleBuild.dom.test.ts", "src/lib/store.dom.test.tsx", "src/lib/subscriptionCache.test.ts", @@ -1948,7 +1947,6 @@ "src/lib/savedViews.ts", "src/lib/seekableEnd.ts", "src/lib/sortOptions.ts", - "src/lib/sourceSize.testkit.ts", "src/lib/staleBuild.ts", "src/lib/storage.ts", "src/lib/store.ts", diff --git a/frontend/src/lib/sourceSize.contract.test.ts b/frontend/src/lib/sourceSize.contract.test.ts deleted file mode 100644 index d759ba8f..00000000 --- a/frontend/src/lib/sourceSize.contract.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - boundNamesIn, - 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) — a listed one, by its -// NAME, may not rise; no other function may cross the limit. (Ranked per file until the E32 -// review: one function could grow by what another in the same file lost.) A rename reads as a -// new offender plus a stale entry: move its row. -// 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 = { - "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 = { - "App.tsx::App": 28, - "components/AccountPanel.tsx::AccountPanel": 29, - "components/AdminLogs.tsx::AdminLogs": 35, - "components/ChannelPage.tsx::ChannelPage": 91, - "components/Channels.tsx::Channels": 53, - "components/ConfigPanel.tsx::ConfigPanel": 24, - "components/ConfigPanel.tsx::Field": 28, - "components/DownloadCenter.tsx::DownloadCenter": 36, - "components/Feed.tsx::Feed": 64, - "components/Home.tsx::Hero": 32, - "components/Home.tsx::Home": 34, - "components/Home.tsx::useHomeCardMenu": 21, - "components/IssueComposer.tsx::ComposerBody": 34, - "components/NotificationsPanel.tsx::NotificationRow": 28, - "components/OnboardingWizard.tsx::OnboardingWizard": 21, - "components/PersonalViewsModal.tsx::PersonalViewsModal": 22, - "components/PlayerModal.tsx::PlayerModal": 98, - "components/Playlists.tsx::Playlists": 66, - "components/PlexBrowse.tsx::PlexBrowse": 45, - "components/PlexCollectionView.tsx::PlexCollectionView": 41, - "components/PlexDiscover.tsx::PlexDiscover": 21, - "components/PlexInfo.tsx::PlexInfo": 65, - "components/PlexPlayer.tsx::onKey": 22, - "components/PlexPlayer.tsx::PlexPlayer": 103, - "components/PlexPlaylistView.tsx::PlexPlaylistView": 22, - "components/PlexSidebar.tsx::PlexSidebar": 32, - "components/PlexStatsOverlay.tsx::sample": 25, - "components/Scheduler.tsx::JobRow": 21, - "components/Scheduler.tsx::Scheduler": 21, - "components/SetupWizard.tsx::SetupWizard": 21, - "components/SyncStatus.tsx::SyncStatus": 22, - "components/VideoCard.tsx::VideoCard": 39, - "components/VideoEditor.tsx::VideoEditor": 49, - "components/WatchPage.tsx::WatchPage": 30, - "components/Welcome.tsx::AuthCard": 39, - "components/Welcome.tsx::onSubmit": 24, - "components/auditColumns.tsx::localDetails": 24, - "components/downloadRow.tsx::DownloadRow": 25, - "components/plexCards.tsx::PlexPosterCard": 58, - "components/plexViews.tsx::PlexPersonView": 30, - "components/plexViews.tsx::PlexSeasonView": 21, - "components/plexViews.tsx::PlexShowView": 50, - "components/ui/AnchoredPopover.tsx::PopoverSurfaceInner": 22, - "components/ui/Combobox.tsx::Combobox": 22, - "components/ui/DataTable.tsx::DataTable": 42, - "components/ui/PanelGroup.tsx::PanelGroup": 23, - "components/ui/SidePanel.tsx::SidePanel": 45, - "components/ui/SortControl.tsx::SortControl": 23, - "components/ui/anchoredPopoverHooks.ts::useAnchoredPopover": 30, - "components/usePlaylistActions.tsx::pushSteps": 23, - "components/usePlexActiveFilters.ts::buildPlexActiveFilters": 25, - "components/useVideoActions.tsx::items": 25, - "lib/api/core.ts::localizeDetail": 48, - "lib/api/core.ts::req": 22, - "lib/api/plex.ts::appendPlexFilters": 26, - "lib/diagnostics.ts::applyDiagnostics": 22, - "lib/keymap.ts::dispatch": 28, - "lib/logHooks.ts::controlAttrs": 21, - "lib/plexDetail.ts::useDetailAside": 24, - "lib/plexPlayerHooks.ts::usePlexTracks": 30, - "lib/urlState.ts::filtersToParams": 24, - "lib/urlState.ts::paramsToFilters": 26, - "lib/useActiveFilters.ts::useActiveFilters": 25, -}; - -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 = Object.entries(actual) - .filter(([fn, v]) => v > (COMPLEX[fn] ?? MAX_COMPLEXITY)) - .map(([fn, v]) => `${fn}: ${v} (ceiling ${COMPLEX[fn] ?? MAX_COMPLEXITY})`); - expect(worse).toEqual([]); - }); - - it("lists no function that is back under the limit, or gone (delete its row)", () => { - expect(Object.keys(COMPLEX).filter((fn) => !(fn in actual))).toEqual([]); - }); -}); - -describe("function names (the ratchet's keys)", () => { - it("names an unnamed function by what it is bound to, through a wrapping call", () => { - const code = [ - "const onSave = useCallback(() => 1);", - "useEffect(() => {});", - "const o = { k: function () {} };", - "const v = useMemo(() => g(() => 2));", - ].join(" "); - expect(boundNamesIn(code)).toEqual(["onSave", "useEffect callback", "k", "v", "g callback"]); - }); -}); diff --git a/frontend/src/lib/sourceSize.testkit.ts b/frontend/src/lib/sourceSize.testkit.ts deleted file mode 100644 index 9cf51a4c..00000000 --- a/frontend/src/lib/sourceSize.testkit.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { ESLint, Linter, type Rule } 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 { - const out: Record = {}; - for (const f of files) out[f] = readFileSync(SRC + f, "utf8").split("\n").length; - return out; -} - -/** `file::function` → its complexity, for every function over MAX_COMPLEXITY. Keyed by NAME, not by - * rank in the file: a ranked list let one function grow while another in the same file shrank by as - * much (E32 review). ESLint names declarations and methods; a function it leaves unnamed takes the - * name it is bound to (`const onSave = () => …`, `{ onSave: () => … }`), through a wrapping call - * (`const onSave = useCallback(() => …)`, round 3), else ` callback` (`useEffect - * callback`), else `(anonymous)`. A name taken twice in a file gets ` 2`, ` 3` in source order — - * left only for repeated unbound callbacks (two `useEffect`s), where the order IS the identity. A - * bare rule run (no type info, no other rules), so it costs a couple of seconds, not the full lint. */ -type Node = Rule.Node; -const ident = (n: { type: string; name?: string } | null | undefined): string | undefined => - n?.type === "Identifier" ? n.name : undefined; -function boundName(p: Node): string | undefined { - switch (p.type) { - case "VariableDeclarator": - return ident(p.id); - case "Property": - case "PropertyDefinition": - return ident(p.key); - case "AssignmentExpression": - return ident(p.left.type === "MemberExpression" ? p.left.property : p.left); - case "CallExpression": { - // a wrapped function takes the wrapper's binding: `const onSave = useCallback(() => …)` - const outer = p.parent as Node | null; - const bound = outer && outer.type !== "CallExpression" ? boundName(outer) : undefined; - if (bound) return bound; - const callee = ident(p.callee.type === "MemberExpression" ? p.callee.property : p.callee); - return callee && `${callee} callback`; - } - default: - return undefined; - } -} -/** The name each function is bound to, reported where `complexity` reports it (the function's HEAD: - * the `=>` of an arrow, the key of a method, else the function's start) so the two line up. */ -const functionNames: Rule.RuleModule = { - create(ctx) { - const visit = (node: Node) => { - const p = node.parent as Node | null; - const name = p && boundName(p); - if (!p || !name) return; - const head = - node.type === "ArrowFunctionExpression" - ? ctx.sourceCode.getTokenBefore(node.body, (t) => t.value === "=>")!.loc.start - : p.type === "Property" || - p.type === "MethodDefinition" || - p.type === "PropertyDefinition" - ? p.loc!.start - : node.loc!.start; - ctx.report({ loc: head, message: name }); - }; - return { ArrowFunctionExpression: visit, FunctionExpression: visit }; - }, -}; - -/** The name `functionNames` gives each unnamed function in `code`, in source order (its own test). */ -export function boundNamesIn(code: string): string[] { - return new Linter() - .verify( - code, - [ - { - files: ["**/*.tsx"], - languageOptions: { - parser: tseslint.parser, - parserOptions: { ecmaFeatures: { jsx: true } }, - }, - plugins: { size: { rules: { "function-names": functionNames } } }, - rules: { "size/function-names": "error" }, - }, - ], - "probe.tsx", - ) - .map((m) => m.message); -} - -export async function complexityOffenders(): Promise> { - 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 } }, - }, - plugins: { size: { rules: { "function-names": functionNames } } }, - rules: { complexity: ["error", MAX_COMPLEXITY], "size/function-names": "error" }, - }, - ], - }); - const out: Record = {}; - for (const r of await eslint.lintFiles(["src/**/*.{ts,tsx}"])) { - const file = r.filePath - .split("\\") - .join("/") - .replace(/^.*\/frontend\/src\//, ""); - const at = (m: { line: number; column: number }) => `${m.line}:${m.column}`; - const bound = new Map( - r.messages.filter((m) => m.ruleId === "size/function-names").map((m) => [at(m), m.message]), - ); - for (const m of r.messages) { - if (m.ruleId !== "complexity") continue; - const name = /'([^']+)'/.exec(m.message)?.[1] ?? bound.get(at(m)) ?? "(anonymous)"; - let key = `${file}::${name}`; - for (let n = 2; key in out; n++) key = `${file}::${name} ${n}`; - out[key] = Number(/complexity of (\d+)/.exec(m.message)?.[1]); - } - } - return out; -} diff --git a/scripts/audit_metrics.py b/scripts/audit_metrics.py index f7bc80ce..5a55670a 100644 --- a/scripts/audit_metrics.py +++ b/scripts/audit_metrics.py @@ -24,8 +24,8 @@ BE_APP = ROOT / "backend" / "app" BE_TESTS = ROOT / "backend" / "tests" FE_SRC = ROOT / "frontend" / "src" BIG_FILE = 800 -BE_COMPLEXITY = 10 # ruff's C901 default, the backend ratchet's limit -FE_COMPLEXITY = 20 # the frontend ratchet's limit +BE_COMPLEXITY = 10 # ruff's C901 default +FE_COMPLEXITY = 20 # the frontend's line (E32) EPIC_CODE = re.compile(r"\b(E\d{2}|R\d{1,2})(\s?S\d+)?\b") NARRATION = re.compile(r"\b(used to|no longer)\b", re.IGNORECASE) diff --git a/scripts/fe_complexity.mjs b/scripts/fe_complexity.mjs index 64f8a806..171e98a7 100644 --- a/scripts/fe_complexity.mjs +++ b/scripts/fe_complexity.mjs @@ -3,7 +3,7 @@ // one JSON object: {"limit": N, "over": count, "max": highest}. Shipped code only — tests and test // kits are lists of cases, not logic anyone has to change. // -// node scripts/fe_complexity.mjs [limit] (default 20, the frontend's ratchet limit) +// node scripts/fe_complexity.mjs [limit] (default 20, the frontend's line since E32) import { readdirSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { join } from "node:path";