mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 10:23:11 +00:00
Run live isolation on the merge-queue event so ci-complete actually gates mixed change sets, without treating the group union as one PR.
431 lines
14 KiB
JavaScript
431 lines
14 KiB
JavaScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { shouldRunLiveIsolation, usesPerCommitIsolation } from "./g13-live-isolation.mjs";
|
|
|
|
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 classifyIsolationPaths(files) {
|
|
return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
|
|
cwd: ROOT,
|
|
encoding: "utf8",
|
|
input: files.length > 0 ? `${files.join("\n")}\n` : "",
|
|
});
|
|
}
|
|
|
|
function firstParentCommitDiffs(baseRef) {
|
|
const shas = gitLines(["rev-list", "--reverse", "--first-parent", `${baseRef}..HEAD`]);
|
|
return shas.map((sha) => ({
|
|
sha,
|
|
files: gitLines(["diff", "--name-only", "--diff-filter=ACMR", `${sha}^`, sha]),
|
|
}));
|
|
}
|
|
|
|
function runIsolationGate(baseRef, eventName) {
|
|
if (usesPerCommitIsolation(eventName)) {
|
|
const commits = firstParentCommitDiffs(baseRef);
|
|
return {
|
|
mode: "per-commit",
|
|
results: commits.map((commit) => ({
|
|
...classifyIsolationPaths(commit.files),
|
|
sha: commit.sha,
|
|
})),
|
|
};
|
|
}
|
|
// 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"]);
|
|
return {
|
|
mode: "range",
|
|
mergeBase,
|
|
results: [{ ...classifyIsolationPaths(files), sha: null }],
|
|
};
|
|
}
|
|
|
|
function recordIsolationFailures(isolation, failures) {
|
|
const startError = isolation.results.find((result) => result.error);
|
|
if (startError) {
|
|
failures.push(`G13: could not start: ${startError.error.message}`);
|
|
}
|
|
if (isolation.results.some((result) => !result.error && result.status !== 0)) {
|
|
failures.push("G13: do not mix deployable application files with terraform/");
|
|
}
|
|
}
|
|
|
|
function logIsolationGate(baseRef, isolation) {
|
|
if (isolation.mode === "per-commit") {
|
|
console.log(
|
|
`G13: application and Terraform isolation (merge_group, ${plural(isolation.results.length, "queued PR")} vs ${baseRef.slice(0, 7)})`,
|
|
);
|
|
for (const result of isolation.results) {
|
|
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
const prefix = result.sha ? result.sha.slice(0, 7) : "commit";
|
|
if (output) {
|
|
console.log(` ${prefix}: ${output.replaceAll("\n", "\n ")}`);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
const mergeBase = isolation.mergeBase ?? "unresolvable";
|
|
console.log(
|
|
`G13: application and Terraform isolation (${baseRef}...HEAD, merge base ${mergeBase.slice(0, 7)})`,
|
|
);
|
|
const result = isolation.results[0];
|
|
if (!result) return;
|
|
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
|
|
}
|
|
|
|
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));
|
|
const eventName = process.env.GITHUB_EVENT_NAME;
|
|
if (!shouldRunLiveIsolation(eventName)) {
|
|
console.log(
|
|
`G13: skipped — live isolation runs on pull_request, merge_group, and local (event: ${eventName})`,
|
|
);
|
|
} 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, eventName);
|
|
} catch (error) {
|
|
console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`);
|
|
console.log(" FAIL (could not resolve isolation diffs)");
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
failures.push(`G13: could not resolve isolation diffs against HEAD: ${message}`);
|
|
}
|
|
if (isolation) {
|
|
logIsolationGate(baseRef, isolation);
|
|
recordIsolationFailures(isolation, failures);
|
|
}
|
|
}
|
|
|
|
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();
|