Files
OmniRoute/scripts/check/newCodeMode.mjs
Diego Rodrigues de Sa e Souza 8b7afc0eba feat(quality): new-code mode for the complexity and dead-code ratchets (Clean as You Code) (#12142)
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".
2026-08-30 19:08:32 -03:00

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();
}