Merge pull request #1698 from Runfusion/gsxdsm/smaller-files
chore(ci): add file line-count guardrail to pretest
This commit is contained in:
@@ -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",
|
||||
|
||||
85
scripts/__tests__/check-file-line-count.test.mjs
Normal file
85
scripts/__tests__/check-file-line-count.test.mjs
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
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 {
|
||||
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/);
|
||||
});
|
||||
176
scripts/check-file-line-count.mjs
Normal file
176
scripts/check-file-line-count.mjs
Normal file
@@ -0,0 +1,176 @@
|
||||
#!/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.
|
||||
//
|
||||
// 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, URL } 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) {
|
||||
// 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 (error) {
|
||||
throw new Error(`[check-file-line-count] failed to read tracked file ${filePath}: ${error.message}`);
|
||||
}
|
||||
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);
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
console.error(formatFailureMessage(violations));
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
process.exitCode = main();
|
||||
}
|
||||
108
scripts/line-count-baseline.json
Normal file
108
scripts/line-count-baseline.json
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user