mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-05 14:32:13 +03:00
A global ratchet ("total ≤ baseline") reds an innocent PR whenever the base
drifted, and lets a PR that adds 10 violations pass as long as someone else
removed 11 — both happened this week. On pull_request events quality.yml now
passes --base-ref <PR base SHA> to check:complexity-ratchets and check:dead-code
(file-size already had it); in that mode the gate compares HEAD with the
merge-base RESTRICTED to the files the PR touched:
- blocking: violations / dead exports the PR added in files it changed
(complexityNewCode=, cognitiveComplexityNewCode=, deadExportsNewCode=)
- advisory: the global total vs the frozen baseline (re-frozen at release,
watched by the nightly headroom job)
scripts/check/newCodeMode.mjs holds the git side (merge-base, changed files,
throwaway `git worktree` of the base with node_modules linked — no stash, no
checkout) and the pure comparison helpers (13 unit tests). ESLint runs only on
the changed files in both trees (~20 s); knip runs twice (~70 s).
Exercised locally against the last 8 merges: complexity flagged
src/lib/credentialHealth/scheduler.ts (2→3, cognitive 1→2) and dead-code flagged
src/lib/resilience/settings.ts:CredentialHealthCheckSettings — findings the
global totals were hiding under the relaxed baselines.
workflow_dispatch, the release-green sweep and the headroom job have no PR base
and keep the absolute comparison. Docs: QUALITY_GATES.md → "New-code mode".
164 lines
5.7 KiB
JavaScript
164 lines
5.7 KiB
JavaScript
// scripts/check/newCodeMode.mjs
|
|
// "New code" mode for the ratchet gates (Sonar "Clean as You Code", applied 2026-08-30).
|
|
//
|
|
// A global ratchet ("total violations ≤ baseline") makes an innocent PR red whenever the
|
|
// base drifted — and it lets a PR that adds 10 violations pass as long as someone else
|
|
// removed 11. In new-code mode (`--base-ref <sha>`, PR events only) a gate compares the
|
|
// PR's HEAD against the merge-base **restricted to the files the PR touched**:
|
|
//
|
|
// blocking → the PR added violations / dead symbols in files it changed
|
|
// advisory → the global total vs. the frozen baseline (printed, never exit 1);
|
|
// the release reconciliation re-measures and re-freezes it
|
|
//
|
|
// Shared by check-complexity-ratchets.mjs and check-dead-code.mjs. Everything git-related
|
|
// is here; the pure comparison helpers are unit-tested (tests/unit/build/new-code-mode.test.ts).
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
|
/** CLI arg helper shared by the gates: `--base-ref <sha>` → sha | null. */
|
|
export function baseRefArg(argv = process.argv) {
|
|
const i = argv.indexOf("--base-ref");
|
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : null;
|
|
}
|
|
|
|
function git(args, opts = {}) {
|
|
return execFileSync("git", args, {
|
|
cwd: ROOT,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
...opts,
|
|
}).trim();
|
|
}
|
|
|
|
/** Merge-base between the PR base ref and HEAD (falls back to the ref itself). */
|
|
export function resolveMergeBase(baseRef) {
|
|
try {
|
|
return git(["merge-base", baseRef, "HEAD"]);
|
|
} catch {
|
|
return git(["rev-parse", baseRef]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Files added/copied/modified/renamed between `mergeBase` and HEAD, filtered to the gate's
|
|
* scope. Deleted files are irrelevant (nothing to measure on HEAD).
|
|
*/
|
|
export function listChangedFiles(mergeBase, { dirs, exts }) {
|
|
const out = git(["diff", "--name-only", "--diff-filter=ACMR", `${mergeBase}...HEAD`]);
|
|
return filterScope(out.split("\n"), { dirs, exts });
|
|
}
|
|
|
|
/** Pure: keep paths under one of `dirs` with one of `exts`. */
|
|
export function filterScope(paths, { dirs, exts }) {
|
|
return paths
|
|
.map((p) => p.trim())
|
|
.filter(Boolean)
|
|
.filter((p) => dirs.some((d) => p === d || p.startsWith(`${d}/`)))
|
|
.filter((p) => exts.some((e) => p.endsWith(e)))
|
|
.sort();
|
|
}
|
|
|
|
/**
|
|
* Materialize `sha` in a throwaway worktree with node_modules linked from ROOT, run `fn(dir)`,
|
|
* always tear it down. Never touches the caller's tree or index (no stash, no checkout).
|
|
*/
|
|
export function withBaseWorktree(sha, fn) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-newcode-base-"));
|
|
fs.rmdirSync(dir); // git worktree add wants a non-existent path
|
|
git(["worktree", "add", "--detach", "--quiet", dir, sha]);
|
|
try {
|
|
const nm = path.join(ROOT, "node_modules");
|
|
if (fs.existsSync(nm)) fs.symlinkSync(nm, path.join(dir, "node_modules"), "dir");
|
|
return fn(dir);
|
|
} finally {
|
|
try {
|
|
git(["worktree", "remove", "--force", dir]);
|
|
} catch {
|
|
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
try {
|
|
git(["worktree", "prune"]);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pure: per-file count of ESLint messages whose ruleId is in `rules`.
|
|
* `filePath` is made relative to `cwd` so HEAD and base reports share keys.
|
|
*/
|
|
export function perFileRuleCounts(report, rules, cwd) {
|
|
const counts = new Map();
|
|
for (const entry of report || []) {
|
|
const rel = path.isAbsolute(entry.filePath)
|
|
? path.relative(cwd, entry.filePath).split(path.sep).join("/")
|
|
: entry.filePath;
|
|
let n = 0;
|
|
for (const m of entry.messages || []) if (rules.has(m.ruleId)) n++;
|
|
counts.set(rel, (counts.get(rel) || 0) + n);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
/**
|
|
* Pure: which changed files gained violations. A file absent from the base map is new
|
|
* (base = 0). Returns the per-file regressions and the net delta over the changed set.
|
|
*/
|
|
export function diffNewCode(headCounts, baseCounts, changedFiles) {
|
|
const regressions = [];
|
|
let head = 0;
|
|
let base = 0;
|
|
for (const file of changedFiles) {
|
|
const h = headCounts.get(file) || 0;
|
|
const b = baseCounts.get(file) || 0;
|
|
head += h;
|
|
base += b;
|
|
if (h > b) regressions.push({ file, base: b, head: h });
|
|
}
|
|
return { regressions, head, base, delta: head - base };
|
|
}
|
|
|
|
/** Pure: `file:symbol` keys of every dead export/type in a knip JSON report. */
|
|
export function deadSymbolKeys(knipJson) {
|
|
const keys = new Set();
|
|
for (const entry of knipJson?.issues || []) {
|
|
for (const field of ["exports", "types", "nsExports", "nsTypes"]) {
|
|
for (const sym of entry[field] || []) keys.add(`${entry.file}:${sym.name}`);
|
|
}
|
|
}
|
|
for (const entry of knipJson?.issues || []) {
|
|
for (const f of entry.files || []) keys.add(`${f}:<file>`);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/**
|
|
* Pure: dead symbols present on HEAD but not on the base, in files the PR touched — the
|
|
* only ones the PR is answerable for. A symbol that went dead in an UNTOUCHED file because
|
|
* the PR deleted its last consumer is also flagged when `includeUntouched` is set.
|
|
*/
|
|
export function newDeadSymbols(
|
|
headKnip,
|
|
baseKnip,
|
|
changedFiles,
|
|
{ includeUntouched = false } = {}
|
|
) {
|
|
const changed = new Set(changedFiles);
|
|
const base = deadSymbolKeys(baseKnip);
|
|
const out = [];
|
|
for (const key of deadSymbolKeys(headKnip)) {
|
|
if (base.has(key)) continue;
|
|
const file = key.slice(0, key.lastIndexOf(":"));
|
|
if (changed.has(file) || includeUntouched) out.push(key);
|
|
}
|
|
return out.sort();
|
|
}
|