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