mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
* feat(ci): automate ratchet shrink-banking on the release branch (#8584)
The quality ratchet is only half automatic, and it is the wrong half. Raising a
cap is a manual JSON edit that takes ten seconds and is the fastest way to unblock
a red PR. Lowering one requires someone to run `--update` and commit the result —
and no workflow does: grepping `.github/workflows/` for `--update` finds only
wiki-sync.yml (unrelated) and ci.yml's check-quality-ratchet.mjs --require-tighten
(a different script against a different metric).
Measured on release/v3.8.49 at 4053e2314: 18 frozen files already at or under the
800-line new-file cap, the worst at 132x (src/shared/validation/schemas.ts, 19
lines carrying a 2,523 cap); the complexity ceiling walked 1794 -> 2169 across ~37
rebaseline notes with exactly one decrease (-1); "tighten via --update next cycle"
written 31 times and honoured once in six weeks. A cap that outlives the code that
earned it converts every completed decomposition into a growth allowance for
whoever edits that file next.
New job `bank-ratchet-shrinks` in nightly-release-green.yml measures the active
release branch, runs the two shrink-only `--update` paths, and opens ONE
always-current PR with the result. Schedule/dispatch only, deliberately not on
push: banking has no latency requirement, while a per-merge run would rebuild the
PR branch during merge campaigns and pay for a full ESLint walk each time.
Detection stays on push (release-green); only banking is batched. The job never
pushes to release/* — a human merges, so a bad measurement cannot land unreviewed.
scripts/quality/verify-ratchet-bank.mjs is the hard guarantee that the automation
can only ever write downward. It diffs the post-`--update` tree against HEAD and
aborts the job before a commit exists — opening no PR — unless every change is a
frozen/testFrozen entry lowered or removed, `count` lowered, or
cognitiveComplexity.value lowered. Raising a number, adding an entry, changing
cap/testCap, or deleting/rewriting a `_rebaseline_*` note all fail. A bot that
could raise a cap would be strictly worse than the status quo.
Verified both directions against the real baselines: --update + verifier reports
77 lowered / 14 removed / nothing raised, exit 0; hand-raising chatCore.ts to 9999
and cap to 1200 is rejected with exit 1. 22 unit tests cover each way the
automation could go wrong.
No product code changes.
* chore(skills): sync cli-backup-sync SKILL.md after rebase onto tip
246 lines
9.1 KiB
JavaScript
246 lines
9.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Safety verifier for the automated ratchet-banking lane (#8584 proposal 2).
|
|
*
|
|
* WHY THIS EXISTS: the ratchet is asymmetric. Raising a cap is a ten-second manual
|
|
* JSON edit made under merge pressure; lowering one requires someone to run
|
|
* `--update` and commit, which no workflow does. The result is a high-water mark,
|
|
* not a ratchet — caps outlive the shrinks that earned them (#8584 measured 18 of
|
|
* them, up to 132x the file's actual size).
|
|
*
|
|
* The automation that fixes that has to write to the baselines, so it needs a hard
|
|
* guarantee it can only ever write in the shrink direction. This script is that
|
|
* guarantee: it diffs the post-`--update` working tree against HEAD and FAILS if
|
|
* anything moved the wrong way. A bot that could raise a cap would be strictly worse
|
|
* than the status quo, so the workflow aborts (opens no PR) on any problem here.
|
|
*
|
|
* Allowed, and nothing else:
|
|
* file-size-baseline.json — a frozen/testFrozen numeric entry LOWERED or REMOVED
|
|
* complexity-baseline.json — `count` LOWERED
|
|
* quality-baseline.json — `metrics.cognitiveComplexity.value` LOWERED
|
|
*
|
|
* Explicitly rejected: raising any number, adding any entry, changing cap/testCap,
|
|
* and deleting or rewriting a `_rebaseline_*` / `_comment` note (the notes are the
|
|
* audit trail for why each ceiling exists — see the 37 of them in complexity-baseline).
|
|
*
|
|
* Usage: node scripts/quality/verify-ratchet-bank.mjs [--ref HEAD]
|
|
* stdout — markdown summary of what would be banked (for the PR body)
|
|
* stderr — human log
|
|
* exit 0 — safe (see summary for whether anything changed at all)
|
|
* exit 1 — UNSAFE, the caller must not commit
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const ROOT = process.cwd();
|
|
|
|
export const BANKED_BASELINES = {
|
|
fileSize: "config/quality/file-size-baseline.json",
|
|
complexity: "config/quality/complexity-baseline.json",
|
|
quality: "config/quality/quality-baseline.json",
|
|
};
|
|
|
|
/** Deep structural equality over JSON-shaped values. */
|
|
function jsonEqual(a, b) {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
}
|
|
|
|
/**
|
|
* Verifies one frozen map (`frozen` / `testFrozen`). Numeric entries may only be
|
|
* lowered or removed; string entries are `_rebaseline_*` notes stored inside the
|
|
* same object and must survive verbatim.
|
|
* @returns {{problems: string[], removed: string[], lowered: [string, number, number][]}}
|
|
*/
|
|
export function verifyFrozenMap(before = {}, after = {}, label = "frozen") {
|
|
const problems = [];
|
|
const removed = [];
|
|
const lowered = [];
|
|
|
|
for (const [key, prev] of Object.entries(before)) {
|
|
const has = Object.prototype.hasOwnProperty.call(after, key);
|
|
if (typeof prev !== "number") {
|
|
// note key
|
|
if (!has) problems.push(`${label}: note "${key}" was deleted — notes must be preserved`);
|
|
else if (after[key] !== prev)
|
|
problems.push(`${label}: note "${key}" was rewritten — notes must be preserved verbatim`);
|
|
continue;
|
|
}
|
|
if (!has) {
|
|
removed.push(key); // banked: dropped below the cap and left the baseline
|
|
continue;
|
|
}
|
|
const next = after[key];
|
|
if (typeof next !== "number") problems.push(`${label}: ${key} is no longer a number`);
|
|
else if (next > prev) problems.push(`${label}: ${key} RAISED ${prev} → ${next}`);
|
|
else if (next < prev) lowered.push([key, prev, next]);
|
|
}
|
|
|
|
for (const key of Object.keys(after)) {
|
|
if (!Object.prototype.hasOwnProperty.call(before, key))
|
|
problems.push(`${label}: ${key} was ADDED — banking may not introduce entries`);
|
|
}
|
|
|
|
return { problems, removed, lowered };
|
|
}
|
|
|
|
/** Every top-level key except `except` must be byte-identical. */
|
|
function verifyRestUntouched(before, after, except, file) {
|
|
const problems = [];
|
|
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
for (const key of keys) {
|
|
if (except.has(key)) continue;
|
|
if (!jsonEqual(before[key], after[key]))
|
|
problems.push(`${file}: "${key}" changed — only the ratcheted values may move`);
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
/** @returns {{problems: string[], removed: string[], lowered: [string, number, number][]}} */
|
|
export function verifyFileSizeBaseline(before, after) {
|
|
const src = verifyFrozenMap(before.frozen, after.frozen, "frozen");
|
|
const tst = verifyFrozenMap(before.testFrozen, after.testFrozen, "testFrozen");
|
|
return {
|
|
problems: [
|
|
...verifyRestUntouched(before, after, new Set(["frozen", "testFrozen"]), "file-size"),
|
|
...src.problems,
|
|
...tst.problems,
|
|
],
|
|
removed: [...src.removed, ...tst.removed],
|
|
lowered: [...src.lowered, ...tst.lowered],
|
|
};
|
|
}
|
|
|
|
/** Lowers a single numeric field, everything else frozen. */
|
|
function verifyScalar(before, after, prev, next, label, file, except) {
|
|
const problems = verifyRestUntouched(before, after, except, file);
|
|
const lowered = [];
|
|
if (typeof next !== "number") problems.push(`${file}: ${label} is missing or not a number`);
|
|
else if (next > prev) problems.push(`${file}: ${label} RAISED ${prev} → ${next}`);
|
|
else if (next < prev) lowered.push([label, prev, next]);
|
|
return { problems, removed: [], lowered };
|
|
}
|
|
|
|
export function verifyComplexityBaseline(before, after) {
|
|
return verifyScalar(
|
|
before,
|
|
after,
|
|
before.count,
|
|
after.count,
|
|
"count",
|
|
"complexity",
|
|
new Set(["count"])
|
|
);
|
|
}
|
|
|
|
export function verifyQualityBaseline(before, after) {
|
|
const prev = before.metrics?.cognitiveComplexity?.value;
|
|
const next = after.metrics?.cognitiveComplexity?.value;
|
|
// Only metrics.cognitiveComplexity.value may move; compare the rest of `metrics`
|
|
// by swapping in the old value and requiring a byte-identical result.
|
|
const patched = JSON.parse(JSON.stringify(after));
|
|
if (patched.metrics?.cognitiveComplexity) patched.metrics.cognitiveComplexity.value = prev;
|
|
const problems = jsonEqual(before, patched)
|
|
? []
|
|
: ["quality: something other than metrics.cognitiveComplexity.value changed"];
|
|
const lowered = [];
|
|
if (typeof next !== "number") problems.push("quality: cognitiveComplexity.value is not a number");
|
|
else if (next > prev) problems.push(`quality: cognitiveComplexity RAISED ${prev} → ${next}`);
|
|
else if (next < prev) lowered.push(["cognitiveComplexity", prev, next]);
|
|
return { problems, removed: [], lowered };
|
|
}
|
|
|
|
/**
|
|
* @param {{fileSize?: {before: object, after: object}, complexity?: ..., quality?: ...}} pairs
|
|
* @returns {{ok: boolean, changed: boolean, problems: string[], removed: string[], lowered: [string, number, number][]}}
|
|
*/
|
|
export function verifyRatchetBank(pairs) {
|
|
const verifiers = {
|
|
fileSize: verifyFileSizeBaseline,
|
|
complexity: verifyComplexityBaseline,
|
|
quality: verifyQualityBaseline,
|
|
};
|
|
const problems = [];
|
|
const removed = [];
|
|
const lowered = [];
|
|
for (const [kind, verify] of Object.entries(verifiers)) {
|
|
const pair = pairs[kind];
|
|
if (!pair) continue;
|
|
const r = verify(pair.before, pair.after);
|
|
problems.push(...r.problems);
|
|
removed.push(...r.removed);
|
|
lowered.push(...r.lowered);
|
|
}
|
|
return {
|
|
ok: problems.length === 0,
|
|
changed: removed.length > 0 || lowered.length > 0,
|
|
problems,
|
|
removed,
|
|
lowered,
|
|
};
|
|
}
|
|
|
|
export function formatBankSummary(result) {
|
|
if (!result.changed) return "Nothing to bank — every baseline already matches the code.\n";
|
|
const lines = [];
|
|
if (result.lowered.length) {
|
|
lines.push(
|
|
`### Lowered (${result.lowered.length})`,
|
|
"",
|
|
"| entry | was | now |",
|
|
"| --- | --- | --- |"
|
|
);
|
|
for (const [key, prev, next] of result.lowered)
|
|
lines.push(`| \`${key}\` | ${prev} | ${next} |`);
|
|
lines.push("");
|
|
}
|
|
if (result.removed.length) {
|
|
lines.push(
|
|
`### Removed — now at or under the cap (${result.removed.length})`,
|
|
"",
|
|
...result.removed.map((f) => `- \`${f}\``),
|
|
""
|
|
);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function readJson(file) {
|
|
return JSON.parse(fs.readFileSync(path.join(ROOT, file), "utf8"));
|
|
}
|
|
|
|
function readJsonAtRef(ref, file) {
|
|
// execFileSync (no shell) — `ref`/`file` are never interpolated into a command string.
|
|
return JSON.parse(execFileSync("git", ["show", `${ref}:${file}`], { encoding: "utf8" }));
|
|
}
|
|
|
|
function main() {
|
|
const i = process.argv.indexOf("--ref");
|
|
const ref = i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : "HEAD";
|
|
|
|
const pairs = {};
|
|
for (const [kind, file] of Object.entries(BANKED_BASELINES)) {
|
|
pairs[kind] = { before: readJsonAtRef(ref, file), after: readJson(file) };
|
|
}
|
|
|
|
const result = verifyRatchetBank(pairs);
|
|
process.stdout.write(formatBankSummary(result));
|
|
|
|
if (!result.ok) {
|
|
console.error(
|
|
`[verify-ratchet-bank] UNSAFE — ${result.problems.length} problem(s); refusing to bank:\n` +
|
|
result.problems.map((p) => " ✗ " + p).join("\n") +
|
|
"\n → the banking lane may only lower or remove. Investigate before committing."
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.error(
|
|
result.changed
|
|
? `[verify-ratchet-bank] OK — ${result.lowered.length} lowered, ${result.removed.length} removed, nothing raised.`
|
|
: "[verify-ratchet-bank] OK — no baseline movement."
|
|
);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|