Files
OmniRoute/scripts/check/complexityEslintReport.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

140 lines
3.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Shared ESLint runner for complexity + cognitive-complexity ratchets.
* One tree walk → JSON report; consumers count by ruleId (not errorCount).
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const CONFIG_PATH = path.join(ROOT, "eslint.complexity-ratchets.config.mjs");
/** Positional dirs — must match config `files` scopes (see check-complexity tests). */
export const ESLINT_SCAN_DIRS = ["src", "open-sse", "electron", "bin"];
const ESLINT_BIN = path.join(
ROOT,
"node_modules",
".bin",
process.platform === "win32" ? "eslint.cmd" : "eslint"
);
/** Args after the eslint binary (tests lock scan dirs on this array). */
export const ESLINT_ARGS = [
"--no-config-lookup",
"--config",
CONFIG_PATH,
"--format",
"json",
"--cache",
"--cache-location",
".eslintcache-complexity",
...ESLINT_SCAN_DIRS,
];
const COMPLEXITY_RULES = new Set(["complexity", "max-lines-per-function"]);
/**
* @param {Array<{messages?: Array<{ruleId?: string}>}>} report
* @returns {number}
*/
export function countComplexityViolations(report) {
let count = 0;
for (const file of report) {
for (const msg of file.messages || []) {
if (COMPLEXITY_RULES.has(msg.ruleId)) count++;
}
}
return count;
}
/**
* @param {Array<{messages?: Array<{ruleId?: string}>}>} report
* @returns {number}
*/
export function countCognitiveViolations(report) {
let count = 0;
for (const file of report) {
for (const msg of file.messages || []) {
if (msg.ruleId === "sonarjs/cognitive-complexity") count++;
}
}
return count;
}
/**
* Run ESLint once (or reuse COMPLEXITY_ESLINT_REPORT / in-process cache).
* @returns {Array<object>}
*/
export function getComplexityEslintReport() {
const fromEnv = process.env.COMPLEXITY_ESLINT_REPORT;
if (fromEnv && fs.existsSync(fromEnv)) {
return JSON.parse(fs.readFileSync(fromEnv, "utf8"));
}
if (getComplexityEslintReport._cache) return getComplexityEslintReport._cache;
let stdout;
try {
// Prefer local bin (Windows-safe); shell only needed for .cmd shims.
stdout = execFileSync(ESLINT_BIN, ESLINT_ARGS, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
shell: process.platform === "win32",
});
} catch (err) {
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) throw err;
}
const report = JSON.parse(stdout);
getComplexityEslintReport._cache = report;
const outDir = path.join(ROOT, ".artifacts");
try {
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, "complexity-eslint.json"), stdout);
} catch {
// best-effort cache for sibling steps / local inspection
}
return report;
}
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);
}