mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
1fab280924
* refactor(scripts): unify shrink-ratchet mechanics under one owner * refactor(scripts): tighten shrink-ratchet owner * fix(scripts): widen ratchet comparator type * test(scripts): type heterogeneous ratchet fixtures
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import ts from "typescript";
|
|
import { main as checkEnvVarCount } from "./check-env-var-count.mts";
|
|
import {
|
|
compareRatchetSets,
|
|
listRatchetRenames,
|
|
loadRatchetReference,
|
|
loadRatchetSnapshot,
|
|
loadRatchetSources,
|
|
parseRatchetPaths,
|
|
reportRatchetFailures,
|
|
reportRatchetSuccess,
|
|
resolveRatchetBase,
|
|
} from "./lib/shrink-ratchet.mts";
|
|
|
|
const BASELINE_PATH = "config/max-lines-baseline.txt";
|
|
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
const SOURCE_ROOTS = ["src", "ui/src", "packages", "extensions"];
|
|
const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]);
|
|
const BASELINE_HEADER = [
|
|
"# Files currently allowed to exceed the oxlint max-lines budget.",
|
|
"# Ratchet: this list may only shrink. Split files; never add entries.",
|
|
"# Existing suppressions carry a TODO at the file site.",
|
|
"",
|
|
].join("\n");
|
|
const compareStrings = (left: string, right: string) => left.localeCompare(right);
|
|
|
|
export function isGovernedSourcePath(filePath: string) {
|
|
const normalized = filePath.replaceAll("\\", "/");
|
|
if (!SOURCE_ROOTS.some((root) => normalized === root || normalized.startsWith(root + "/"))) {
|
|
return false;
|
|
}
|
|
if (!SOURCE_EXTENSIONS.has(path.posix.extname(normalized))) {
|
|
return false;
|
|
}
|
|
return !(
|
|
normalized.startsWith("ui/src/i18n/locales/") ||
|
|
normalized.startsWith("src/wizard/i18n/locales/") ||
|
|
/(?:^|\/)(?:__generated__|generated|protocol-gen|dist)(?:\/|$)/u.test(normalized) ||
|
|
/\.generated\.[^/]+$/u.test(normalized)
|
|
);
|
|
}
|
|
|
|
export function collectLintDisableDirectives(source: string, filePath = "source.ts") {
|
|
if (!source.includes("oxlint-disable") && !source.includes("eslint-disable")) {
|
|
return [];
|
|
}
|
|
const directive = /^(?:eslint|oxlint)-disable(?:-next-line|-line)?(?=$|\s)([\s\S]*)$/u;
|
|
const scriptKind = /\.[cm]?[jt]sx$/u.test(filePath) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
const sourceFile = ts.createSourceFile(
|
|
filePath,
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
false,
|
|
scriptKind,
|
|
);
|
|
const comments = new Map<number, string>();
|
|
const addComments = (ranges: readonly ts.CommentRange[] | undefined) => {
|
|
for (const range of ranges ?? []) {
|
|
comments.set(range.pos, source.slice(range.pos, range.end));
|
|
}
|
|
};
|
|
const visit = (node: ts.Node) => {
|
|
addComments(ts.getLeadingCommentRanges(source, node.pos));
|
|
addComments(ts.getTrailingCommentRanges(source, node.end));
|
|
// getChildren includes delimiter tokens; forEachChild misses directives before closing tokens.
|
|
for (const child of node.getChildren(sourceFile)) {
|
|
visit(child);
|
|
}
|
|
};
|
|
visit(sourceFile);
|
|
addComments(ts.getLeadingCommentRanges(source, sourceFile.endOfFileToken.pos));
|
|
|
|
const directives: string[][] = [];
|
|
for (const text of comments.values()) {
|
|
const comment = text.slice(2, text.startsWith("/*") ? -2 : undefined);
|
|
const match = directive.exec(comment.trim());
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const directiveBody = match[1] ?? "";
|
|
const reason = /--|(?<=\s)-(?=\s)/u.exec(directiveBody);
|
|
const rules = (reason ? directiveBody.slice(0, reason.index) : directiveBody).trim();
|
|
directives.push(rules === "" ? [] : rules.split(/[\s,]+/u));
|
|
}
|
|
return directives;
|
|
}
|
|
|
|
export function isMaxLinesRule(rule: string) {
|
|
return rule === "max-lines" || rule.endsWith("/max-lines");
|
|
}
|
|
|
|
export function hasMaxLinesDisable(source: string, filePath = "source.ts") {
|
|
return collectLintDisableDirectives(source, filePath).some((rules) => rules.some(isMaxLinesRule));
|
|
}
|
|
|
|
export function hasAllRuleDisable(source: string, filePath = "source.ts") {
|
|
return collectLintDisableDirectives(source, filePath).some((rules) => rules.length === 0);
|
|
}
|
|
|
|
function baselineWithVerifiedRenames(
|
|
root: string,
|
|
baseRef: string,
|
|
staged: boolean,
|
|
baseline: ReadonlySet<string>,
|
|
baseBaseline: ReadonlySet<string>,
|
|
) {
|
|
const allowed = new Set(baseBaseline);
|
|
for (const { from, to } of listRatchetRenames(root, baseRef, staged, SOURCE_ROOTS)) {
|
|
if (baseBaseline.has(from) && !baseline.has(from) && baseline.has(to)) {
|
|
allowed.delete(from);
|
|
allowed.add(to);
|
|
}
|
|
}
|
|
return allowed;
|
|
}
|
|
|
|
function listStagedSuppressionCandidates(root: string) {
|
|
// The staged policy covers the whole index. Narrow candidates once so a one-file
|
|
// check does not spawn a Git process for every governed source.
|
|
const result = spawnSync(
|
|
"git",
|
|
[
|
|
"grep",
|
|
"--cached",
|
|
"-z",
|
|
"-l",
|
|
"-e",
|
|
"oxlint-disable",
|
|
"-e",
|
|
"eslint-disable",
|
|
"--",
|
|
...SOURCE_ROOTS,
|
|
],
|
|
{ cwd: root, maxBuffer: GIT_MAX_BUFFER },
|
|
);
|
|
if (result.status === 1) {
|
|
return [];
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error(result.stderr.toString("utf8").trim() || "git grep failed");
|
|
}
|
|
return result.stdout.toString("utf8").split("\0").filter(Boolean);
|
|
}
|
|
|
|
export function collectCurrentSuppressionState(
|
|
root = process.cwd(),
|
|
options: { staged?: boolean } = {},
|
|
) {
|
|
const staged = options.staged === true;
|
|
const filePaths = staged
|
|
? listStagedSuppressionCandidates(root)
|
|
: execFileSync(
|
|
"git",
|
|
["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...SOURCE_ROOTS],
|
|
{ cwd: root, maxBuffer: GIT_MAX_BUFFER },
|
|
)
|
|
.toString("utf8")
|
|
.split("\0");
|
|
const governedPaths = filePaths
|
|
.filter(Boolean)
|
|
.filter(isGovernedSourcePath)
|
|
.filter((filePath) => staged || fs.existsSync(path.join(root, filePath)));
|
|
const sources = staged
|
|
? [...loadRatchetSources(root, governedPaths)]
|
|
: governedPaths.map((filePath): [string, string] => [
|
|
filePath,
|
|
fs.readFileSync(path.join(root, filePath), "utf8"),
|
|
]);
|
|
return {
|
|
allRules: sources
|
|
.filter(([filePath, source]) => hasAllRuleDisable(source, filePath))
|
|
.map(([filePath]) => filePath)
|
|
.toSorted(compareStrings),
|
|
explicit: sources
|
|
.filter(([filePath, source]) => hasMaxLinesDisable(source, filePath))
|
|
.map(([filePath]) => filePath)
|
|
.toSorted(compareStrings),
|
|
};
|
|
}
|
|
|
|
function writeBaseline(root: string, entries: string[]) {
|
|
fs.writeFileSync(path.join(root, BASELINE_PATH), BASELINE_HEADER + entries.join("\n") + "\n");
|
|
}
|
|
|
|
function parseArgs(argv: string[]) {
|
|
const args: { base?: string; prune: boolean; staged: boolean } = { prune: false, staged: false };
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--prune") {
|
|
args.prune = true;
|
|
continue;
|
|
}
|
|
if (arg === "--staged") {
|
|
args.staged = true;
|
|
continue;
|
|
}
|
|
if (arg === "--base" && argv[index + 1]) {
|
|
args.base = argv[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
throw new Error("Unknown or incomplete argument: " + arg);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function envVarCountArgs(argv: string[]) {
|
|
const args = parseArgs(argv);
|
|
return [...(args.staged ? ["--staged"] : []), ...(args.base ? ["--base", args.base] : [])];
|
|
}
|
|
|
|
export function main(root = process.cwd(), argv: string[] = process.argv.slice(2)) {
|
|
try {
|
|
const args = parseArgs(argv);
|
|
if (args.staged && args.prune) {
|
|
throw new Error("--prune cannot be combined with --staged");
|
|
}
|
|
|
|
let baselineSource;
|
|
try {
|
|
baselineSource = loadRatchetSnapshot(root, BASELINE_PATH, args.staged, parseRatchetPaths);
|
|
} catch {
|
|
throw new Error("Missing " + BASELINE_PATH + (args.staged ? " in the index" : ""));
|
|
}
|
|
const baseline = baselineSource;
|
|
const { allRules, explicit: current } = collectCurrentSuppressionState(root, {
|
|
staged: args.staged,
|
|
});
|
|
const { added, removed: stale } = compareRatchetSets(current, baseline, compareStrings);
|
|
const baseRef = resolveRatchetBase(root, { base: args.base, staged: args.staged });
|
|
const baseBaseline = baseRef
|
|
? loadRatchetReference(root, baseRef, BASELINE_PATH, parseRatchetPaths)
|
|
: null;
|
|
const allowedBaseline =
|
|
baseRef && baseBaseline
|
|
? baselineWithVerifiedRenames(root, baseRef, args.staged, baseline, baseBaseline)
|
|
: baseBaseline;
|
|
const expanded = allowedBaseline
|
|
? compareRatchetSets(baseline, allowedBaseline, compareStrings).added
|
|
: [];
|
|
|
|
if (
|
|
reportRatchetFailures([
|
|
{ entries: added, title: "New max-lines suppressions are forbidden; split these files:" },
|
|
{
|
|
entries: expanded,
|
|
title: "The max-lines baseline may only shrink; remove these entries:",
|
|
},
|
|
{
|
|
entries: allRules,
|
|
title: "All-rule lint disables are forbidden; name only the required rules:",
|
|
},
|
|
])
|
|
) {
|
|
return 1;
|
|
}
|
|
|
|
if (args.prune) {
|
|
const kept = [...baseline]
|
|
.filter((entry) => current.includes(entry))
|
|
.toSorted(compareStrings);
|
|
writeBaseline(root, kept);
|
|
reportRatchetSuccess(
|
|
"Pruned " + BASELINE_PATH + ": " + baseline.size + " -> " + kept.length + ".",
|
|
);
|
|
return 0;
|
|
}
|
|
if (
|
|
reportRatchetFailures([
|
|
{
|
|
entries: stale,
|
|
title: "Remove stale max-lines baseline entries (or run with --prune):",
|
|
},
|
|
])
|
|
) {
|
|
return 1;
|
|
}
|
|
|
|
reportRatchetSuccess(
|
|
"max-lines ratchet OK: " + current.length + " grandfathered suppressions.",
|
|
);
|
|
return 0;
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
function runBaselineRatchets(root = process.cwd(), argv: string[] = process.argv.slice(2)) {
|
|
const maxLinesStatus = main(root, argv);
|
|
if (maxLinesStatus !== 0) {
|
|
return maxLinesStatus;
|
|
}
|
|
try {
|
|
// CI invokes this entry with its frozen fork-point ref. Carry the same snapshot
|
|
// into the env budget so every baseline ratchet judges one tested tree.
|
|
checkEnvVarCount(envVarCountArgs(argv), root);
|
|
return 0;
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exitCode = runBaselineRatchets();
|
|
}
|