From 2d6ba99caf49527c16c27329e2ac8ff1608d692e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 23:57:59 -0700 Subject: [PATCH 1/2] chore(ci): add file line-count guardrail to pretest Add a pretest guard that caps new source files at 2,000 lines to stop god-files from being born, following the existing check-no-* guard pattern. Existing oversized files (106 of them) are grandfathered via a ratchet baseline (scripts/line-count-baseline.json): each is pinned to its current line count and may shrink but never grow. Files refactored under the cap drop out of the baseline and cannot regress. Generated, lock, locale, and .d.ts files are out of scope via the source-extension filter. Wired into pretest and pretest:full; covered by 11 unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 4 +- .../__tests__/check-file-line-count.test.mjs | 76 ++++++++ scripts/check-file-line-count.mjs | 163 ++++++++++++++++++ scripts/line-count-baseline.json | 108 ++++++++++++ 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 scripts/__tests__/check-file-line-count.test.mjs create mode 100644 scripts/check-file-line-count.mjs create mode 100644 scripts/line-count-baseline.json diff --git a/package.json b/package.json index e09f31f118..a7cae9f43f 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "type": "module", "packageManager": "pnpm@10.33.0", "scripts": { - "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs", - "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs", + "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-file-line-count.mjs", + "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-file-line-count.mjs", "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @runfusion/fusion test:ci-shape", "smoke:boot": "node scripts/boot-smoke.mjs", "local": "node scripts/start-local.mjs", diff --git a/scripts/__tests__/check-file-line-count.test.mjs b/scripts/__tests__/check-file-line-count.test.mjs new file mode 100644 index 0000000000..a611e9f349 --- /dev/null +++ b/scripts/__tests__/check-file-line-count.test.mjs @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + MAX_LINES, + countLines, + evaluate, + formatFailureMessage, +} from "../check-file-line-count.mjs"; + +test("countLines counts a trailing-newline file without an extra empty line", () => { + assert.equal(countLines("a\nb\nc\n"), 3); +}); + +test("countLines counts a file with no trailing newline", () => { + assert.equal(countLines("a\nb\nc"), 3); +}); + +test("countLines treats an empty file as zero lines", () => { + assert.equal(countLines(""), 0); +}); + +test("evaluate flags a new file over the cap", () => { + const { violations } = evaluate({ "packages/x/src/new.ts": MAX_LINES + 1 }, {}); + assert.equal(violations.length, 1); + assert.equal(violations[0].grandfathered, false); + assert.equal(violations[0].ceiling, MAX_LINES); +}); + +test("evaluate passes a new file at exactly the cap", () => { + const { violations } = evaluate({ "packages/x/src/new.ts": MAX_LINES }, {}); + assert.equal(violations.length, 0); +}); + +test("evaluate allows a grandfathered file at or below its recorded ceiling", () => { + const baseline = { "packages/x/src/big.ts": 5000 }; + const { violations } = evaluate({ "packages/x/src/big.ts": 4800 }, baseline); + assert.equal(violations.length, 0); +}); + +test("evaluate flags a grandfathered file that grew past its ceiling", () => { + const baseline = { "packages/x/src/big.ts": 5000 }; + const { violations } = evaluate({ "packages/x/src/big.ts": 5001 }, baseline); + assert.equal(violations.length, 1); + assert.equal(violations[0].grandfathered, true); + assert.equal(violations[0].ceiling, 5000); +}); + +test("evaluate reports a grandfathered file that shrank as tightenable", () => { + const baseline = { "packages/x/src/big.ts": 5000 }; + const { staleBaseline } = evaluate({ "packages/x/src/big.ts": 4500 }, baseline); + assert.equal(staleBaseline.some((s) => s.reason === "shrank"), true); +}); + +test("evaluate reports a grandfathered file that dropped under the cap as tightenable", () => { + const baseline = { "packages/x/src/big.ts": 5000 }; + const { violations, staleBaseline } = evaluate( + { "packages/x/src/big.ts": MAX_LINES - 1 }, + baseline, + ); + assert.equal(violations.length, 0); + assert.equal(staleBaseline.some((s) => s.reason === "under-cap"), true); +}); + +test("evaluate reports a deleted baseline file as tightenable", () => { + const baseline = { "packages/x/src/gone.ts": 5000 }; + const { staleBaseline } = evaluate({}, baseline); + assert.equal(staleBaseline.some((s) => s.reason === "deleted"), true); +}); + +test("formatFailureMessage cites the file, count, and remediation", () => { + const msg = formatFailureMessage([ + { filePath: "packages/x/src/new.ts", lines: 2500, ceiling: MAX_LINES, grandfathered: false }, + ]); + assert.match(msg, /packages\/x\/src\/new\.ts: 2500 lines/); + assert.match(msg, /focused modules/); +}); diff --git a/scripts/check-file-line-count.mjs b/scripts/check-file-line-count.mjs new file mode 100644 index 0000000000..faf451939f --- /dev/null +++ b/scripts/check-file-line-count.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +// Repo-wide guard: hand-written source files may not exceed a hard line-count +// cap (MAX_LINES). This stops the next god-file from being born while leaving +// today's known offenders to be refactored down over time. +// +// Existing oversized files are grandfathered via scripts/line-count-baseline.json, +// which records each file's current line count as its personal ceiling. The +// baseline is a RATCHET: a grandfathered file may shrink (or stay put) but may +// never grow past its recorded count, and once it drops to the cap it is removed +// from the baseline and can never regress. New files get no grandfathering and +// must stay at or under MAX_LINES. +// +// Generated, vendored, and data files are out of scope: only source extensions +// under SCAN_ROOTS are scanned, and *.d.ts is excluded. Lockfiles, CHANGELOG, +// locale JSON, and snapshots never match because of the extension filter. +// +// Run `node scripts/check-file-line-count.mjs --update` to rewrite the baseline +// after an intentional, reviewed change to the set of oversized files. +import { readFileSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +export const MAX_LINES = 2000; + +const SCAN_ROOTS = ["packages", "scripts", "plugins"]; +const SOURCE_EXT = /\.(m?[jt]sx?|cjs)$/; +const DECLARATION_EXT = /\.d\.ts$/; + +const BASELINE_PATH = fileURLToPath(new URL("./line-count-baseline.json", import.meta.url)); + +export function loadBaseline(path = BASELINE_PATH) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return {}; + } +} + +export function listTrackedSources() { + const result = spawnSync("git", ["ls-files", "--", ...SCAN_ROOTS], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || "git ls-files failed"); + } + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .filter((path) => SOURCE_EXT.test(path) && !DECLARATION_EXT.test(path)); +} + +export function countLines(content) { + if (content === "") return 0; + const withoutTrailingNewline = content.endsWith("\n") ? content.slice(0, -1) : content; + return withoutTrailingNewline.split(/\r?\n/).length; +} + +// Returns { violations, staleBaseline } for the given file→lineCount map. +// `violations` are hard failures; `staleBaseline` lists baseline entries that +// could be tightened (file shrank to/under the cap, or no longer exists). +export function evaluate(counts, baseline = loadBaseline()) { + const violations = []; + for (const [filePath, lines] of Object.entries(counts)) { + const ceiling = filePath in baseline ? baseline[filePath] : MAX_LINES; + if (lines > ceiling) { + violations.push({ + filePath, + lines, + ceiling, + grandfathered: filePath in baseline, + }); + } + } + + const staleBaseline = []; + for (const [filePath, recorded] of Object.entries(baseline)) { + if (!(filePath in counts)) { + staleBaseline.push({ filePath, reason: "deleted" }); + } else if (counts[filePath] <= MAX_LINES) { + staleBaseline.push({ filePath, reason: "under-cap", lines: counts[filePath] }); + } else if (counts[filePath] < recorded) { + staleBaseline.push({ filePath, reason: "shrank", lines: counts[filePath], recorded }); + } + } + + return { violations, staleBaseline }; +} + +export function collectCounts(files = listTrackedSources()) { + const counts = {}; + for (const filePath of files) { + let content; + try { + content = readFileSync(filePath, "utf8"); + } catch { + continue; + } + counts[filePath] = countLines(content); + } + return counts; +} + +export function formatFailureMessage(violations) { + const lines = violations.map(({ filePath, lines: n, ceiling, grandfathered }) => + grandfathered + ? `${filePath}: ${n} lines (grandfathered ceiling ${ceiling} — this file grew and must shrink, not expand)` + : `${filePath}: ${n} lines (cap ${ceiling})`, + ); + return [ + `[check-file-line-count] ${violations.length} file(s) exceed the line-count guardrail:`, + "", + ...lines, + "", + `New source files must stay at or under ${MAX_LINES} lines. Split the file into`, + "focused modules. Grandfathered files (in scripts/line-count-baseline.json) may", + "shrink but never grow; refactor them down rather than raising their ceiling.", + "If a larger file is genuinely justified, update the baseline with", + "`node scripts/check-file-line-count.mjs --update` in a reviewed change.", + ].join("\n"); +} + +function buildBaseline(counts) { + const baseline = {}; + for (const filePath of Object.keys(counts).sort()) { + if (counts[filePath] > MAX_LINES) baseline[filePath] = counts[filePath]; + } + return baseline; +} + +export function main(argv = process.argv.slice(2)) { + const counts = collectCounts(); + + if (argv.includes("--update")) { + const baseline = buildBaseline(counts); + writeFileSync(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`); + console.error( + `[check-file-line-count] baseline rewritten: ${Object.keys(baseline).length} file(s) over ${MAX_LINES} lines.`, + ); + return 0; + } + + const { violations, staleBaseline } = evaluate(counts); + + if (staleBaseline.length > 0) { + const shrunk = staleBaseline.filter((s) => s.reason !== "deleted"); + if (shrunk.length > 0) { + console.error( + `[check-file-line-count] note: ${staleBaseline.length} baseline entr(ies) can be tightened ` + + "(files shrank or were removed). Run with --update to ratchet the baseline down.", + ); + } + } + + if (violations.length === 0) return 0; + console.error(formatFailureMessage(violations)); + return 1; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + process.exitCode = main(); +} diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json new file mode 100644 index 0000000000..318e6f4099 --- /dev/null +++ b/scripts/line-count-baseline.json @@ -0,0 +1,108 @@ +{ + "packages/cli/src/__tests__/extension.test.ts": 4186, + "packages/cli/src/bin.ts": 2065, + "packages/cli/src/commands/__tests__/dashboard.test.ts": 3346, + "packages/cli/src/commands/__tests__/serve.test.ts": 2100, + "packages/cli/src/commands/__tests__/task.test.ts": 3424, + "packages/cli/src/commands/dashboard-tui/app.tsx": 4665, + "packages/cli/src/commands/dashboard.ts": 2951, + "packages/cli/src/extension.ts": 4704, + "packages/core/src/__tests__/agent-store.test.ts": 2997, + "packages/core/src/__tests__/central-core.test.ts": 3263, + "packages/core/src/__tests__/db.test.ts": 3601, + "packages/core/src/__tests__/mission-store.test.ts": 4287, + "packages/core/src/__tests__/plugin-loader.test.ts": 2783, + "packages/core/src/__tests__/store-settings.test.ts": 2196, + "packages/core/src/agent-store.ts": 2946, + "packages/core/src/central-core.ts": 3854, + "packages/core/src/db.ts": 5770, + "packages/core/src/mission-store.ts": 4293, + "packages/core/src/store.ts": 16537, + "packages/core/src/types.ts": 7163, + "packages/dashboard/app/api/legacy.ts": 10565, + "packages/dashboard/app/App.tsx": 2295, + "packages/dashboard/app/components/__tests__/AgentsView.test.tsx": 2761, + "packages/dashboard/app/components/__tests__/App.test.tsx": 4304, + "packages/dashboard/app/components/__tests__/ChatView.test.tsx": 5677, + "packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx": 3274, + "packages/dashboard/app/components/__tests__/ListView.test.tsx": 4113, + "packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2037, + "packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx": 4575, + "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 2746, + "packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx": 2816, + "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4526, + "packages/dashboard/app/components/__tests__/SettingsModal.test.tsx": 5375, + "packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 5121, + "packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2366, + "packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 2917, + "packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2297, + "packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 5578, + "packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx": 2877, + "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx": 3138, + "packages/dashboard/app/components/AgentDetailView.tsx": 5399, + "packages/dashboard/app/components/AgentsView.tsx": 2101, + "packages/dashboard/app/components/ChatView.tsx": 3964, + "packages/dashboard/app/components/GitManagerModal.tsx": 3186, + "packages/dashboard/app/components/ListView.tsx": 2397, + "packages/dashboard/app/components/MissionManager.tsx": 4999, + "packages/dashboard/app/components/ModelOnboardingModal.tsx": 2932, + "packages/dashboard/app/components/PlanningModeModal.tsx": 3319, + "packages/dashboard/app/components/QuickChatFAB.tsx": 3559, + "packages/dashboard/app/components/QuickEntryBox.tsx": 2206, + "packages/dashboard/app/components/SettingsModal.tsx": 3239, + "packages/dashboard/app/components/TaskCard.tsx": 2528, + "packages/dashboard/app/components/TaskDetailModal.tsx": 4568, + "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4381, + "packages/dashboard/app/hooks/__tests__/useChat.test.ts": 4097, + "packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts": 2706, + "packages/dashboard/app/hooks/__tests__/useTasks.test.ts": 2582, + "packages/dashboard/src/__tests__/chat-manager.test.ts": 2636, + "packages/dashboard/src/__tests__/file-service.test.ts": 2123, + "packages/dashboard/src/__tests__/github.test.ts": 2222, + "packages/dashboard/src/__tests__/plugin-routes.test.ts": 2069, + "packages/dashboard/src/__tests__/routes-agents.test.ts": 4921, + "packages/dashboard/src/__tests__/routes-auth.test.ts": 3980, + "packages/dashboard/src/__tests__/routes-automation.test.ts": 2393, + "packages/dashboard/src/__tests__/routes-github.test.ts": 2855, + "packages/dashboard/src/__tests__/routes-nodes-sync.test.ts": 2476, + "packages/dashboard/src/__tests__/routes-planning.test.ts": 4346, + "packages/dashboard/src/__tests__/routes-settings.test.ts": 3122, + "packages/dashboard/src/__tests__/routes-tasks-ops.test.ts": 4158, + "packages/dashboard/src/__tests__/routes-tasks.test.ts": 2696, + "packages/dashboard/src/__tests__/server.test.ts": 2964, + "packages/dashboard/src/__tests__/usage.test.ts": 4327, + "packages/dashboard/src/chat.ts": 2193, + "packages/dashboard/src/github.ts": 4178, + "packages/dashboard/src/mission-routes.ts": 3948, + "packages/dashboard/src/planning.ts": 2696, + "packages/dashboard/src/routes.ts": 5251, + "packages/dashboard/src/routes/register-git-github.ts": 5637, + "packages/dashboard/src/routes/register-settings-memory-routes.ts": 2421, + "packages/dashboard/src/routes/register-task-workflow-routes.ts": 3738, + "packages/dashboard/src/server.ts": 2338, + "packages/engine/src/__tests__/executor-pause.test.ts": 2836, + "packages/engine/src/__tests__/executor-prompt.test.ts": 2572, + "packages/engine/src/__tests__/executor-recovery.test.ts": 3600, + "packages/engine/src/__tests__/executor-step-session.test.ts": 3731, + "packages/engine/src/__tests__/executor-worktree.test.ts": 2534, + "packages/engine/src/__tests__/heartbeat-executor.test.ts": 3944, + "packages/engine/src/__tests__/merger-merge-lifecycle.test.ts": 3253, + "packages/engine/src/__tests__/merger-verification.test.ts": 3163, + "packages/engine/src/__tests__/mission-execution-loop.test.ts": 2455, + "packages/engine/src/__tests__/pi-create-fn-agent.test.ts": 2191, + "packages/engine/src/__tests__/project-engine.test.ts": 2851, + "packages/engine/src/__tests__/scheduler.test.ts": 5395, + "packages/engine/src/__tests__/self-healing.test.ts": 9641, + "packages/engine/src/__tests__/step-session-executor.test.ts": 2911, + "packages/engine/src/__tests__/triage.test.ts": 4514, + "packages/engine/src/agent-heartbeat.ts": 4548, + "packages/engine/src/agent-tools.ts": 3584, + "packages/engine/src/executor.ts": 15657, + "packages/engine/src/merger.ts": 12643, + "packages/engine/src/pi.ts": 2421, + "packages/engine/src/project-engine.ts": 3663, + "packages/engine/src/scheduler.ts": 2638, + "packages/engine/src/self-healing.ts": 10316, + "packages/engine/src/triage.ts": 2793, + "plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx": 2559 +} From b7182da349f452b5d3b69b13a514e87f36d9b0fb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 00:05:41 -0700 Subject: [PATCH 2/2] Address PR review feedback (#1698) - Import URL from node:url to clear ESLint no-undef (coderabbit) - Throw on unreadable tracked files instead of silently skipping, so a file can't evade the cap and false-pass (coderabbit) - Emit the "baseline can be tightened" note for deleted-only stale entries too, with a correct count (greptile) - Add FNXC:CI comments to the guard and its test per AGENTS.md (greptile) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/check-file-line-count.test.mjs | 9 +++++ scripts/check-file-line-count.mjs | 33 +++++++++++++------ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/scripts/__tests__/check-file-line-count.test.mjs b/scripts/__tests__/check-file-line-count.test.mjs index a611e9f349..23c1f2b3f2 100644 --- a/scripts/__tests__/check-file-line-count.test.mjs +++ b/scripts/__tests__/check-file-line-count.test.mjs @@ -1,3 +1,12 @@ +/* +FNXC:CI 2026-06-21-00:04: +Tests for the line-count guardrail. Pin the behavior the guard promises: a new +file at exactly MAX_LINES passes but one line over fails; grandfathered files may +sit at or below their recorded ceiling but a single line of growth is a failure; +and shrunk, under-cap, or deleted baseline entries surface as tightenable so the +ratchet can only move down. Line counting must agree whether or not the file ends +in a trailing newline. +*/ import test from "node:test"; import assert from "node:assert/strict"; import { diff --git a/scripts/check-file-line-count.mjs b/scripts/check-file-line-count.mjs index faf451939f..0c9c7228b2 100644 --- a/scripts/check-file-line-count.mjs +++ b/scripts/check-file-line-count.mjs @@ -1,4 +1,15 @@ #!/usr/bin/env node +/* +FNXC:CI 2026-06-21-00:04: +New source files must not be born as god-files. This guard caps new files under +packages/scripts/plugins at MAX_LINES (2000) lines to keep the codebase +splittable and reviewable. The 106 files already over the cap can't be +refactored in one PR, so they are grandfathered through a ratchet baseline +(line-count-baseline.json): each is pinned to its current count and may shrink +but never grow, applying steady downward pressure without blocking unrelated +work. Generated/lock/locale/.d.ts files are out of scope so the guard only +governs hand-written source. +*/ // Repo-wide guard: hand-written source files may not exceed a hard line-count // cap (MAX_LINES). This stops the next god-file from being born while leaving // today's known offenders to be refactored down over time. @@ -18,7 +29,7 @@ // after an intentional, reviewed change to the set of oversized files. import { readFileSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, URL } from "node:url"; export const MAX_LINES = 2000; @@ -91,11 +102,13 @@ export function evaluate(counts, baseline = loadBaseline()) { export function collectCounts(files = listTrackedSources()) { const counts = {}; for (const filePath of files) { + // A tracked file that can't be read must fail the guard, not be skipped: + // silently continuing would let an unreadable file evade the cap (false pass). let content; try { content = readFileSync(filePath, "utf8"); - } catch { - continue; + } catch (error) { + throw new Error(`[check-file-line-count] failed to read tracked file ${filePath}: ${error.message}`); } counts[filePath] = countLines(content); } @@ -143,14 +156,14 @@ export function main(argv = process.argv.slice(2)) { const { violations, staleBaseline } = evaluate(counts); + // Any stale entry — shrunk, dropped under the cap, or deleted — means the + // baseline can ratchet down. Deleted-only entries must trigger this note too, + // otherwise removed files sit in the baseline forever with no prompt to prune. if (staleBaseline.length > 0) { - const shrunk = staleBaseline.filter((s) => s.reason !== "deleted"); - if (shrunk.length > 0) { - console.error( - `[check-file-line-count] note: ${staleBaseline.length} baseline entr(ies) can be tightened ` + - "(files shrank or were removed). Run with --update to ratchet the baseline down.", - ); - } + console.error( + `[check-file-line-count] note: ${staleBaseline.length} baseline entr(ies) can be tightened ` + + "(files shrank, dropped under the cap, or were deleted). Run with --update to ratchet the baseline down.", + ); } if (violations.length === 0) return 0;