diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 834dca413b..0fed21369e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -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") diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index 490b87cc96..46e53b5f86 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -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 ` 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 diff --git a/scripts/check/check-complexity-ratchets.mjs b/scripts/check/check-complexity-ratchets.mjs index 22bcc02fef..59ae575a93 100644 --- a/scripts/check/check-complexity-ratchets.mjs +++ b/scripts/check/check-complexity-ratchets.mjs @@ -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 `): 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); diff --git a/scripts/check/check-dead-code.mjs b/scripts/check/check-dead-code.mjs index 1c7f5c5e34..ca5eddcd8c 100644 --- a/scripts/check/check-dead-code.mjs +++ b/scripts/check/check-dead-code.mjs @@ -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 `): 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(); diff --git a/scripts/check/complexityEslintReport.mjs b/scripts/check/complexityEslintReport.mjs index 4c65efa3a3..e77863a61a 100644 --- a/scripts/check/complexityEslintReport.mjs +++ b/scripts/check/complexityEslintReport.mjs @@ -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} + */ +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); +} diff --git a/scripts/check/newCodeMode.mjs b/scripts/check/newCodeMode.mjs new file mode 100644 index 0000000000..3dad8c96be --- /dev/null +++ b/scripts/check/newCodeMode.mjs @@ -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 `, 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 | 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}:`); + } + 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(); +} diff --git a/tests/unit/build/new-code-mode.test.ts b/tests/unit/build/new-code-mode.test.ts new file mode 100644 index 0000000000..e9cfdafa91 --- /dev/null +++ b/tests/unit/build/new-code-mode.test.ts @@ -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 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:", + ]); + 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"]), []); +});