shoc-frontend-new/scripts/governance-check.mjs

387 lines
13 KiB
JavaScript
Raw Normal View History

import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..");
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
const MAX_FILE_LINES = 500;
const MAINTAINABILITY_RULES = [
'complexity: ["error", { "max": 20 }]',
'max-lines-per-function: ["error", { "skipComments": true, "max": 150 }]',
'max-params: ["error", 4]',
'max-depth: ["error", 4]',
];
const GOVERNED_ROOTS = ["src/", "config/"];
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
// Repository-level gates that run after the source gates. Each is an npm
// script so it can also be run on its own.
const REPOSITORY_GATES = [
["Terraform import-plan contract", "test:terraform-import-plan"],
["Terraform formatting and validation", "test:terraform"],
["HCP run guard", "test:hcp-run-guard"],
["CloudFront release verify", "test:cloudfront-release-verify"],
["GitHub workflow shell", "test:github-workflows"],
["App/Terraform isolation tests", "test:app-terraform-isolation"],
];
function isGoverned(relativePath) {
return (
GOVERNED_ROOTS.some((root) => relativePath.startsWith(root)) &&
/\.(ts|tsx)$/.test(relativePath) &&
!EXCLUDE_DIR.test(relativePath) &&
!EXCLUDE_NAME.test(relativePath)
);
}
function gitText(args) {
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
}
function gitLines(args) {
return gitText(args).split("\n").filter(Boolean);
}
function governedFiles() {
const tracked = gitLines(["ls-files"]);
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
return [...new Set([...tracked, ...untracked])].filter(
(relativePath) => isGoverned(relativePath) && existsSync(path.join(ROOT, relativePath)),
);
}
function lineCount(relativePath) {
const content = readFileSync(path.join(ROOT, relativePath), "utf8");
if (content.length === 0) return 0;
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
}
function readBaseline() {
return JSON.parse(readFileSync(BASELINE_PATH, "utf8"));
}
function readBaselineAtRef(ref) {
try {
const content = execFileSync("git", ["show", `${ref}:scripts/governance-baseline.json`], {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
return JSON.parse(content);
} catch {
return null;
}
}
function godfileRatchet(baseRef) {
const baseline = readBaseline();
const cap = baseline.maxFileLines ?? MAX_FILE_LINES;
const debtEntries = new Map(
(baseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
);
const files = governedFiles();
const newDebt = [];
const grownDebt = [];
for (const file of files) {
const lines = lineCount(file);
const debtCap = debtEntries.get(file);
if (lines > cap && debtCap === undefined) {
newDebt.push({ path: file, lines });
} else if (debtCap !== undefined && lines > debtCap) {
grownDebt.push({ path: file, lines, maxLines: debtCap });
}
}
const stale = [];
const remaining = [];
for (const [debtPath, maxLines] of debtEntries) {
const lines = files.includes(debtPath) ? lineCount(debtPath) : -1;
if (lines === -1 || lines <= cap) {
stale.push({ path: debtPath, lines });
} else {
remaining.push({ path: debtPath, lines, maxLines });
}
}
const baselineLoosening = [];
const baseBaseline = baseRef ? readBaselineAtRef(baseRef) : null;
if (baseBaseline) {
const baseCap = baseBaseline.maxFileLines ?? MAX_FILE_LINES;
if (cap > baseCap) {
baselineLoosening.push(`global cap increased from ${baseCap} to ${cap}`);
}
const baseEntries = new Map(
(baseBaseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
);
for (const [debtPath, maxLines] of debtEntries) {
const priorMax = baseEntries.get(debtPath);
if (priorMax === undefined) {
baselineLoosening.push(`new debt entry: ${debtPath}`);
} else if (maxLines > priorMax) {
baselineLoosening.push(`cap increased for ${debtPath}: ${priorMax} -> ${maxLines}`);
}
}
}
return {
cap,
newDebt,
grownDebt,
stale,
remaining,
baselineLoosening,
comparedBaseline: Boolean(baseBaseline),
};
}
function resolveBaseRef() {
if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
for (const candidate of ["origin/main", "origin/dev"]) {
try {
execFileSync("git", ["rev-parse", "--verify", candidate], {
cwd: ROOT,
encoding: "utf8",
stdio: "ignore",
});
return candidate;
} catch {
// candidate ref not present locally; try the next
}
}
return null;
}
function changedGovernedFiles(baseRef) {
let mergeBase;
try {
mergeBase = execFileSync("git", ["merge-base", baseRef, "HEAD"], {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return null;
}
const diffed = gitLines(["diff", "--name-only", "--diff-filter=AMR", mergeBase, "HEAD"]);
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
return [...new Set([...diffed, ...untracked])].filter(isGoverned);
}
function maintainabilityGate(files) {
if (files.length === 0) {
return { skipped: true, reason: "no changed governed TS/TSX files" };
}
// Invoke eslint via node so Windows (no shebang exec) and Unix both work.
const eslintJs = path.join(ROOT, "node_modules", "eslint", "bin", "eslint.js");
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
const result = spawnSync(
process.execPath,
[
eslintJs,
...files,
...ruleArgs,
"--max-warnings=0",
"--no-warn-ignored",
"--no-error-on-unmatched-pattern",
],
{ cwd: ROOT, encoding: "utf8" },
);
return {
skipped: false,
status: result.status,
stdout: result.stdout?.trim() ?? "",
stderr: result.stderr?.trim() ?? "",
files,
};
}
function plural(count, word) {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
function runRepositoryGate(label, script) {
// Reuse the npm that launched us when available (matches its version and
// config); fall back to PATH for direct `node scripts/governance-check.mjs`.
const npmCli = process.env.npm_execpath;
const executable = npmCli ? process.execPath : "npm";
const args = npmCli ? [npmCli, "run", script] : ["run", script];
const result = spawnSync(executable, args, {
cwd: ROOT,
encoding: "utf8",
stdio: "inherit",
});
return { label, status: result.status, error: result.error };
}
function shouldRunLiveIsolation() {
const eventName = process.env.GITHUB_EVENT_NAME;
return !eventName || eventName === "pull_request";
}
function runIsolationGate(baseRef) {
// Diff from the merge base, not the moving base tip. A two-dot diff against
// a branch that has advanced reports everything the base gained after the
// branch point as if this change reverted it.
const mergeBase = gitText(["merge-base", baseRef, "HEAD"]);
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", mergeBase, "HEAD"]);
const result = spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
cwd: ROOT,
encoding: "utf8",
input: `${files.join("\n")}\n`,
});
return { ...result, mergeBase };
}
function main() {
const failures = [];
const baseRef = resolveBaseRef();
if (!baseRef) {
failures.push(
"base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
);
} else {
try {
gitText(["merge-base", baseRef, "HEAD"]);
} catch {
failures.push(
`base ref '${baseRef}' cannot be resolved against HEAD. Fetch it or set GOVERNANCE_BASE correctly.`,
);
}
}
console.log("─".repeat(64));
console.log("godfile ratchet: legacy caps may only shrink");
const god = godfileRatchet(baseRef);
console.log(
` cap: ${god.cap} lines | grandfathered debt: ${plural(god.remaining.length, "file")} | new violations: ${god.newDebt.length}`,
);
for (const entry of god.remaining) {
console.log(` debt ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
}
for (const entry of god.newDebt) {
console.log(` NEW ${String(entry.lines).padStart(4)} ${entry.path}`);
}
for (const entry of god.grownDebt) {
console.log(` GREW ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
}
if (god.newDebt.length > 0) {
failures.push(
`godfile ratchet: ${plural(god.newDebt.length, "file")} exceed ${god.cap} lines. Refactor them under the cap; new baseline debt is forbidden.`,
);
}
if (god.grownDebt.length > 0) {
failures.push(
`godfile ratchet: ${plural(god.grownDebt.length, "grandfathered file")} exceeded its frozen line cap.`,
);
}
if (god.baselineLoosening.length > 0) {
failures.push(
`governance baseline was loosened: ${god.baselineLoosening.join("; ")}. Only cap reductions and entry removals are allowed.`,
);
}
if (!god.comparedBaseline) {
console.log(" baseline comparison unavailable (initial adoption or missing base file)");
}
if (god.stale.length > 0) {
console.log(` stale baseline entries (now compliant — remove to ratchet tighter):`);
for (const entry of god.stale) {
console.log(` stale ${entry.path}`);
}
}
console.log("─".repeat(64));
if (!baseRef) {
console.log("changed-file maintainability gate: FAIL (no valid base ref)");
} else {
const files = changedGovernedFiles(baseRef);
console.log(
`changed-file maintainability gate (base: ${baseRef}): ${files === null ? "unresolvable" : plural(files.length, "changed governed file")}`,
);
if (files === null) {
console.log(" failed — base ref could not be resolved against HEAD");
} else {
const gate = maintainabilityGate(files);
if (gate.skipped) {
console.log(` skipped — ${gate.reason}`);
} else {
const clean = gate.status === 0;
console.log(
` result: ${clean ? "PASS" : "FAIL"} (complexity<=20, function<=150 lines, params<=4, depth<=4)`,
);
if (!clean) {
if (gate.stdout) console.log(gate.stdout);
if (gate.stderr) console.log(gate.stderr);
failures.push(
"changed-file maintainability gate: see ESLint output above. Extract functions/components to meet the thresholds; do not relax the thresholds.",
);
}
}
}
}
for (const [label, script] of REPOSITORY_GATES) {
console.log("─".repeat(64));
console.log(`${label}: npm run ${script}`);
const gate = runRepositoryGate(label, script);
if (gate.error) {
failures.push(`${label}: could not start: ${gate.error.message}`);
} else if (gate.status !== 0) {
failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`);
}
}
console.log("─".repeat(64));
if (!shouldRunLiveIsolation()) {
console.log(
`G13: skipped — live isolation is a pull-request property (event: ${process.env.GITHUB_EVENT_NAME})`,
);
} else if (!baseRef) {
console.log("G13: application and Terraform isolation (no base...HEAD)");
console.log(" FAIL (no valid base ref)");
failures.push(
"G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
);
} else {
let isolation;
try {
isolation = runIsolationGate(baseRef);
} catch (error) {
console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`);
console.log(" FAIL (could not resolve merge base)");
const message = error instanceof Error ? error.message : String(error);
failures.push(`G13: could not resolve merge base against HEAD: ${message}`);
}
if (isolation) {
const mergeBase = isolation.mergeBase ?? "unresolvable";
console.log(
`G13: application and Terraform isolation (${baseRef}...HEAD, merge base ${mergeBase.slice(0, 7)})`,
);
if (isolation.error) {
console.log(" FAIL (could not start)");
failures.push(`G13: could not start: ${isolation.error.message}`);
} else {
const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim();
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
if (isolation.status !== 0) {
failures.push("G13: do not mix deployable application files with terraform/");
}
}
}
}
console.log("─".repeat(64));
if (failures.length > 0) {
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
for (const failure of failures) console.log(` - ${failure}`);
process.exit(1);
}
console.log("RESULT: PASS — all governance gates green");
}
main();