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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-20 23:57:59 -07:00
parent 2c1cff820b
commit 2d6ba99caf
4 changed files with 349 additions and 2 deletions

View File

@@ -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",

View File

@@ -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/);
});

View File

@@ -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();
}

View File

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