mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Release v3.8.24 (#3747)
Release v3.8.24 — see CHANGELOG.md [3.8.24] for the full notes and the PR description for the contributors hall. Integration of release/v3.8.24 into main.
This commit is contained in:
committed by
GitHub
parent
420d62b420
commit
76a07cf7a5
@@ -2,6 +2,8 @@
|
||||
// scripts/quality/check-quality-ratchet.mjs
|
||||
// Catraca genérica multi-métrica. Clona o espírito de check-t11-any-budget.mjs:
|
||||
// um baseline congelado por métrica; falha em qualquer regressão; só anda num sentido.
|
||||
//
|
||||
// v2 (6A.5): --require-tighten, eps por métrica, warning de métricas órfãs.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -18,7 +20,13 @@ const UPDATE = process.argv.includes("--update");
|
||||
// Uso local: cobertura só existe no CI; localmente quality:gate roda com este flag.
|
||||
// No CI o job quality-gate roda SEM o flag (estrito — baixa o coverage mergeado antes).
|
||||
const ALLOW_MISSING = process.argv.includes("--allow-missing");
|
||||
const EPS = 0.01;
|
||||
// --require-tighten: falha quando uma métrica melhorou além de tightenSlack sem que o
|
||||
// baseline tenha sido apertado. Garante que melhorias permanentes sejam capturadas.
|
||||
// Sem esta flag, melhorias são apenas registradas (comportamento v1 — retrocompat).
|
||||
const REQUIRE_TIGHTEN = process.argv.includes("--require-tighten");
|
||||
|
||||
// Global fallback eps. Cada métrica pode sobrepor via `eps` no baseline.
|
||||
const GLOBAL_EPS = 0.01;
|
||||
|
||||
function load(p) {
|
||||
if (!fs.existsSync(p)) {
|
||||
@@ -31,6 +39,7 @@ function load(p) {
|
||||
const baseline = load(BASELINE);
|
||||
const metrics = load(METRICS);
|
||||
const failures = [];
|
||||
const tightenFailures = [];
|
||||
const improvements = [];
|
||||
const rows = [];
|
||||
|
||||
@@ -38,6 +47,13 @@ for (const [key, spec] of Object.entries(baseline.metrics)) {
|
||||
const current = metrics[key];
|
||||
const base = spec.value;
|
||||
const dir = spec.direction; // "down" = menor-é-melhor | "up" = maior-é-melhor
|
||||
|
||||
// Per-metric eps; falls back to global EPS if not specified in the spec.
|
||||
const eps = spec.eps !== undefined ? spec.eps : GLOBAL_EPS;
|
||||
|
||||
// Per-metric tightenSlack; falls back to eps (same tolerance as regression check).
|
||||
const tightenSlack = spec.tightenSlack !== undefined ? spec.tightenSlack : eps;
|
||||
|
||||
if (current === undefined) {
|
||||
if (ALLOW_MISSING) {
|
||||
rows.push([key, base, "—", "SKIP (ausente)"]);
|
||||
@@ -49,25 +65,47 @@ for (const [key, spec] of Object.entries(baseline.metrics)) {
|
||||
}
|
||||
let status = "ok";
|
||||
if (dir === "down") {
|
||||
if (current > base + EPS) {
|
||||
if (current > base + eps) {
|
||||
failures.push(`${key}: ${current} > baseline ${base} (não pode aumentar)`);
|
||||
status = "REGRESSÃO";
|
||||
} else if (current < base - EPS) {
|
||||
} else if (current < base - eps) {
|
||||
improvements.push([key, current]);
|
||||
status = "↑ melhorou";
|
||||
if (REQUIRE_TIGHTEN && base - current > tightenSlack) {
|
||||
tightenFailures.push(
|
||||
`${key}: melhorou de ${base} para ${current} (delta ${(base - current).toFixed(4)} > slack ${tightenSlack}) — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (current < base - EPS) {
|
||||
if (current < base - eps) {
|
||||
failures.push(`${key}: ${current} < baseline ${base} (não pode cair)`);
|
||||
status = "REGRESSÃO";
|
||||
} else if (current > base + EPS) {
|
||||
} else if (current > base + eps) {
|
||||
improvements.push([key, current]);
|
||||
status = "↑ melhorou";
|
||||
if (REQUIRE_TIGHTEN && current - base > tightenSlack) {
|
||||
tightenFailures.push(
|
||||
`${key}: melhorou de ${base} para ${current} (delta ${(current - base).toFixed(4)} > slack ${tightenSlack}) — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.push([key, base, current, status]);
|
||||
}
|
||||
|
||||
// Behavior 3: warn about orphan metrics (present in collected metrics but absent in baseline).
|
||||
const baselineKeys = new Set(Object.keys(baseline.metrics));
|
||||
const orphans = Object.keys(metrics).filter((k) => !baselineKeys.has(k));
|
||||
if (orphans.length > 0) {
|
||||
console.warn(
|
||||
`[quality-ratchet] WARN: ${orphans.length} métrica(s) órfã(s) — presente(s) em ${path.basename(METRICS)} mas sem entrada no baseline: ${orphans.join(", ")}`,
|
||||
);
|
||||
console.warn(
|
||||
`[quality-ratchet] WARN: adicione ${orphans.length === 1 ? "essa métrica" : "essas métricas"} ao baseline (com value/direction) para que sejam catraceadas.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (SUMMARY) {
|
||||
const md = [
|
||||
"# Quality Ratchet",
|
||||
@@ -84,6 +122,9 @@ if (SUMMARY) {
|
||||
fs.writeFileSync(SUMMARY, md + "\n");
|
||||
}
|
||||
|
||||
// Tighten check runs only when there are no regressions (regressions take priority).
|
||||
// With --update, improvements are captured into the baseline, so tighten check
|
||||
// is bypassed (the update itself is the required action).
|
||||
if (UPDATE && failures.length === 0 && improvements.length) {
|
||||
for (const [key, val] of improvements) baseline.metrics[key].value = val;
|
||||
fs.writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + "\n");
|
||||
@@ -94,4 +135,14 @@ if (failures.length) {
|
||||
console.error("[quality-ratchet] FALHOU:\n" + failures.map((f) => " ✗ " + f).join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Behavior 1: --require-tighten gate (only triggers when there are no regressions and no --update).
|
||||
if (REQUIRE_TIGHTEN && !UPDATE && tightenFailures.length > 0) {
|
||||
console.error(
|
||||
"[quality-ratchet] FALHOU (--require-tighten): métrica(s) melhoraram mas o baseline não foi apertado:\n" +
|
||||
tightenFailures.map((f) => " ✗ " + f).join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[quality-ratchet] OK (${rows.length} métricas, ${improvements.length} melhoraram)`);
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
// scripts/quality/collect-metrics.mjs — emite quality-metrics.json
|
||||
// Coletores incrementais: Fase 1 traz ESLint warnings + cobertura.
|
||||
// Fases 3/4 estendem com duplicação (jscpd), tamanho de arquivo e cobertura por módulo.
|
||||
// Fase 6A.11: openapiCoverage.pct + i18nUiCoverage.pct (mínimo entre locales).
|
||||
// Task 7.9: coverage.<modulo>.lines para ~8 módulos críticos, lidos do
|
||||
// coverage/coverage-summary.json se existir (sem erro se ausente).
|
||||
import fs from "node:fs";
|
||||
import { promises as fsAsync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const out = {};
|
||||
@@ -37,7 +43,213 @@ function coverage() {
|
||||
out["coverage.branches"] = t.branches.pct;
|
||||
}
|
||||
|
||||
eslintCounts();
|
||||
coverage();
|
||||
fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n");
|
||||
console.log("[collect-metrics]", JSON.stringify(out));
|
||||
// 3) Coverage per critical module (Task 7.9)
|
||||
// Reads coverage/coverage-summary.json (produced by `npm run test:coverage` via c8).
|
||||
// If the file is absent → silently skips (no error). This allows the gate to
|
||||
// function normally in environments where coverage was not run (e.g. lint-only CI).
|
||||
//
|
||||
// The summary JSON produced by c8 looks like:
|
||||
// { "total": {...}, "/abs/path/to/file.ts": { lines: { pct: 78 }, ... }, ... }
|
||||
//
|
||||
// modulePaths is a record of { metricSuffix: relPathFromRoot[] } — the first
|
||||
// matching key in the summary wins.
|
||||
|
||||
/**
|
||||
* Pure function — extracts per-module line-coverage percentages from a
|
||||
* coverage-summary.json object.
|
||||
*
|
||||
* @param {Record<string, { lines?: { pct: number } }>} summaryJson
|
||||
* The parsed coverage-summary.json (keys are absolute file paths or "total").
|
||||
* @param {Record<string, string[]>} modulePaths
|
||||
* Map of { metricKey: [relPath, ...fallbacks] } where relPath is relative to
|
||||
* the repo root (forward slashes). Returns the lines.pct of the first match.
|
||||
* @param {string} repoRoot Absolute path to the repo root (used to build keys).
|
||||
* @returns {Record<string, number>} Map of metricKey → lines.pct (0-100).
|
||||
*/
|
||||
export function extractModuleCoverage(summaryJson, modulePaths, repoRoot) {
|
||||
const result = {};
|
||||
// Build a normalised lookup: absolute path (forward slashes) → pct
|
||||
const lookup = new Map();
|
||||
for (const [rawKey, data] of Object.entries(summaryJson)) {
|
||||
if (rawKey === "total") continue;
|
||||
const norm = rawKey.replace(/\\/g, "/");
|
||||
const pct = data?.lines?.pct;
|
||||
if (typeof pct === "number") lookup.set(norm, pct);
|
||||
}
|
||||
|
||||
const normRoot = repoRoot.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
|
||||
for (const [metricKey, candidates] of Object.entries(modulePaths)) {
|
||||
for (const rel of candidates) {
|
||||
const abs = `${normRoot}/${rel.replace(/\\/g, "/").replace(/^\//, "")}`;
|
||||
if (lookup.has(abs)) {
|
||||
result[metricKey] = lookup.get(abs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The 8 critical modules tracked by Task 7.9 (relative paths from repo root). */
|
||||
export const CRITICAL_MODULE_PATHS = {
|
||||
"coverage.chatCore.lines": ["open-sse/handlers/chatCore.ts"],
|
||||
"coverage.combo.lines": ["open-sse/services/combo.ts"],
|
||||
"coverage.accountFallback.lines": ["open-sse/services/accountFallback.ts"],
|
||||
"coverage.auth.lines": ["src/sse/services/auth.ts"],
|
||||
"coverage.routeGuard.lines": ["src/server/authz/routeGuard.ts"],
|
||||
"coverage.error.lines": ["open-sse/utils/error.ts"],
|
||||
"coverage.publicCreds.lines": ["open-sse/utils/publicCreds.ts"],
|
||||
"coverage.circuitBreaker.lines": ["src/shared/utils/circuitBreaker.ts"],
|
||||
};
|
||||
|
||||
function coverageByModule() {
|
||||
const p = path.join(cwd, "coverage", "coverage-summary.json");
|
||||
if (!fs.existsSync(p)) return; // absent → skip silently (Task 7.9 spec)
|
||||
let summaryJson;
|
||||
try {
|
||||
summaryJson = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
} catch {
|
||||
return; // malformed file → skip
|
||||
}
|
||||
const moduleMetrics = extractModuleCoverage(summaryJson, CRITICAL_MODULE_PATHS, cwd);
|
||||
Object.assign(out, moduleMetrics);
|
||||
}
|
||||
|
||||
// 4) OpenAPI coverage: percentage of implemented routes documented in openapi.yaml
|
||||
function openapiCoverage() {
|
||||
const API_ROOT = path.join(cwd, "src", "app", "api");
|
||||
const OPENAPI_PATH = path.join(cwd, "docs", "reference", "openapi.yaml");
|
||||
if (!fs.existsSync(API_ROOT) || !fs.existsSync(OPENAPI_PATH)) return;
|
||||
|
||||
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}");
|
||||
}
|
||||
|
||||
const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath);
|
||||
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
|
||||
const documentedPaths = new Set(Object.keys(raw.paths || {}));
|
||||
const covered = implementedPaths.filter((p) => documentedPaths.has(p)).length;
|
||||
const total = implementedPaths.length;
|
||||
if (total > 0) out["openapiCoverage.pct"] = parseFloat(((covered / total) * 100).toFixed(1));
|
||||
}
|
||||
|
||||
// 4) i18n UI coverage: minimum real coverage across all non-en locales
|
||||
async function i18nUiCoverage() {
|
||||
const MESSAGES_DIR = path.join(cwd, "src", "i18n", "messages");
|
||||
const CONFIG_PATH = path.join(cwd, "config", "i18n.json");
|
||||
const SOURCE_LOCALE = "en";
|
||||
const PLACEHOLDER_PREFIX = "__MISSING__:";
|
||||
|
||||
if (!fs.existsSync(MESSAGES_DIR)) return;
|
||||
const sourcePath = path.join(MESSAGES_DIR, `${SOURCE_LOCALE}.json`);
|
||||
if (!fs.existsSync(sourcePath)) return;
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function collectLeafPaths(obj, prefix = []) {
|
||||
const paths = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const next = [...prefix, key];
|
||||
if (isPlainObject(value)) {
|
||||
paths.push(...collectLeafPaths(value, next));
|
||||
} else {
|
||||
paths.push(next);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
const FORBIDDEN_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
function lookupPath(obj, parts) {
|
||||
let cur = obj;
|
||||
for (const part of parts) {
|
||||
if (!isPlainObject(cur)) return undefined;
|
||||
if (FORBIDDEN_KEY_SEGMENTS.has(part)) return undefined;
|
||||
if (!Object.prototype.hasOwnProperty.call(cur, part)) return undefined;
|
||||
const entry = Object.entries(cur).find(([k]) => k === part);
|
||||
cur = entry ? entry[1] : undefined;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
const source = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
|
||||
const enPaths = collectLeafPaths(source);
|
||||
const totalEn = enPaths.length;
|
||||
if (totalEn === 0) return;
|
||||
|
||||
let configCodes = null;
|
||||
if (fs.existsSync(CONFIG_PATH)) {
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
||||
if (Array.isArray(cfg.locales)) configCodes = new Set(cfg.locales.map((l) => l.code));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const onDisk = (await fsAsync.readdir(MESSAGES_DIR))
|
||||
.filter((f) => f.endsWith(".json") && f !== `${SOURCE_LOCALE}.json`)
|
||||
.map((f) => f.slice(0, -5))
|
||||
.filter((code) => (configCodes ? configCodes.has(code) : true));
|
||||
|
||||
let minCoverage = 100;
|
||||
for (const locale of onDisk) {
|
||||
const localePath = path.join(MESSAGES_DIR, `${locale}.json`);
|
||||
let target;
|
||||
try {
|
||||
target = JSON.parse(fs.readFileSync(localePath, "utf8"));
|
||||
} catch {
|
||||
minCoverage = 0;
|
||||
continue;
|
||||
}
|
||||
let present = 0;
|
||||
let placeholder = 0;
|
||||
for (const pathParts of enPaths) {
|
||||
const value = lookupPath(target, pathParts);
|
||||
if (value === undefined || isPlainObject(value)) continue;
|
||||
present++;
|
||||
if (typeof value === "string" && value.startsWith(PLACEHOLDER_PREFIX)) placeholder++;
|
||||
}
|
||||
const coverage = ((present - placeholder) / totalEn) * 100;
|
||||
if (coverage < minCoverage) minCoverage = coverage;
|
||||
}
|
||||
|
||||
if (onDisk.length > 0) out["i18nUiCoverage.pct"] = parseFloat(minCoverage.toFixed(1));
|
||||
}
|
||||
|
||||
// Only run the collection pipeline when this file is executed directly.
|
||||
// When imported (e.g. in tests), only the exported pure functions are available
|
||||
// without triggering the expensive ESLint + i18n filesystem walks.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
eslintCounts();
|
||||
coverage();
|
||||
coverageByModule();
|
||||
openapiCoverage();
|
||||
await i18nUiCoverage();
|
||||
fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n");
|
||||
console.log("[collect-metrics]", JSON.stringify(out));
|
||||
}
|
||||
|
||||
169
scripts/quality/run-all-gates.mjs
Normal file
169
scripts/quality/run-all-gates.mjs
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/quality/run-all-gates.mjs
|
||||
// Agregador paralelo de quality gates determinísticos.
|
||||
// Roda os gates filesystem-only em paralelo (pool ~4), agrega resultados, e exibe
|
||||
// uma tabela consolidada com {gate, status, durationMs}. Sai com código 1 se
|
||||
// qualquer gate falhar. Alvo: < 3 min no total.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/quality/run-all-gates.mjs # run all gates
|
||||
// node scripts/quality/run-all-gates.mjs --fast # skip slow gates (duplication)
|
||||
//
|
||||
// Via npm: npm run quality:scan
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const FAST_ONLY = process.argv.includes("--fast");
|
||||
|
||||
// Gates determinísticos filesystem-only, agrupados por tempo estimado.
|
||||
// Cada entrada: { name: string, cmd: string[], label?: string }
|
||||
// "slow" gates são omitidos com --fast.
|
||||
const GATES = [
|
||||
// Group A — instant (<1s)
|
||||
{ name: "check:tracked-artifacts", cmd: ["node", "scripts/check/check-tracked-artifacts.mjs"] },
|
||||
{ name: "check:any-budget:t11", cmd: ["node", "scripts/check/check-t11-any-budget.mjs"] },
|
||||
{ name: "check:migration-numbering", cmd: ["node", "scripts/check/check-migration-numbering.mjs"] },
|
||||
{ name: "check:node-runtime", cmd: ["node", "--import", "tsx", "scripts/check/check-supported-node-runtime.ts"] },
|
||||
|
||||
// Group B — fast (<5s)
|
||||
{ name: "check:provider-consistency", cmd: ["node", "--import", "tsx", "scripts/check/check-provider-consistency.ts"] },
|
||||
{ name: "check:public-creds", cmd: ["node", "scripts/check/check-public-creds.mjs"] },
|
||||
{ name: "check:error-helper", cmd: ["node", "scripts/check/check-error-helper.mjs"] },
|
||||
{ name: "check:fetch-targets", cmd: ["node", "scripts/check/check-fetch-targets.mjs"] },
|
||||
{ name: "check:openapi-routes", cmd: ["node", "scripts/check/check-openapi-routes.mjs"] },
|
||||
{ name: "check:deps", cmd: ["node", "scripts/check/check-deps.mjs"] },
|
||||
|
||||
// Group C — moderate (<15s)
|
||||
{ name: "check:db-rules", cmd: ["node", "scripts/check/check-db-rules.mjs"] },
|
||||
{ name: "check:file-size", cmd: ["node", "scripts/check/check-file-size.mjs"] },
|
||||
{ name: "check:complexity", cmd: ["node", "scripts/check/check-complexity.mjs"] },
|
||||
{ name: "check:docs-symbols", cmd: ["node", "scripts/check/check-docs-symbols.mjs"] },
|
||||
{ name: "check:known-symbols", cmd: ["node", "--import", "tsx", "scripts/check/check-known-symbols.ts"] },
|
||||
{ name: "check:route-guard-membership", cmd: ["node", "--import", "tsx", "scripts/check/check-route-guard-membership.ts"] },
|
||||
{ name: "check:test-discovery", cmd: ["node", "scripts/check/check-test-discovery.mjs"] },
|
||||
{ name: "check:test-masking", cmd: ["node", "scripts/check/check-test-masking.mjs"] },
|
||||
|
||||
// Group D — slow (>15s); skipped with --fast
|
||||
{ name: "check:duplication", cmd: ["node", "scripts/check/check-duplication.mjs"], slow: true },
|
||||
{ name: "check:cycles", cmd: ["node", "scripts/check/check-cycles.mjs"], slow: true },
|
||||
];
|
||||
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
/**
|
||||
* Run a single gate command, capturing last line of stdout/stderr.
|
||||
* @param {{ name: string, cmd: string[] }} gate
|
||||
* @returns {Promise<{ name: string, exitCode: number, durationMs: number, lastLine: string }>}
|
||||
*/
|
||||
function runGate(gate) {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
const [bin, ...args] = gate.cmd;
|
||||
const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], cwd: process.cwd() });
|
||||
|
||||
const lines = [];
|
||||
const collectLine = (chunk) => {
|
||||
const text = chunk.toString();
|
||||
for (const line of text.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (t) lines.push(t);
|
||||
}
|
||||
};
|
||||
|
||||
proc.stdout.on("data", collectLine);
|
||||
proc.stderr.on("data", collectLine);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
const durationMs = Date.now() - start;
|
||||
const lastLine = lines[lines.length - 1] ?? "";
|
||||
resolve({ name: gate.name, exitCode: code ?? 1, durationMs, lastLine });
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
const durationMs = Date.now() - start;
|
||||
resolve({ name: gate.name, exitCode: 1, durationMs, lastLine: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run gates with a concurrency pool.
|
||||
* @param {typeof GATES} gates
|
||||
* @param {number} concurrency
|
||||
* @returns {Promise<ReturnType<typeof runGate>[]>}
|
||||
*/
|
||||
async function runWithPool(gates, concurrency) {
|
||||
const results = [];
|
||||
let idx = 0;
|
||||
|
||||
async function worker() {
|
||||
while (idx < gates.length) {
|
||||
const gate = gates[idx++];
|
||||
const result = await runGate(gate);
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, gates.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function formatTable(results) {
|
||||
const COL_NAME = 35;
|
||||
const COL_STATUS = 8;
|
||||
const COL_MS = 10;
|
||||
const COL_MSG = 60;
|
||||
|
||||
const header =
|
||||
" " +
|
||||
"GATE".padEnd(COL_NAME) +
|
||||
"STATUS".padEnd(COL_STATUS) +
|
||||
"TIME(ms)".padEnd(COL_MS) +
|
||||
"LAST LINE";
|
||||
|
||||
const separator = " " + "─".repeat(COL_NAME + COL_STATUS + COL_MS + COL_MSG);
|
||||
|
||||
const rows = results.map((r) => {
|
||||
const status = r.exitCode === 0 ? "PASS" : "FAIL";
|
||||
const name = r.name.padEnd(COL_NAME);
|
||||
const statusCol = (r.exitCode === 0 ? "✔ " + status : "✗ " + status).padEnd(COL_STATUS + 2);
|
||||
const ms = String(r.durationMs).padStart(6) + "ms ";
|
||||
const msg = r.lastLine.slice(0, COL_MSG);
|
||||
return ` ${name}${statusCol}${ms}${msg}`;
|
||||
});
|
||||
|
||||
return [separator, header, separator, ...rows, separator].join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const gates = FAST_ONLY ? GATES.filter((g) => !g.slow) : GATES;
|
||||
|
||||
console.log(`\n[quality:scan] Running ${gates.length} gate(s) with concurrency=${CONCURRENCY}...\n`);
|
||||
const wallStart = Date.now();
|
||||
|
||||
const results = await runWithPool(gates, CONCURRENCY);
|
||||
|
||||
const wallMs = Date.now() - wallStart;
|
||||
const failed = results.filter((r) => r.exitCode !== 0);
|
||||
const passed = results.filter((r) => r.exitCode === 0);
|
||||
|
||||
console.log(formatTable(results));
|
||||
console.log(
|
||||
`\n Summary: ${passed.length} passed, ${failed.length} failed` +
|
||||
` | Total: ${(wallMs / 1000).toFixed(1)}s (wall clock)\n`
|
||||
);
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.error(`[quality:scan] FAIL — ${failed.length} gate(s) failed:`);
|
||||
for (const r of failed) {
|
||||
console.error(` ✗ ${r.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("[quality:scan] All gates passed.");
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
Reference in New Issue
Block a user