openclaw/scripts/check-env-var-count.mts
Peter Steinberger 9ed5a04a65
ci: warn on size and performance limits in GitHub Actions
Source and performance budgets shared fatal exits with correctness checks,
so ordinary growth could block otherwise valid changes. Centralize limit
severity and GitHub annotations/summaries, keep local checks strict, and
carry warnings across native lint, container, and BuildKit boundaries.

Keep semantic lint, types, API inventories, source ownership, invalid
measurements, process failures, and runner admission guards blocking.

Validation: P2 review clean; three Linux owner-configuration replays and
20 standalone policy runs passed; typechecks, targeted type-aware lint,
Knip export scans, workflow checks, formatting, and diff checks passed.
The changed gate found a test environment typing error, corrected and
verified by the root-test typecheck. One unrelated preparation process
cleanup failure did not reproduce in its diagnostic replay; no fix claimed.
2026-09-23 02:35:32 -07:00

176 lines
5.7 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 { reportLimitViolations } from "./lib/check-limits.mts";
import {
loadRatchetReference,
loadRatchetSnapshot,
loadRatchetSources,
parseRatchetScalar,
reportRatchetSuccess,
} from "./lib/shrink-ratchet.mts";
const BUDGET_PATH = "config/env-var-count-budget.txt";
const SOURCE_ROOTS = ["src", "packages", "extensions"];
const SOURCE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
const ENV_VAR_PATTERN = /OPENCLAW_[A-Z0-9_]+/gu;
export function isCountedSourcePath(filePath: string) {
const normalized = filePath.replaceAll("\\", "/");
if (!SOURCE_ROOTS.some((root) => normalized.startsWith(root + "/"))) {
return false;
}
if (!SOURCE_EXTENSIONS.has(path.posix.extname(normalized))) {
return false;
}
if (
/^(?:extensions\/(?:qa-lab|test-support)|.*\/(?:__tests__|test|tests|test-utils|test-support))\//u.test(
normalized,
)
) {
return false;
}
return !/(?:^|[./-])(?:e2e|live-helpers|live-harness|spec|suite|test|test-helpers|test-harness|test-setup|test-support|test-utils)(?:[./-]|$)/u.test(
normalized,
);
}
export type EnvVarNamesByPath = ReadonlyMap<string, ReadonlySet<string>>;
export function addEnvVarNames(source: string, names: Set<string>) {
for (const match of source.matchAll(ENV_VAR_PATTERN)) {
names.add(match[0]);
}
}
export function collectEnvVarNames(
root = process.cwd(),
options: { staged?: boolean; preparedNames?: EnvVarNamesByPath } = {},
) {
const staged = options.staged === true;
const files = execFileSync(
"git",
[
"ls-files",
"-z",
"--cached",
...(staged ? [] : ["--others", "--exclude-standard"]),
"--",
...SOURCE_ROOTS,
],
{ cwd: root, maxBuffer: 256 * 1024 * 1024 },
)
.toString("utf8")
.split("\0")
.filter(isCountedSourcePath)
.filter((file) => staged || fs.existsSync(path.join(root, file)));
const sources = staged ? loadRatchetSources(root, files).values() : files;
const names = new Set<string>();
for (const entry of sources) {
const prepared = staged ? undefined : options.preparedNames?.get(entry);
if (prepared !== undefined) {
for (const name of prepared) {
names.add(name);
}
continue;
}
const source = staged ? entry : fs.readFileSync(path.join(root, entry), "utf8");
addEnvVarNames(source, names);
}
return [...names].toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0));
}
function parseBudget(source: string) {
return parseRatchetScalar(source, BUDGET_PATH);
}
function readBaseBudget(root: string, ref: string) {
const resolved = spawnSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
cwd: root,
encoding: "utf8",
});
if (resolved.status !== 0) {
throw new Error(`Could not resolve env-var count base ref: ${ref}`);
}
const mergeBase = spawnSync("git", ["merge-base", "HEAD", ref], {
cwd: root,
encoding: "utf8",
});
const baselineRef = mergeBase.stdout.trim();
// Exit 1 with no output is git reporting no shared ancestor; a real failure exits 128.
// Shallow clones and grafted agent checkouts resolve the ref but truncate history, and
// only the growth comparison needs a baseline, so skip it rather than failing the gate.
if (mergeBase.status === 1 && !baselineRef) {
process.stderr.write(
`[env-var-count] ${ref} shares no reachable ancestor here; skipping the base-budget comparison\n`,
);
return null;
}
if (mergeBase.status !== 0 || !baselineRef) {
throw new Error(`Could not resolve env-var count merge base for: ${ref}`);
}
return loadRatchetReference(root, baselineRef, BUDGET_PATH, parseBudget);
}
export function main(
argv: string[] = process.argv.slice(2),
root = process.cwd(),
preparedNames?: EnvVarNamesByPath,
) {
const baseIndex = argv.indexOf("--base");
const baseRef = baseIndex < 0 ? "origin/main" : argv[baseIndex + 1];
const staged = argv.includes("--staged");
const expectedLength = (baseIndex >= 0 ? 2 : 0) + (staged ? 1 : 0);
if (!baseRef || argv.length !== expectedLength) {
throw new Error(
"Usage: node --import tsx scripts/check-env-var-count.mts [--staged] [--base <git-ref>]",
);
}
const budget = loadRatchetSnapshot(root, BUDGET_PATH, staged, parseBudget);
const baseBudget = readBaseBudget(root, baseRef);
const growth =
baseBudget !== null && budget > baseBudget
? [
{
file: BUDGET_PATH,
title: "Environment variable count budget",
message: `OPENCLAW_* budget grew from ${baseBudget} to ${budget}`,
},
]
: [];
if (reportLimitViolations(growth)) {
throw new Error(growth[0]!.message);
}
const names = collectEnvVarNames(root, { staged, preparedNames });
const messages =
names.length === budget
? []
: [
`OPENCLAW_* count ${names.length} ${names.length < budget ? "is below" : "exceeds"} budget ${budget}; update ${BUDGET_PATH}`,
];
if (
reportLimitViolations(
messages.map((message) => ({
file: BUDGET_PATH,
title: "Environment variable count budget",
message,
})),
)
) {
throw new Error(messages.join("\n"));
}
if (messages.length === 0 && growth.length === 0) {
reportRatchetSuccess(`OPENCLAW_* count ${names.length}/${budget}`);
}
return names.length;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}