Fase 7 finalize — 3 catracas advisory→bloqueante + re-baseline consciente v3.8.25 (#3809)

Integrated into release/v3.8.25 — Fase 7 finalize: 3 catracas advisory→bloqueante (dead-code/cognitive-complexity/type-coverage) + re-baseline consciente.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-14 18:06:56 -03:00
committed by GitHub
parent c4f2af70f0
commit cbb332d355
10 changed files with 500 additions and 45 deletions

View File

@@ -94,6 +94,15 @@ jobs:
run: npm run check:duplication
- name: Complexity ratchet
run: npm run check:complexity
# Fase 7 INT: dead-code, cognitive-complexity, type-coverage promovidos de
# advisory (quality-extended) para BLOQUEANTES aqui. Os 3 leem seus baseline
# de quality-baseline.json e saem 1 em regressão.
- name: Dead-code ratchet (knip)
run: npm run check:dead-code
- name: Cognitive complexity ratchet (sonarjs)
run: npm run check:cognitive-complexity
- name: Type coverage ratchet
run: npm run check:type-coverage
- name: Append summary
if: always()
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY"
@@ -121,12 +130,8 @@ jobs:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- name: Dead-code (knip)
run: npm run check:dead-code
- name: Cognitive complexity (sonarjs)
run: npm run check:cognitive-complexity
- name: Type coverage
run: npm run check:type-coverage
# Dead-code, cognitive-complexity, type-coverage foram promovidos ao job
# quality-gate (bloqueante) na Fase 7 INT — não rodam aqui para evitar duplo custo.
- name: Circular deps (dpdm)
run: npm run check:circular-deps
- name: Bundle size

View File

@@ -1,6 +1,7 @@
{
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
"count": 1794,
"count": 1800,
"_rebaseline_2026_06_13_v3825": "Re-baseline consciente: drift 1794->1800 (+6) do ciclo v3.8.24->v3.8.25 (features #3799-#3806). Mesma familia dos re-baselines anteriores — crescimento de feature legitima, nao regressao. Reducao fica como debt de refactor dedicado.",
"_rebaseline_2026_06_10": "Re-baseline consciente: 1739 foi medido na branch das Fases 0-6 (base ~v3.8.17); a v3.8.18 publicada ja carrega 1746 (provado: o commit-base 5f2722bd6, anterior a qualquer commit do ciclo v3.8.19, mede 1746 — funcoes complexas dos reworks RequestLoggerV2/stream/combo). Mesma familia dos re-baselines de eslintWarnings/file-size. Reducao = Fase 6A (2026-06-16).",
"_rebaseline_2026_06_13_6a11": "Re-baseline consciente Task 6A.11: escopo ampliado para src+open-sse+electron+bin (electron/bin contribuem 0 violacoes novas — todos os 4 arquivos .ts em bin/ estao abaixo dos thresholds). Drift 1746→1794 pre-existente de features mergeadas nos ciclos v3.8.22/v3.8.23 (nao causado por esta task). Congelado no valor real medido para destrancar o gate."
}

View File

@@ -1,5 +1,6 @@
{
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
"_rebaseline_v3.8.25": "Drift consciente do ciclo v3.8.24->v3.8.25 (features #3799-#3806: free-provider-rankings, plugins menu, proxy IP-family selector). 3 arquivos cresceram por feature legitima, nao por regressao de qualidade: ProxyRegistryManager.tsx 1072->1089, sidebarVisibility.ts 990->1006, schemas.ts 2519->2522. Encolher fica como debt para um refactor dedicado.",
"cap": 800,
"frozen": {
"open-sse/config/providerRegistry.ts": 4703,

View File

@@ -2,7 +2,7 @@
"_comment": "Catraca de qualidade. 'down' = nao pode aumentar; 'up' = nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (somente quando melhora). Cada valor e um numero REAL medido, nunca um chute. Cobertura entra na Fase 4 a partir de um run de cobertura mergeada no CI.",
"metrics": {
"eslintWarnings": {
"value": 3658,
"value": 3669,
"direction": "down"
},
"eslintErrors": {
@@ -36,11 +36,26 @@
"value": 80.1,
"direction": "up",
"eps": 0.5
},
"deadExports": {
"value": 327,
"direction": "down"
},
"cognitiveComplexity": {
"value": 738,
"direction": "down"
},
"typeCoveragePct": {
"value": 92.17,
"direction": "up",
"eps": 0.05
}
},
"_coverage_note": "Pisos anti-flake ~2pt abaixo do real do CI mergeado MEDIDO COM os 135 testes religados (run 27247237268: statements 78.4 / lines 78.4 / functions 83.84 / branches 75.73). O religamento da 6A.1 HONESTIFICOU a regua: os ~82.5 anteriores eram inflados porque modulos nunca importados ficavam fora do denominador do c8. Apertar via --require-tighten na Fase 6A (2026-06-16).",
"_eslint_rebaseline_2026_06_09": "Baseline 3482 foi congelado na branch das Fases 0-6 (base ~v3.8.17) ANTES da v3.8.18 sair; a tag v3.8.18 publicada ja media 3501 (delta nasceu no fim daquele ciclo, antes do job quality-gate existir no ci.yml). O ciclo v3.8.19 esta NEUTRO (tag 3501 == HEAD 3501). Re-baseline para o estado real da main publicada; reduzir os ~19 (any em codigo do ciclo v3.8.18) e ligar --require-tighten ficam para a Fase 6A (2026-06-16).",
"_eslint_rebaseline_2026_06_13_int6a": "3501 -> 3653. Medido: dos 152 de drift, ~132 sao pre-existentes dos ciclos v3.8.20-v3.8.24 (base 33667fcf3 mede ~3633) e ~20 vem dos scripts .mjs de tooling novos das Fases 6A.3-6A.12 (gates de qualidade; warnings de estilo aceitaveis em tooling). Apertar via --require-tighten fica para o fim do ciclo.",
"_eslint_rebaseline_2026_06_13_phase7": "3653 -> 3658: +5 warnings dos scripts .mjs de tooling novos da Fase 7 (check-vuln-ratchet/codeql/secrets/workflows/dead-code/etc). Tooling de qualidade; apertar via --require-tighten no fim do ciclo.",
"_metrics_added_2026_06_13_6a11": "openapiCoverage.pct (38.3) e i18nUiCoverage.pct (80.1) promovidas de pisos manuais THRESHOLD para metricas de catraca (direction up) na Task 6A.11. eslintErrors fixado em 0 (era metrica orfa detectada pelo motor v2 da 6A.5)."
"_eslint_rebaseline_2026_06_13_v3825": "3658 -> 3669: +11 drift consciente do ciclo v3.8.24->v3.8.25 (features #3799-#3806: free-provider-rankings, plugins menu, proxy IP-family). Medido em release/v3.8.25 (e38d22512). Crescimento de feature legitima, nao regressao; apertar via --require-tighten no fim do ciclo.",
"_metrics_added_2026_06_13_6a11": "openapiCoverage.pct (38.3) e i18nUiCoverage.pct (80.1) promovidas de pisos manuais THRESHOLD para metricas de catraca (direction up) na Task 6A.11. eslintErrors fixado em 0 (era metrica orfa detectada pelo motor v2 da 6A.5).",
"_int_wiring_2026_06_13": "Fase 7 INT: 3 metricas promovidas de ADVISORY para RATCHET BLOQUEANTE. Valores REAIS medidos em 2026-06-13 no HEAD desta branch: deadExports=327 (knip --reporter json, DEAD_TOTAL=exports+files), cognitiveComplexity=738 (eslint sonarjs/cognitive-complexity via eslint.sonarjs.config.mjs), typeCoveragePct=92.17 (type-coverage --json-output -p open-sse/tsconfig.json). Os scripts check-dead-code.mjs/check-cognitive-complexity.mjs/check-type-coverage.mjs foram convertidos de advisory (exit 0 sempre) para ratchet (exit 1 em regressao). vulnCount e codeqlAlerts permanecem advisory no job quality-extended: dependem de binarios externos (osv-scanner) e token GitHub (gh api) nao disponiveis localmente — nao podem ser medidos/congelados honestamente sem infraestrutura de CI."
}

View File

@@ -1,30 +1,39 @@
#!/usr/bin/env node
// scripts/check/check-cognitive-complexity.mjs
// Advisory gate para complexidade cognitiva (sonarjs/cognitive-complexity).
// Ratchet bloqueante para complexidade cognitiva (sonarjs/cognitive-complexity).
// Fase 7 INT: promovido de ADVISORY para RATCHET.
//
// Roda o ESLint sobre src+open-sse usando um config flat STANDALONE
// (eslint.sonarjs.config.mjs) que liga APENAS `sonarjs/cognitive-complexity` —
// mantendo a contagem ISOLADA do orçamento de warnings do lint principal (3653).
// mantendo a contagem ISOLADA do orçamento de warnings do lint principal.
//
// Modo advisory: sai com código 0 independente da contagem. Imprime o valor
// para anotação do baseline conceitual. O ratchet INT virá quando o baseline
// for congelado em quality-baseline.json.
// Lê o baseline de quality-baseline.json (metrics.cognitiveComplexity).
// Falha com exit 1 se a contagem SUBIR. Suporta --update.
//
// Saída canônica: cognitiveComplexity=N (parseable por collect-metrics.mjs)
//
// Uso:
// node scripts/check/check-cognitive-complexity.mjs
// node scripts/check/check-cognitive-complexity.mjs --quiet # só a linha canônica
// node scripts/check/check-cognitive-complexity.mjs --update # ratcheta baseline se melhorou
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const UPDATE = process.argv.includes("--update");
const CONFIG_PATH = path.join(ROOT, "eslint.sonarjs.config.mjs");
const ESLINT_BIN = path.join(ROOT, "node_modules", ".bin", "eslint");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "quality-baseline.json")
);
const ESLINT_ARGS = [
"--no-config-lookup",
"--config",
@@ -56,6 +65,23 @@ export function countCognitiveViolations(report) {
return count;
}
/**
* Avalia a contagem atual de violações cognitivas contra o baseline.
* Direction: down (contagem só pode CAIR).
*
* Exported for unit testing.
*
* @param {number} current
* @param {number} baseline
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateCognitiveComplexity(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
function runEslint() {
let stdout;
try {
@@ -73,22 +99,56 @@ function runEslint() {
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(
`[cognitive-complexity] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`
);
process.exit(2);
}
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const baselineMetric = baselineJson.metrics && baselineJson.metrics.cognitiveComplexity;
if (!baselineMetric || typeof baselineMetric.value !== "number") {
process.stderr.write(
"[cognitive-complexity] FAIL — metrics.cognitiveComplexity ausente em quality-baseline.json.\n"
);
process.exit(2);
}
const baselineValue = baselineMetric.value;
const report = runEslint();
const count = countCognitiveViolations(report);
// Canonical machine-readable output consumed by collect-metrics.mjs and shell scripts.
console.log(`cognitiveComplexity=${count}`);
if (!QUIET) {
console.log(
`[cognitive-complexity] advisory — ${count} function(s) exceed the cognitive-complexity threshold (15).`
);
console.log(
`[cognitive-complexity] Annotate this value as the baseline in quality-baseline.json when the INT ratchet is wired.`
`[cognitive-complexity] ${count} function(s) exceed the cognitive-complexity threshold (15).`
);
}
// Canonical machine-readable output consumed by collect-metrics.mjs
console.log(`cognitiveComplexity=${count}`);
const { regressed, improved } = evaluateCognitiveComplexity(count, baselineValue);
if (UPDATE && improved) {
baselineJson.metrics.cognitiveComplexity.value = count;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[cognitive-complexity] baseline ratcheado: ${count} (era ${baselineValue})`);
}
if (regressed) {
process.stderr.write(
`[cognitive-complexity] REGRESSÃO — ${count} violações > baseline ${baselineValue}\n` +
` → Quebre as funções complexas em helpers menores, ou rode\n` +
` 'node scripts/check/check-cognitive-complexity.mjs --update' se a contagem caiu legitimamente.\n`
);
process.exit(1);
}
if (!QUIET) {
console.log(`[cognitive-complexity] OK — ${count} violações (baseline ${baselineValue})`);
}
// Advisory: always exit 0
process.exit(0);
}

View File

@@ -1,8 +1,9 @@
#!/usr/bin/env node
// scripts/check/check-dead-code.mjs
// Gate de dead-code via knip — unused exports, unused files.
// Esta versão é ADVISORY (sai 0 sempre, exceto erro de execução).
// O ratchet no quality-baseline.json entra no bloco INT da Fase 7.
// Fase 7 INT: promovido de ADVISORY para RATCHET bloqueante.
// Lê o baseline de quality-baseline.json (metrics.deadExports), compara e
// falha com exit 1 se a contagem SUBIR. Suporta --update para ratchetar o baseline.
//
// Saída (stdout):
// DEAD_EXPORTS=<n> — exports/re-exports/tipos não utilizados
@@ -11,8 +12,10 @@
//
// Use --json para imprimir o relatório completo do knip em JSON.
// Use --quiet para suprimir logs de diagnóstico.
// Use --update para ratchetar o baseline quando a contagem cair legitimamente.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -20,6 +23,13 @@ const ROOT = process.cwd();
const KNIP_BIN = path.join(ROOT, "node_modules", ".bin", "knip");
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const UPDATE = process.argv.includes("--update");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "quality-baseline.json")
);
/**
* Conta dead exports e dead files a partir do output JSON do knip.
@@ -53,7 +63,15 @@ export function parseKnipMetrics(knipJson) {
// (conservador: só contar quando files[] está presente e populado)
// Dead exports: somar todos os símbolos mortos por tipo de export
const exportFields = ["exports", "types", "nsExports", "nsTypes", "enumMembers", "namespaceMembers", "duplicates"];
const exportFields = [
"exports",
"types",
"nsExports",
"nsTypes",
"enumMembers",
"namespaceMembers",
"duplicates",
];
for (const field of exportFields) {
if (Array.isArray(fileEntry[field])) {
deadExports += fileEntry[field].length;
@@ -68,11 +86,29 @@ export function parseKnipMetrics(knipJson) {
};
}
/**
* Avalia a contagem atual de dead-code total contra o baseline.
* Direction: down (contagem só pode CAIR).
*
* Exported for unit testing.
*
* @param {number} current
* @param {number} baseline
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateDeadCode(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
function runKnip() {
const args = [
"--reporter", "json",
"--reporter",
"json",
"--no-progress",
"--no-exit-code", // não falha por contagem — só coletamos métricas
"--no-exit-code", // não falha por contagem — só coletamos métricas
];
if (!QUIET) {
@@ -109,6 +145,21 @@ function runKnip() {
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(`[dead-code] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`);
process.exit(2);
}
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const baselineMetric = baselineJson.metrics && baselineJson.metrics.deadExports;
if (!baselineMetric || typeof baselineMetric.value !== "number") {
process.stderr.write(
"[dead-code] FAIL — metrics.deadExports ausente em quality-baseline.json.\n"
);
process.exit(2);
}
const baselineValue = baselineMetric.value;
const knipJson = runKnip();
if (PRINT_JSON) {
@@ -123,16 +174,24 @@ function main() {
console.log(`DEAD_FILES=${deadFiles}`);
console.log(`DEAD_TOTAL=${deadTotal}`);
if (!QUIET) {
process.stderr.write(
`[dead-code] exports mortos: ${deadExports} | arquivos mortos: ${deadFiles} | total: ${deadTotal}\n`
);
process.stderr.write(
`[dead-code] ADVISORY — esta versão não falha pela contagem (ratchet entra no INT da Fase 7).\n`
);
const { regressed, improved } = evaluateDeadCode(deadTotal, baselineValue);
if (UPDATE && improved) {
baselineJson.metrics.deadExports.value = deadTotal;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[dead-code] baseline ratcheado: ${deadTotal} (era ${baselineValue})`);
}
// Sai 0 sempre nesta versão (advisory)
if (regressed) {
process.stderr.write(
`[dead-code] REGRESSÃO — ${deadTotal} símbolos mortos > baseline ${baselineValue}\n` +
` → Remova exports/arquivos não utilizados ou rode\n` +
` 'node scripts/check/check-dead-code.mjs --update' se a contagem caiu legitimamente.\n`
);
process.exit(1);
}
console.log(`[dead-code] OK — ${deadTotal} símbolos mortos (baseline ${baselineValue})`);
process.exitCode = 0;
}

View File

@@ -1,11 +1,11 @@
#!/usr/bin/env node
// scripts/check/check-type-coverage.mjs
// Type-coverage ratchet (Task 6 of Fase 7).
// Fase 7 INT: promovido de ADVISORY para RATCHET bloqueante.
//
// Measures the % of typed symbols across the codebase using the `type-coverage`
// tool and prints `typeCoveragePct=<N>`. This is advisory in Phase-INT (exits 0)
// — it complements the per-file explicit-any budget in check-t11-any-budget.mjs
// with a project-wide %-typed view.
// tool and prints `typeCoveragePct=<N>`. Lê o baseline de quality-baseline.json
// (metrics.typeCoveragePct) e falha com exit 1 se a % CAIR além do eps.
//
// tsconfig used: open-sse/tsconfig.json
// - Rationale: the only tsconfig that covers the full open-sse workspace
@@ -15,12 +15,11 @@
// resolves both workspaces correctly and yields a representative global %.
//
// Direction: up (% can only improve; ratchet blocks drops once wired into INT).
// Eps: 0.05 (float noise tolerance — type-coverage may vary by ~0.01% between runs).
//
// Run:
// node scripts/check/check-type-coverage.mjs
// node scripts/check/check-type-coverage.mjs --update # ratchet baseline up
//
// Exit codes: 0 = advisory pass (current version), 1 = ratchet regression.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
@@ -29,6 +28,16 @@ import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const TSCONFIG = path.join(ROOT, "open-sse", "tsconfig.json");
const UPDATE = process.argv.includes("--update");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "quality-baseline.json")
);
// Small epsilon to absorb float noise between runs (type-coverage can vary ~0.01%).
const DEFAULT_EPS = 0.05;
/**
* Parse the JSON output produced by `type-coverage --json-output`.
@@ -54,6 +63,23 @@ export function parseTypeCoverageOutput(jsonText) {
return parsed.percent;
}
/**
* Avalia a % de type-coverage atual contra o baseline.
* Direction: up (% só pode SUBIR; queda além de eps é regressão).
*
* Exported for unit testing.
*
* @param {number} current
* @param {number} baseline
* @param {number} [eps=0] - tolerance for float noise
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateTypeCoverage(current, baseline, eps = 0) {
const regressed = current < baseline - eps;
const improved = current > baseline + eps;
return { regressed, improved };
}
function runTypeCoverage() {
const typeCoverageBin = path.join(ROOT, "node_modules", ".bin", "type-coverage");
@@ -82,6 +108,22 @@ function runTypeCoverage() {
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(`[type-coverage] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`);
process.exit(2);
}
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const baselineMetric = baselineJson.metrics && baselineJson.metrics.typeCoveragePct;
if (!baselineMetric || typeof baselineMetric.value !== "number") {
process.stderr.write(
"[type-coverage] FAIL — metrics.typeCoveragePct ausente em quality-baseline.json.\n"
);
process.exit(2);
}
const baselineValue = baselineMetric.value;
const eps = typeof baselineMetric.eps === "number" ? baselineMetric.eps : DEFAULT_EPS;
console.log("[type-coverage] Running type-coverage (this may take ~30-60 s)…");
console.log(`[type-coverage] tsconfig: ${path.relative(ROOT, TSCONFIG)}`);
@@ -89,17 +131,33 @@ function main() {
try {
pct = runTypeCoverage();
} catch (err) {
console.error(`[type-coverage] FAIL — ${err.message}`);
// Advisory: exit 0 so CI is not blocked until INT wiring.
process.exit(0);
process.stderr.write(`[type-coverage] FAIL — ${err.message}\n`);
process.exit(2);
}
// Canonical output line consumed by collect-metrics.mjs and shell scripts.
console.log(`typeCoveragePct=${pct}`);
console.log(`[type-coverage] Advisory OK — ${pct}% symbols typed (direction: up)`);
// Advisory: always exit 0 in this version.
// Once wired into quality-baseline.json (INT), exit 1 on regression here.
const { regressed, improved } = evaluateTypeCoverage(pct, baselineValue, eps);
if (UPDATE && improved) {
baselineJson.metrics.typeCoveragePct.value = pct;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[type-coverage] baseline ratcheado: ${pct} (era ${baselineValue})`);
}
if (regressed) {
process.stderr.write(
`[type-coverage] REGRESSÃO — ${pct}% < baseline ${baselineValue}% (eps=${eps})\n` +
` → Adicione anotações de tipo ou rode\n` +
` 'node scripts/check/check-type-coverage.mjs --update' se a % subiu legitimamente.\n`
);
process.exit(1);
}
console.log(
`[type-coverage] OK — ${pct}% symbols typed (baseline ${baselineValue}%, eps=${eps})`
);
process.exit(0);
}

View File

@@ -0,0 +1,79 @@
import { test } from "node:test";
import assert from "node:assert";
import {
countCognitiveViolations,
evaluateCognitiveComplexity,
} from "../../scripts/check/check-cognitive-complexity.mjs";
// The .mjs module has no .d.ts; type the exported functions locally.
type EslintReport = Array<{ messages: Array<{ ruleId: string }> }>;
type CogComplexityVerdict = { regressed: boolean; improved: boolean };
const count = countCognitiveViolations as (report: EslintReport) => number;
const evaluate = evaluateCognitiveComplexity as (
current: number,
baseline: number
) => CogComplexityVerdict;
// --- countCognitiveViolations tests ---
test("countCognitiveViolations: empty report returns 0", () => {
assert.equal(count([]), 0);
});
test("countCognitiveViolations: counts sonarjs/cognitive-complexity messages", () => {
const report: EslintReport = [
{
messages: [
{ ruleId: "sonarjs/cognitive-complexity" },
{ ruleId: "no-unused-vars" }, // should not count
],
},
{
messages: [
{ ruleId: "sonarjs/cognitive-complexity" },
{ ruleId: "sonarjs/cognitive-complexity" },
],
},
];
assert.equal(count(report), 3);
});
test("countCognitiveViolations: no matching rules returns 0", () => {
const report: EslintReport = [{ messages: [{ ruleId: "no-eval" }, { ruleId: "no-console" }] }];
assert.equal(count(report), 0);
});
// --- evaluateCognitiveComplexity tests ---
const FROZEN = 738;
test("evaluateCognitiveComplexity: equal to baseline passes", () => {
const r = evaluate(FROZEN, FROZEN);
assert.equal(r.regressed, false);
assert.equal(r.improved, false);
});
test("evaluateCognitiveComplexity: one more violation is a regression", () => {
const r = evaluate(FROZEN + 1, FROZEN);
assert.equal(r.regressed, true);
assert.equal(r.improved, false);
});
test("evaluateCognitiveComplexity: fewer violations is an improvement", () => {
const r = evaluate(FROZEN - 1, FROZEN);
assert.equal(r.regressed, false);
assert.equal(r.improved, true);
});
test("evaluateCognitiveComplexity: zero violations is maximum improvement", () => {
const r = evaluate(0, FROZEN);
assert.equal(r.regressed, false);
assert.equal(r.improved, true);
});
test("evaluateCognitiveComplexity: strict integer comparison", () => {
assert.equal(evaluate(11, 10).regressed, true);
assert.equal(evaluate(10, 10).regressed, false);
assert.equal(evaluate(9, 10).regressed, false);
});

View File

@@ -0,0 +1,99 @@
import { test } from "node:test";
import assert from "node:assert";
import { parseKnipMetrics, evaluateDeadCode } from "../../scripts/check/check-dead-code.mjs";
// The .mjs module has no .d.ts; type the exported functions locally.
type KnipResult = { deadExports: number; deadFiles: number; deadTotal: number };
type DeadCodeVerdict = { regressed: boolean; improved: boolean };
const parse = parseKnipMetrics as (json: unknown) => KnipResult;
const evaluate = evaluateDeadCode as (current: number, baseline: number) => DeadCodeVerdict;
// --- parseKnipMetrics tests ---
test("parseKnipMetrics: null/undefined returns zeros", () => {
const r = parse(null);
assert.deepStrictEqual(r, { deadExports: 0, deadFiles: 0, deadTotal: 0 });
});
test("parseKnipMetrics: empty issues array returns zeros", () => {
const r = parse({ issues: [] });
assert.deepStrictEqual(r, { deadExports: 0, deadFiles: 0, deadTotal: 0 });
});
test("parseKnipMetrics: counts exports in each category", () => {
const knipJson = {
issues: [
{
exports: [{ name: "foo" }, { name: "bar" }],
types: [{ name: "MyType" }],
nsExports: [],
nsTypes: [],
},
],
};
const r = parse(knipJson);
assert.equal(r.deadExports, 3); // 2 exports + 1 type
assert.equal(r.deadFiles, 0);
assert.equal(r.deadTotal, 3);
});
test("parseKnipMetrics: counts dead files", () => {
const knipJson = {
issues: [
{
files: ["a.ts", "b.ts"],
},
],
};
const r = parse(knipJson);
assert.equal(r.deadFiles, 2);
assert.equal(r.deadTotal, 2);
});
test("parseKnipMetrics: combines exports + files across multiple entries", () => {
const knipJson = {
issues: [
{ exports: [{ name: "x" }], files: ["dead.ts"] },
{ types: [{ name: "T" }], nsTypes: [{ name: "N" }] },
],
};
const r = parse(knipJson);
assert.equal(r.deadExports, 3); // x + T + N
assert.equal(r.deadFiles, 1);
assert.equal(r.deadTotal, 4);
});
// --- evaluateDeadCode tests ---
const FROZEN = 327;
test("evaluateDeadCode: equal to baseline passes", () => {
const r = evaluate(FROZEN, FROZEN);
assert.equal(r.regressed, false);
assert.equal(r.improved, false);
});
test("evaluateDeadCode: one more dead symbol is a regression", () => {
const r = evaluate(FROZEN + 1, FROZEN);
assert.equal(r.regressed, true);
assert.equal(r.improved, false);
});
test("evaluateDeadCode: fewer dead symbols is an improvement", () => {
const r = evaluate(FROZEN - 1, FROZEN);
assert.equal(r.regressed, false);
assert.equal(r.improved, true);
});
test("evaluateDeadCode: zero dead symbols is a maximum improvement", () => {
const r = evaluate(0, FROZEN);
assert.equal(r.regressed, false);
assert.equal(r.improved, true);
});
test("evaluateDeadCode: strict integer comparison — any increase regresses", () => {
assert.equal(evaluate(11, 10).regressed, true);
assert.equal(evaluate(10, 10).regressed, false);
assert.equal(evaluate(9, 10).regressed, false);
});

View File

@@ -0,0 +1,78 @@
import { test } from "node:test";
import assert from "node:assert";
import {
parseTypeCoverageOutput,
evaluateTypeCoverage,
} from "../../scripts/check/check-type-coverage.mjs";
// The .mjs module has no .d.ts; type the exported functions locally.
type TypeCoverageVerdict = { regressed: boolean; improved: boolean };
const parse = parseTypeCoverageOutput as (jsonText: string) => number;
const evaluate = evaluateTypeCoverage as (
current: number,
baseline: number,
eps?: number
) => TypeCoverageVerdict;
// --- parseTypeCoverageOutput tests ---
test("parseTypeCoverageOutput: parses valid JSON with percent field", () => {
const pct = parse(JSON.stringify({ percent: 92.17, atLeast: 90 }));
assert.equal(pct, 92.17);
});
test("parseTypeCoverageOutput: throws on invalid JSON", () => {
assert.throws(() => parse("not json"), /Failed to parse JSON output/);
});
test("parseTypeCoverageOutput: throws if percent field is missing", () => {
assert.throws(() => parse(JSON.stringify({ atLeast: 90 })), /missing numeric 'percent' field/);
});
test("parseTypeCoverageOutput: throws if percent is not a number", () => {
assert.throws(
() => parse(JSON.stringify({ percent: "92.17" })),
/missing numeric 'percent' field/
);
});
// --- evaluateTypeCoverage tests (direction = up: drops are regressions) ---
const FROZEN = 92.17;
const EPS = 0.05; // small tolerance for float noise
test("evaluateTypeCoverage: equal to baseline passes (within eps)", () => {
const r = evaluate(FROZEN, FROZEN, EPS);
assert.equal(r.regressed, false);
assert.equal(r.improved, false);
});
test("evaluateTypeCoverage: drop beyond eps is a regression", () => {
const r = evaluate(FROZEN - 0.1, FROZEN, EPS);
assert.equal(r.regressed, true);
assert.equal(r.improved, false);
});
test("evaluateTypeCoverage: drop within eps is not a regression", () => {
const r = evaluate(FROZEN - 0.03, FROZEN, EPS);
assert.equal(r.regressed, false);
});
test("evaluateTypeCoverage: improvement passes and is flagged", () => {
const r = evaluate(FROZEN + 1, FROZEN, EPS);
assert.equal(r.regressed, false);
assert.equal(r.improved, true);
});
test("evaluateTypeCoverage: 100% coverage is maximum improvement", () => {
const r = evaluate(100, FROZEN, EPS);
assert.equal(r.regressed, false);
assert.equal(r.improved, true);
});
test("evaluateTypeCoverage: no eps defaults to 0 (exact comparison)", () => {
// With eps=0, even a tiny drop regresses
const r = evaluate(FROZEN - 0.01, FROZEN);
assert.equal(r.regressed, true);
});