mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +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".
179 lines
6.4 KiB
JavaScript
179 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* One ESLint walk → both complexity ratchets.
|
|
*
|
|
* Existence reasons (unchanged):
|
|
* - cyclomatic + max-lines vs complexity-baseline.json
|
|
* - cognitive-complexity vs quality-baseline metrics.cognitiveComplexity
|
|
*
|
|
* CI should call this instead of sequential check:complexity + check:cognitive
|
|
* so PR→release / quality-gate pay for one tree walk, not two.
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { evaluateComplexity } from "./check-complexity.mjs";
|
|
import { evaluateCognitiveComplexity } from "./check-cognitive-complexity.mjs";
|
|
import {
|
|
countCognitiveViolations,
|
|
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");
|
|
|
|
const COMPLEXITY_BASELINE = path.resolve(
|
|
process.argv.includes("--baseline")
|
|
? process.argv[process.argv.indexOf("--baseline") + 1]
|
|
: path.join(ROOT, "config/quality/complexity-baseline.json")
|
|
);
|
|
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);
|
|
}
|
|
if (!fs.existsSync(QUALITY_BASELINE)) {
|
|
console.error(`[complexity-ratchets] FAIL — quality-baseline.json ausente.`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const report = getComplexityEslintReport();
|
|
const complexityCount = countComplexityViolations(report);
|
|
const cognitiveCount = countCognitiveViolations(report);
|
|
|
|
// Machine-readable lines for collect-metrics / scripts
|
|
console.log(`complexity=${complexityCount}`);
|
|
console.log(`cognitiveComplexity=${cognitiveCount}`);
|
|
|
|
const complexityBaseline = JSON.parse(fs.readFileSync(COMPLEXITY_BASELINE, "utf8"));
|
|
const qualityBaseline = JSON.parse(fs.readFileSync(QUALITY_BASELINE, "utf8"));
|
|
const cognitiveMetric = qualityBaseline.metrics?.cognitiveComplexity;
|
|
if (!cognitiveMetric || typeof cognitiveMetric.value !== "number") {
|
|
console.error(
|
|
"[complexity-ratchets] FAIL — metrics.cognitiveComplexity ausente em quality-baseline.json."
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
const cyc = evaluateComplexity(complexityCount, complexityBaseline.count);
|
|
const cog = evaluateCognitiveComplexity(cognitiveCount, cognitiveMetric.value);
|
|
|
|
if (UPDATE && cyc.improved) {
|
|
console.log(
|
|
`[complexity] baseline ratcheado: ${complexityCount} (era ${complexityBaseline.count})`
|
|
);
|
|
complexityBaseline.count = complexityCount;
|
|
fs.writeFileSync(COMPLEXITY_BASELINE, JSON.stringify(complexityBaseline, null, 2) + "\n");
|
|
}
|
|
if (UPDATE && cog.improved) {
|
|
console.log(
|
|
`[cognitive-complexity] baseline ratcheado: ${cognitiveCount} (era ${cognitiveMetric.value})`
|
|
);
|
|
qualityBaseline.metrics.cognitiveComplexity.value = cognitiveCount;
|
|
fs.writeFileSync(QUALITY_BASELINE, JSON.stringify(qualityBaseline, null, 2) + "\n");
|
|
}
|
|
|
|
let failed = false;
|
|
if (cyc.regressed) {
|
|
console.error(
|
|
`[complexity] REGRESSÃO — ${complexityCount} violações > baseline ${complexityBaseline.count}`
|
|
);
|
|
failed = true;
|
|
} else {
|
|
console.log(
|
|
`[complexity] OK — ${complexityCount} violações (baseline ${complexityBaseline.count})`
|
|
);
|
|
}
|
|
|
|
if (cog.regressed) {
|
|
console.error(
|
|
`[cognitive-complexity] REGRESSÃO — ${cognitiveCount} violações > baseline ${cognitiveMetric.value}`
|
|
);
|
|
failed = true;
|
|
} else {
|
|
console.log(
|
|
`[cognitive-complexity] OK — ${cognitiveCount} violações (baseline ${cognitiveMetric.value})`
|
|
);
|
|
}
|
|
|
|
process.exit(failed ? 1 : 0);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|