diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 285ab51bef..25badf8168 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/complexity-baseline.json b/complexity-baseline.json index 6305923730..8b3463ccd3 100644 --- a/complexity-baseline.json +++ b/complexity-baseline.json @@ -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." } diff --git a/file-size-baseline.json b/file-size-baseline.json index c20eeccadd..373ce79811 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -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, diff --git a/quality-baseline.json b/quality-baseline.json index 2488a1c320..41179c15f3 100644 --- a/quality-baseline.json +++ b/quality-baseline.json @@ -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." } diff --git a/scripts/check/check-cognitive-complexity.mjs b/scripts/check/check-cognitive-complexity.mjs index 014e5f8e2f..90ea5d3e55 100644 --- a/scripts/check/check-cognitive-complexity.mjs +++ b/scripts/check/check-cognitive-complexity.mjs @@ -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); } diff --git a/scripts/check/check-dead-code.mjs b/scripts/check/check-dead-code.mjs index c6e7028bb1..3e93890a07 100644 --- a/scripts/check/check-dead-code.mjs +++ b/scripts/check/check-dead-code.mjs @@ -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= — 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; } diff --git a/scripts/check/check-type-coverage.mjs b/scripts/check/check-type-coverage.mjs index 6e536f2b77..f6291c77ba 100644 --- a/scripts/check/check-type-coverage.mjs +++ b/scripts/check/check-type-coverage.mjs @@ -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=`. 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=`. 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); } diff --git a/tests/unit/check-cognitive-complexity.test.ts b/tests/unit/check-cognitive-complexity.test.ts new file mode 100644 index 0000000000..16b8fca929 --- /dev/null +++ b/tests/unit/check-cognitive-complexity.test.ts @@ -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); +}); diff --git a/tests/unit/check-dead-code.test.ts b/tests/unit/check-dead-code.test.ts new file mode 100644 index 0000000000..ba1f5fdeef --- /dev/null +++ b/tests/unit/check-dead-code.test.ts @@ -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); +}); diff --git a/tests/unit/check-type-coverage.test.ts b/tests/unit/check-type-coverage.test.ts new file mode 100644 index 0000000000..814106992a --- /dev/null +++ b/tests/unit/check-type-coverage.test.ts @@ -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); +});