diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index bfe4fcc8570..216f6d8a6fc 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -1,59 +1,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs"; +import { rules } from "./file-size-rules.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); -const MAX_LINES = 1000; - -const rules = [ - { root: "src-tauri/src", extensions: new Set([".rs"]), maxLines: MAX_LINES }, - // Workspace member crates. Without this the ratchet's only Rust root is - // `src-tauri/src`, and a crate under `src-tauri/crates/` is born outside the - // repo's one size discipline -- silently, since the check still exits 0. - { - root: "src-tauri/crates", - extensions: new Set([".rs"]), - maxLines: MAX_LINES, - }, - { - root: "src/app", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/features", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/api", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/context", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/lib", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/ui", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/styles", - extensions: new Set([".css"]), - maxLines: MAX_LINES, - }, -]; - await runFileSizeCheck({ projectRoot, rules, diff --git a/desktop/scripts/file-size-rules.mjs b/desktop/scripts/file-size-rules.mjs new file mode 100644 index 00000000000..f4ae17ce6ca --- /dev/null +++ b/desktop/scripts/file-size-rules.mjs @@ -0,0 +1,60 @@ +// Rule table for the Desktop file-size ratchet, kept in its own module so tests +// can assert the real configuration instead of a restatement of it. The runner +// (`check-file-sizes.mjs`) stays unconditional: a module that both exports its +// rules and self-guards its execution can silently stop gating, which is the +// same class of failure this table's `.mjs` coverage exists to prevent. + +export const MAX_LINES = 1000; + +// Desktop's test suite is `*.test.mjs` by convention and its shared test rigs +// are plain `.mjs` modules, so listing only `.ts`/`.tsx` here left all of them +// outside the ceiling AGENTS.md documents as enforced -- inside roots this +// ratchet already governs, and silently, since the check still exits 0. +export const SCRIPT_EXTENSIONS = new Set([".ts", ".tsx", ".mjs"]); + +export const rules = [ + { root: "src-tauri/src", extensions: new Set([".rs"]), maxLines: MAX_LINES }, + // Workspace member crates. Without this the ratchet's only Rust root is + // `src-tauri/src`, and a crate under `src-tauri/crates/` is born outside the + // repo's one size discipline -- silently, since the check still exits 0. + { + root: "src-tauri/crates", + extensions: new Set([".rs"]), + maxLines: MAX_LINES, + }, + { + root: "src/app", + extensions: SCRIPT_EXTENSIONS, + maxLines: MAX_LINES, + }, + { + root: "src/features", + extensions: SCRIPT_EXTENSIONS, + maxLines: MAX_LINES, + }, + { + root: "src/shared/api", + extensions: SCRIPT_EXTENSIONS, + maxLines: MAX_LINES, + }, + { + root: "src/shared/context", + extensions: SCRIPT_EXTENSIONS, + maxLines: MAX_LINES, + }, + { + root: "src/shared/lib", + extensions: SCRIPT_EXTENSIONS, + maxLines: MAX_LINES, + }, + { + root: "src/shared/ui", + extensions: SCRIPT_EXTENSIONS, + maxLines: MAX_LINES, + }, + { + root: "src/shared/styles", + extensions: new Set([".css"]), + maxLines: MAX_LINES, + }, +]; diff --git a/scripts/check-file-sizes-core.test.mjs b/scripts/check-file-sizes-core.test.mjs index 9b4b910404d..bd240e14339 100644 --- a/scripts/check-file-sizes-core.test.mjs +++ b/scripts/check-file-sizes-core.test.mjs @@ -1,9 +1,12 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; +import { rules as desktopRules } from "../desktop/scripts/file-size-rules.mjs"; +import { rules as webRules } from "../web/scripts/file-size-rules.mjs"; import { allowedLineCount, countLines, @@ -117,3 +120,169 @@ test("an inherited oversized file may hold or shrink but not grow", () => { true, ); }); + +// --- The gate's own coverage --------------------------------------------- +// +// An omitted extension does not fail loudly: `runFileSizeCheck` skips the file +// and the check still exits 0, so an uncovered root looks exactly like a clean +// one. That makes the allowlist something to assert rather than assume. These +// read the real rule tables the runners execute, not a restatement of them. + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); + +test("every script root governs .mjs alongside .ts and .tsx", () => { + for (const [label, rules] of [ + ["desktop", desktopRules], + ["web", webRules], + ]) { + const scriptRoots = rules.filter((rule) => rule.extensions.has(".ts")); + assert.ok( + scriptRoots.length > 0, + `${label} declares no TypeScript roots, so this assertion cannot fail for the reason it exists; the rule table shape changed`, + ); + for (const rule of scriptRoots) { + assert.ok( + rule.extensions.has(".mjs"), + `${label} root ${rule.root} governs .ts but not .mjs, so test modules and shared rigs there sit outside the ${rule.maxLines}-line ceiling`, + ); + } + } +}); + +// Runs the real desktop rule table against a throwaway repository in a child +// process. A child keeps `process.exitCode` and `console.error` out of this +// test's own process, where a leaked exit code would mark the whole file failed. +const COMPLETION_SENTINEL = "__file_size_gate_completed__"; + +function runDesktopGate({ fixtureRoot, baseRef }) { + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import { runFileSizeCheck } from ${JSON.stringify(path.join(scriptsDir, "check-file-sizes-core.mjs"))}; + import { rules } from ${JSON.stringify(path.join(scriptsDir, "..", "desktop", "scripts", "file-size-rules.mjs"))}; + await runFileSizeCheck({ + projectRoot: ${JSON.stringify(path.join(fixtureRoot, "desktop"))}, + rules, + label: "Desktop", + }); + // Printed only if the awaited call returned normally. On stdout so it + // cannot be confused with any part of the violation report. + process.stdout.write(${JSON.stringify(COMPLETION_SENTINEL)}); + `, + ], + { + encoding: "utf8", + env: { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith("GIT_"), + ), + ), + CHECK_FILE_SIZES_BASE: baseRef, + }, + }, + ); + // Node exits 1 for an uncaught exception too, so status alone cannot tell a + // ratchet violation from a crash. Split the two questions: the sentinel proves + // the gate ran to completion, and only then does the exit status mean a policy + // decision. Asserting on the report heading alone would accept a child that + // started printing violations and then crashed part-way through. + assert.ok( + result.stdout.includes(COMPLETION_SENTINEL), + `the ratchet child did not run to completion (exit ${result.status}, signal ${result.signal}), so it crashed rather than gated:\n${result.stderr}`, + ); + const failed = result.status === 1; + if (failed) { + assert.match( + result.stderr, + /file size ratchet failed \(base /, + `the ratchet child exited 1 without reporting a violation:\n${result.stderr}`, + ); + } else { + assert.equal( + result.status, + 0, + `the ratchet child exited ${result.status} (signal ${result.signal}):\n${result.stderr}`, + ); + } + return { failed, output: result.stderr }; +} + +function fixtureRepo(prefix) { + const repo = mkdtempSync(path.join(tmpdir(), prefix)); + mkdirSync(path.join(repo, "desktop/src/features/agents"), { + recursive: true, + }); + git(repo, "init", "-b", "main"); + git(repo, "config", "user.name", "Test"); + git(repo, "config", "user.email", "test@example.com"); + return repo; +} + +test("the desktop rules hold a new .mjs file in a governed root to the ceiling", () => { + const repo = fixtureRepo("file-size-mjs-new-"); + git(repo, "commit", "--allow-empty", "-m", "base"); + const target = "desktop/src/features/agents/oversize.test.mjs"; + + // `countLines` counts a trailing newline as a final empty line, so N repeats + // of a newline-terminated line is N + 1 lines. Land exactly on the ceiling. + writeFileSync(path.join(repo, target), `${"// line\n".repeat(999)}// line`); + const atCeiling = runDesktopGate({ fixtureRoot: repo, baseRef: "HEAD" }); + assert.equal( + atCeiling.failed, + false, + `a new .mjs at exactly the ceiling must pass: ${atCeiling.output}`, + ); + + writeFileSync(path.join(repo, target), "// line\n".repeat(1000)); + const overCeiling = runDesktopGate({ fixtureRoot: repo, baseRef: "HEAD" }); + assert.equal( + overCeiling.failed, + true, + "a new 1001-line .mjs under src/features must violate the ceiling", + ); + assert.match( + overCeiling.output, + /src\/features\/agents\/oversize\.test\.mjs: new -> 1001 lines \(allowed 1000\)/, + ); +}); + +test("an inherited oversize .mjs holds or shrinks but may not grow", () => { + const repo = fixtureRepo("file-size-mjs-inherited-"); + const target = "desktop/src/features/agents/inherited.test.mjs"; + + // Commit it already over the ceiling, as the existing oversize suites are. + writeFileSync(path.join(repo, target), "// line\n".repeat(1199)); + git(repo, "add", "-A"); + git(repo, "commit", "-m", "inherited oversize test module"); + const baseRef = git(repo, "rev-parse", "HEAD"); + + // Same line count, different content: an edit that does not grow the file. + writeFileSync(path.join(repo, target), "// edit\n".repeat(1199)); + const held = runDesktopGate({ fixtureRoot: repo, baseRef }); + assert.equal( + held.failed, + false, + `an inherited oversize .mjs must be grandfathered, not failed on sight: ${held.output}`, + ); + + writeFileSync(path.join(repo, target), "// line\n".repeat(1100)); + const shrunk = runDesktopGate({ fixtureRoot: repo, baseRef }); + assert.equal( + shrunk.failed, + false, + `shrinking an inherited oversize .mjs must pass: ${shrunk.output}`, + ); + + writeFileSync(path.join(repo, target), "// line\n".repeat(1200)); + const grown = runDesktopGate({ fixtureRoot: repo, baseRef }); + assert.equal( + grown.failed, + true, + "growing past the inherited size must fail the ratchet", + ); + assert.match(grown.output, /1200 -> 1201 \(\+1\) lines \(allowed 1200\)/); +}); diff --git a/web/scripts/check-file-sizes.mjs b/web/scripts/check-file-sizes.mjs index 810a2b7ae72..2fa3e801532 100644 --- a/web/scripts/check-file-sizes.mjs +++ b/web/scripts/check-file-sizes.mjs @@ -1,30 +1,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs"; +import { rules } from "./file-size-rules.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); -const MAX_LINES = 1000; - -const rules = [ - { - root: "src/app", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/features", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/api", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, -]; - await runFileSizeCheck({ projectRoot, rules, diff --git a/web/scripts/file-size-rules.mjs b/web/scripts/file-size-rules.mjs new file mode 100644 index 00000000000..134f50c3508 --- /dev/null +++ b/web/scripts/file-size-rules.mjs @@ -0,0 +1,26 @@ +// Rule table for the Web file-size ratchet. See the sibling Desktop table for +// why the rules live apart from the runner. + +export const MAX_LINES = 1000; + +// `.mjs` is listed alongside `.ts`/`.tsx` so a future test rig or script module +// under these roots is governed from birth rather than discovered later. +export const SCRIPT_EXTENSIONS = new Set([".ts", ".tsx", ".mjs"]); + +export const rules = [ + { + root: "src/app", + extensions: SCRIPT_EXTENSIONS, + maxLines: MAX_LINES, + }, + { + root: "src/features", + extensions: SCRIPT_EXTENSIONS, + maxLines: MAX_LINES, + }, + { + root: "src/shared/api", + extensions: SCRIPT_EXTENSIONS, + maxLines: MAX_LINES, + }, +];