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".
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-30 19:08:32 -03:00
committed by GitHub
parent af65171e3f
commit 8b7afc0eba
7 changed files with 455 additions and 3 deletions

View File

@@ -303,7 +303,12 @@ jobs:
# #8522: file-size is base-relative on PR events (compare against
# max(frozen, base)) so inherited drift doesn't red an innocent PR;
# workflow_dispatch (no PR base) falls back to absolute comparison.
if [ "$g" = "file-size" ] && [ -n "${PR_BASE_SHA:-}" ]; then
# New-code mode (Clean-as-You-Code, 2026-08-30): complexity-ratchets and
# dead-code compare the PR's files against the merge-base and block only on
# what the PR added; the global totals are advisory on PRs and re-frozen at
# release. See scripts/check/newCodeMode.mjs.
case "$g" in file-size|complexity-ratchets|dead-code) NEW_CODE=1 ;; *) NEW_CODE= ;; esac
if [ -n "$NEW_CODE" ] && [ -n "${PR_BASE_SHA:-}" ]; then
npm run "check:$g" -- --base-ref "$PR_BASE_SHA" || failed+=("$g")
else
npm run "check:$g" || failed+=("$g")

View File

@@ -262,6 +262,23 @@ docs/env contract, i18n parity, unit tests) are unchanged — a red test is stil
is the early warning: a budget that fills in days means the relaxation is being consumed by
a few PRs, not by the whole team — look at the offending gate's `_rebaseline_*` notes.
**New-code mode (Clean-as-You-Code) — since 2026-08-30, PR fast-path only**
On `pull_request` events `quality.yml` passes `--base-ref <PR base SHA>` to `check:file-size`,
`check:complexity-ratchets` and `check:dead-code`. In that mode the gate compares HEAD with the
merge-base **restricted to the files the PR touched** (`scripts/check/newCodeMode.mjs`: the
merge-base is materialized in a throwaway `git worktree`, ESLint/knip run there and on HEAD, the
per-file counts are diffed):
- **blocking** — the PR added cyclomatic/cognitive violations or dead exports in files it changed
(`complexityNewCode=`, `cognitiveComplexityNewCode=`, `deadExportsNewCode=` in the log);
- **advisory** — the global total vs. the frozen baseline. Inherited drift never reds an
innocent PR; the drift is re-frozen at release reconciliation and watched by the headroom job.
`workflow_dispatch` runs, the release-green sweep and the nightly headroom job have no PR base
and keep the absolute (global) comparison. Coverage, duplication and type-coverage stay global
for now (their tools do not produce a per-file diff cheaply) — candidates for the same treatment.
**Closing the phase at v4.0 (LTS = tighter than before, not "back to normal")**
1. On the pure `release/v4.0.0` tip: `npm run quality:headroom --json` for the record, then

View File

@@ -19,6 +19,78 @@ import {
countComplexityViolations,
getComplexityEslintReport,
} from "./complexityEslintReport.mjs";
import {
baseRefArg,
diffNewCode,
listChangedFiles,
perFileRuleCounts,
resolveMergeBase,
withBaseWorktree,
} from "./newCodeMode.mjs";
import { runComplexityEslintOn } from "./complexityEslintReport.mjs";
const BASE_REF = baseRefArg();
const NEW_CODE_SCOPE = {
dirs: ["src", "open-sse", "electron", "bin"],
exts: [".ts", ".tsx", ".js", ".mjs"],
};
const CYCLOMATIC_RULES = new Set(["complexity", "max-lines-per-function"]);
const COGNITIVE_RULES = new Set(["sonarjs/cognitive-complexity"]);
/**
* New-code mode (PR events, `--base-ref <sha>`): blocking only on violations the PR added in
* the files it touched; the global totals are NOT measured here (the release reconciliation
* and the nightly headroom job run the full walk). See newCodeMode.mjs.
*/
function mainNewCode() {
const mergeBase = resolveMergeBase(BASE_REF);
const changed = listChangedFiles(mergeBase, NEW_CODE_SCOPE);
console.log(
`[complexity-ratchets] new-code mode: merge-base ${mergeBase.slice(0, 12)}, ${changed.length} changed file(s) in scope`
);
if (changed.length === 0) {
console.log("[complexity-ratchets] OK — no source files changed; nothing to compare.");
return;
}
const headReport = runComplexityEslintOn(changed, ROOT);
const headCounts = {
complexity: perFileRuleCounts(headReport, CYCLOMATIC_RULES, ROOT),
cognitive: perFileRuleCounts(headReport, COGNITIVE_RULES, ROOT),
};
// Base paths are absolute inside the throwaway worktree → relativize while it exists.
const baseCounts = withBaseWorktree(mergeBase, (dir) => {
const report = runComplexityEslintOn(changed, dir);
return {
complexity: perFileRuleCounts(report, CYCLOMATIC_RULES, dir),
cognitive: perFileRuleCounts(report, COGNITIVE_RULES, dir),
};
});
let failed = false;
for (const [label, key, metric] of [
["complexity", "complexity", "complexityNewCode"],
["cognitive-complexity", "cognitive", "cognitiveComplexityNewCode"],
]) {
const { regressions, head, base, delta } = diffNewCode(
headCounts[key],
baseCounts[key],
changed
);
console.log(`${metric}=${delta}`);
if (regressions.length) {
console.error(
`[${label}] REGRESSÃO (código novo) — ${head} violações nos arquivos tocados vs ${base} na base (+${delta}):\n` +
regressions.map((r) => `${r.file}: ${r.base}${r.head}`).join("\n") +
"\n → quebre a função em helpers menores; o total global do repo NÃO conta aqui, só o que esta PR adicionou."
);
failed = true;
} else {
console.log(
`[${label}] OK (código novo) — ${head} violações nos arquivos tocados (base ${base})`
);
}
}
if (failed) process.exit(1);
}
const ROOT = process.cwd();
const UPDATE = process.argv.includes("--update");
@@ -31,6 +103,7 @@ const COMPLEXITY_BASELINE = path.resolve(
const QUALITY_BASELINE = path.join(ROOT, "config/quality/quality-baseline.json");
function main() {
if (BASE_REF) return mainNewCode();
if (!fs.existsSync(COMPLEXITY_BASELINE)) {
console.error(`[complexity-ratchets] FAIL — complexity-baseline.json ausente.`);
process.exit(2);

View File

@@ -18,10 +18,18 @@ import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
baseRefArg,
listChangedFiles,
newDeadSymbols,
resolveMergeBase,
withBaseWorktree,
} from "./newCodeMode.mjs";
const ROOT = process.cwd();
const KNIP_BIN = path.join(ROOT, "node_modules", ".bin", "knip");
const QUIET = process.argv.includes("--quiet");
const BASE_REF = baseRefArg();
const PRINT_JSON = process.argv.includes("--json");
const UPDATE = process.argv.includes("--update");
@@ -103,7 +111,7 @@ export function evaluateDeadCode(current, baseline) {
};
}
function runKnip() {
function runKnip(cwd = ROOT) {
const args = [
"--reporter",
"json",
@@ -118,7 +126,7 @@ function runKnip() {
let stdout;
try {
stdout = execFileSync(KNIP_BIN, args, {
cwd: ROOT,
cwd,
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
timeout: 300_000, // 5 min (knip pode ser lento em monorepos grandes)
@@ -144,6 +152,44 @@ function runKnip() {
return knipJson;
}
/**
* New-code mode (PR events, `--base-ref <sha>`): knip on HEAD and on the merge-base; blocking
* only on dead symbols the PR introduced in files it touched. The global count is printed as
* an advisory (the release reconciliation re-freezes it). See newCodeMode.mjs.
*/
function mainNewCode(baselineValue) {
const mergeBase = resolveMergeBase(BASE_REF);
const changed = listChangedFiles(mergeBase, {
dirs: ["src", "open-sse", "electron", "bin", "scripts"],
exts: [".ts", ".tsx", ".js", ".mjs"],
});
const headKnip = runKnip();
const { deadTotal } = parseKnipMetrics(headKnip);
console.log(`DEAD_TOTAL=${deadTotal}`);
const over = deadTotal > baselineValue ? " — OVER, re-freeze at release" : "";
console.log(
`[dead-code] new-code mode: merge-base ${mergeBase.slice(0, 12)}, ${changed.length} changed file(s); global ${deadTotal} vs baseline ${baselineValue} (advisory${over})`
);
if (changed.length === 0) {
console.log("[dead-code] OK — no source files changed; nothing to compare.");
return;
}
const baseKnip = withBaseWorktree(mergeBase, (dir) => runKnip(dir));
const added = newDeadSymbols(headKnip, baseKnip, changed);
console.log(`deadExportsNewCode=${added.length}`);
if (added.length) {
process.stderr.write(
`[dead-code] REGRESSÃO (código novo) — ${added.length} símbolo(s) morto(s) introduzido(s) nos arquivos tocados:\n` +
added.map((k) => `${k}`).join("\n") +
"\n → remova o export (ou use-o). O total global do repo não conta aqui.\n"
);
process.exit(1);
}
console.log(
`[dead-code] OK (código novo) — nenhum símbolo morto novo nos ${changed.length} arquivo(s) tocado(s)`
);
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(`[dead-code] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`);
@@ -159,6 +205,7 @@ function main() {
process.exit(2);
}
const baselineValue = baselineMetric.value;
if (BASE_REF && !PRINT_JSON && !UPDATE) return mainNewCode(baselineValue);
const knipJson = runKnip();

View File

@@ -102,3 +102,38 @@ export function getComplexityEslintReport() {
}
getComplexityEslintReport._cache = null;
/**
* New-code mode (#newCodeMode): lint ONLY `files` (relative to `cwd`) with the same config,
* in `cwd` (ROOT for HEAD, a throwaway base worktree for the merge-base). No cache: the
* cache key would otherwise leak between the two trees. Returns [] for an empty file list.
* @param {string[]} files
* @param {string} cwd
* @returns {Array<object>}
*/
export function runComplexityEslintOn(files, cwd = ROOT) {
const existing = files.filter((f) => fs.existsSync(path.join(cwd, f)));
if (existing.length === 0) return [];
const args = [
"--no-config-lookup",
"--config",
path.join(cwd, "eslint.complexity-ratchets.config.mjs"),
"--format",
"json",
"--no-error-on-unmatched-pattern",
...existing,
];
let stdout;
try {
stdout = execFileSync(ESLINT_BIN, args, {
cwd,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
shell: process.platform === "win32",
});
} catch (err) {
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) throw err;
}
return JSON.parse(stdout);
}

View File

@@ -0,0 +1,163 @@
// 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();
}

View File

@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
baseRefArg,
deadSymbolKeys,
diffNewCode,
filterScope,
newDeadSymbols,
perFileRuleCounts,
} from "../../../scripts/check/newCodeMode.mjs";
test("baseRefArg reads --base-ref <sha> and ignores a dangling flag", () => {
assert.equal(baseRefArg(["node", "x", "--base-ref", "abc123"]), "abc123");
assert.equal(baseRefArg(["node", "x", "--base-ref"]), null);
assert.equal(baseRefArg(["node", "x"]), null);
});
test("filterScope keeps only in-scope dirs/extensions, sorted and trimmed", () => {
const files = filterScope(
[
" src/a.ts",
"open-sse/b.tsx",
"tests/unit/c.test.ts",
"src/d.md",
"srcx/e.ts",
"",
"bin/f.mjs",
],
{ dirs: ["src", "open-sse", "bin"], exts: [".ts", ".tsx", ".mjs"] }
);
assert.deepEqual(files, ["bin/f.mjs", "open-sse/b.tsx", "src/a.ts"]);
});
test("perFileRuleCounts counts only the requested rules and relativizes absolute paths", () => {
const report = [
{
filePath: "/repo/src/a.ts",
messages: [
{ ruleId: "complexity" },
{ ruleId: "max-lines-per-function" },
{ ruleId: "no-unused-vars" },
],
},
{ filePath: "/repo/src/b.ts", messages: [{ ruleId: "sonarjs/cognitive-complexity" }] },
{ filePath: "src/c.ts", messages: [] },
];
const cyc = perFileRuleCounts(report, new Set(["complexity", "max-lines-per-function"]), "/repo");
assert.equal(cyc.get("src/a.ts"), 2);
assert.equal(cyc.get("src/b.ts"), 0);
assert.equal(cyc.get("src/c.ts"), 0);
const cog = perFileRuleCounts(report, new Set(["sonarjs/cognitive-complexity"]), "/repo");
assert.equal(cog.get("src/b.ts"), 1);
});
test("diffNewCode flags only changed files that grew; new files count from zero", () => {
const head = new Map([
["src/a.ts", 3],
["src/new.ts", 1],
["src/untouched.ts", 9],
]);
const base = new Map([
["src/a.ts", 3],
["src/untouched.ts", 2],
]);
const r = diffNewCode(head, base, ["src/a.ts", "src/new.ts"]);
assert.deepEqual(r.regressions, [{ file: "src/new.ts", base: 0, head: 1 }]);
assert.equal(r.head, 4);
assert.equal(r.base, 3);
assert.equal(r.delta, 1);
// a file that got better is not a regression
const better = diffNewCode(new Map([["src/a.ts", 1]]), new Map([["src/a.ts", 3]]), ["src/a.ts"]);
assert.deepEqual(better.regressions, []);
assert.equal(better.delta, -2);
});
test("deadSymbolKeys covers exports, types, namespace members and unused files", () => {
const keys = deadSymbolKeys({
issues: [
{
file: "src/a.ts",
exports: [{ name: "foo" }],
types: [{ name: "Bar" }],
nsExports: [{ name: "ns" }],
},
{ file: "src/dead.ts", files: ["src/dead.ts"] },
],
});
assert.deepEqual([...keys].sort(), [
"src/a.ts:Bar",
"src/a.ts:foo",
"src/a.ts:ns",
"src/dead.ts:<file>",
]);
assert.equal(deadSymbolKeys(null).size, 0);
});
test("newDeadSymbols reports only symbols that are new on HEAD, in touched files by default", () => {
const base = { issues: [{ file: "src/a.ts", exports: [{ name: "old" }] }] };
const head = {
issues: [
{ file: "src/a.ts", exports: [{ name: "old" }, { name: "fresh" }] },
{ file: "src/other.ts", exports: [{ name: "orphaned" }] },
],
};
assert.deepEqual(newDeadSymbols(head, base, ["src/a.ts"]), ["src/a.ts:fresh"]);
assert.deepEqual(newDeadSymbols(head, base, ["src/a.ts"], { includeUntouched: true }), [
"src/a.ts:fresh",
"src/other.ts:orphaned",
]);
assert.deepEqual(newDeadSymbols(base, head, ["src/a.ts"]), []);
});