Files
buzz/scripts/check-file-sizes-core.mjs
T
9227bdf58a fix(ci): ratchet file sizes against the base tree (#3352)
## Summary

- replace the whole-tree file-size gate with a stateless differential
ratchet
- allow inherited files over 1,000 lines to hold or shrink, but never
grow
- delete the 44-entry numeric override ledger and run the same policy
across Desktop, Web, and Mobile CI
- fail closed when the local base cannot be resolved and cover policy,
Git status parsing, and base resolution in unit tests

This removes the shared mutable policy state that caused unrelated PRs
to fail after neighboring merges. It does **not** by itself prevent two
stale green PRs from becoming invalid when combined; that requires merge
queue or up-to-date branch enforcement.

### Related issue

None found. This follows the design discussion in the linked Buzz
channel.

### Testing

- `node --test scripts/check-file-sizes-core.test.mjs` (6/6)
- Desktop, Web, and Mobile ratchet entrypoints
- `just desktop-check`
- `just web-check`
- Mobile analysis
- `git diff --check`

The repository pre-push suite also exposed an unrelated existing Mobile
widget failure in `ChannelDetailPage keeps follow mode off while a tall
newest message stays visible`; it reproduces in isolation and this
branch does not touch Mobile widget behavior.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-07-28 22:17:36 +00:00

175 lines
5.1 KiB
JavaScript

import { execFileSync } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
function git(args, cwd, options = {}) {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
...options,
});
}
function toPosixPath(relativePath) {
return relativePath.split(path.sep).join("/");
}
export function countLines(content) {
if (content.length === 0) {
return 0;
}
return content.split(/\r?\n/).length;
}
export function allowedLineCount(baseLines, maxLines) {
return baseLines == null || baseLines <= maxLines ? maxLines : baseLines;
}
export function evaluateFileSize({ baseLines, candidateLines, maxLines }) {
const limit = allowedLineCount(baseLines, maxLines);
return { limit, violates: candidateLines > limit };
}
function findRule(rules, relativePath) {
return rules.find((rule) => relativePath.startsWith(`${rule.root}/`));
}
export function resolveBaseRef(repoRoot, env = process.env) {
if (env.CHECK_FILE_SIZES_BASE) {
return env.CHECK_FILE_SIZES_BASE;
}
if (env.GITHUB_ACTIONS === "true") {
return "HEAD^1";
}
try {
const mergeBase = git(
["merge-base", "origin/main", "HEAD"],
repoRoot,
).trim();
const head = git(["rev-parse", "HEAD"], repoRoot).trim();
return mergeBase === head ? "HEAD" : mergeBase;
} catch (error) {
throw new Error(
"Could not resolve the file-size base from origin/main. Fetch origin/main or set CHECK_FILE_SIZES_BASE to an explicit commit.",
{ cause: error },
);
}
}
export function parseChangedFiles(output) {
const fields = output.split("\0");
const changes = [];
for (let index = 0; index < fields.length - 1; ) {
const status = fields[index++];
if (status.startsWith("R") || status.startsWith("C")) {
changes.push({
status: status[0],
oldPath: fields[index++],
path: fields[index++],
});
} else {
changes.push({ status: status[0], path: fields[index++] });
}
}
return changes;
}
function changedProjectFiles({ repoRoot, projectRelative, baseRef }) {
const output = git(
["diff", "--name-status", "-z", "-M", baseRef, "--", projectRelative],
repoRoot,
);
const changes = parseChangedFiles(output);
const trackedPaths = new Set(changes.map((change) => change.path));
const untracked = git(
["ls-files", "--others", "--exclude-standard", "-z", "--", projectRelative],
repoRoot,
)
.split("\0")
.filter(Boolean);
for (const filePath of untracked) {
if (!trackedPaths.has(filePath)) {
changes.push({ status: "A", path: filePath });
}
}
return changes;
}
function readBaseFile(repoRoot, baseRef, filePath) {
return git(["show", `${baseRef}:${filePath}`], repoRoot, {
encoding: null,
}).toString("utf8");
}
export async function runFileSizeCheck({ projectRoot, rules, label }) {
// Every governed project is a direct child of the repository root. Derive
// these paths without Git so hook-provided repository environment variables
// cannot collapse the project pathspec to an empty string.
const repoRoot = path.dirname(projectRoot);
const projectRelative = toPosixPath(path.basename(projectRoot));
const baseRef = resolveBaseRef(repoRoot);
// Fail clearly instead of silently turning a missing/shallow base into a pass.
git(["cat-file", "-e", `${baseRef}^{commit}`], repoRoot);
const violations = [];
for (const change of changedProjectFiles({
repoRoot,
projectRelative,
baseRef,
})) {
if (change.status === "D") continue;
const relativePath = toPosixPath(
path.relative(projectRelative, change.path),
);
const rule = findRule(rules, relativePath);
if (!rule || !rule.extensions.has(path.extname(relativePath))) continue;
const candidatePath = path.join(repoRoot, change.path);
const candidateLines = countLines(await fs.readFile(candidatePath, "utf8"));
const basePath = change.oldPath ?? change.path;
const baseContent =
change.status === "A" ? null : readBaseFile(repoRoot, baseRef, basePath);
const baseLines = baseContent == null ? null : countLines(baseContent);
const result = evaluateFileSize({
baseLines,
candidateLines,
maxLines: rule.maxLines,
});
if (result.violates) {
violations.push({
relativePath,
baseLines,
candidateLines,
limit: result.limit,
});
}
}
if (violations.length === 0) return;
console.error(`${label} file size ratchet failed (base ${baseRef}):`);
for (const violation of violations) {
const before = violation.baseLines == null ? "new" : violation.baseLines;
const delta =
violation.baseLines == null
? ""
: ` (${violation.candidateLines - violation.baseLines >= 0 ? "+" : ""}${violation.candidateLines - violation.baseLines})`;
console.error(
`- ${violation.relativePath}: ${before} -> ${violation.candidateLines}${delta} lines (allowed ${violation.limit})`,
);
}
console.error(
"Keep new files at or below the limit; files already over it may not grow.",
);
process.exitCode = 1;
}