ci(quality): cut PR gate wall time without dropping protection (#6716)

Collapse duplicate CI spend while keeping each gate's existence reason:

- quality.yml: TIA __RUN_ALL__ defers full unit to fast-unit 4-shard (#6781);
  path filters via classify-pr-changes; docs-gates split; draft skip
- ci.yml: wire docs/i18n/code path filters; ESLint JSON artifact for quality-gate;
  drop advisory typecheck:noimplicit; float actions/cache@v6
- TIA parity: memory/usage/combo/serial; **/*.test.mjs any depth; electron/bin
  no longer force unit __RUN_ALL__
- check:complexity-ratchets: one ESLint walk, ruleId-isolated baselines + cache
- check:api-docs-refs + lib/apiRoutes: shared API route inventory
- husky pre-push: intentionally light (gates live in pre-commit); CLAUDE.md +
  QUALITY_GATES.md docs synced
- collect-metrics / lint:json: path.resolve cache path; Windows-safe eslint bin
- env-doc allowlist for ESLINT_RESULTS_JSON / COMPLEXITY_ESLINT_REPORT
- release-green --full-ci expects check:api-docs-refs (not docs-symbols alone)

Tests: select-impacted, classify-pr-changes, api-routes lib, complexity-rule-count,
validate-release-green.

Reconciled after #6781 (fast-unit 2→4 shards) per maintainer request on #6716.

Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
backryun
2026-07-11 20:03:11 +09:00
committed by GitHub
parent 2263377530
commit d1d75fdbf4
31 changed files with 1196 additions and 333 deletions

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* Combined anti-hallucination gate: OpenAPI paths + docs prose /api refs.
*
* Existence reasons (both still enforced):
* - openapi-routes: invented/obsolete paths in docs/openapi.yaml
* - docs-symbols: invented/obsolete /api paths in docs markdown *
* Shared walk of src/app/api (lib/apiRoutes.mjs) — one filesystem inventory,
* two independent failure messages. Prefer this on docs-gates CI; individual
* scripts remain for targeted local runs.
*/
import { pathToFileURL } from "node:url";
import { collectApiRouteFiles, collectApiRouteUrlPaths } from "./lib/apiRoutes.mjs";
import { runOpenapiRoutesCheck } from "./check-openapi-routes.mjs";
import { runDocsSymbolsCheck } from "./check-docs-symbols.mjs";
function main() {
const implPaths = collectApiRouteUrlPaths();
const routeFiles = collectApiRouteFiles();
const openapi = runOpenapiRoutesCheck({ implPaths });
const docs = runDocsSymbolsCheck({ routeFiles });
if (openapi.ok) console.log(openapi.message);
else console.error(openapi.message);
if (docs.ok) console.log(docs.message);
else console.error(docs.message);
process.exit(openapi.ok && docs.ok ? 0 : 1);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -1,32 +1,21 @@
#!/usr/bin/env node
// scripts/check/check-cognitive-complexity.mjs
// 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.
//
// 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";
// Shares ESLint walk with check-complexity via complexityEslintReport.mjs.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
countCognitiveViolations,
getComplexityEslintReport,
} from "./complexityEslintReport.mjs";
// Re-export for existing unit tests.
export { countCognitiveViolations };
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")
@@ -34,43 +23,8 @@ const BASELINE_PATH = path.resolve(
: path.join(ROOT, "config/quality/quality-baseline.json")
);
const ESLINT_ARGS = [
"--no-config-lookup",
"--config",
CONFIG_PATH,
"--format",
"json",
"src",
"open-sse",
];
/**
* Parses the ESLint JSON output (array of file results) and counts total
* `sonarjs/cognitive-complexity` violations.
*
* Exported so unit tests can call it directly with synthetic data.
*
* @param {Array<{messages: Array<{ruleId: string}>}>} report
* @returns {number}
*/
export function countCognitiveViolations(report) {
let count = 0;
for (const file of report) {
for (const msg of file.messages) {
if (msg.ruleId === "sonarjs/cognitive-complexity") {
count++;
}
}
}
return count;
}
/**
* 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 }}
@@ -82,22 +36,6 @@ export function evaluateCognitiveComplexity(current, baseline) {
};
}
function runEslint() {
let stdout;
try {
stdout = execFileSync(ESLINT_BIN, ESLINT_ARGS, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
} catch (err) {
// ESLint exits non-zero when there are lint errors; the JSON report is still
// in stdout. Re-throw only if there is no parseable output.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) throw err;
}
return JSON.parse(stdout);
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(
@@ -116,10 +54,9 @@ function main() {
}
const baselineValue = baselineMetric.value;
const report = runEslint();
const report = getComplexityEslintReport();
const count = countCognitiveViolations(report);
// Canonical machine-readable output consumed by collect-metrics.mjs and shell scripts.
console.log(`cognitiveComplexity=${count}`);
if (!QUIET) {

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* One ESLint walk → both complexity ratchets.
*
* Existence reasons (unchanged):
* - cyclomatic + max-lines vs complexity-baseline.json
* - cognitive-complexity vs quality-baseline metrics.cognitiveComplexity
*
* CI should call this instead of sequential check:complexity + check:cognitive
* so PR→release / quality-gate pay for one tree walk, not two.
*/
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { evaluateComplexity } from "./check-complexity.mjs";
import { evaluateCognitiveComplexity } from "./check-cognitive-complexity.mjs";
import {
countCognitiveViolations,
countComplexityViolations,
getComplexityEslintReport,
} from "./complexityEslintReport.mjs";
const ROOT = process.cwd();
const UPDATE = process.argv.includes("--update");
const COMPLEXITY_BASELINE = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/complexity-baseline.json")
);
const QUALITY_BASELINE = path.join(ROOT, "config/quality/quality-baseline.json");
function main() {
if (!fs.existsSync(COMPLEXITY_BASELINE)) {
console.error(`[complexity-ratchets] FAIL — complexity-baseline.json ausente.`);
process.exit(2);
}
if (!fs.existsSync(QUALITY_BASELINE)) {
console.error(`[complexity-ratchets] FAIL — quality-baseline.json ausente.`);
process.exit(2);
}
const report = getComplexityEslintReport();
const complexityCount = countComplexityViolations(report);
const cognitiveCount = countCognitiveViolations(report);
// Machine-readable lines for collect-metrics / scripts
console.log(`complexity=${complexityCount}`);
console.log(`cognitiveComplexity=${cognitiveCount}`);
const complexityBaseline = JSON.parse(fs.readFileSync(COMPLEXITY_BASELINE, "utf8"));
const qualityBaseline = JSON.parse(fs.readFileSync(QUALITY_BASELINE, "utf8"));
const cognitiveMetric = qualityBaseline.metrics?.cognitiveComplexity;
if (!cognitiveMetric || typeof cognitiveMetric.value !== "number") {
console.error(
"[complexity-ratchets] FAIL — metrics.cognitiveComplexity ausente em quality-baseline.json."
);
process.exit(2);
}
const cyc = evaluateComplexity(complexityCount, complexityBaseline.count);
const cog = evaluateCognitiveComplexity(cognitiveCount, cognitiveMetric.value);
if (UPDATE && cyc.improved) {
console.log(
`[complexity] baseline ratcheado: ${complexityCount} (era ${complexityBaseline.count})`
);
complexityBaseline.count = complexityCount;
fs.writeFileSync(COMPLEXITY_BASELINE, JSON.stringify(complexityBaseline, null, 2) + "\n");
}
if (UPDATE && cog.improved) {
console.log(
`[cognitive-complexity] baseline ratcheado: ${cognitiveCount} (era ${cognitiveMetric.value})`
);
qualityBaseline.metrics.cognitiveComplexity.value = cognitiveCount;
fs.writeFileSync(QUALITY_BASELINE, JSON.stringify(qualityBaseline, null, 2) + "\n");
}
let failed = false;
if (cyc.regressed) {
console.error(
`[complexity] REGRESSÃO — ${complexityCount} violações > baseline ${complexityBaseline.count}`
);
failed = true;
} else {
console.log(
`[complexity] OK — ${complexityCount} violações (baseline ${complexityBaseline.count})`
);
}
if (cog.regressed) {
console.error(
`[cognitive-complexity] REGRESSÃO — ${cognitiveCount} violações > baseline ${cognitiveMetric.value}`
);
failed = true;
} else {
console.log(
`[cognitive-complexity] OK — ${cognitiveCount} violações (baseline ${cognitiveMetric.value})`
);
}
process.exit(failed ? 1 : 0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -1,19 +1,17 @@
#!/usr/bin/env node
// scripts/check/check-complexity.mjs
// Catraca de complexidade de código. Roda o ESLint sobre src+open-sse usando um config
// flat STANDALONE (eslint.complexity.config.mjs) que liga APENAS duas regras CORE do
// ESLint — `complexity` (ciclomática) e `max-lines-per-function` (tamanho de função) —
// e compara a contagem total de violações contra um baseline congelado
// (complexity-baseline.json). Falha se a contagem SUBIR. Completa a dimensão
// "complexity" do snapshot de qualidade, ao lado de duplicação/tamanho-de-arquivo.
//
// O config dedicado evita poluir a contagem de warnings do lint principal (ratcheada
// em exatamente 3482): este gate roda isolado, com seu próprio par de regras. --update
// ratcheta (a contagem só pode CAIR).
// Catraca de complexidade de código (cyclomatic + max-lines-per-function).
// Shares one ESLint walk with cognitive-complexity via complexityEslintReport.mjs
// / eslint.complexity-ratchets.config.mjs. Counts by ruleId so cognitive
// violations never inflate this baseline.
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import {
ESLINT_ARGS,
countComplexityViolations,
getComplexityEslintReport,
} from "./complexityEslintReport.mjs";
const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
@@ -22,24 +20,9 @@ const BASELINE_PATH = path.resolve(
: path.join(ROOT, "config/quality/complexity-baseline.json")
);
const UPDATE = process.argv.includes("--update");
const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs");
// Exported for the gate's own unit test (tests/unit/build/check-complexity.test.ts), which
// locks the scan scope to the one documented in eslint.complexity.config.mjs `files` and in
// complexity-baseline.json. The positional paths MUST match that scope (src+open-sse+electron+bin)
// — ESLint flat config only walks the directories passed here, so a `files` glob for bin/electron
// is inert unless the directory is also passed as a positional argument.
export const ESLINT_ARGS = [
"eslint",
"--no-config-lookup",
"--config",
CONFIG_PATH,
"--format",
"json",
"src",
"open-sse",
"electron",
"bin",
];
// Re-export for tests that lock scan scope (src+open-sse+electron+bin).
export { ESLINT_ARGS };
/** Avalia a contagem atual de violações contra o baseline. */
export function evaluateComplexity(current, baseline) {
@@ -50,20 +33,7 @@ export function evaluateComplexity(current, baseline) {
}
function measureComplexityCount() {
let stdout;
try {
stdout = execFileSync("npx", ["--yes", ...ESLINT_ARGS], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
} catch (err) {
// ESLint sai com código !=0 quando há erros (e nossas regras são "error"); o relatório
// JSON ainda vai no stdout. Só relançamos se não houver stdout parseável.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) throw err;
}
const report = JSON.parse(stdout);
return report.reduce((sum, file) => sum + file.errorCount, 0);
return countComplexityViolations(getComplexityEslintReport());
}
function main() {

View File

@@ -19,11 +19,11 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
import { reportStaleEntries } from "./lib/allowlist.mjs";
import { collectApiRouteFiles } from "./lib/apiRoutes.mjs";
const ROOT = process.cwd();
const DOCS = path.join(ROOT, "docs");
const API = path.join(ROOT, "src/app/api");
// Padrões que NÃO são rotas internas do OmniRoute (ruído estrutural, não drift).
// Adicione aqui (com justificativa) em vez da allowlist quando uma categoria gera
@@ -73,10 +73,9 @@ function walk(dir, filter, acc = []) {
return acc;
}
export function collectRouteFiles() {
return new Set(
walk(API, (n) => /^route\.tsx?$/.test(n)).map((p) => path.relative(ROOT, p).replace(/\\/g, "/"))
);
/** @deprecated prefer collectApiRouteFiles from lib/apiRoutes.mjs — re-export for tests. */
export function collectRouteFiles(root = ROOT) {
return collectApiRouteFiles(root);
}
/** Normaliza um segmento dinâmico ({param} / [param] / [...param] / :param) para wildcard. */
@@ -177,45 +176,67 @@ export function findStaleDocApiRefs(docPathsByFile, routeFiles, allowlist) {
return misses;
}
function main() {
const routeFiles = collectRouteFiles();
/**
* @param {{ root?: string, routeFiles?: Set<string> }} [opts]
* @returns {{ ok: boolean, exitCode: number, message: string }}
*/
export function runDocsSymbolsCheck(opts = {}) {
const root = opts.root || ROOT;
const docsDir = path.join(root, "docs");
const routeFiles = opts.routeFiles || collectApiRouteFiles(root);
// docs/i18n/** são espelhos auto-gerados das docs canônicas — validar só o canônico
// evita 40× de ruído duplicado (e os mirrors herdam qualquer fix do canônico).
// docs/superpowers/** são planos internos de implementação (snapshots históricos
// de intenção — podem citar rotas planejadas/abandonadas), não claims sobre o
// código atual; fora do escopo do gate (drift surgiu no ciclo v3.8.18).
const docFiles = walk(DOCS, (n) => /\.md$/.test(n)).filter((f) => {
const rel = path.relative(ROOT, f).replace(/\\/g, "/");
const docFiles = walk(docsDir, (n) => /\.md$/.test(n)).filter((f) => {
const rel = path.relative(root, f).replace(/\\/g, "/");
return !rel.startsWith("docs/i18n/") && !rel.startsWith("docs/superpowers/");
});
const docPathsByFile = docFiles.map((f) => ({
file: path.relative(ROOT, f).replace(/\\/g, "/"),
file: path.relative(root, f).replace(/\\/g, "/"),
paths: extractDocApiPaths(fs.readFileSync(f, "utf8")),
}));
// Live misses BEFORE allowlist filtering — used for stale-enforcement.
// The paths (not "file → path" strings) are the unit that the allowlist keys on.
const allMisses = findStaleDocApiRefs(docPathsByFile, routeFiles, new Set());
const liveMissPaths = allMisses.map((m) => m.split(" → ")[1]);
assertNoStale(KNOWN_STALE_DOC_REFS, liveMissPaths, "check-docs-symbols");
const stale = reportStaleEntries(KNOWN_STALE_DOC_REFS, liveMissPaths, "check-docs-symbols");
const misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS);
const parts = [];
if (stale.length) {
parts.push(
`[check-docs-symbols] ${stale.length} entrada(s) obsoleta(s) na allowlist ` +
`— a violação foi corrigida; REMOVA a entrada para travar a correção:\n` +
stale.map((e) => `${e}`).join("\n")
);
}
if (misses.length) {
console.error(
parts.push(
`[check-docs-symbols] ${misses.length} ref(s) /api em docs sem rota real:\n` +
misses.map((m) => " ✗ " + m).join("\n") +
`\n → crie o route.ts, corrija o path na doc, ou (se for upstream/placeholder)` +
` adicione um padrão a IGNORE com justificativa. NÃO adicione à allowlist sem` +
` confirmar que é drift pré-existente real.`
);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
if (parts.length) {
return { ok: false, exitCode: 1, message: parts.join("\n") };
}
return {
ok: true,
exitCode: 0,
message:
`[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` +
`${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas`
);
}
`${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas`,
};
}
function main() {
const result = runDocsSymbolsCheck();
if (result.ok) console.log(result.message);
else console.error(result.message);
process.exit(result.exitCode);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -51,6 +51,9 @@ const IGNORE_FROM_CODE = new Set([
"CI",
"GITHUB_ACTIONS",
"RUNNER_OS",
// Quality-gate harness knobs (optional cache/report paths for CI scripts — not product config).
"ESLINT_RESULTS_JSON",
"COMPLEXITY_ESLINT_REPORT",
// Agent environment / system execution paths.
"PROJECT_ROOT",
"ARTIFACTS_DIR",

View File

@@ -10,9 +10,10 @@
import fs from "node:fs";
import path from "node:path";
import * as yaml from "js-yaml";
import { apiRoot, collectApiRouteUrlPaths } from "./lib/apiRoutes.mjs";
const ROOT = process.cwd();
const API_ROOT = path.join(ROOT, "src", "app", "api");
const API_ROOT = apiRoot(ROOT);
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
// Floor recorded on 2026-05-26 for release/v3.8.4: 137/365 routes documented.
// The original ≥99% target tracks the OpenAPI audit follow-up (#2701);
@@ -21,30 +22,6 @@ const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
// instead of the absolute target. Raise this back to 99 once the backlog clears.
const THRESHOLD = 36;
function collectRoutePaths(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const paths = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
paths.push(...collectRoutePaths(fullPath));
continue;
}
if (entry.isFile() && entry.name === "route.ts") {
const apiPath = path
.dirname(fullPath)
.replace(API_ROOT, "")
.replace(/\[([^\]]+)\]/g, "{$1}");
paths.push(`/api${apiPath}`);
}
}
return paths;
}
function normalizePath(p) {
return p.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}").replace(/\[([^\]]+)\]/g, "{$1}");
}
if (!fs.existsSync(API_ROOT)) {
console.error(`[openapi-coverage] FAIL — API root not found: ${API_ROOT}`);
process.exit(1);
@@ -55,7 +32,7 @@ if (!fs.existsSync(OPENAPI_PATH)) {
process.exit(1);
}
const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath).sort();
const implementedPaths = collectApiRouteUrlPaths(ROOT).sort();
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const documentedPaths = new Set(Object.keys(raw.paths || {}));

View File

@@ -10,10 +10,10 @@ import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as yaml from "js-yaml";
import { assertNoStale } from "./lib/allowlist.mjs";
import { reportStaleEntries } from "./lib/allowlist.mjs";
import { apiRoot, collectApiRouteUrlPaths } from "./lib/apiRoutes.mjs";
const ROOT = process.cwd();
const API_ROOT = path.join(ROOT, "src", "app", "api");
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
// Entradas da spec sem rota real, congeladas para triagem (catraca: bloqueia NOVAS).
@@ -33,51 +33,68 @@ export function findSpecPathsWithoutRoute(specPaths, implPaths) {
return specPaths.filter((p) => !impl.has(normalizeParams(p)));
}
function collectRoutePaths(dir) {
const paths = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
paths.push(...collectRoutePaths(full));
} else if (entry.isFile() && entry.name === "route.ts") {
const apiPath = path
.dirname(full)
.replace(API_ROOT, "")
.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}")
.replace(/\[([^\]]+)\]/g, "{$1}");
paths.push(`/api${apiPath}`);
}
/**
* @param {{ root?: string, openapiPath?: string, implPaths?: string[] }} [opts]
* @returns {{ ok: boolean, exitCode: number, message: string }}
*/
export function runOpenapiRoutesCheck(opts = {}) {
const root = opts.root || ROOT;
const openapiPath = opts.openapiPath || path.join(root, "docs", "openapi.yaml");
if (!fs.existsSync(openapiPath)) {
return {
ok: false,
exitCode: 1,
message: `[openapi-routes] FAIL — openapi.yaml não encontrado: ${openapiPath}`,
};
}
if (!fs.existsSync(apiRoot(root))) {
return {
ok: false,
exitCode: 1,
message: `[openapi-routes] FAIL — API root not found: ${apiRoot(root)}`,
};
}
return paths;
}
function main() {
if (!fs.existsSync(OPENAPI_PATH)) {
console.error(`[openapi-routes] FAIL — openapi.yaml não encontrado: ${OPENAPI_PATH}`);
process.exit(1);
}
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const raw = yaml.load(fs.readFileSync(openapiPath, "utf-8"));
const specPaths = Object.keys(raw.paths || {}).filter((p) => p.startsWith("/api"));
const implPaths = collectRoutePaths(API_ROOT);
const implPaths = opts.implPaths || collectApiRouteUrlPaths(root);
// Live orphans BEFORE allowlist filtering (needed for stale-enforcement).
const liveOrphans = findSpecPathsWithoutRoute(specPaths, implPaths);
assertNoStale(KNOWN_STALE_SPEC, liveOrphans, "openapi-routes");
const stale = reportStaleEntries(KNOWN_STALE_SPEC, liveOrphans, "openapi-routes");
const orphans = liveOrphans.filter((p) => !KNOWN_STALE_SPEC.has(p));
const parts = [];
if (stale.length) {
parts.push(
`[openapi-routes] ${stale.length} entrada(s) obsoleta(s) na allowlist ` +
`— a violação foi corrigida; REMOVA a entrada para travar a correção:\n` +
stale.map((e) => `${e}`).join("\n")
);
}
if (orphans.length) {
console.error(
parts.push(
`[openapi-routes] ${orphans.length} path(s) documentado(s) sem rota real:\n` +
orphans.map((p) => " ✗ " + p).join("\n") +
`\n → crie a rota, corrija/remova a entrada na spec, ou adicione a KNOWN_STALE_SPEC com justificativa.`
);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)`
);
if (parts.length) {
return { ok: false, exitCode: 1, message: parts.join("\n") };
}
return {
ok: true,
exitCode: 0,
message: `[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)`,
};
}
function main() {
// Keep assertNoStale side-effect path for CLI parity with other gates when
// runOpenapiRoutesCheck is not used alone — here we print structured result.
const result = runOpenapiRoutesCheck();
if (result.ok) console.log(result.message);
else console.error(result.message);
process.exit(result.exitCode);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

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

View File

@@ -0,0 +1,82 @@
/**
* Shared filesystem inventory of Next.js App Router API routes.
*
* Existence reason: openapi-routes (spec→route), docs-symbols (prose→route),
* and openapi-coverage (route→spec %) all need the same walk of src/app/api.
* One collector keeps path normalization consistent and avoids triple walks
* when a combined gate runs them together.
*/
import fs from "node:fs";
import path from "node:path";
/**
* @param {string} [root] repo root
* @returns {string} absolute path to src/app/api
*/
export function apiRoot(root = process.cwd()) {
return path.join(root, "src", "app", "api");
}
/**
* Convert a directory under src/app/api (the folder that contains route.ts)
* to an OpenAPI-style /api/... path.
* Dynamic segments: [id] → {id}, [...slug] → {slug}.
*
* @param {string} routeDir absolute directory containing route.ts
* @param {string} apiRootAbs absolute src/app/api
* @returns {string}
*/
export function toApiUrlPath(routeDir, apiRootAbs) {
const rel = path.relative(apiRootAbs, routeDir).replace(/\\/g, "/");
if (!rel || rel === ".") return "/api";
const normalized = rel
.replace(/\[\.\.\.([^\]]+)\]/g, "{$1}")
.replace(/\[([^\]]+)\]/g, "{$1}");
return `/api/${normalized}`;
}
/**
* Walk src/app/api for route.ts(x) → OpenAPI-style URL paths.
* @param {string} [root]
* @returns {string[]}
*/
export function collectApiRouteUrlPaths(root = process.cwd()) {
const API = apiRoot(root);
if (!fs.existsSync(API)) return [];
const out = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && /^route\.tsx?$/.test(entry.name)) {
out.push(toApiUrlPath(path.dirname(full), API));
}
}
}
walk(API);
return out;
}
/**
* Walk src/app/api → relative repo paths to route.ts (docs-symbols resolver).
* @param {string} [root]
* @returns {Set<string>}
*/
export function collectApiRouteFiles(root = process.cwd()) {
const API = apiRoot(root);
const out = new Set();
if (!fs.existsSync(API)) return out;
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && /^route\.tsx?$/.test(entry.name)) {
out.add(path.relative(root, full).replace(/\\/g, "/"));
}
}
}
walk(API);
return out;
}