mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
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:
committed by
GitHub
parent
af65171e3f
commit
8b7afc0eba
7
.github/workflows/quality.yml
vendored
7
.github/workflows/quality.yml
vendored
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
163
scripts/check/newCodeMode.mjs
Normal file
163
scripts/check/newCodeMode.mjs
Normal 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();
|
||||
}
|
||||
112
tests/unit/build/new-code-mode.test.ts
Normal file
112
tests/unit/build/new-code-mode.test.ts
Normal 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"]), []);
|
||||
});
|
||||
Reference in New Issue
Block a user