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:
Diego Rodrigues de Sa e Souza
2026-06-13 17:27:40 -03:00
committed by GitHub
parent 420d62b420
commit 76a07cf7a5
308 changed files with 22331 additions and 5873 deletions

View File

@@ -32,7 +32,7 @@
* Turbopack hashed-chunk patch (.next/server/ *.js) - Y - SHARED (opt-in: patchTurbopackChunks)
* --- npm-UNIQUE ---
* MITM tsc compile -> app/src/mitm/ - Y - UNIQUE (prepublish)
* MCP server esbuild -> app/open-sse/mcp-server/server.js - Y - UNIQUE (prepublish)
* MCP server esbuild -> dist/open-sse/mcp-server/server.js - Y - UNIQUE (prepublish)
* CLI esbuild -> bin/omniroute.mjs - Y - UNIQUE (prepublish)
* sidecar/doc copies (.env.example, docs/, sync-env, etc.) - Y - UNIQUE (prepublish)
* prune + validate (pack-artifact-policy) - Y - UNIQUE (prepublish)

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env node
// scripts/check/check-bundle-size.mjs
// Catraca de bundle size (Task 12 — Fase 7).
//
// MODO PREFERENCIAL — size-limit + @size-limit/file (ou outro plugin):
// Rodar `size-limit --json` via .size-limit.json; extrair o campo `size` de cada
// entry e somar. Emite `bundleSize=<bytes>`.
//
// MODO FALLBACK — raw fs.statSync() (sem plugins instalados):
// Quando size-limit retorna "no plugins" (isEmpty — só o core está instalado), o
// script lê os `path` declarados em .size-limit.json diretamente via fs.statSync()
// e soma os bytes. Mesmas entradas, mesma métrica. Emite `bundleSize=<bytes>`.
//
// MODO SKIP — entradas inexistentes:
// Se nenhuma das entradas do .size-limit.json existir (ex: build não rodou e os
// arquivos apontados são artefatos gerados), emite `bundleSize=SKIP reason=no-build`
// e sai 0.
//
// Esta versão é ADVISORY: sempre sai 0 independente do resultado.
// O ratchet (direction:down) é gerenciado pelo motor de quality-baseline.json na
// integração com o CI (Task 12 INT).
//
// Uso:
// node scripts/check/check-bundle-size.mjs
// node scripts/check/check-bundle-size.mjs --json (força saída JSON de size-limit se possível)
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL, fileURLToPath } from "node:url";
const ROOT = process.cwd();
const SIZE_LIMIT_CONFIG = path.join(ROOT, ".size-limit.json");
const SIZE_LIMIT_BIN = path.join(ROOT, "node_modules", ".bin", "size-limit");
/**
* Tenta rodar size-limit --json e retorna o array de resultados.
* Lança se size-limit não tiver plugins instalados (plugins.isEmpty).
*
* @returns {Array<{name: string, size: number, sizeLimit?: number, passed?: boolean}>}
* @throws {SizeLimitNoPluginsError}
*/
export function runSizeLimit(cwd = ROOT, binPath = SIZE_LIMIT_BIN) {
if (!fs.existsSync(binPath)) {
throw Object.assign(new Error("size-limit binary not found"), { code: "SL_NO_BIN" });
}
let stdout;
try {
stdout = execFileSync("node", [binPath, "--json"], {
encoding: "utf8",
cwd,
maxBuffer: 8 * 1024 * 1024,
});
} catch (err) {
const combined = (err.stdout || "") + (err.stderr || "");
if (
combined.includes("Install Size Limit preset") ||
combined.includes("plugins.isEmpty") ||
combined.includes("@size-limit/preset")
) {
throw Object.assign(new Error("size-limit: no plugins installed"), {
code: "SL_NO_PLUGINS",
});
}
throw err;
}
return JSON.parse(stdout.trim());
}
/**
* Parseia o JSON de saída do size-limit e retorna o total em bytes.
* Lança se o JSON não tiver o campo `size` em pelo menos uma entrada.
*
* @param {Array<{name: string, size?: number}>} results
* @returns {number} total em bytes
*/
export function parseSizeLimitResults(results) {
if (!Array.isArray(results)) {
throw new TypeError("parseSizeLimitResults: esperado array de resultados");
}
let total = 0;
let hasMeasured = false;
for (const entry of results) {
if (typeof entry.size === "number") {
total += entry.size;
hasMeasured = true;
}
}
if (!hasMeasured) {
throw new Error("parseSizeLimitResults: nenhuma entrada com campo `size` numérico");
}
return total;
}
/**
* Fallback: lê os `path` do .size-limit.json via fs.statSync().
* Retorna {total, entries, allMissing} onde:
* - total: soma dos bytes dos arquivos encontrados
* - entries: [{name, path, size}]
* - allMissing: true se NENHUM arquivo existia (skip)
*
* @param {string} configPath
* @param {string} cwd
*/
export function measureViaFileStat(configPath = SIZE_LIMIT_CONFIG, cwd = ROOT) {
if (!fs.existsSync(configPath)) {
return { total: 0, entries: [], allMissing: true };
}
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
let total = 0;
let found = 0;
const entries = [];
for (const entry of config) {
const entryPath = path.isAbsolute(entry.path)
? entry.path
: path.join(cwd, entry.path);
if (!fs.existsSync(entryPath)) {
entries.push({ name: entry.name, path: entry.path, size: null });
continue;
}
const size = fs.statSync(entryPath).size;
total += size;
found++;
entries.push({ name: entry.name, path: entry.path, size });
}
return { total, entries, allMissing: found === 0 };
}
function main() {
// Step 1: tenta com size-limit + plugin instalado
let totalBytes = null;
let mode = "size-limit";
try {
const results = runSizeLimit(ROOT, SIZE_LIMIT_BIN);
totalBytes = parseSizeLimitResults(results);
} catch (err) {
if (err.code === "SL_NO_PLUGINS" || err.code === "SL_NO_BIN") {
// Step 2: fallback para leitura direta de arquivo
mode = "fallback-stat";
const { total, entries, allMissing } = measureViaFileStat(SIZE_LIMIT_CONFIG, ROOT);
if (allMissing) {
// Step 3: skip gracioso — entradas não existem (build necessário)
console.log("bundleSize=SKIP reason=no-build");
if (process.env.CI) {
console.log("::notice::check-bundle-size skipped — entradas do .size-limit.json não encontradas (build necessário)");
}
return;
}
totalBytes = total;
for (const e of entries) {
if (e.size !== null) {
const kb = (e.size / 1024).toFixed(2);
console.log(` ${e.name}: ${kb} KB (${e.size} bytes)`);
} else {
console.log(` ${e.name}: ausente (não contabilizado)`);
}
}
} else {
// Erro inesperado — reporta mas não falha (advisory)
console.error(`[bundle-size] Aviso: size-limit retornou erro inesperado: ${err.message}`);
console.log("bundleSize=SKIP reason=size-limit-error");
return;
}
}
const kb = (totalBytes / 1024).toFixed(2);
console.log(`bundleSize=${totalBytes}`);
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
// scripts/check/check-circular-deps.mjs
// Gate: dpdm circular-deps cross-check (segunda opinião complementar ao check-cycles.mjs).
//
// check-cycles.mjs usa AST-TS próprio mas cobre apenas 5 sub-árvores + somente
// imports relativos. Este script usa dpdm (v4) que rastreia path-aliases via
// tsconfig.json e cobre entrypoints de alto risco.
//
// Advisory nesta versão: exit 0 sempre; imprime `circularDeps=N` para baseline.
// Direção da catraca: down (não pode subir). Adicionar ao quality-baseline.json
// como `{ value: N, direction: "down" }` após a primeira run verde no CI.
//
// Escopo limitado a 4 entrypoints principais para manter o tempo de análise
// abaixo de 60s. dpdm rastreia transitivamente todas as deps de cada entry.
// Cobrir mais entries aumenta o tempo sem proporcional ganho (as deps core se
// repetem via transitividade).
//
// Nota: dpdm pode reportar mais ciclos que check-cycles.mjs porque conta
// permutações de paths que passam pelo mesmo SCC, não apenas SCCs únicos.
// Isso é esperado — ferramentas diferentes, métricas complementares.
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, "../..");
// Entrypoints: cobrem o pipeline principal (chat, combo, MCP, DB).
// Mantido enxuto para que `dpdm -T` (transform) termine em < 60s.
const ENTRYPOINTS = [
"open-sse/handlers/chatCore.ts",
"open-sse/services/combo.ts",
"open-sse/mcp-server/index.ts",
"src/lib/db/core.ts",
];
const DPDM_BIN = resolve(projectRoot, "node_modules/.bin/dpdm");
const TSCONFIG = resolve(projectRoot, "tsconfig.json");
/**
* Parseia a saída JSON do dpdm e retorna a contagem de ciclos.
* Função exportada para ser testada isoladamente sem executar o dpdm.
*
* @param {string} jsonStr - string com o JSON de saída do dpdm (campo "circulars").
* @returns {{ count: number, circulars: string[][] }}
*/
export function parseDpdmOutput(jsonStr) {
let parsed;
try {
parsed = JSON.parse(jsonStr);
} catch {
throw new Error(`dpdm JSON parse failed: ${jsonStr.slice(0, 200)}`);
}
const circulars = Array.isArray(parsed.circulars) ? parsed.circulars : [];
return { count: circulars.length, circulars };
}
/**
* Executa o dpdm e retorna a string JSON bruta do arquivo de saída.
*
* @returns {string} conteúdo JSON do arquivo temporário.
*/
function runDpdm() {
if (!existsSync(DPDM_BIN)) {
throw new Error(`dpdm binary not found at ${DPDM_BIN}. Run: npm install`);
}
const tmpFile = path.join(os.tmpdir(), `dpdm-output-${process.pid}.json`);
try {
execFileSync(
"node",
[
DPDM_BIN,
"--circular",
"--no-warning",
"--no-tree",
"-T",
"--tsconfig",
TSCONFIG,
"-o",
tmpFile,
...ENTRYPOINTS,
],
{
cwd: projectRoot,
stdio: "inherit",
timeout: 120_000,
}
);
if (!existsSync(tmpFile)) {
throw new Error(`dpdm did not produce output file at ${tmpFile}`);
}
const raw = readFileSync(tmpFile, "utf8");
return raw;
} finally {
try {
fs.unlinkSync(tmpFile);
} catch {
// best-effort cleanup
}
}
}
function main() {
console.log("[circular-deps] Running dpdm cross-check...");
console.log(`[circular-deps] Entrypoints: ${ENTRYPOINTS.join(", ")}`);
let raw;
try {
raw = runDpdm();
} catch (err) {
console.error(`[circular-deps] ERROR running dpdm: ${err.message}`);
process.exit(1);
}
let result;
try {
result = parseDpdmOutput(raw);
} catch (err) {
console.error(`[circular-deps] ERROR parsing dpdm output: ${err.message}`);
process.exit(1);
}
// Advisory mode: always exit 0. Catraca pode ser adicionada no quality-baseline.json
// após baseline ser estabelecida.
console.log(`[circular-deps] circularDeps=${result.count}`);
console.log(
`[circular-deps] Advisory — add to quality-baseline.json: { "value": ${result.count}, "direction": "down" }`
);
process.exit(0);
}
// Permite que o módulo seja importado em testes sem executar main().
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,307 @@
#!/usr/bin/env node
// scripts/check/check-codeql-ratchet.mjs
// Catraca de alertas CodeQL (Task 7.3 — Fase 7).
//
// Usa a GitHub API via `gh` CLI para buscar alertas de code-scanning abertos e
// não-dismissed (respeita Hard Rule #14: alertas dismissed não contam).
//
// Saída (stdout):
// codeqlAlerts=N — contagem de alertas CodeQL abertos, não-dismissed
// codeqlAlerts=SKIP reason=binary-absent — `gh` não está no PATH
// codeqlAlerts=SKIP reason=no-auth — `gh` presente mas sem autenticação
// codeqlAlerts=SKIP reason=api-error:<code> — erro da API GitHub
//
// Esta versão é ADVISORY (sai 0 sempre). O ratchet (direction:down) é gerenciado
// pelo motor quality-baseline.json no CI (Task 7.3 INT).
//
// Uso:
// node scripts/check/check-codeql-ratchet.mjs
// node scripts/check/check-codeql-ratchet.mjs --json # imprime array de alertas
// node scripts/check/check-codeql-ratchet.mjs --quiet # suprime logs de diagnóstico
import { execFileSync, spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Conta alertas CodeQL abertos e não-dismissed a partir do JSON da GitHub API.
*
* A GitHub API /code-scanning/alerts retorna um array de:
* {
* number: number,
* state: "open" | "dismissed" | "fixed",
* dismissed_reason: string | null,
* dismissed_at: string | null,
* tool: { name: string, ... },
* rule: { id: string, severity: string, security_severity_level?: string, ... },
* ...
* }
*
* Hard Rule #14: alertas com `state="dismissed"` NÃO contam, independente da razão.
* Filtramos por state="open" E tool.name contendo "CodeQL" (case-insensitive).
* Alertas de outras ferramentas (ex: Semgrep) são ignorados.
*
* @param {Array|null} alerts - Array de alertas da API GitHub
* @returns {{ alertCount: number, bySeverity: Record<string, number>, byRule: Record<string, number> }}
*/
export function parseCodeQLAlerts(alerts) {
if (!Array.isArray(alerts)) {
return { alertCount: 0, bySeverity: {}, byRule: {} };
}
let alertCount = 0;
const bySeverity = {};
const byRule = {};
for (const alert of alerts) {
// Ignorar alertas não-CodeQL (outras ferramentas de code scanning)
const toolName = alert?.tool?.name ?? "";
if (!toolName.toLowerCase().includes("codeql")) continue;
// Hard Rule #14: alertas dismissed não contam
if (alert.state === "dismissed") continue;
// Só alertas abertos
if (alert.state !== "open") continue;
alertCount++;
// Coletar por severidade (security_severity_level ou severity da rule)
const severity = (
alert?.rule?.security_severity_level ??
alert?.rule?.severity ??
"unknown"
).toLowerCase();
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
// Coletar por rule ID
const ruleId = alert?.rule?.id ?? "unknown";
byRule[ruleId] = (byRule[ruleId] ?? 0) + 1;
}
return { alertCount, bySeverity, byRule };
}
// ---------------------------------------------------------------------------
// Repository detection
// ---------------------------------------------------------------------------
/**
* Detecta o owner/repo do repositório atual usando `gh repo view`.
* Retorna null se `gh` não estiver disponível ou não autenticado.
*
* @param {string} ghBin - Caminho para o binário gh
* @returns {string|null} "owner/repo" ou null
*/
export function detectRepo(ghBin) {
try {
const stdout = execFileSync(ghBin, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], {
encoding: "utf8",
timeout: 15_000,
});
return stdout.trim() || null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
/**
* Detecta se o binário `gh` está disponível no PATH.
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
*
* @returns {string|null} Caminho absoluto para o binário, ou null se ausente.
*/
export function findGhCli() {
try {
const result = spawnSync("which", ["gh"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status === 0) {
return result.stdout.trim();
}
} catch {
// which não disponível
}
// Fallback: tentar executar diretamente para verificar ENOENT
try {
const result = spawnSync("gh", ["--version"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.error?.code === "ENOENT") return null;
if (result.status !== null) return "gh"; // found in PATH
} catch {
// noop
}
return null;
}
// ---------------------------------------------------------------------------
// API caller
// ---------------------------------------------------------------------------
/**
* Busca alertas CodeQL abertos via `gh api`.
* Pagina automaticamente (GitHub retorna max 100 por página).
*
* @param {string} ghBin - Caminho para o binário gh
* @param {string} repo - "owner/repo"
* @returns {Array} Array de alertas
*/
function fetchCodeQLAlerts(ghBin, repo) {
const allAlerts = [];
let page = 1;
const perPage = 100;
while (true) {
const endpoint = `/repos/${repo}/code-scanning/alerts?state=open&tool_name=CodeQL&per_page=${perPage}&page=${page}`;
if (!QUIET) {
process.stderr.write(`[codeql-ratchet] Buscando alertas: página ${page} ...\n`);
}
let stdout;
try {
stdout = execFileSync(ghBin, ["api", endpoint], {
encoding: "utf8",
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
});
} catch (err) {
const errMsg = String(err.stderr ?? err.message ?? "");
// Sem autenticação
if (errMsg.includes("authentication") || errMsg.includes("401") || errMsg.includes("not logged")) {
return { error: "no-auth", message: errMsg };
}
// Rate limit ou outro erro HTTP
const codeMatch = /HTTP (\d{3})/.exec(errMsg);
const code = codeMatch ? codeMatch[1] : "unknown";
return { error: `api-error:${code}`, message: errMsg };
}
let page_alerts;
try {
page_alerts = JSON.parse(stdout);
} catch (parseErr) {
process.stderr.write(`[codeql-ratchet] ERRO ao parsear resposta da API: ${parseErr.message}\n`);
process.exit(2);
}
// A API retorna null quando não há mais páginas (ou array vazio)
if (!Array.isArray(page_alerts) || page_alerts.length === 0) break;
allAlerts.push(...page_alerts);
// Se retornou menos que perPage, chegamos à última página
if (page_alerts.length < perPage) break;
page++;
}
return allAlerts;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const ghBin = findGhCli();
if (!ghBin) {
console.log("codeqlAlerts=SKIP reason=binary-absent");
if (!QUIET) {
process.stderr.write(
"[codeql-ratchet] SKIP — `gh` CLI não encontrado no PATH.\n" +
"[codeql-ratchet] Instale via: https://cli.github.com/\n" +
"[codeql-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
);
}
process.exitCode = 0;
return;
}
// Detectar repositório
const repo = detectRepo(ghBin);
if (!repo) {
console.log("codeqlAlerts=SKIP reason=no-repo");
if (!QUIET) {
process.stderr.write(
"[codeql-ratchet] SKIP — não foi possível detectar o repositório GitHub.\n" +
"[codeql-ratchet] Execute dentro de um repositório GitHub com `gh` autenticado.\n"
);
}
process.exitCode = 0;
return;
}
if (!QUIET) {
process.stderr.write(`[codeql-ratchet] Repositório detectado: ${repo}\n`);
}
// Buscar alertas
const result = fetchCodeQLAlerts(ghBin, repo);
// Tratar erros da API com skip gracioso
if (!Array.isArray(result)) {
const { error, message } = result;
console.log(`codeqlAlerts=SKIP reason=${error}`);
if (!QUIET) {
process.stderr.write(`[codeql-ratchet] SKIP — erro ao consultar API GitHub: ${message.slice(0, 200)}\n`);
}
process.exitCode = 0;
return;
}
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
return;
}
const { alertCount, bySeverity, byRule } = parseCodeQLAlerts(result);
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
console.log(`codeqlAlerts=${alertCount}`);
if (!QUIET) {
const severitySummary = Object.entries(bySeverity)
.map(([k, v]) => `${k}=${v}`)
.join(", ") || "nenhum";
const topRules = Object.entries(byRule)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([r, n]) => `${r}(${n})`)
.join(", ") || "nenhum";
process.stderr.write(
`[codeql-ratchet] Alertas CodeQL abertos (não-dismissed): ${alertCount}\n`
);
if (alertCount > 0) {
process.stderr.write(`[codeql-ratchet] Por severidade: ${severitySummary}\n`);
process.stderr.write(`[codeql-ratchet] Top regras: ${topRules}\n`);
}
process.stderr.write(
"[codeql-ratchet] ADVISORY — esta versão não falha pela contagem (ratchet entra no CI).\n"
);
}
// Sai 0 sempre nesta versão (advisory)
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env node
// scripts/check/check-cognitive-complexity.mjs
// Advisory gate para complexidade cognitiva (sonarjs/cognitive-complexity).
//
// 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).
//
// 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.
//
// 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
import { execFileSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const CONFIG_PATH = path.join(ROOT, "eslint.sonarjs.config.mjs");
const ESLINT_BIN = path.join(ROOT, "node_modules", ".bin", "eslint");
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;
}
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() {
const report = runEslint();
const count = countCognitiveViolations(report);
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.`
);
}
// Canonical machine-readable output consumed by collect-metrics.mjs
console.log(`cognitiveComplexity=${count}`);
// Advisory: always exit 0
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -11,9 +11,12 @@
// (c) Nenhum SQL cru em src/app/api/**/route.ts ou open-sse/handlers/*.ts.
// SQL deve viver em src/lib/db/ (Hard Rule #5). Ofensores pré-existentes
// são congelados; QUALQUER novo SQL cru em rota/handler falha.
// Stale-enforcement (6A.3): entradas em INTENTIONALLY_INTERNAL / EXTERNAL_DB_ALLOWED
// que não suprimem nenhuma violação real → gate falha com instrução de remoção.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
const DB_DIR = path.join(cwd, "src/lib/db");
@@ -215,10 +218,16 @@ export function collectSqlScanFiles(apiDir = API_DIR, handlersDir = HANDLERS_DIR
function main() {
const failures = [];
const localDbSource = fs.readFileSync(LOCAL_DB, "utf8");
// (a) re-export completeness
const dbModules = collectDbModules();
const reexported = extractReexportedModules(fs.readFileSync(LOCAL_DB, "utf8"));
const reexported = extractReexportedModules(localDbSource);
// Live unexported modules BEFORE allowlist filtering (needed for stale-enforcement).
const liveUnexported = dbModules.filter((mod) => !reexported.has(mod));
assertNoStale(INTENTIONALLY_INTERNAL, liveUnexported, "check-db-rules:unexported");
const missing = findMissingReexports(dbModules, reexported);
if (missing.length) {
failures.push(
@@ -230,7 +239,7 @@ function main() {
}
// (b) localDb sem lógica
if (hasLogic(fs.readFileSync(LOCAL_DB, "utf8"))) {
if (hasLogic(localDbSource)) {
failures.push(
`[#2 sem-lógica] src/lib/localDb.ts contém lógica (function/class/arrow). É camada de` +
` re-export apenas — mova a lógica para um módulo src/lib/db/.`
@@ -238,7 +247,12 @@ function main() {
}
// (c) SQL cru fora de db/
const rawSql = findRawSql(collectSqlScanFiles());
// Live raw-SQL offenders BEFORE allowlist filtering (needed for stale-enforcement).
const scanFiles = collectSqlScanFiles();
const liveRawSql = findRawSql(scanFiles, new Set());
assertNoStale(EXTERNAL_DB_ALLOWED, liveRawSql, "check-db-rules:raw-sql");
const rawSql = findRawSql(scanFiles);
if (rawSql.length) {
failures.push(
`[#5 sql-cru] ${rawSql.length} arquivo(s) com SQL cru fora de src/lib/db/:\n` +
@@ -250,12 +264,14 @@ function main() {
if (failures.length) {
console.error(`[check-db-rules] FALHOU:\n\n` + failures.join("\n\n"));
process.exit(1);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[check-db-rules] OK (${dbModules.length} módulos db/, ${reexported.size} re-exportados, ` +
`${INTENTIONALLY_INTERNAL.size} intencionalmente-internos (Rule #2); ${EXTERNAL_DB_ALLOWED.size} leituras de DB externo permitidas (#3500))`
);
}
console.log(
`[check-db-rules] OK (${dbModules.length} módulos db/, ${reexported.size} re-exportados, ` +
`${INTENTIONALLY_INTERNAL.size} intencionalmente-internos (Rule #2); ${EXTERNAL_DB_ALLOWED.size} leituras de DB externo permitidas (#3500))`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,139 @@
#!/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.
//
// Saída (stdout):
// DEAD_EXPORTS=<n> — exports/re-exports/tipos não utilizados
// DEAD_FILES=<n> — arquivos sem nenhum consumidor
// DEAD_TOTAL=<n> — soma de ambos (métrica primária para o ratchet)
//
// Use --json para imprimir o relatório completo do knip em JSON.
// Use --quiet para suprimir logs de diagnóstico.
import { execFileSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
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");
/**
* Conta dead exports e dead files a partir do output JSON do knip.
*
* O reporter JSON do knip emite:
* { issues: Array<{ file, exports?, files?, types?, nsExports?, nsTypes?, ... }> }
*
* Cada entrada em `exports`, `types`, `nsExports`, `nsTypes` é um símbolo morto naquele
* arquivo. A presença do arquivo em si na lista (campo `files: []` não-vazio ou arquivo
* sem outros campos relevantes com `files: true` no include) indica arquivo morto.
*
* @param {object} knipJson - Objeto JSON parseado do output do knip
* @returns {{ deadExports: number, deadFiles: number, deadTotal: number }}
*/
export function parseKnipMetrics(knipJson) {
if (!knipJson || !Array.isArray(knipJson.issues)) {
return { deadExports: 0, deadFiles: 0, deadTotal: 0 };
}
let deadExports = 0;
let deadFiles = 0;
for (const fileEntry of knipJson.issues) {
// Dead file: o arquivo aparece na lista com campo `files` populado
// (knip emite um entry com files:[] indicando "este arquivo é morto")
if (Array.isArray(fileEntry.files) && fileEntry.files.length > 0) {
deadFiles += fileEntry.files.length;
}
// Alguns reporters indicam arquivo morto sem campo files — o entry existe
// sem exports/types = o arquivo inteiro não tem consumidor
// (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"];
for (const field of exportFields) {
if (Array.isArray(fileEntry[field])) {
deadExports += fileEntry[field].length;
}
}
}
return {
deadExports,
deadFiles,
deadTotal: deadExports + deadFiles,
};
}
function runKnip() {
const args = [
"--reporter", "json",
"--no-progress",
"--no-exit-code", // não falha por contagem — só coletamos métricas
];
if (!QUIET) {
process.stderr.write("[dead-code] Rodando knip --reporter json ...\n");
}
let stdout;
try {
stdout = execFileSync(KNIP_BIN, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
timeout: 300_000, // 5 min (knip pode ser lento em monorepos grandes)
});
} catch (err) {
// knip sai com código != 0 quando encontra issues; o JSON ainda vai no stdout.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) {
process.stderr.write(`[dead-code] ERRO ao executar knip: ${err.message}\n`);
process.exit(2);
}
}
let knipJson;
try {
knipJson = JSON.parse(stdout);
} catch (parseErr) {
process.stderr.write(`[dead-code] ERRO ao parsear JSON do knip: ${parseErr.message}\n`);
process.stderr.write(`[dead-code] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`);
process.exit(2);
}
return knipJson;
}
function main() {
const knipJson = runKnip();
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(knipJson, null, 2) + "\n");
return;
}
const { deadExports, deadFiles, deadTotal } = parseKnipMetrics(knipJson);
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
console.log(`DEAD_EXPORTS=${deadExports}`);
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`
);
}
// Sai 0 sempre nesta versão (advisory)
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -1,18 +1,78 @@
#!/usr/bin/env node
// scripts/check/check-deps.mjs
// Gate anti-slopsquatting: toda dependência em package.json (raiz + electron) deve
// Gate anti-slopsquatting: toda dependência em QUALQUER package.json do repo deve
// estar numa allowlist commitada (dependency-allowlist.json). Uma dep nova exige
// adição EXPLÍCITA à allowlist — assim um agente não consegue introduzir um pacote
// alucinado/typosquatted silenciosamente (CSA 2026: 19,7% do código IA cita pacotes
// inexistentes; 43% dos nomes alucinados reaparecem, registráveis por atacantes).
// A revisão humana ao adicionar à allowlist é o ponto de controle.
//
// 6A.8: Expandido de 2 manifests hardcoded (package.json + electron/package.json)
// para descoberta automática de TODOS os package.json do repo, excluindo:
// - node_modules/ (dep tree)
// - .next/, .build/, dist/, dist-electron/ (build artefatos)
// - .claude/ (worktrees de agentes)
// - _references/, _mono_repo/ (código de referência não pertencente ao repo)
// Isso garante que workspaces novos (opencode-plugin, opencode-provider, open-sse, etc.)
// sejam automaticamente cobertos sem edição do script.
//
// Task 7.8: Anti-slopsquatting completo — para deps NOVAS (fora da allowlist),
// dois sub-checks adicionais ANTES de falhar:
// (a) a dep EXISTE no npm registry (npm view <pkg> version)
// (b) foi publicada há ≥72h (age-cooldown contra typosquatting de nomes alucinados)
// Ambas as chamadas são tolerantes a falha de rede: se o registry estiver inacessível,
// emite aviso mas não bloqueia — o gate principal (allowlist) ainda captura a dep nova.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";
import { assertNoStale } from "./lib/allowlist.mjs";
const ROOT = process.cwd();
const ALLOWLIST_PATH = path.join(ROOT, "dependency-allowlist.json");
const MANIFESTS = ["package.json", path.join("electron", "package.json")];
// Directories to exclude when discovering package.json files.
// Using a set of path segment prefixes (relative to ROOT, forward slashes).
const EXCLUDED_SEGMENTS = new Set([
"node_modules",
".next",
".build",
"dist",
"dist-electron",
".claude",
"_references",
"_mono_repo",
]);
/**
* 6A.8: Discover all package.json files in the repo, excluding build artefacts,
* reference code, and agent worktrees. Returns relative paths (forward slashes).
*/
export function discoverManifests(root) {
const out = [];
function walk(dir, depth) {
if (depth > 5) return; // guard against very deep nesting
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (EXCLUDED_SEGMENTS.has(e.name)) continue;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
walk(full, depth + 1);
} else if (e.name === "package.json") {
out.push(path.relative(root, full).replace(/\\/g, "/"));
}
}
}
walk(root, 0);
return out.sort();
}
/** Nomes de deps no manifesto que não estão na allowlist (de-dup, ordem preservada). */
export function findUnapprovedDeps(depNames, allowlist) {
@@ -26,19 +86,133 @@ export function findUnapprovedDeps(depNames, allowlist) {
return out;
}
function depNamesFromManifest(file) {
const full = path.join(ROOT, file);
function depNamesFromManifest(root, rel) {
const full = path.join(root, rel);
if (!fs.existsSync(full)) return [];
const pkg = JSON.parse(fs.readFileSync(full, "utf8"));
let pkg;
try {
pkg = JSON.parse(fs.readFileSync(full, "utf8"));
} catch {
return []; // skip malformed manifests (e.g. reference code)
}
return [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
];
}
function collectDepNames() {
return MANIFESTS.flatMap(depNamesFromManifest);
function collectDepNames(root) {
return discoverManifests(root).flatMap((rel) => depNamesFromManifest(root, rel));
}
// ─── Task 7.8: registry-existence + age-cooldown ──────────────────────────────
/**
* Pure function — determines whether a package is old enough to be trusted.
*
* A dep that was just registered (within the last 72h) is a red flag for
* slopsquatting: an attacker can register the name an AI hallucinated within
* minutes of the hallucination becoming public. The 72h window gives the npm
* security team time to act and gives maintainers a chance to notice.
*
* @param {number} timeCreatedMs - Unix timestamp (ms) of when the package was
* first published to the registry (npm `time.created` field).
* @param {number} nowMs - Unix timestamp (ms) for "now" (injectable for tests).
* @param {number} minAgeHours - Minimum acceptable age in hours (default 72).
* @returns {{ ok: boolean; ageHours: number }} ok=true if old enough.
*/
export function evaluateDepAge(timeCreatedMs, nowMs, minAgeHours = 72) {
const ageHours = (nowMs - timeCreatedMs) / (1000 * 60 * 60);
return { ok: ageHours >= minAgeHours, ageHours };
}
/**
* Queries the npm registry for a package.
* Returns { exists: boolean, createdMs: number | null } or null on network error.
* Network failures are treated as "offline" — the caller decides what to do.
*
* @param {string} pkgName
* @param {number} timeoutMs - How long to wait for the registry (default 8 000).
* @returns {{ exists: boolean; createdMs: number | null } | null}
*/
export function queryNpmRegistry(pkgName, timeoutMs = 8000) {
// Scope packages need URL-encoding for the `npm view` command.
// `npm view` accepts scoped packages natively — no encoding needed.
try {
const raw = execFileSync(
"npm",
["view", pkgName, "time.created", "--json"],
{
encoding: "utf8",
timeout: timeoutMs,
// Suppress npm progress/warn output on stderr
stdio: ["ignore", "pipe", "pipe"],
}
);
// npm view --json emits a quoted string or null/empty for missing fields
const trimmed = raw.trim();
if (!trimmed) {
// Package exists but has no time.created (very unusual; treat as exists, age unknown)
return { exists: true, createdMs: null };
}
const parsed = JSON.parse(trimmed);
if (!parsed) return { exists: true, createdMs: null };
const ms = new Date(parsed).getTime();
return { exists: true, createdMs: Number.isFinite(ms) ? ms : null };
} catch (err) {
// npm exits with code 1 when the package is NOT found ("E404")
const stderr = err.stderr?.toString() || "";
const stdout = err.stdout?.toString() || "";
if (stderr.includes("E404") || stdout.includes("E404") || stderr.includes("npm ERR! code E404")) {
return { exists: false, createdMs: null };
}
// Any other error (ETIMEDOUT, ENOTFOUND, etc.) = network/offline — return null
return null;
}
}
/**
* For a list of new (unapproved) deps, performs registry existence + age checks.
* Returns an object with three lists:
* - notFound: packages that do NOT exist in the registry (likely hallucinated)
* - tooNew: packages that exist but were published within the last 72h
* - offline: packages we could not verify (registry unreachable)
*
* Designed to run AFTER findUnapprovedDeps — only called when there are new deps.
*
* @param {string[]} newDeps
* @param {number} minAgeHours
* @param {number} nowMs
* @returns {{ notFound: string[]; tooNew: Array<{name:string,ageHours:number}>; offline: string[] }}
*/
export function auditNewDepsRegistry(newDeps, minAgeHours = 72, nowMs = Date.now()) {
const notFound = [];
const tooNew = [];
const offline = [];
for (const dep of newDeps) {
const result = queryNpmRegistry(dep);
if (result === null) {
// Network error — skip gracefully
offline.push(dep);
continue;
}
if (!result.exists) {
notFound.push(dep);
continue;
}
if (result.createdMs !== null) {
const { ok, ageHours } = evaluateDepAge(result.createdMs, nowMs, minAgeHours);
if (!ok) {
tooNew.push({ name: dep, ageHours: Math.round(ageHours * 10) / 10 });
}
}
// exists + old enough (or age unknown) → pass silently
}
return { notFound, tooNew, offline };
}
function main() {
@@ -50,17 +224,66 @@ function main() {
process.exit(1);
}
const allowlist = new Set(JSON.parse(fs.readFileSync(ALLOWLIST_PATH, "utf8")).allowed || []);
const unapproved = findUnapprovedDeps(collectDepNames(), allowlist);
const allDepNames = collectDepNames(ROOT);
// 6A.8: stale-allowlist enforcement.
// A dep in the allowlist that is no longer used in ANY manifest is stale — the dep
// was removed, but the allowlist entry was not. Stale entries let the dep silently
// re-appear without triggering the review gate (regression risk).
// Note: only flag entries that appear in NO manifest; a dep may be in the allowlist
// but only transitively installed, so we check against what manifests declare.
const liveDepSet = new Set(allDepNames);
assertNoStale(allowlist, liveDepSet, "check-deps");
const unapproved = findUnapprovedDeps(allDepNames, allowlist);
if (unapproved.length) {
// Task 7.8: For each new dep, run registry-existence + age-cooldown checks.
// This enriches the error message — tells the reviewer whether the package
// even exists and how recently it was published, before they allowlist it.
// Failures here do NOT add extra exit(1) calls — the allowlist gate already
// fails; these are purely informational addenda to the error output.
console.error(
`[check-deps] ${unapproved.length} dependência(s) FORA da allowlist:\n` +
unapproved.map((d) => " ✗ " + d).join("\n") +
`\n → confirme que o pacote é legítimo (existe no registry, publisher conhecido, não é typosquat)\n` +
` e adicione o nome a dependency-allowlist.json ("allowed"). Esse é o ponto de revisão humana.`
);
// Registry audit (Task 7.8) — runs only when there are new deps.
// Failures are non-fatal on network errors; registry check is advisory enrichment
// (the allowlist gate above is the hard block).
console.error(`[check-deps] Verificando deps novas no registry npm (Task 7.8)…`);
const { notFound, tooNew, offline } = auditNewDepsRegistry(unapproved);
if (offline.length) {
console.warn(
`[check-deps] WARN — registry npm inacessível (offline?); ` +
`não foi possível verificar: ${offline.join(", ")}`
);
}
if (notFound.length) {
console.error(
`[check-deps] BLOQUEIO EXTRA — ${notFound.length} dep(s) NÃO encontrada(s) no registry npm ` +
`(provável nome alucinado — NÃO adicionar à allowlist!):\n` +
notFound.map((d) => ` ✗✗ ${d} (não existe no registry)`).join("\n")
);
}
if (tooNew.length) {
console.error(
`[check-deps] BLOQUEIO EXTRA — ${tooNew.length} dep(s) publicada(s) há <72h ` +
`(age-cooldown anti-slopsquatting — aguarde 72h após publicação):\n` +
tooNew.map((d) => ` ✗✗ ${d.name} (publicada há ~${d.ageHours}h)`).join("\n")
);
}
process.exit(1);
}
console.log(`[check-deps] OK — ${allowlist.size} dependências na allowlist, nenhuma nova`);
if (process.exitCode === 1) return; // stale entries already logged
const manifests = discoverManifests(ROOT);
console.log(
`[check-deps] OK — ${allowlist.size} dependências na allowlist, ` +
`${manifests.length} manifests escaneados, nenhuma nova dep`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -14,9 +14,12 @@
// Tudo que é ruído conhecido (superfície proxy OpenAI-compat, refs a arquivos-fonte,
// APIs upstream de terceiros, placeholders) vai para IGNORE com justificativa, NÃO para
// a allowlist. A allowlist congela só drift REAL pré-existente de docs.
// Stale-enforcement (6A.3): entrada em KNOWN_STALE_DOC_REFS que não suprime nenhum miss
// real → gate falha com instrução de remoção (evita furo de regressão silencioso).
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const ROOT = process.cwd();
const DOCS = path.join(ROOT, "docs");
@@ -201,6 +204,13 @@ function main() {
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 misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS);
if (misses.length) {
console.error(
@@ -210,12 +220,14 @@ function main() {
` adicione um padrão a IGNORE com justificativa. NÃO adicione à allowlist sem` +
` confirmar que é drift pré-existente real.`
);
process.exit(1);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` +
`${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas`
);
}
console.log(
`[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` +
`${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -19,7 +19,9 @@ const BASELINE_PATH = path.resolve(
);
const UPDATE = process.argv.includes("--update");
const EPS = 0.05; // tolerância de ruído de float (jscpd é determinístico; isto é margem)
const JSCPD_ARGS = ["jscpd@4", "src", "open-sse", "--reporters", "json", "--silent", "--min-tokens", "50", "--ignore", "**/*.test.ts,**/*.test.tsx,**/__tests__/**"];
// Use local binary (pinned in package.json devDependencies — no registry download at CI time)
const JSCPD_BIN = path.join(ROOT, "node_modules", ".bin", "jscpd");
const JSCPD_FIXED_ARGS = ["src", "open-sse", "--reporters", "json", "--silent", "--min-tokens", "50", "--ignore", "**/*.test.ts,**/*.test.tsx,**/__tests__/**"];
/** Avalia a % atual contra o baseline. */
export function evaluateDuplication(current, baseline, eps = EPS) {
@@ -31,7 +33,7 @@ export function evaluateDuplication(current, baseline, eps = EPS) {
function measureDuplicationPct() {
const out = fs.mkdtempSync(path.join(os.tmpdir(), "jscpd-"));
execFileSync("npx", ["--yes", ...JSCPD_ARGS, "--output", out], { stdio: "ignore" });
execFileSync(JSCPD_BIN, [...JSCPD_FIXED_ARGS, "--output", out], { stdio: "ignore" });
const report = JSON.parse(fs.readFileSync(path.join(out, "jscpd-report.json"), "utf8"));
return report.statistics.total.percentage;
}

View File

@@ -76,6 +76,9 @@ const IGNORE_FROM_CODE = new Set([
// CI providers (set by the runner).
"GITHUB_BASE_REF",
"GITHUB_BASE_SHA",
// PR body injected by GitHub Actions into the pr-evidence gate (github.event.pull_request.body);
// a CI-only signal, never an OmniRoute runtime config (Phase 7.10).
"PR_BODY",
// CLI machine-id token opt-out (server-side flag; not user-configurable via .env).
"OMNIROUTE_DISABLE_CLI_TOKEN",
// update-notifier opt-out for the CLI binary.

View File

@@ -19,19 +19,38 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
// Directories to scan (Hard Rule #12 applies to ALL error-response-building surfaces).
// 6A.8: expanded from executors+handlers to include MCP server tools and API route files.
const SCAN_DIRS = [
path.join(cwd, "open-sse/executors"),
path.join(cwd, "open-sse/handlers"),
path.join(cwd, "open-sse/mcp-server"),
];
// Glob-style pattern for API route files under src/app/api/ (matched by path test below).
const IS_API_ROUTE = /^src\/app\/api\/.+\/route\.tsx?$/;
// Pre-existing violators frozen so the gate is green NOW and blocks only NEW leaks.
// Each entry is a real Rule #12 gap (raw err.message forwarded into a response body
// with no utils/error import) and should become a tracked cleanup issue: route the
// message through sanitizeErrorMessage()/buildErrorBody()/makeExecutorErrorResult().
// Do NOT add new entries without a justification — that defeats the gate.
export const KNOWN_MISSING_ERROR_HELPER = new Set([]);
export const KNOWN_MISSING_ERROR_HELPER = new Set([
// --- original open-sse/executors + handlers scope (pre-6A.8) ---
// --- 6A.8 expanded scope: src/app/api/**/route.ts pre-existing violations ---
// TODO(6A.8): pre-existing, triage — route through buildErrorBody()/sanitizeErrorMessage()
"src/app/api/cli-tools/backups/route.ts",
"src/app/api/cli-tools/guide-settings/[toolId]/route.ts",
"src/app/api/logs/export/route.ts",
"src/app/api/models/catalog/route.ts",
"src/app/api/providers/test-batch/route.ts",
"src/app/api/settings/import-json/route.ts",
"src/app/api/usage/proxy-logs/route.ts",
]);
// Import specifiers that count as "uses the error helper" (path ends in utils/error).
const ERROR_HELPER_IMPORT =
@@ -213,6 +232,7 @@ export function findErrorHelperViolations(files, allowlist) {
function collectFiles() {
const files = [];
// Standard scan dirs (open-sse/executors, handlers, mcp-server).
for (const dir of SCAN_DIRS) {
for (const p of walk(dir)) {
files.push({
@@ -221,26 +241,28 @@ function collectFiles() {
});
}
}
// 6A.8: also scan all src/app/api/**/route.ts files.
const apiRoot = path.join(cwd, "src/app/api");
for (const p of walk(apiRoot)) {
const rel = path.relative(cwd, p).replace(/\\/g, "/");
if (IS_API_ROUTE.test(rel)) {
files.push({ path: rel, source: fs.readFileSync(p, "utf8") });
}
}
return files;
}
function main() {
const files = collectFiles();
// 6A.8: stale-allowlist enforcement.
// Compute live violations WITHOUT the allowlist so we can detect entries that are
// now stale (the violation was fixed, but the freeze entry was not removed).
const liveViolations = findErrorHelperViolations(files, new Set());
assertNoStale(KNOWN_MISSING_ERROR_HELPER, liveViolations, "check-error-helper");
// Suppress known pre-existing violations so only NEW leaks fail the gate.
const violations = findErrorHelperViolations(files, KNOWN_MISSING_ERROR_HELPER);
// Surface allowlist drift: entries that no longer match a real file (cleaned up or
// renamed) so the allowlist does not rot. This is a warning, not a failure.
const present = new Set(files.map((f) => f.path));
const stale = [...KNOWN_MISSING_ERROR_HELPER].filter((p) => !present.has(p));
if (stale.length) {
console.warn(
`[check-error-helper] WARN: ${stale.length} allowlist entr${
stale.length === 1 ? "y" : "ies"
} no longer match a file (remove from KNOWN_MISSING_ERROR_HELPER):\n` +
stale.map((p) => " - " + p).join("\n")
);
}
if (violations.length) {
console.error(
`[check-error-helper] ${violations.length} file(s) build an error response/result with a ` +
@@ -252,6 +274,7 @@ function main() {
);
process.exit(1);
}
if (process.exitCode === 1) return; // stale entries already logged
console.log(
`[check-error-helper] OK (${files.length} files scanned, ${KNOWN_MISSING_ERROR_HELPER.size} known-missing frozen)`
);

View File

@@ -270,6 +270,11 @@ const ENV_VAR_DENYLIST = new Set([
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_FLOOR",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_CEILING",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_CAP",
// Gate allowlist constant names (JS identifiers, not env vars) — documented in
// docs/architecture/QUALITY_GATES.md and docs/research/DISCOVERY_TOOL_DESIGN.md
"KNOWN_STALE_DOC_REFS", // export const in check-docs-symbols.mjs
"KNOWN_MISSING", // export const in check-fetch-targets.mjs
"KNOWN_RAW_SQL", // export const in check-db-rules.mjs
]);
/** Endpoints that don't follow the standard route.ts pattern. */

View File

@@ -1,32 +1,49 @@
#!/usr/bin/env node
// scripts/check/check-fetch-targets.mjs
// Gate anti-alucinação: todo fetch("/api/...") em src/app/(dashboard) deve resolver
// para um route.ts real em src/app/api/. Mata rotas inventadas (a IA editando a UI
// "chuta" um endpoint que não existe). 300 paths hardcoded sem ligação de compilação
// com as 488 rotas — este gate cria essa ligação no CI.
// scripts/check/check-fetch-targets.mjs v2
// Gate anti-alucinação: todo fetch("/api/...") em src/ (client-side) deve
// resolver para um route.ts real em src/app/api/. Mata rotas inventadas.
//
// Três subchecks (6A.7):
// 1. Paths estáticos literais: fetch("/api/foo") → rota deve existir
// 2. Prefixo de template literal: fetch(`/api/x/${id}`) → prefixo estático deve ter
// ao menos uma rota filha/irmã
// 3. Método HTTP literal: fetch("/api/foo", { method: "POST" }) → route.ts
// deve exportar POST (inclui re-exports)
//
// Escopo (v2): todo src/**/*.{ts,tsx} client-side
// Excluídos: src/app/api/**, src/lib/db/**, *.test.ts, *.spec.ts, node_modules
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
const DASH = path.join(cwd, "src/app/(dashboard)");
const SRC = path.join(cwd, "src");
const API = path.join(cwd, "src/app/api");
// Paths que o checker não resolve estaticamente (allowlist com justificativa):
// - /api/v1/* é a superfície OpenAI-compat (proxy), não rotas internas do dashboard.
// - paths construídos por template/concatenação não são literais estáticos.
const IGNORE = [
/^\/api\/v1\//, // superfície OpenAI-compat
];
// Mismatches dashboard→rota PRÉ-EXISTENTES (UI chama rota que não existe → 404 ou
// código morto). Congelados para a catraca ficar verde e bloquear QUALQUER nova rota
// inventada. CADA UM precisa de triagem: criar a rota, corrigir o path, ou remover a
// chamada morta. NÃO adicione novos aqui sem justificativa — esse é o ponto do gate.
// Mismatches src/**→rota PRÉ-EXISTENTES ou não-resolvíveis estaticamente.
// Congelados para a catraca ficar verde e bloquear qualquer nova rota inventada.
// NÃO adicione novos sem justificativa — esse é o ponto do gate.
//
// Format for stale-enforcement: entries must match the string produced by
// the checkers below (i.e. the raw apiPath or prefix string, not the file+arrow).
const KNOWN_MISSING = new Set([
// All previously known-missing routes have been resolved.
// src/lib/evals/evalRunner.ts → /api/data (server-side eval runner calling a
// local data endpoint that is not a Next.js route; needs a real route or fix)
"/api/data",
// src/app/(dashboard)/…/AgentBridgePageClient.tsx calls bypass with PUT but
// the route only exports GET/POST/DELETE — real method miss, tracked for fix.
"/api/tools/agent-bridge/bypass::PUT",
]);
// ─── filesystem helpers ───────────────────────────────────────────────────────
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -37,6 +54,19 @@ function walk(dir, acc = []) {
return acc;
}
/** Is this file excluded from client-side scanning? */
function isExcluded(filePath) {
const rel = path.relative(cwd, filePath).replace(/\\/g, "/");
return (
rel.startsWith("src/app/api/") ||
rel.includes("node_modules") ||
rel.includes(".next") ||
rel.startsWith("src/lib/db/") ||
/\.test\.(ts|tsx)$/.test(rel) ||
/\.spec\.(ts|tsx)$/.test(rel)
);
}
function collectRouteFiles() {
return new Set(
walk(API)
@@ -45,55 +75,226 @@ function collectRouteFiles() {
);
}
// /api/providers/abc/models → src/app/api/providers/[id]/models/route.ts
export function resolveApiPathToRoute(apiPath, routeFiles) {
// ─── route resolution helpers (exported for tests) ───────────────────────────
/**
* Resolves an API path to the most specific matching route file.
* Prefers static routes over dynamic [param] routes when both match.
*
* @param {string} apiPath - e.g. "/api/combos/test"
* @param {Set<string>} routeFiles - relative paths like "src/app/api/…/route.ts"
* @returns {string | null} matched route file path, or null
*/
export function resolveApiPathToRouteFile(apiPath, routeFiles) {
const segs = apiPath
.replace(/^\//, "")
.replace(/[?#].*$/, "")
.split("/");
let staticMatch = null;
let dynamicMatch = null;
for (const rf of routeFiles) {
const rsegs = rf
.replace(/^src\/app\//, "")
.replace(/\/route\.tsx?$/, "")
.split("/");
if (rsegs.length !== segs.length) continue;
const isDynamic = rsegs.some((rs) => /^\[.*\]$/.test(rs));
const ok = rsegs.every((rs, i) => rs === segs[i] || /^\[.*\]$/.test(rs));
if (ok) {
if (!isDynamic) staticMatch = rf;
else if (!dynamicMatch) dynamicMatch = rf;
}
}
return staticMatch || dynamicMatch;
}
/**
* Returns true if the API path resolves to any known route file.
* Exported for backward compatibility with existing tests.
*/
export function resolveApiPathToRoute(apiPath, routeFiles) {
return resolveApiPathToRouteFile(apiPath, routeFiles) !== null;
}
/**
* Prefix-match for template literals: checks whether any route exists whose
* path starts with the static prefix (same depth or deeper).
*
* @param {string} prefix - static prefix extracted from a template literal,
* e.g. "/api/providers/" or "/api/usage/analytics?since="
* @param {Set<string>} routeFiles
* @returns {boolean}
*/
export function resolveApiPrefixToRoute(prefix, routeFiles) {
// Strip query params and trailing slash from the prefix
const cleanPrefix = prefix.replace(/[?#].*$/, "").replace(/\/$/, "");
const prefixSegs = cleanPrefix.replace(/^\//, "").split("/");
for (const rf of routeFiles) {
const rsegs = rf
.replace(/^src\/app\//, "")
.replace(/\/route\.tsx?$/, "")
.split("/");
// Route must be at least as deep as the prefix
if (rsegs.length < prefixSegs.length) continue;
const ok = prefixSegs.every((ps, i) => ps === rsegs[i] || /^\[.*\]$/.test(rsegs[i]));
if (ok) return true;
}
return false;
}
function extractFetchPaths(file) {
const src = fs.readFileSync(file, "utf8");
// Só literais ESTÁTICOS começando em /api/ (não template literals com ${...}).
/**
* Checks whether a route file's source exports the given HTTP method.
* Handles:
* - `export async function POST(…)`
* - `export const DELETE = …`
* - `export { GET, PUT } from "…"` (re-exports)
*
* @param {string} routeSource - the CONTENT of the route file (string)
* @param {string} method - uppercase HTTP method, e.g. "POST"
* @returns {boolean}
*/
export function routeExportsMethod(routeSource, method) {
// Direct export: `export [async] function METHOD` or `export const METHOD`
const directRe = new RegExp(
`export\\s+(?:async\\s+)?(?:function|const)\\s+${method}\\b`
);
if (directRe.test(routeSource)) return true;
// Re-export: `export { GET, PUT } from "…"`
const reExportRe = /export\s*\{([^}]+)\}\s*from/g;
let m;
while ((m = reExportRe.exec(routeSource))) {
const names = m[1]
.split(",")
.map((s) => s.trim().split(/\s+as\s+/)[0].trim());
if (names.includes(method)) return true;
}
return false;
}
// ─── extraction helpers ───────────────────────────────────────────────────────
/**
* Extracts static /api/ paths from fetch/fetchJson/apiFetch calls.
* Returns only full static literals (no template expressions).
*/
function extractStaticFetchPaths(content) {
// Matches: fetch("/api/foo"), fetch('/api/foo'), fetch(`/api/foo`) (no ${ })
// The negative lookahead (?!.*\$\{) is applied on the matched string itself
const re = /(?:fetch|fetchJson|apiFetch)\(\s*["'`](\/api\/[A-Za-z0-9_\-/[\]]+)["'`]/g;
const out = [];
let m;
while ((m = re.exec(src))) out.push(m[1]);
while ((m = re.exec(content))) out.push(m[1]);
return out;
}
/**
* Extracts static prefixes from template-literal fetch calls that contain
* at least one dynamic expression (${…}).
*/
function extractTemplateFetchPrefixes(content) {
const re = /(?:fetch|fetchJson|apiFetch)\(\s*`(\/api\/[^`]*)`/g;
const out = [];
let m;
while ((m = re.exec(content))) {
const full = m[1];
const dynIdx = full.indexOf("${");
if (dynIdx !== -1) {
out.push(full.substring(0, dynIdx));
}
}
return out;
}
/**
* Extracts static fetch paths together with the HTTP method literal, when
* present in the options object (2nd argument of fetch).
* Returns { apiPath, method } pairs where method defaults to "GET".
*/
function extractStaticFetchPathsWithMethod(content) {
// Match static path + optional second argument block (up to 500 chars)
const re =
/(?:fetch|fetchJson|apiFetch)\(\s*["'](\/api\/[A-Za-z0-9_\-/[\]]+)["']\s*(?:,\s*(\{[^)]{0,500}))?/g;
const out = [];
let m;
while ((m = re.exec(content))) {
const apiPath = m[1];
const optStr = m[2] || "";
const methodMatch = /method\s*:\s*["']([A-Z]+)["']/.exec(optStr);
const method = methodMatch ? methodMatch[1] : "GET";
out.push({ apiPath, method });
}
return out;
}
// ─── main ─────────────────────────────────────────────────────────────────────
function main() {
const routeFiles = collectRouteFiles();
const misses = [];
for (const f of walk(DASH)) {
for (const apiPath of extractFetchPaths(f)) {
const liveMissesStatic = new Set();
const liveMissesMethod = new Set();
for (const f of walk(SRC)) {
if (isExcluded(f)) continue;
const content = fs.readFileSync(f, "utf8");
// Subcheck 1: static paths
for (const apiPath of extractStaticFetchPaths(content)) {
if (IGNORE.some((rx) => rx.test(apiPath))) continue;
if (KNOWN_MISSING.has(apiPath)) continue;
if (KNOWN_MISSING.has(apiPath)) {
liveMissesStatic.add(apiPath); // record as live so stale-check works
continue;
}
if (!resolveApiPathToRoute(apiPath, routeFiles)) {
misses.push(`${path.relative(cwd, f)}${apiPath}`);
console.error(
`[check-fetch-targets] ✗ rota inexistente: ${path.relative(cwd, f)}${apiPath}`
);
process.exitCode = 1;
liveMissesStatic.add(apiPath);
}
}
// Subcheck 2: template literal prefixes
for (const prefix of extractTemplateFetchPrefixes(content)) {
if (IGNORE.some((rx) => rx.test(prefix))) continue;
if (!resolveApiPrefixToRoute(prefix, routeFiles)) {
console.error(
`[check-fetch-targets] ✗ prefixo de template inexistente: ${path.relative(cwd, f)} → "${prefix}"`
);
process.exitCode = 1;
}
}
// Subcheck 3: HTTP method on static paths
for (const { apiPath, method } of extractStaticFetchPathsWithMethod(content)) {
if (method === "GET") continue;
if (IGNORE.some((rx) => rx.test(apiPath))) continue;
const routeFile = resolveApiPathToRouteFile(apiPath, routeFiles);
if (!routeFile) continue; // Already caught by subcheck 1
const key = `${apiPath}::${method}`;
if (KNOWN_MISSING.has(key)) {
liveMissesMethod.add(key); // record as live for stale-check
continue;
}
const routeSource = fs.readFileSync(path.join(cwd, routeFile), "utf8");
if (!routeExportsMethod(routeSource, method)) {
console.error(
`[check-fetch-targets] ✗ método ${method} não exportado: ${path.relative(cwd, f)}${apiPath} (em ${routeFile})`
);
process.exitCode = 1;
liveMissesMethod.add(key);
}
}
}
if (misses.length) {
console.error(
`[check-fetch-targets] ${misses.length} fetch(es) para rota inexistente:\n` +
misses.map((m) => " ✗ " + m).join("\n") +
`\n → crie o route.ts faltante, corrija o path, ou adicione um padrão a IGNORE com justificativa.`
);
process.exit(1);
// Stale-enforcement: any entry in KNOWN_MISSING that was NOT seen as a live
// violation means the problem was fixed — the entry must be removed to lock
// in the improvement (6A.3 pattern).
const allLive = new Set([...liveMissesStatic, ...liveMissesMethod]);
assertNoStale([...KNOWN_MISSING], allLive, "fetch-targets");
if (!process.exitCode) {
console.log(`[check-fetch-targets] OK (${routeFiles.size} rotas conhecidas)`);
}
console.log(`[check-fetch-targets] OK (${routeFiles.size} rotas conhecidas)`);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -17,7 +17,9 @@ function getArg(name, fallback) {
}
const BASELINE_PATH = path.resolve(getArg("--baseline", path.join(ROOT, "file-size-baseline.json")));
const UPDATE = process.argv.includes("--update");
const SCAN_DIRS = ["src", "open-sse"];
const SCAN_DIRS = ["src", "open-sse", "electron", "bin"];
// Directories to skip when walking — build artifacts and installed packages.
const SKIP_DIRS = new Set(["node_modules", "dist-electron", ".next", ".build", "dist", "coverage"]);
/**
* Avalia LOC atuais contra o baseline congelado.
@@ -45,8 +47,11 @@ function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, acc);
else if (/\.(ts|tsx)$/.test(e.name) && !/\.test\.tsx?$/.test(e.name) && !/\.d\.ts$/.test(e.name)) acc.push(p);
if (e.isDirectory()) {
if (!SKIP_DIRS.has(e.name)) walk(p, acc);
} else if (/\.(ts|tsx)$/.test(e.name) && !/\.test\.tsx?$/.test(e.name) && !/\.d\.ts$/.test(e.name)) {
acc.push(p);
}
}
return acc;
}

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
// scripts/check/check-known-symbols.ts
// Gate anti-alucinação: known-symbol allow-lists. Mata o padrão "símbolo inventado
// que silenciosamente vira no-op" em três superfícies de despacho por-string/por-chave:
// que silenciosamente vira no-op" em seis superfícies de despacho por-string/por-chave:
//
// (1) EXECUTOR CONFORMANCE — toda entrada registrada no mapa de executores
// (open-sse/executors/index.ts) DEVE resolver, via getExecutor(), para uma
@@ -21,13 +21,27 @@
// se um par registrado some, falha (regressão de cobertura de formato). Pares
// novos não falham — apenas são reportados — para não bloquear adições legítimas.
//
// (4) MCP TOOLS — todos os tools registrados em createMcpServer() (base MCP_TOOLS +
// memoryTools + skillTools + gamificationTools + pluginTools + notionTools +
// obsidianTools) DEVEM ter ao menos um escopo atribuído (scope-enforcement). Os
// nomes são congelados em KNOWN_MCP_TOOL_NAMES. Catraca: tool removido = fail.
// Tool novo = report (não bloqueia adições legítimas).
//
// (5) A2A SKILLS — chaves de A2A_SKILL_HANDLERS (src/lib/a2a/taskExecution.ts) DEVEM
// bater bidirecionalmente com skills[].id expostos no Agent Card
// (src/app/.well-known/agent.json/route.ts). Divergência em qualquer direção = fail.
//
// (6) CLOUD AGENTS — entradas de AGENTS em src/lib/cloudAgent/registry.ts DEVEM bater
// bidirecionalmente com os arquivos de classe em src/lib/cloudAgent/agents/
// (basename sem extensão). Divergência = fail.
//
// Catraca: cada divergência pré-existente fica numa allowlist documentada e sai 0 hoje.
// Padrão herdado de scripts/check/check-provider-consistency.ts (gate .ts via
// `node --import tsx` que IMPORTA módulos reais + funções puras + main() guardado).
import { readFileSync } from "node:fs";
import { readFileSync, readdirSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, resolve as resolvePath } from "node:path";
import { dirname, resolve as resolvePath, basename, extname } from "node:path";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolvePath(HERE, "..", "..");
@@ -182,7 +196,236 @@ export function findNewTranslatorPairs(frozen: readonly string[], live: Set<stri
}
// ───────────────────────────────────────────────────────────────────────────
// main() — importa módulos reais, lê fontes, roda as três sub-checagens
// (4) MCP TOOLS — scope check + snapshot catraca
// ───────────────────────────────────────────────────────────────────────────
export type McpToolLike = {
name: string;
scopes?: string[] | readonly string[];
};
/**
* Returns the names of tools that have no scopes assigned (empty array or
* undefined). Every registered MCP tool must declare at least one scope so
* that scope-enforcement can filter callers correctly.
*/
export function checkMcpToolsHaveScopes(tools: McpToolLike[]): string[] {
return tools.filter((t) => !t.scopes || t.scopes.length === 0).map((t) => t.name);
}
/**
* Returns tools in the frozen snapshot that are no longer in the live
* registry (removals are regressions).
*/
export function findMissingMcpTools(frozen: readonly string[], live: Set<string>): string[] {
return frozen.filter((name) => !live.has(name));
}
/**
* Returns live tools not present in the frozen snapshot (additions are
* informative, not failures).
*/
export function findNewMcpTools(frozen: readonly string[], live: Set<string>): string[] {
const frozenSet = new Set(frozen);
return [...live].filter((name) => !frozenSet.has(name)).sort();
}
/**
* Snapshot of all MCP tool names registered by createMcpServer() as of
* 2026-06-13. Catraca: tool removed = fail; tool added = informative report.
* To update after an intentional removal/rename: edit this list and document
* the reason in the commit message.
*
* Sources:
* - MCP_TOOLS (33 base tools: omniroute_* + compression + agent_skills)
* - memoryTools (3): omniroute_memory_*
* - skillTools (4): omniroute_skills_*
* - gamificationTools (8): gamification_*
* - pluginTools (8): plugin_*
* - notionTools (6): notion_*
* - obsidianTools (22): obsidian_*
* agentSkillTools and compressionTools are included in MCP_TOOLS (deduped by RESERVED_MCP_NAMES).
*/
export const KNOWN_MCP_TOOL_NAMES: readonly string[] = [
// MCP_TOOLS base (33)
"omniroute_get_health",
"omniroute_list_combos",
"omniroute_get_combo_metrics",
"omniroute_switch_combo",
"omniroute_check_quota",
"omniroute_route_request",
"omniroute_cost_report",
"omniroute_list_models_catalog",
"omniroute_web_search",
"omniroute_simulate_route",
"omniroute_set_budget_guard",
"omniroute_set_routing_strategy",
"omniroute_set_resilience_profile",
"omniroute_test_combo",
"omniroute_get_provider_metrics",
"omniroute_best_combo_for_task",
"omniroute_explain_route",
"omniroute_get_session_snapshot",
"omniroute_db_health_check",
"omniroute_sync_pricing",
"omniroute_cache_stats",
"omniroute_cache_flush",
"omniroute_compression_status",
"omniroute_compression_configure",
"omniroute_set_compression_engine",
"omniroute_list_compression_combos",
"omniroute_compression_combo_stats",
"omniroute_oneproxy_fetch",
"omniroute_oneproxy_rotate",
"omniroute_oneproxy_stats",
"omniroute_agent_skills_list",
"omniroute_agent_skills_get",
"omniroute_agent_skills_coverage",
// memoryTools (3)
"omniroute_memory_search",
"omniroute_memory_add",
"omniroute_memory_clear",
// skillTools (4)
"omniroute_skills_list",
"omniroute_skills_enable",
"omniroute_skills_execute",
"omniroute_skills_executions",
// gamificationTools (8)
"gamification_leaderboard",
"gamification_rank",
"gamification_profile",
"gamification_badges",
"gamification_transfer",
"gamification_invite",
"gamification_servers",
"gamification_anomalies",
// pluginTools (8)
"plugin_list",
"plugin_install",
"plugin_activate",
"plugin_deactivate",
"plugin_uninstall",
"plugin_configure",
"plugin_executions",
"plugin_scan",
// notionTools (6)
"notion_search",
"notion_get_page",
"notion_list_block_children",
"notion_query_database",
"notion_get_database",
"notion_append_blocks",
// obsidianTools (22)
"obsidian_check_status",
"obsidian_search_simple",
"obsidian_search_structured",
"obsidian_read_note",
"obsidian_list_vault",
"obsidian_get_document_map",
"obsidian_get_note_metadata",
"obsidian_get_active_file",
"obsidian_get_periodic_note",
"obsidian_get_tags",
"obsidian_list_commands",
"obsidian_write_note",
"obsidian_append_note",
"obsidian_patch_note",
"obsidian_delete_note",
"obsidian_move_note",
"obsidian_execute_command",
"obsidian_open_file",
"obsidian_sync_status",
"obsidian_sync_trigger",
"obsidian_sync_conflicts",
"obsidian_sync_resolve_conflict",
];
// ───────────────────────────────────────────────────────────────────────────
// (5) A2A SKILLS — bidirectional diff between handlers and agent card
// ───────────────────────────────────────────────────────────────────────────
export type A2ASkillDiff = {
/** Skills registered in A2A_SKILL_HANDLERS but not exposed in the Agent Card */
inHandlersNotCard: string[];
/** Skills exposed in the Agent Card but not registered in A2A_SKILL_HANDLERS */
inCardNotHandlers: string[];
};
/**
* Bidirectionally diffs A2A skill handler keys against Agent Card skill IDs.
* Both directions matter:
* - inHandlersNotCard: skill is routable but agents can't discover it
* - inCardNotHandlers: skill is advertised but calling it fails silently
*/
export function diffA2ASkills(
handlers: Set<string>,
agentCard: Set<string>
): A2ASkillDiff {
const inHandlersNotCard = [...handlers].filter((s) => !agentCard.has(s)).sort();
const inCardNotHandlers = [...agentCard].filter((s) => !handlers.has(s)).sort();
return { inHandlersNotCard, inCardNotHandlers };
}
// ───────────────────────────────────────────────────────────────────────────
// (6) CLOUD AGENTS — registry keys vs agent class files
// ───────────────────────────────────────────────────────────────────────────
export type CloudAgentDiff = {
/** Registry keys with no corresponding agent file in agents/ */
inRegistryNotFiles: string[];
/** Agent files with no corresponding registry key */
inFilesNotRegistry: string[];
};
/**
* Bidirectionally diffs cloud agent registry keys against agent file basenames
* (filename without extension, e.g. "codex.ts" → "codex"). Note: registry key
* "codex-cloud" maps to file "codex.ts" — this mapping is handled by the
* caller (main) which reads the actual class-name-to-key binding from registry.ts.
* The pure function here just diffs two already-normalised sets.
*/
export function diffCloudAgents(
registryKeys: Set<string>,
agentFiles: Set<string>
): CloudAgentDiff {
const inRegistryNotFiles = [...registryKeys].filter((k) => !agentFiles.has(k)).sort();
const inFilesNotRegistry = [...agentFiles].filter((f) => !registryKeys.has(f)).sort();
return { inRegistryNotFiles, inFilesNotRegistry };
}
/**
* Reads the registry.ts source file and returns the set of provider IDs
* (keys in the AGENTS object literal).
*/
export function extractCloudAgentRegistryKeys(registrySource: string): Set<string> {
// Match the AGENTS object: find start, extract keys
const start = registrySource.indexOf("const AGENTS: Record<string, CloudAgentBase> = {");
if (start < 0) throw new Error("could not find `const AGENTS:` in cloudAgent/registry.ts");
const end = registrySource.indexOf("\n};", start);
if (end < 0) throw new Error("could not find end of AGENTS map");
const block = registrySource.slice(start, end);
// Match quoted or bare keys: "codex-cloud": or jules:
const keyRe = /^\s*(?:"([^"]+)"|([A-Za-z0-9_$-]+))\s*:/gm;
const keys = new Set<string>();
let match: RegExpExecArray | null;
while ((match = keyRe.exec(block)) !== null) {
const key = match[1] ?? match[2];
// Skip the TypeScript type annotation line
if (key && key !== "Record") keys.add(key);
}
return keys;
}
/**
* Lists agent file basenames (without extension) from the agents/ directory.
* Maps known filename→registryKey aliases (e.g. "codex" → "codex-cloud").
*/
export const AGENT_FILE_TO_REGISTRY_KEY: Record<string, string> = {
codex: "codex-cloud",
};
// ───────────────────────────────────────────────────────────────────────────
// main() — importa módulos reais, lê fontes, roda as seis sub-checagens
// ───────────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
@@ -270,6 +513,123 @@ async function main(): Promise<void> {
}
const newPairs = findNewTranslatorPairs(KNOWN_TRANSLATOR_PAIRS, livePairs);
// ── (4) MCP tools scope + snapshot ───────────────────────────────────────
const { MCP_TOOLS } = await import("@omniroute/open-sse/mcp-server/schemas/tools.ts");
const { memoryTools } = await import("@omniroute/open-sse/mcp-server/tools/memoryTools.ts");
const { skillTools } = await import("@omniroute/open-sse/mcp-server/tools/skillTools.ts");
const { gamificationTools } = await import(
"@omniroute/open-sse/mcp-server/tools/gamificationTools.ts"
);
const { pluginTools } = await import("@omniroute/open-sse/mcp-server/tools/pluginTools.ts");
const { notionTools } = await import("@omniroute/open-sse/mcp-server/tools/notionTools.ts");
const { obsidianTools } = await import("@omniroute/open-sse/mcp-server/tools/obsidianTools.ts");
// Build the full live set of registered tools (deduped by RESERVED_MCP_NAMES logic:
// agentSkillTools + compressionTools are already in MCP_TOOLS).
const liveMcpTools: McpToolLike[] = [
...(MCP_TOOLS as unknown as McpToolLike[]),
...Object.values(memoryTools as Record<string, McpToolLike>),
...Object.values(skillTools as Record<string, McpToolLike>),
...(gamificationTools as unknown as McpToolLike[]),
...(pluginTools as unknown as McpToolLike[]),
...(notionTools as unknown as McpToolLike[]),
...(obsidianTools as unknown as McpToolLike[]),
];
const liveMcpToolNames = new Set(liveMcpTools.map((t) => t.name));
// 4a. Every registered tool must have at least one scope.
const toolsWithoutScopes = checkMcpToolsHaveScopes(liveMcpTools);
if (toolsWithoutScopes.length) {
failures.push(
`[mcp-tools] ${toolsWithoutScopes.length} tool(s) sem scope(s) atribuído(s) — todo tool registrado deve ter ao menos 1 scope para scope-enforcement:\n` +
toolsWithoutScopes.map((n) => `${n}`).join("\n") +
`\n → adicione o campo scopes: [...] na definição do tool.`
);
}
// 4b. Snapshot catraca: tools removed are regressions.
const missingMcpTools = findMissingMcpTools(KNOWN_MCP_TOOL_NAMES, liveMcpToolNames);
if (missingMcpTools.length) {
failures.push(
`[mcp-tools] ${missingMcpTools.length} tool(s) congelado(s) sumiram do registry vivo (regressão):\n` +
missingMcpTools.map((n) => `${n}`).join("\n") +
`\n → restaure o tool ou, se a remoção foi intencional, atualize KNOWN_MCP_TOOL_NAMES.`
);
}
const newMcpTools = findNewMcpTools(KNOWN_MCP_TOOL_NAMES, liveMcpToolNames);
// ── (5) A2A skills ───────────────────────────────────────────────────────
const { A2A_SKILL_HANDLERS } = await import("@/lib/a2a/taskExecution.ts");
const handlerKeys = new Set(Object.keys(A2A_SKILL_HANDLERS as Record<string, unknown>));
// Parse the Agent Card route statically (the skills array is a literal in the source).
const agentCardSource = readFileSync(
resolvePath(REPO_ROOT, "src/app/.well-known/agent.json/route.ts"),
"utf8"
);
// Extract skill IDs: `id: "..."` lines inside the skills array.
const skillIdRe = /\bid:\s*"([^"]+)"/g;
const agentCardSkills = new Set<string>();
let skillMatch: RegExpExecArray | null;
while ((skillMatch = skillIdRe.exec(agentCardSource)) !== null) {
agentCardSkills.add(skillMatch[1]);
}
if (agentCardSkills.size === 0) {
failures.push(
`[a2a-skills] parse do Agent Card não encontrou nenhum skill id (regex quebrada ou arquivo movido?)`
);
}
const { inHandlersNotCard, inCardNotHandlers } = diffA2ASkills(handlerKeys, agentCardSkills);
if (inHandlersNotCard.length) {
failures.push(
`[a2a-skills] ${inHandlersNotCard.length} skill(s) em A2A_SKILL_HANDLERS mas ausente(s) do Agent Card (agentes não conseguem descobrir):\n` +
inHandlersNotCard.map((s) => `${s}`).join("\n") +
`\n → adicione o skill em src/app/.well-known/agent.json/route.ts (skills array).`
);
}
if (inCardNotHandlers.length) {
failures.push(
`[a2a-skills] ${inCardNotHandlers.length} skill(s) expostos no Agent Card mas ausente(s) de A2A_SKILL_HANDLERS (chamada silenciosamente falha):\n` +
inCardNotHandlers.map((s) => `${s}`).join("\n") +
`\n → registre o handler em src/lib/a2a/taskExecution.ts (A2A_SKILL_HANDLERS).`
);
}
// ── (6) Cloud agents ─────────────────────────────────────────────────────
const registrySource = readFileSync(
resolvePath(REPO_ROOT, "src/lib/cloudAgent/registry.ts"),
"utf8"
);
const registryKeys = extractCloudAgentRegistryKeys(registrySource);
// Read agent file basenames from agents/ directory, applying the alias map.
const agentsDir = resolvePath(REPO_ROOT, "src/lib/cloudAgent/agents");
const agentFileBases = new Set(
readdirSync(agentsDir)
.filter((f) => /\.(ts|js)$/.test(f) && !f.endsWith(".d.ts") && !f.endsWith(".test.ts"))
.map((f) => {
const base = basename(f, extname(f));
return AGENT_FILE_TO_REGISTRY_KEY[base] ?? base;
})
);
const { inRegistryNotFiles, inFilesNotRegistry } = diffCloudAgents(registryKeys, agentFileBases);
if (inRegistryNotFiles.length) {
failures.push(
`[cloud-agents] ${inRegistryNotFiles.length} chave(s) no registry sem arquivo de classe em agents/:\n` +
inRegistryNotFiles.map((k) => `${k}`).join("\n") +
`\n → crie o arquivo src/lib/cloudAgent/agents/<name>.ts ou atualize AGENT_FILE_TO_REGISTRY_KEY.`
);
}
if (inFilesNotRegistry.length) {
failures.push(
`[cloud-agents] ${inFilesNotRegistry.length} arquivo(s) em agents/ sem entrada no registry:\n` +
inFilesNotRegistry.map((f) => `${f}`).join("\n") +
`\n → registre o agente em src/lib/cloudAgent/registry.ts ou adicione o alias em AGENT_FILE_TO_REGISTRY_KEY.`
);
}
// ── Resultado ─────────────────────────────────────────────────────────────
if (failures.length) {
console.error(`[known-symbols] ${failures.length} sub-checagem(ns) falharam:\n\n${failures.join("\n\n")}`);
@@ -279,8 +639,17 @@ async function main(): Promise<void> {
const newPairsNote = newPairs.length
? ` (${newPairs.length} par(es) novo(s) não-congelado(s): ${newPairs.join(", ")} — atualize KNOWN_TRANSLATOR_PAIRS se intencional)`
: "";
const newMcpNote = newMcpTools.length
? ` (${newMcpTools.length} tool(s) novo(s) não-congelado(s): ${newMcpTools.join(", ")} — atualize KNOWN_MCP_TOOL_NAMES se intencional)`
: "";
console.log(
`[known-symbols] OK — ${aliases.length} executores conformes; ${canonical.length} estratégias canônicas (${handled.size} via despacho + ${Object.keys(IMPLICIT_DEFAULT_STRATEGIES).length} default(s) implícito(s)); ${livePairs.size} pares de tradutor vivos vs ${KNOWN_TRANSLATOR_PAIRS.length} congelados${newPairsNote}`
`[known-symbols] OK — ` +
`${aliases.length} executores conformes; ` +
`${canonical.length} estratégias canônicas (${handled.size} via despacho + ${Object.keys(IMPLICIT_DEFAULT_STRATEGIES).length} default(s) implícito(s)); ` +
`${livePairs.size} pares de tradutor vivos vs ${KNOWN_TRANSLATOR_PAIRS.length} congelados${newPairsNote}; ` +
`${liveMcpToolNames.size} tools MCP (${toolsWithoutScopes.length === 0 ? "todos com scope" : `${toolsWithoutScopes.length} sem scope`}) vs ${KNOWN_MCP_TOOL_NAMES.length} congelados${newMcpNote}; ` +
`${handlerKeys.size} A2A skills (handlers↔card OK); ` +
`${registryKeys.size} cloud agents (registry↔files OK)`
);
}

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env node
// scripts/check/check-licenses.mjs
// Gate de license compliance — PLANO-QUALITY-GATES-FASE7.md, Task 20.
//
// Política: OmniRoute é MIT. Dependências de PRODUÇÃO com licença fora da allowlist SPDX
// e sem exceção registrada em .license-allowlist.json => FALHA (policy violation).
// devDependencies com licença não-padrão => advisory (impressas, não falham).
//
// Ferramenta: license-checker-rseidelsohn v4+ (node_modules/.bin/license-checker-rseidelsohn).
//
// Uso:
// node scripts/check/check-licenses.mjs # modo normal
// node scripts/check/check-licenses.mjs --verbose # lista todos os pacotes classificados
// node scripts/check/check-licenses.mjs --json # emite o raw JSON do license-checker
//
// Sair com código 0 = tudo OK (ou apenas advisory).
// Sair com código 1 = violação de política em dep de produção.
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 ALLOWLIST_PATH = path.join(ROOT, ".license-allowlist.json");
const CHECKER_BIN = path.join(ROOT, "node_modules", ".bin", "license-checker-rseidelsohn");
const VERBOSE = process.argv.includes("--verbose");
const PRINT_JSON = process.argv.includes("--json");
// ---------------------------------------------------------------------------
// Allowlist loading
// ---------------------------------------------------------------------------
/**
* Loads and returns the license allowlist from .license-allowlist.json.
*
* @returns {{ allowed: string[], allowedExpressions: string[], exceptions: Record<string, {license:string, justification:string, risk:string}> }}
*/
export function loadAllowlist() {
if (!fs.existsSync(ALLOWLIST_PATH)) {
throw new Error(`Allowlist not found: ${ALLOWLIST_PATH}. Create .license-allowlist.json first.`);
}
const raw = fs.readFileSync(ALLOWLIST_PATH, "utf-8");
const parsed = JSON.parse(raw);
return {
allowed: parsed.allowed ?? [],
allowedExpressions: parsed.allowedExpressions ?? [],
exceptions: parsed.exceptions ?? {},
};
}
// ---------------------------------------------------------------------------
// Classification logic (pure, testable)
// ---------------------------------------------------------------------------
/**
* Classifies a package+license against the allowlist.
*
* @param {string} packageName - Package name without version, e.g. "lightningcss"
* @param {string} license - License string from license-checker, e.g. "MPL-2.0"
* @param {{ allowed: string[], allowedExpressions: string[], exceptions: Record<string,any> }} allowlist
* @returns {{ status: "allowed" | "exception" | "denied", reason: string }}
*/
export function classifyLicense(packageName, license, allowlist) {
const { allowed, allowedExpressions, exceptions } = allowlist;
// 1. Direct SPDX match
if (allowed.includes(license)) {
return { status: "allowed", reason: `SPDX match: ${license}` };
}
// 2. Expression match (e.g. "(MIT OR Apache-2.0)")
if (allowedExpressions.includes(license)) {
return { status: "allowed", reason: `allowed expression: ${license}` };
}
// 3. Per-package exception (strip version suffix for lookup)
const baseName = stripVersion(packageName);
if (exceptions[baseName]) {
const exc = exceptions[baseName];
return {
status: "exception",
reason: `exception: ${exc.justification} [risk=${exc.risk}]`,
};
}
// 4. Denied
return { status: "denied", reason: `license '${license}' not in allowlist and no exception registered for '${baseName}'` };
}
/**
* Strips the @version suffix from a package key returned by license-checker.
* e.g. "lightningcss@1.32.0" => "lightningcss"
* "@img/sharp-libvips-linux-x64@1.2.4" => "@img/sharp-libvips-linux-x64"
*
* @param {string} pkgKey - Package key as returned by license-checker
* @returns {string}
*/
export function stripVersion(pkgKey) {
// Handle scoped packages: @scope/name@version
const scopedMatch = pkgKey.match(/^(@[^/]+\/[^@]+)(?:@.*)?$/);
if (scopedMatch) return scopedMatch[1];
// Regular: name@version
const regularMatch = pkgKey.match(/^([^@]+)(?:@.*)?$/);
if (regularMatch) return regularMatch[1];
return pkgKey;
}
// ---------------------------------------------------------------------------
// Runner
// ---------------------------------------------------------------------------
/**
* Runs license-checker-rseidelsohn --production and returns parsed JSON.
*
* @returns {Record<string, { licenses: string, path: string }>}
*/
function runLicenseChecker() {
if (!fs.existsSync(CHECKER_BIN)) {
throw new Error(
`license-checker-rseidelsohn not found at ${CHECKER_BIN}.\n` +
`Install it: npm install --save-dev license-checker-rseidelsohn`
);
}
const output = execFileSync(CHECKER_BIN, ["--production", "--json"], {
cwd: ROOT,
encoding: "utf-8",
maxBuffer: 32 * 1024 * 1024, // 32 MB
});
return JSON.parse(output);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
/** @type {Record<string, { licenses: string, path: string }>} */
let licenseData;
try {
licenseData = runLicenseChecker();
} catch (err) {
console.error("[check-licenses] Falha ao rodar license-checker-rseidelsohn:");
console.error(err.message ?? err);
process.exitCode = 1;
return;
}
if (PRINT_JSON) {
console.log(JSON.stringify(licenseData, null, 2));
return;
}
/** @type {{ allowed: string[], allowedExpressions: string[], exceptions: Record<string,any> }} */
let allowlist;
try {
allowlist = loadAllowlist();
} catch (err) {
console.error("[check-licenses]", err.message);
process.exitCode = 1;
return;
}
const violations = [];
const exceptions = [];
const advisory = [];
let allowedCount = 0;
for (const [pkgKey, info] of Object.entries(licenseData)) {
const license = info.licenses ?? "UNKNOWN";
const result = classifyLicense(pkgKey, license, allowlist);
if (result.status === "allowed") {
allowedCount++;
if (VERBOSE) {
console.log(`${pkgKey}: ${license}`);
}
} else if (result.status === "exception") {
exceptions.push({ pkgKey, license, reason: result.reason });
} else {
violations.push({ pkgKey, license, reason: result.reason });
}
}
const total = Object.keys(licenseData).length;
// Print summary
console.log(`[check-licenses] Escaneados ${total} pacotes de produção.`);
console.log(` Permitidos: ${allowedCount}`);
console.log(` Exceções registradas: ${exceptions.length}`);
console.log(` Violações de política: ${violations.length}`);
// Print exceptions (informational)
if (exceptions.length > 0) {
console.log("\n[check-licenses] Exceções registradas (não bloqueantes, revisar periodicamente):");
for (const { pkgKey, license } of exceptions) {
const baseName = stripVersion(pkgKey);
const exc = allowlist.exceptions[baseName];
const riskTag = exc?.risk === "medium" ? " ⚠️ RISK=medium" : "";
console.log(`${pkgKey}: ${license}${riskTag}`);
if (exc?.risk === "medium") {
console.log(`${exc.justification}`);
}
}
}
// Print advisory (devDep non-standard — empty here since we run --production)
if (advisory.length > 0) {
console.log("\n[check-licenses] Advisory (devDeps com licença não-padrão):");
for (const { pkgKey, license } of advisory) {
console.log(` ${pkgKey}: ${license}`);
}
}
// Print violations and fail
if (violations.length > 0) {
console.error("\n[check-licenses] ❌ VIOLAÇÕES DE POLÍTICA — deps de produção com licença não permitida:");
for (const { pkgKey, license, reason } of violations) {
console.error(`${pkgKey}: ${license}`);
console.error(`${reason}`);
}
console.error(
"\nAdicione a licença à allowlist 'allowed' em .license-allowlist.json (se SPDX-permissiva)\n" +
"ou registre uma exceção por-pacote em 'exceptions' com justificativa e 'reviewAt'.\n" +
"NÃO mascare copyleft forte sem registrar a justificativa. Ver PLANO-QUALITY-GATES-FASE7.md § Task 20."
);
process.exitCode = 1;
return;
}
console.log("\n[check-licenses] ✅ Todos os pacotes de produção estão em conformidade com a política de licenças.");
}
// Run only when invoked directly (not when imported by tests)
const isMain = process.argv[1] === pathToFileURL(import.meta.url).pathname ||
process.argv[1]?.endsWith("check-licenses.mjs");
if (isMain) {
main();
}

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env node
// scripts/check/check-lockfile.mjs
// Gate de política de lockfile (CLAUDE.md — extensão Hard Rule #1).
//
// Objetivo: detectar supply-chain poisoning no package-lock.json antes que código
// malicioso entre no repo. Verifica:
// --validate-https → toda URL "resolved" deve usar HTTPS (bloqueia http://)
// --validate-integrity → todo pacote deve ter hash de integridade sha512
// --allowed-hosts npm → apenas registry.npmjs.org é host permitido
//
// Complementa check-deps (Fase 2 / allowlist de nomes): aquele garante que só
// nomes aprovados entram; este garante que os pacotes instalados vieram do registry
// legítimo com integridade verificável.
//
// Referência: PLANO-QUALITY-GATES-FASE7.md, Task 7.7.
// Tool: lockfile-lint v5 (node_modules/.bin/lockfile-lint).
import { execFileSync } from "node:child_process";
import path from "node:path";
import fs from "node:fs";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
/**
* Returns the canonical lockfile-lint configuration used by this gate.
* Exporting this object makes the policy auditable and unit-testable without
* spawning a child process.
*
* @returns {{
* lockfilePath: string,
* type: string,
* validateHttps: boolean,
* validateIntegrity: boolean,
* allowedHosts: string[],
* }}
*/
export function getLockfileLintConfig() {
return {
lockfilePath: path.join(ROOT, "package-lock.json"),
type: "npm",
validateHttps: true,
validateIntegrity: true,
// Only the official npm registry is permitted.
// registry.npmjs.org resolves to the "npm" shorthand in lockfile-lint.
// If the project ever adopts a scoped/private registry, add its hostname here
// and document the justification.
allowedHosts: ["npm"],
};
}
/**
* Builds the argv array to pass to the lockfile-lint binary, derived from
* the config returned by getLockfileLintConfig().
*
* @param {ReturnType<typeof getLockfileLintConfig>} cfg
* @returns {string[]}
*/
export function buildLockfileLintArgs(cfg) {
const args = [
"--path", cfg.lockfilePath,
"--type", cfg.type,
];
if (cfg.validateHttps) args.push("--validate-https");
if (cfg.validateIntegrity) args.push("--validate-integrity");
if (cfg.allowedHosts.length) {
args.push("--allowed-hosts", ...cfg.allowedHosts);
}
return args;
}
function main() {
const cfg = getLockfileLintConfig();
if (!fs.existsSync(cfg.lockfilePath)) {
console.error(
`[check-lockfile] FAIL — lockfile not found: ${cfg.lockfilePath}\n` +
" → Run `npm install` to generate package-lock.json"
);
process.exit(1);
}
const bin = path.join(ROOT, "node_modules", ".bin", "lockfile-lint");
if (!fs.existsSync(bin)) {
console.error(
`[check-lockfile] FAIL — lockfile-lint binary not found at:\n ${bin}\n` +
" → Run `npm install` to install dev dependencies"
);
process.exit(1);
}
const args = buildLockfileLintArgs(cfg);
try {
const output = execFileSync(bin, args, { encoding: "utf8" });
// lockfile-lint outputs a green ✔ message on success
console.log("[check-lockfile] OK —", output.trim());
} catch (err) {
const stdout = err.stdout ?? "";
const stderr = err.stderr ?? "";
console.error("[check-lockfile] FAIL — lockfile-lint found policy violations:");
if (stdout) console.error(stdout);
if (stderr) console.error(stderr);
console.error(
"\n Possible causes:\n" +
" • A package was resolved from a non-HTTPS URL (http:// poisoning attempt)\n" +
" • A package is missing its integrity hash (tampered or legacy entry)\n" +
" • A package was resolved from a host other than registry.npmjs.org\n" +
" If a scoped/private registry is intentionally used, add its hostname\n" +
" to getLockfileLintConfig().allowedHosts in scripts/check/check-lockfile.mjs"
);
process.exit(1);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -11,9 +11,12 @@
// As anomalias conhecidas são derivadas das listas de migrationRunner.ts
// (LEGACY_VERSION_SLOT_MIGRATIONS / SUPERSEDED_DUPLICATE_MIGRATIONS) + a auditoria
// de gaps de sequência. NÃO adicione novos itens sem justificativa — esse é o ponto.
// Stale-enforcement (6A.3): entrada em KNOWN_GAPS / KNOWN_DUPLICATE_VERSIONS que não
// suprime nenhuma anomalia real → gate falha com instrução de remoção.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
const MIGRATIONS_DIR = path.join(cwd, "src/lib/db/migrations");
@@ -26,12 +29,15 @@ const MIGRATION_NAME_RE = /^(\d{3,})_(.+)\.sql$/;
// ALLOWLIST 1 — duplicatas de versão CONHECIDAS.
// Fonte: src/lib/db/migrationRunner.ts → SUPERSEDED_DUPLICATE_MIGRATIONS (~L188).
// O runner já aceita estes slots de versão reutilizados (a migration "renomeada"
// foi promovida para um número novo, e o slot antigo é tolerado). No disco atual
// NÃO há arquivos físicos colidindo, mas congelamos os números reconhecidos para
// que, se um arquivo legado reaparecer com esse prefixo, o gate não exploda.
// foi promovida para um número novo, e o slot antigo é tolerado). Adicionar aqui
// SOMENTE se houver um arquivo físico duplicado no disco (stale-enforcement 6A.3
// detecta entradas sem duplicata física viva e força remoção).
// ---------------------------------------------------------------------------
export const KNOWN_DUPLICATE_VERSIONS = new Set([
"041", // session_account_affinity → promovida para 050 (SUPERSEDED_DUPLICATE_MIGRATIONS)
// "041" was removed: 041_session_account_affinity.sql no longer exists on disk
// (only 041_compression_receipts.sql remains), so no physical duplicate is present.
// The SUPERSEDED_DUPLICATE_MIGRATIONS entry in migrationRunner.ts handles the runner
// compatibility at runtime without needing an allowlist here. (#6A.3 stale cleanup)
]);
// ---------------------------------------------------------------------------
@@ -108,6 +114,14 @@ function listMigrationFilenames() {
function main() {
const filenames = listMigrationFilenames();
// Compute raw anomalies WITHOUT allowlists — needed for stale-enforcement (6A.3).
const raw = findMigrationAnomalies(filenames, new Set(), new Set());
const liveGaps = raw.gaps;
const liveDupVersions = raw.duplicates.map((d) => d.version);
assertNoStale(KNOWN_GAPS, liveGaps, "check-migration-numbering:gaps");
assertNoStale(KNOWN_DUPLICATE_VERSIONS, liveDupVersions, "check-migration-numbering:duplicates");
const { duplicates, gaps, badNames } = findMigrationAnomalies(
filenames,
KNOWN_DUPLICATE_VERSIONS,
@@ -133,13 +147,15 @@ function main() {
`adicione o número às allowlists KNOWN_DUPLICATE_VERSIONS / KNOWN_GAPS com ` +
`justificativa rastreável a src/lib/db/migrationRunner.ts.`
);
process.exit(1);
process.exitCode = 1;
}
console.log(
`[check-migration-numbering] OK (${filenames.length} migrations, ` +
`${KNOWN_GAPS.size} gap(s) conhecido(s), ${KNOWN_DUPLICATE_VERSIONS.size} duplicata(s) conhecida(s))`
);
if (!process.exitCode) {
console.log(
`[check-migration-numbering] OK (${filenames.length} migrations, ` +
`${KNOWN_GAPS.size} gap(s) conhecido(s), ${KNOWN_DUPLICATE_VERSIONS.size} duplicata(s) conhecida(s))`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -4,17 +4,20 @@
// deve resolver para um route.ts real em src/app/api/. Pega endpoint INVENTADO/obsoleto
// na spec (a IA escreve docs descrevendo rota que não existe). Complementa
// check-openapi-coverage.mjs (que mede a direção inversa: % de rotas documentadas).
// Stale-enforcement (6A.3): entrada em KNOWN_STALE_SPEC que não suprime nenhum path
// órfão real → gate falha com instrução de remoção (evita furo de regressão silencioso).
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import yaml from "js-yaml";
import { assertNoStale } from "./lib/allowlist.mjs";
const ROOT = process.cwd();
const API_ROOT = path.join(ROOT, "src", "app", "api");
const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml");
// Entradas da spec sem rota real, congeladas para triagem (catraca: bloqueia NOVAS).
const KNOWN_STALE_SPEC = new Set([
export const KNOWN_STALE_SPEC = new Set([
// openapi.yaml documenta um state por-agente, mas a rota real é o state GLOBAL
// (/api/tools/agent-bridge/state); por-agente só há /{id}, /{id}/detect, /mappings, /dns.
]);
@@ -56,20 +59,25 @@ function main() {
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const specPaths = Object.keys(raw.paths || {}).filter((p) => p.startsWith("/api"));
const implPaths = collectRoutePaths(API_ROOT);
const orphans = findSpecPathsWithoutRoute(specPaths, implPaths).filter(
(p) => !KNOWN_STALE_SPEC.has(p)
);
// Live orphans BEFORE allowlist filtering (needed for stale-enforcement).
const liveOrphans = findSpecPathsWithoutRoute(specPaths, implPaths);
assertNoStale(KNOWN_STALE_SPEC, liveOrphans, "openapi-routes");
const orphans = liveOrphans.filter((p) => !KNOWN_STALE_SPEC.has(p));
if (orphans.length) {
console.error(
`[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.exit(1);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)`
);
}
console.log(
`[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env node
// scripts/check/check-pr-evidence.mjs
// Gate: Hard Rule #18 — "evidence before assertions".
//
// When a PR body makes claims about test/validation success (e.g. "tests pass",
// "all green", "fixed", "added endpoint") it MUST include a block of command
// output as proof. A PR body with claims but no attached evidence => FAIL.
//
// Skip policy: when not running in a PR context (no PR_BODY env var and `gh pr
// view` is unavailable), exits 0 silently so the gate never blocks local dev.
//
// Conservative by design (two-signal requirement):
// - A PR body with NO claim-trigger terms always passes.
// - A PR body with a claim but also an evidence block always passes.
// - Only the combination of claim(s) + NO evidence block => FAIL.
//
// What counts as a TRIGGER (claim term)?
// See CLAIM_TRIGGERS below. We require at least ONE strong trigger OR two
// weak triggers to fire (reduces false positives from casual phrases).
//
// What counts as EVIDENCE?
// - A fenced code block (``` ... ```) that contains ≥1 line of output
// characters (not just whitespace/backticks). The block must look like
// terminal/command output: numbers, colons, path separators, status words.
// - OR an explicit "Evidence" / "Validation" / "Test output" / "Output"
// section header (##/### prefix) followed by non-empty content.
// - OR an inline `code span` that matches common test-runner patterns
// (e.g. "passing", "failed 0", "✓", "PASS", "ok").
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
// ---------------------------------------------------------------------------
// Claim triggers
// ---------------------------------------------------------------------------
// STRONG triggers: single occurrence is sufficient to fire.
// These are explicit outcome/validation assertions.
const STRONG_TRIGGERS = [
/\ball\s+(?:tests?\s+)?(?:pass(?:ed|ing)?|green)\b/i,
/\btests?\s+(?:pass(?:ed|ing)?|are\s+(?:green|passing))\b/i,
/\b(?:typecheck|lint|build)\s+(?:pass(?:ed|ing)?|(?:is\s+)?clean|(?:is\s+)?green|(?:is\s+)?ok)\b/i,
/\bvalidated\s+(?:on|in|via|with|against|locally|on\s+vps)\b/i,
/\b(?:zero|0)\s+(?:errors?|failures?|regressions?)\b/i,
/\btdd\b.*\b(?:pass(?:ed|ing)?|green)\b/i,
/\bfixed\b.*\band\s+(?:verified|confirmed|tested)\b/i,
/\bproof\s*:/i,
];
// WEAK triggers: need at least 2 present to fire.
// These are common "seems fine" phrases that alone don't warrant evidence.
const WEAK_TRIGGERS = [
/\b(?:added|implemented|created)\s+(?:a\s+)?(?:new\s+)?(?:endpoint|route|test|handler|check)\b/i,
/\bfixed\b/i,
/\bresolves?\s+#\d+/i,
/\bshould\s+(?:work|pass|be\s+(?:fine|ok|green))\b/i,
/\b(?:verified|confirmed|validated)\b/i,
/\b(?:all|no)\s+(?:regressions?|breaking\s+changes?)\b/i,
/\blooks?\s+(?:good|fine|ok)\b/i,
/\bworks?\s+(?:correctly|fine|as\s+expected)\b/i,
];
// ---------------------------------------------------------------------------
// Evidence patterns
// ---------------------------------------------------------------------------
// 1. A fenced code block with "command-output-like" content.
// The block content must have at least one line that looks like output
// (contains digit sequences, colons, path separators, or known status tokens).
const FENCED_BLOCK_RE = /```[^\n]*\n([\s\S]*?)```/g;
const OUTPUT_LINE_RE =
/(?:\d+\s+(?:pass(?:ing)?|fail(?:ing)?|pending)|[✓✗ו]\s|\bPASS\b|\bFAIL\b|\bok\b|\berror\b.*\d|\bwarning\b.*\d|\d+\s+(?:test|spec|suite)|exit\s+code\s*[0-9]|at\s+\S+:\d+|[A-Za-z]+:\s*\d+|^\s*\d+\s+\w)/im;
// 2. An explicit evidence/validation section header followed by non-blank content.
const EVIDENCE_SECTION_RE =
/^#{1,4}\s+(?:evidence|validation|test\s+(?:output|results?|run)|output|verified|proof)\b[^\n]*/im;
// 3. An inline code span matching common test-runner result tokens.
const INLINE_RESULT_RE =
/`[^`]*(?:\d+\s+(?:pass(?:ing)?|fail(?:ing)?|test)|✓|✗|PASS(?:ED)?|FAIL(?:ED)?|all\s+\d+)[^`]*`/i;
// ---------------------------------------------------------------------------
// Pure evaluation function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Evaluate a PR body string.
*
* @param {string} body
* @returns {{ result: "pass" | "fail" | "skip"; reason: string }}
*/
export function evaluatePrBody(body) {
if (!body || body.trim().length === 0) {
return { result: "skip", reason: "PR body is empty — nothing to evaluate." };
}
// --- 1. Detect claims ---
const strongMatches = STRONG_TRIGGERS.filter((re) => re.test(body));
const weakMatches = WEAK_TRIGGERS.filter((re) => re.test(body));
const hasClaim = strongMatches.length >= 1 || weakMatches.length >= 2;
if (!hasClaim) {
return { result: "pass", reason: "No outcome-claim terms detected — no evidence required." };
}
// --- 2. Detect evidence ---
const hasEvidence = hasEvidenceBlock(body);
if (hasEvidence) {
return {
result: "pass",
reason: "Outcome claim detected and evidence block found.",
};
}
// Build a helpful message listing which triggers fired.
const firedStrong = strongMatches.map((re) => re.source);
const firedWeak = weakMatches.map((re) => re.source);
return {
result: "fail",
reason:
"PR body contains outcome claims but no evidence block (command output, Evidence section, or inline result span).\n" +
(firedStrong.length > 0 ? ` Strong triggers matched: ${firedStrong.join(", ")}\n` : "") +
(firedWeak.length >= 2 ? ` Weak triggers matched (≥2): ${firedWeak.join(", ")}\n` : "") +
"\n" +
"Hard Rule #18 requires proof that the fix works:\n" +
" a) Add a fenced code block (```) containing test-runner or command output, OR\n" +
" b) Add a section headed '## Evidence', '## Validation', '## Test output', etc., OR\n" +
" c) Add an inline code span with a result token (e.g. `42 passing`, `PASSED`).\n" +
"See CLAUDE.md → Hard Rule #18.",
};
}
/**
* Returns true if the body contains at least one recognised evidence block.
* @param {string} body
* @returns {boolean}
*/
function hasEvidenceBlock(body) {
// Check fenced code blocks for output-like content.
FENCED_BLOCK_RE.lastIndex = 0;
let match;
while ((match = FENCED_BLOCK_RE.exec(body)) !== null) {
const blockContent = match[1] ?? "";
if (blockContent.trim().length > 0 && OUTPUT_LINE_RE.test(blockContent)) {
return true;
}
}
// Check for explicit evidence/validation section header with non-empty body.
if (EVIDENCE_SECTION_RE.test(body)) {
// Ensure there's actual content after the header.
const afterHeader = body.replace(EVIDENCE_SECTION_RE, "").trim();
if (afterHeader.length > 20) {
return true;
}
}
// Check for inline code span containing result tokens.
if (INLINE_RESULT_RE.test(body)) {
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// CLI entry point
// ---------------------------------------------------------------------------
function getArg(name, fallbackValue = "") {
const index = process.argv.indexOf(name);
if (index === -1 || index === process.argv.length - 1) return fallbackValue;
return process.argv[index + 1];
}
function buildReport(lines) {
return `${lines.join("\n")}\n`;
}
// Only run as CLI entry point.
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
const summaryFile = getArg("--summary-file", "");
// --- Resolve PR body ---
let prBody = null;
// Priority 1: PR_BODY env var (set by CI / manual invocation).
if (typeof process.env.PR_BODY === "string") {
prBody = process.env.PR_BODY;
}
// Priority 2: --body-file argument.
const bodyFile = getArg("--body-file", "");
if (prBody === null && bodyFile && existsSync(bodyFile)) {
prBody = readFileSync(bodyFile, "utf8");
}
// Priority 3: `gh pr view` (only available inside a PR context).
if (prBody === null) {
const ghResult = spawnSync("gh", ["pr", "view", "--json", "body", "--jq", ".body"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (ghResult.status === 0 && ghResult.stdout.trim()) {
prBody = ghResult.stdout.trim();
}
}
// No PR context available — skip silently.
if (prBody === null) {
const report = buildReport([
"## PR Evidence Gate",
"",
"Skipped: no PR body available (PR_BODY not set, --body-file not provided, gh pr view unavailable).",
]);
if (summaryFile) {
mkdirSync(path.dirname(summaryFile), { recursive: true });
writeFileSync(summaryFile, report);
}
process.stdout.write(report);
process.exit(0);
}
const { result, reason } = evaluatePrBody(prBody);
const reportLines = ["## PR Evidence Gate", ""];
if (result === "skip") {
reportLines.push("Result: SKIP", "", reason);
} else if (result === "pass") {
reportLines.push("Result: PASS", "", reason);
} else {
reportLines.push("Result: FAIL", "", reason);
}
const report = buildReport(reportLines);
if (summaryFile) {
mkdirSync(path.dirname(summaryFile), { recursive: true });
writeFileSync(summaryFile, report);
}
process.stdout.write(report);
if (result === "fail") {
process.exit(1);
}
}

View File

@@ -5,9 +5,12 @@
// Pega entradas de registry inventadas/meia-registradas (provider com baseUrl+models
// mas ausente da lista canônica → não selecionável pela máquina normal de providers).
// Catraca: exceções pré-existentes ficam em KNOWN_REGISTRY_ONLY; só NOVOS órfãos falham.
// Stale-enforcement (6A.3): entrada em KNOWN_REGISTRY_ONLY que não suprime nenhum órfão
// real → gate falha com instrução de remoção (evita furo de regressão silencioso).
import { pathToFileURL } from "node:url";
import { AI_PROVIDERS, getProviderById } from "@/shared/constants/providers.ts";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { assertNoStale } from "./lib/allowlist.mjs";
// Entradas registry-only conhecidas (meia-registro pré-existente). Cada uma com
// justificativa. Remover daqui ao registrar o provider em providers.ts.
@@ -25,18 +28,25 @@ export function findOrphanRegistryIds(
function main(): void {
const canonical = new Set(Object.keys(AI_PROVIDERS));
const isKnown = (id: string) => canonical.has(id) || Boolean(getProviderById(id));
const orphans = findOrphanRegistryIds(Object.keys(REGISTRY), isKnown, KNOWN_REGISTRY_ONLY);
// Live orphans BEFORE allowlist filtering (needed for stale-enforcement).
const liveOrphans = Object.keys(REGISTRY).filter((id) => !isKnown(id));
assertNoStale(Object.keys(KNOWN_REGISTRY_ONLY), liveOrphans, "provider-consistency");
const orphans = liveOrphans.filter((id) => !(id in KNOWN_REGISTRY_ONLY));
if (orphans.length) {
console.error(
`[provider-consistency] ${orphans.length} entrada(s) no REGISTRY sem provider canônico em providers.ts:\n` +
orphans.map((id) => `${id}`).join("\n") +
`\n → registre o provider em src/shared/constants/providers.ts ou adicione a KNOWN_REGISTRY_ONLY (scripts/check/check-provider-consistency.ts) com justificativa.`
);
process.exit(1);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[provider-consistency] OK — ${Object.keys(REGISTRY).length} entradas REGISTRY, ${canonical.size} providers canônicos, ${Object.keys(KNOWN_REGISTRY_ONLY).length} exceção(ões) conhecida(s)`
);
}
console.log(
`[provider-consistency] OK — ${Object.keys(REGISTRY).length} entradas REGISTRY, ${canonical.size} providers canônicos, ${Object.keys(KNOWN_REGISTRY_ONLY).length} exceção(ões) conhecida(s)`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -19,17 +19,43 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
// Arquivos que carregam configuração de credencial de upstream. O escopo é restrito
// de propósito: estes são os únicos pontos onde client_id/secret públicos vivem.
// Adicionar um novo arquivo de config de credencial? Inclua-o aqui.
const SCANNED_FILES = [
"open-sse/config/providerRegistry.ts",
"src/lib/oauth/constants/oauth.ts",
// 6A.8: Instead of a static hardcoded list, scan the two credential-bearing subtrees
// dynamically so new files (new executor, new OAuth provider) are caught automatically.
// Anchor files (providerRegistry.ts, oauth.ts) are the canonical credential config;
// the broader scan covers new additions in open-sse/ and src/lib/oauth/.
// Exclusions: test files, node_modules, .next.
const SCAN_ROOTS = [
path.join(cwd, "open-sse"),
path.join(cwd, "src", "lib", "oauth"),
];
function walkTs(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name !== "node_modules" && e.name !== ".next") walkTs(p, acc);
} else if (/\.tsx?$/.test(e.name) && !/\.test\.tsx?$/.test(e.name)) {
acc.push(p);
}
}
return acc;
}
function collectScannedFiles() {
const files = [];
for (const root of SCAN_ROOTS) {
for (const abs of walkTs(root)) {
files.push(path.relative(cwd, abs).replace(/\\/g, "/"));
}
}
return files;
}
// Chaves de objeto cujo valor é uma credencial. Atribuir qualquer uma destas a uma
// string literal não-vazia viola a Hard Rule #11.
// - clientIdDefault / clientSecretDefault: forma do providerRegistry (entry.oauth)
@@ -54,10 +80,20 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/;
//
// All five public client_ids (9 call-sites) were migrated to resolvePublicCred() in
// #3493 (embedded as claude_id/codex_id/qwen_id/kimi_id/github_copilot_id in
// open-sse/utils/publicCreds.ts), matching the Gemini/Antigravity pattern. The
// allowlist is now empty — any new literal public client_id must be embedded via
// resolvePublicCred(), not frozen here.
export const KNOWN_LITERAL_CREDS = new Set([]);
// open-sse/utils/publicCreds.ts), matching the Gemini/Antigravity pattern.
//
// 6A.8: Expanded scope to open-sse/** + src/lib/oauth/**. Newly discovered FPs:
//
// open-sse/services/usage.ts L543: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")`
// The CRED_KEY_RE matches `apiKey:` in the TypeScript function-parameter type annotation.
// "minimax" and "minimax-cn" are provider-name strings in the type annotation, NOT credentials.
// This is a false positive (the gate was designed for object-literal assignments, not fn params).
// TODO(6A.8): Consider tightening CRED_KEY_RE to exclude function-signature contexts — but
// that adds complexity; the FP rate is low (1 file). Frozen by file:line:value key.
export const KNOWN_LITERAL_CREDS = new Set([
"open-sse/services/usage.ts:543:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential
"open-sse/services/usage.ts:543:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential
]);
/**
* Encontra atribuições de uma chave de credencial a uma string literal não-vazia.
@@ -115,14 +151,29 @@ export function findLiteralCreds(source, allowlist, relFile = "") {
}
function main() {
const allMisses = [];
for (const rel of SCANNED_FILES) {
const abs = path.join(cwd, rel);
if (!fs.existsSync(abs)) {
console.error(`[check-public-creds] arquivo de escopo não encontrado: ${rel}`);
process.exit(1);
const scannedFiles = collectScannedFiles();
// 6A.8: stale-allowlist enforcement.
// Compute all live violations WITHOUT the allowlist, then check for stale entries.
const liveViolationKeys = new Set();
for (const rel of scannedFiles) {
const src = fs.readFileSync(path.join(cwd, rel), "utf8");
for (const v of findLiteralCreds(src, new Set(), rel)) {
// v is like "L543: apiKey = \"minimax\"" — generate the same file:line:value key
// that the allowlist uses so stale detection matches by canonical key form.
const lineMatch = v.match(/^L(\d+):/);
const lineNo = lineMatch ? lineMatch[1] : "?";
const valMatch = v.match(/"([^"]+)"$/);
const val = valMatch ? valMatch[1] : v;
liveViolationKeys.add(`${rel}:${lineNo}:${val}`);
liveViolationKeys.add(val); // also track plain value for backward compat
}
const src = fs.readFileSync(abs, "utf8");
}
assertNoStale(KNOWN_LITERAL_CREDS, liveViolationKeys, "check-public-creds");
const allMisses = [];
for (const rel of scannedFiles) {
const src = fs.readFileSync(path.join(cwd, rel), "utf8");
for (const v of findLiteralCreds(src, KNOWN_LITERAL_CREDS, rel)) {
allMisses.push(`${rel} ${v}`);
}
@@ -139,8 +190,9 @@ function main() {
);
process.exit(1);
}
if (process.exitCode === 1) return; // stale entries already logged
console.log(
`[check-public-creds] OK (${SCANNED_FILES.length} arquivo(s), ` +
`[check-public-creds] OK (${scannedFiles.length} arquivo(s) em ${SCAN_ROOTS.length} raiz(es), ` +
`${KNOWN_LITERAL_CREDS.size} literal(is) congelado(s))`
);
}

View File

@@ -16,11 +16,31 @@
// with a justification so the gate exits 0 today; only NEW spawn-capable routes
// that slip past the guard fail. KNOWN_UNCLASSIFIED is empty today (clean
// baseline) — keep it that way; an entry here is a documented security debt.
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { pathToFileURL } from "node:url";
import { isLocalOnlyPath } from "@/server/authz/routeGuard.ts";
// Inline stale-allowlist helper (mirrors scripts/check/lib/allowlist.mjs).
// The TypeScript gate cannot import the .mjs helper directly; keep this in sync.
function assertNoStaleEntries(
allowlist: string[] | Record<string, string>,
liveItems: string[],
gateName: string
): void {
const liveSet = new Set(liveItems);
const keys = Array.isArray(allowlist) ? allowlist : Object.keys(allowlist);
const stale = keys.filter((k) => !liveSet.has(k));
if (stale.length > 0) {
console.error(
`[${gateName}] ${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")
);
process.exitCode = 1;
}
}
// Spawn-capable route roots (relative to repo root). Mirrors the spawn-capable
// prefixes documented in routeGuard.ts (SPAWN_CAPABLE_PREFIXES) and CLAUDE.md
// Hard Rules #15/#17 for the dirs that physically exist under src/app/api/.
@@ -65,6 +85,84 @@ export function findUnclassifiedSpawnRoutes(
return apiPaths.filter((p) => !isLocalOnly(p) && !(p in allowlist));
}
// --- 6A.8: source-based spawn detection ---
// Patterns that indicate a route.ts spawns child processes.
// Matches: import from "child_process" / "node:child_process" / "worker_threads" /
// "node:worker_threads" or a spawn( / execFile( / exec( call.
const SPAWN_SOURCE_RE =
/\b(?:from\s+["'](?:node:)?(?:child_process|worker_threads)["']|require\s*\(\s*["'](?:node:)?(?:child_process|worker_threads)["']\s*\)|spawn\s*\(|execFile\s*\(|execFileSync\s*\(|exec\s*\()/;
/**
* Returns true if the given source text of a route.ts file directly imports
* from child_process / worker_threads or calls spawn()/execFile()/exec().
* Used by the 6A.8 source-scan subcheck to find spawn-capable routes outside
* the static SPAWN_CAPABLE_ROUTE_ROOTS list.
*/
export function isSpawnCapableSource(source: string): boolean {
return SPAWN_SOURCE_RE.test(source);
}
/**
* Walk all route.ts files under src/app/api/ from repoRoot and return those whose
* source matches isSpawnCapableSource. Returns relative paths (forward slashes).
*/
export function findSpawnCapableRoutes(repoRoot: string): string[] {
const apiDir = join(repoRoot, "src", "app", "api");
const out: string[] = [];
function walk(dir: string): void {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry);
try {
if (statSync(full).isDirectory()) {
walk(full);
} else if (entry === "route.ts") {
const src = readFileSync(full, "utf8");
if (isSpawnCapableSource(src)) {
out.push(relative(repoRoot, full).replace(/\\/g, "/"));
}
}
} catch {
// skip unreadable
}
}
}
walk(apiDir);
return out.sort();
}
/**
* 6A.8: pre-existing spawn-capable route.ts files that live OUTSIDE
* SPAWN_CAPABLE_ROUTE_ROOTS but are NOT yet classified local-only.
* Each entry is documented security debt (Hard Rules #15/#17):
* the route can spawn child processes and is reachable past the loopback gate.
*
* TODO(6A.8): classify these in LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS
* or add specific auth-only enforcement (no loopback, but require-auth before spawn).
* Adding an entry here requires a justification + follow-up issue.
*/
export const KNOWN_UNCLASSIFIED_SOURCE_SPAWN: Record<string, string> = {
// RESOLVED (6A.8 P1, 2026-06-13): /api/system/version and /api/db-backups/exportAll
// are now classified in LOCAL_ONLY_API_PREFIXES (loopback-enforced before auth).
// The stale-enforcement guard requires this set to stay empty until a NEW
// unclassified spawn-capable route appears.
// NOTE: cli-tools/antigravity-mitm/route.ts triggers child_process INDIRECTLY via
// dynamic import to @/mitm/manager.runtime, but does NOT directly import child_process.
// The source-scan gate covers DIRECT imports/calls only; this route is NOT in the
// spawn-capable set by source analysis. Kept as a comment for documentation but
// NOT in the allowlist (stale-enforcement would flag it). The route has requireCliToolsAuth()
// for auth gating; the underlying spawn happens in mitm/manager.runtime.
// If /api/cli-tools/ is ever added to LOCAL_ONLY_API_PREFIXES, revisit this note.
};
/** Recursively collect every `route.ts` under `dir` (returns [] if dir absent). */
function collectRouteFiles(dir: string): string[] {
let entries: string[];
@@ -86,23 +184,71 @@ function collectRouteFiles(dir: string): string[] {
}
function main(): void {
const cwd = process.cwd();
// --- Subcheck 1 (original): SPAWN_CAPABLE_ROUTE_ROOTS ---
const apiPaths = SPAWN_CAPABLE_ROUTE_ROOTS.flatMap(collectRouteFiles)
.map(routeFileToApiPath)
.sort();
const unclassified = findUnclassifiedSpawnRoutes(apiPaths, isLocalOnlyPath, KNOWN_UNCLASSIFIED);
// --- Subcheck 2 (6A.8): source-based scan — ALL route.ts files ---
// Find every route.ts that imports child_process / worker_threads and verify it is
// either classified local-only or frozen in KNOWN_UNCLASSIFIED_SOURCE_SPAWN.
const spawnCapableFiles = findSpawnCapableRoutes(cwd);
// Stale-enforcement: if a route was fixed (no longer spawn-capable, or was classified),
// the KNOWN_UNCLASSIFIED_SOURCE_SPAWN entry must be removed.
assertNoStaleEntries(
KNOWN_UNCLASSIFIED_SOURCE_SPAWN,
spawnCapableFiles,
"route-guard-membership/source-spawn"
);
// Find spawn-capable routes outside SPAWN_CAPABLE_ROUTE_ROOTS that are not classified
// local-only and not in the source-spawn allowlist.
const unclassifiedSourceSpawn = spawnCapableFiles.filter((rel) => {
const apiPath = routeFileToApiPath(rel);
// Already covered by subcheck 1 (in a SPAWN_CAPABLE_ROUTE_ROOT)? Skip.
if (SPAWN_CAPABLE_ROUTE_ROOTS.some((root) => rel.startsWith(root + "/"))) return false;
// In the source-spawn allowlist? Skip.
if (rel in KNOWN_UNCLASSIFIED_SOURCE_SPAWN) return false;
// Classified local-only? Skip.
if (isLocalOnlyPath(apiPath)) return false;
return true;
});
// Report
let failed = false;
if (unclassified.length) {
console.error(
`[route-guard-membership] CRITICAL — ${unclassified.length} spawn-capable route(s) NOT classified local-only by isLocalOnlyPath() (RCE-via-tunnel risk, Hard Rules #15/#17):\n` +
`[route-guard-membership] CRITICAL — ${unclassified.length} spawn-capable route(s) in SPAWN_CAPABLE_ROUTE_ROOTS NOT classified local-only (RCE-via-tunnel risk, Hard Rules #15/#17):\n` +
unclassified.map((p) => `${p}`).join("\n") +
`\n → add a matching prefix to LOCAL_ONLY_API_PREFIXES or a pattern to LOCAL_ONLY_API_PATTERNS in src/server/authz/routeGuard.ts (loopback enforcement must run before auth), or — only with written justification — freeze it in KNOWN_UNCLASSIFIED (scripts/check/check-route-guard-membership.ts).`
`\n → add a matching prefix to LOCAL_ONLY_API_PREFIXES or a pattern to LOCAL_ONLY_API_PATTERNS in src/server/authz/routeGuard.ts, or freeze in KNOWN_UNCLASSIFIED with justification.`
);
process.exit(1);
failed = true;
}
if (unclassifiedSourceSpawn.length) {
console.error(
`[route-guard-membership] CRITICAL — ${unclassifiedSourceSpawn.length} route.ts file(s) contain child_process/worker_threads but are NOT classified local-only (Hard Rules #15/#17):\n` +
unclassifiedSourceSpawn.map((p) => `${p} (${routeFileToApiPath(p)})`).join("\n") +
`\n → classify in LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS, or freeze in KNOWN_UNCLASSIFIED_SOURCE_SPAWN with justification.`
);
failed = true;
}
if (failed) process.exit(1);
if (process.exitCode === 1) return; // stale entries already logged
console.log(
`[route-guard-membership] OK — ${apiPaths.length} spawn-capable route(s) across ${SPAWN_CAPABLE_ROUTE_ROOTS.length} root(s) all classified local-only, ${Object.keys(KNOWN_UNCLASSIFIED).length} frozen exception(s)`
`[route-guard-membership] OK — ` +
`${apiPaths.length} route(s) in ${SPAWN_CAPABLE_ROUTE_ROOTS.length} root(s) all local-only; ` +
`${spawnCapableFiles.length} source-spawn route(s) scanned, ` +
`${Object.keys(KNOWN_UNCLASSIFIED_SOURCE_SPAWN).length} frozen as security debt, ` +
`0 new gaps`
);
// Explicit exit: importing routeGuard.ts pulls in runtime settings, which opens
// the SQLite DB and starts a background health-check timer that would otherwise

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env node
// scripts/check/check-secrets.mjs
// Catraca de secret scanning via gitleaks (Task 7.18 — Fase 7).
//
// Complementa `check-public-creds.mjs` (Fase 6, cobre credenciais OAuth públicas
// conhecidas em 2 arquivos específicos): este gate pega a classe geral de secrets —
// `const API_KEY = "sk-…"`, tokens em config/teste/docs, secrets em histórico.
//
// Saída (stdout):
// secretFindings=N — número de findings do gitleaks
// secretFindings=SKIP reason=binary-absent — gitleaks não está no PATH
//
// Esta versão é ADVISORY (sai 0 sempre). O ratchet (direction:down) é gerenciado
// pelo motor quality-baseline.json no CI (Task 7.18 INT).
//
// Uso:
// node scripts/check/check-secrets.mjs
// node scripts/check/check-secrets.mjs --json # imprime JSON bruto do gitleaks
// node scripts/check/check-secrets.mjs --quiet # suprime logs de diagnóstico
import fs from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const GITLEAKS_CONFIG = path.join(ROOT, ".gitleaks.toml");
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Conta findings no JSON emitido por `gitleaks detect --report-format json`.
*
* O gitleaks emite um array de findings (ou array vazio / null quando limpo):
* [
* {
* Description: string,
* StartLine: number,
* EndLine: number,
* Match: string, // valor mascarado ou trecho
* Secret: string, // valor mascarado
* File: string, // caminho relativo
* Commit: string,
* Entropy: number,
* Author: string,
* Email: string,
* Date: string,
* Tags: string[],
* RuleID: string,
* Fingerprint: string
* },
* ...
* ]
*
* @param {Array|null} gitleaksJson - Array de findings do gitleaks (ou null)
* @returns {{ findingCount: number, byRule: Record<string, number>, byFile: Record<string, number> }}
*/
export function parseGitleaksJson(gitleaksJson) {
// null ou array vazio = nenhum finding
if (gitleaksJson === null || (Array.isArray(gitleaksJson) && gitleaksJson.length === 0)) {
return { findingCount: 0, byRule: {}, byFile: {} };
}
if (!Array.isArray(gitleaksJson)) {
return { findingCount: 0, byRule: {}, byFile: {} };
}
let findingCount = 0;
const byRule = {};
const byFile = {};
for (const finding of gitleaksJson) {
if (!finding || typeof finding !== "object") continue;
findingCount++;
// Agrupar por RuleID (gitleaks usa PascalCase)
const ruleId = finding.RuleID ?? finding.ruleId ?? "unknown";
byRule[ruleId] = (byRule[ruleId] ?? 0) + 1;
// Agrupar por arquivo
const file = finding.File ?? finding.file ?? "unknown";
byFile[file] = (byFile[file] ?? 0) + 1;
}
return { findingCount, byRule, byFile };
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
/**
* Detecta se o binário `gitleaks` está disponível no PATH.
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
*
* @returns {string|null} Caminho para o binário, ou null se ausente.
*/
export function findGitleaks() {
try {
const result = spawnSync("which", ["gitleaks"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status === 0) {
return result.stdout.trim();
}
} catch {
// which não disponível
}
// Fallback: tentar executar diretamente para verificar ENOENT
try {
const result = spawnSync("gitleaks", ["version"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.error?.code === "ENOENT") return null;
if (result.status !== null) return "gitleaks"; // encontrado no PATH
} catch {
// noop
}
return null;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const gitleaksBin = findGitleaks();
if (!gitleaksBin) {
console.log("secretFindings=SKIP reason=binary-absent");
if (!QUIET) {
process.stderr.write(
"[check-secrets] SKIP — gitleaks não encontrado no PATH.\n" +
"[check-secrets] Instale via: https://github.com/gitleaks/gitleaks\n" +
"[check-secrets] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
);
}
process.exitCode = 0;
return;
}
// Construir args sem interpolação de variáveis no script (Hard Rule #13)
const args = [
"detect",
"--no-git", // escanear diretório em vez de histórico git (mais rápido em CI)
"--report-format", "json",
"--report-path", "-", // output para stdout
"--source", ROOT,
"--no-banner",
];
// Adicionar config personalizada se existir
if (fs.existsSync(GITLEAKS_CONFIG)) {
args.push("--config", GITLEAKS_CONFIG);
}
if (!QUIET) {
process.stderr.write("[check-secrets] Rodando gitleaks detect --no-git --report-format json ...\n");
}
let stdout = "";
try {
stdout = execFileSync(gitleaksBin, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 120_000, // 2 min
});
} catch (err) {
// exit 1 com stdout = findings encontrados (comportamento esperado do gitleaks)
stdout = err.stdout ? String(err.stdout) : "";
const stderr = err.stderr ? String(err.stderr) : "";
if (err.status === 1 && stdout.trim()) {
// Normal: gitleaks achou findings e saiu com exit 1
} else if (!stdout.trim()) {
process.stderr.write(`[check-secrets] ERRO ao executar gitleaks: ${err.message}\n`);
if (stderr) process.stderr.write(`[check-secrets] stderr: ${stderr.slice(0, 500)}\n`);
process.exit(2);
}
}
let gitleaksJson;
if (!stdout.trim() || stdout.trim() === "null") {
gitleaksJson = [];
} else {
try {
const parsed = JSON.parse(stdout.trim());
gitleaksJson = parsed === null ? [] : parsed;
} catch (parseErr) {
process.stderr.write(`[check-secrets] ERRO ao parsear JSON do gitleaks: ${parseErr.message}\n`);
process.stderr.write(`[check-secrets] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`);
process.exit(2);
}
}
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(gitleaksJson, null, 2) + "\n");
return;
}
const { findingCount, byRule, byFile } = parseGitleaksJson(gitleaksJson);
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
console.log(`secretFindings=${findingCount}`);
if (!QUIET) {
if (findingCount > 0) {
const topRules = Object.entries(byRule)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([r, n]) => `${r}(${n})`)
.join(", ");
process.stderr.write(`[check-secrets] Findings: ${findingCount} (top rules: ${topRules})\n`);
process.stderr.write(
"[check-secrets] Para allowlistar findings legítimos (fixtures de teste, creds públicas),\n" +
"[check-secrets] adicione entradas em .gitleaks.toml [[allowlist]] com comentário.\n"
);
} else {
process.stderr.write("[check-secrets] Nenhum finding detectado.\n");
}
process.stderr.write(
"[check-secrets] ADVISORY — esta versão não falha pela contagem (ratchet entra no CI).\n"
);
}
// Sai 0 sempre nesta versão (advisory)
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -5,8 +5,12 @@
// num PR, compara a contagem de asserts base vs HEAD: sinaliza REMOÇÃO LÍQUIDA de asserts
// e NOVAS tautologias `assert.ok(true)`. Heurístico mas alto-sinal. Espelha o plumbing
// de check-pr-test-policy.mjs (diff base...HEAD); no-op fora de contexto de PR.
//
// v2 (6A.10): acrescenta 3 novos subchecks:
// 1. Arquivos de teste DELETADOS: --diff-filter=MDR com detecção de rename.
// 2. Aumento líquido de .skip/.todo/.only/{skip:true}: esconde asserts sem remover.
// 3. Tautologias extras: expect(true).toBe(true), assert.equal(1,1), assert.ok(true).
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
@@ -24,14 +28,77 @@ export function countTautologies(src) {
return (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length;
}
/** Avalia por-arquivo: flag em remoção líquida de asserts ou nova tautologia. */
/**
* (6A.10 subcheck 2) Conta marcadores de skip/todo/only que silenciam testes:
* - .skip(, .todo(, .only( — em qualquer runner (node:test, jest, vitest)
* - { skip: true } — opção de objeto node:test
*/
export function countSkips(src) {
const modifiers = (src.match(/\.\s*(?:skip|todo|only)\s*\(/g) || []).length;
const skipOpt = (src.match(/\{\s*skip\s*:\s*true\s*\}/g) || []).length;
return modifiers + skipOpt;
}
/**
* (6A.10 subcheck 3) Conta tautologias que mantêm os asserts no texto mas nunca
* verificam nada real:
* - expect(true).toBe(true)
* - assert.equal(1, 1) / assert.strictEqual(1, 1)
* - assert.ok(true) (já coberto por countTautologies; incluído aqui para completude)
*/
export function countExtendedTautologies(src) {
let count = 0;
// expect(true).toBe(true)
count += (src.match(/\bexpect\s*\(\s*true\s*\)\s*\.\s*toBe\s*\(\s*true\s*\)/g) || []).length;
// assert.equal(1, 1) / assert.strictEqual(1, 1) — literal numeric identity
count += (src.match(/\bassert\s*\.\s*(?:strict)?[Ee]qual\s*\(\s*1\s*,\s*1\s*\)/g) || []).length;
// assert.ok(true)
count += (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length;
return count;
}
/**
* (6A.10 subcheck 1) Sinaliza arquivos de teste DELETADOS ou renomeados-e-não-
* substituídos. Recebe lista de paths de arquivos de teste que foram deletados
* (filtro D do git diff --diff-filter=MDR).
*/
export function evaluateDeletedFiles(deletedPaths) {
const flags = [];
for (const f of deletedPaths) {
if (TEST_RE.test(f)) {
flags.push(`${f}: arquivo de teste deletado — revisão humana obrigatória (mascaramento alto-sinal)`);
}
}
return flags;
}
/**
* Avalia por-arquivo: flag em remoção líquida de asserts, nova tautologia,
* aumento líquido de skips, ou nova tautologia extendida.
*
* Cada entrada de perFile deve ter:
* { file, baseAsserts, headAsserts, baseTaut, headTaut,
* baseSkips, headSkips, baseExtTaut, headExtTaut }
*
* Os campos de skip e extTaut são opcionais (default 0) para compatibilidade
* com chamadas legadas que só passam baseAsserts/headAsserts/baseTaut/headTaut.
*/
export function evaluateMasking(perFile) {
const flags = [];
for (const f of perFile) {
const baseSkips = f.baseSkips ?? 0;
const headSkips = f.headSkips ?? 0;
const baseExtTaut = f.baseExtTaut ?? 0;
const headExtTaut = f.headExtTaut ?? 0;
if (f.headAsserts < f.baseAsserts)
flags.push(`${f.file}: asserts ${f.baseAsserts}${f.headAsserts} (REMOÇÃO de ${f.baseAsserts - f.headAsserts} — enfraquecimento?)`);
if (f.headTaut > f.baseTaut)
flags.push(`${f.file}: nova(s) ${f.headTaut - f.baseTaut} tautologia(s) assert.ok(true)`);
if (headSkips > baseSkips)
flags.push(`${f.file}: ${headSkips - baseSkips} novo(s) .skip/.todo/.only (asserts silenciados sem remoção)`);
if (headExtTaut > baseExtTaut)
flags.push(`${f.file}: nova(s) ${headExtTaut - baseExtTaut} tautologia(s) estendida(s) (expect(true).toBe(true) / assert.equal(1,1))`);
}
return flags;
}
@@ -56,6 +123,19 @@ function main() {
console.log("[test-masking] sem base ref (não é PR) — pulando.");
return;
}
// (6A.10 subcheck 1) Arquivos de teste deletados/renomeados via MDR filter
const deletedAndRenamed = git([
"diff", "--name-only", "--diff-filter=DR", "-M",
`${base}...HEAD`,
])
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const deletedFlags = evaluateDeletedFiles(deletedAndRenamed);
// Arquivos de teste modificados (subcheck original + skips + extTaut)
const changed = git(["diff", "--name-only", "--diff-filter=M", `${base}...HEAD`])
.split("\n")
.map((s) => s.trim())
@@ -71,19 +151,28 @@ function main() {
headAsserts: countAssertions(headSrc),
baseTaut: countTautologies(baseSrc),
headTaut: countTautologies(headSrc),
baseSkips: countSkips(baseSrc),
headSkips: countSkips(headSrc),
baseExtTaut: countExtendedTautologies(baseSrc),
headExtTaut: countExtendedTautologies(headSrc),
});
}
const flags = evaluateMasking(perFile);
if (flags.length) {
const maskingFlags = evaluateMasking(perFile);
const allFlags = [...deletedFlags, ...maskingFlags];
if (allFlags.length) {
console.error(
`[test-masking] ${flags.length} sinal(is) de enfraquecimento de teste:\n` +
flags.map((f) => " ✗ " + f).join("\n") +
`[test-masking] ${allFlags.length} sinal(is) de enfraquecimento de teste:\n` +
allFlags.map((f) => " ✗ " + f).join("\n") +
`\n → se a redução é legítima (refator/consolidação), explique no PR; senão, restaure os asserts.`
);
process.exit(1);
}
console.log(`[test-masking] OK — ${changed.length} arquivo(s) de teste modificado(s), sem enfraquecimento`);
console.log(
`[test-masking] OK — ${changed.length} arquivo(s) de teste modificado(s), ` +
`${deletedAndRenamed.length > 0 ? deletedAndRenamed.length + " deletado(s)/renomeado(s) OK" : "nenhum deletado"} — sem enfraquecimento`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env node
// scripts/check/check-tracked-artifacts.mjs
// Gate: falha se o git rastreia artefatos de build/artefatos gerados proibidos.
// Guarda contra `git add -A` acidental em worktrees que include node_modules symlinks.
// Incidente registrado 2× neste repo (v3.8.12 / v3.8.13) — Hard Rule #7 extensão.
//
// Artefatos proibidos:
// - node_modules/ — deps de build nunca devem entrar no repo
// - .next/ — output do build Next.js
// - coverage/ — relatórios de cobertura gerados pelo c8
// - quality-metrics.json — saída do collect-metrics.mjs (gerado, não-versionado)
// - symlinks rastreados (mode 120000) — indício de `git add -A` em worktree
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const FORBIDDEN_PREFIXES = ["node_modules/", ".next/", "coverage/"];
const FORBIDDEN_EXACT = new Set(["quality-metrics.json"]);
/**
* Verifica se algum caminho na lista de arquivos rastreados corresponde a um
* artefato proibido. Também aceita uma lista separada de symlinks rastreados.
*
* @param {string[]} trackedFiles - saída de `git ls-files` (caminhos relativos)
* @param {string[]} trackedSymlinks - caminhos com mode 120000 (saída de git ls-files -s)
* @returns {string[]} lista de violações (strings descritivas)
*/
export function checkTrackedArtifacts(trackedFiles, trackedSymlinks = []) {
const violations = [];
for (const file of trackedFiles) {
if (FORBIDDEN_EXACT.has(file)) {
violations.push(`forbidden tracked artifact: ${file}`);
continue;
}
for (const prefix of FORBIDDEN_PREFIXES) {
if (file.startsWith(prefix)) {
violations.push(`forbidden tracked artifact (${prefix}*): ${file}`);
break;
}
}
}
for (const sym of trackedSymlinks) {
violations.push(`forbidden tracked symlink (mode 120000): ${sym}`);
}
return violations;
}
function getTrackedFiles() {
const output = execFileSync("git", ["ls-files"], { encoding: "utf8" });
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
function getTrackedSymlinks() {
// git ls-files -s prints: <mode> <hash> <stage>\t<path>
// mode 120000 = symlink
const output = execFileSync("git", ["ls-files", "-s"], { encoding: "utf8" });
const symlinks = [];
for (const line of output.split("\n")) {
if (line.startsWith("120000")) {
const parts = line.split("\t");
if (parts[1]) symlinks.push(parts[1].trim());
}
}
return symlinks;
}
function main() {
const trackedFiles = getTrackedFiles();
const trackedSymlinks = getTrackedSymlinks();
const violations = checkTrackedArtifacts(trackedFiles, trackedSymlinks);
if (violations.length === 0) {
console.log("[tracked-artifacts] OK — no forbidden artifacts tracked by git");
process.exit(0);
}
console.error(`[tracked-artifacts] FAIL — ${violations.length} forbidden artifact(s) tracked by git:`);
for (const v of violations) {
console.error(`${v}`);
}
console.error(
"\n → Run: git rm --cached <path> to untrack the artifact." +
"\n → Add the path to .gitignore to prevent re-tracking."
);
process.exit(1);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env node
// scripts/check/check-type-coverage.mjs
// Type-coverage ratchet (Task 6 of Fase 7).
//
// 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.
//
// tsconfig used: open-sse/tsconfig.json
// - Rationale: the only tsconfig that covers the full open-sse workspace
// (src+open-sse together). `tsconfig.json` excludes open-sse; the
// `tsconfig.typecheck-core.json` only lists 26 explicit files (partial).
// open-sse/tsconfig.json sets `baseUrl: ".."` and path aliases so it
// resolves both workspaces correctly and yields a representative global %.
//
// Direction: up (% can only improve; ratchet blocks drops once wired into INT).
//
// 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";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const TSCONFIG = path.join(ROOT, "open-sse", "tsconfig.json");
/**
* Parse the JSON output produced by `type-coverage --json-output`.
* Returns the coverage percentage as a number (e.g. 91.66).
* Throws if the output cannot be parsed or has unexpected shape.
*
* Exported for unit-testing against synthetic output.
*/
export function parseTypeCoverageOutput(jsonText) {
let parsed;
try {
parsed = JSON.parse(jsonText);
} catch (err) {
throw new Error(`[type-coverage] Failed to parse JSON output: ${err.message}`);
}
if (typeof parsed.percent !== "number") {
throw new Error(
`[type-coverage] Unexpected output shape — missing numeric 'percent' field. Got: ${JSON.stringify(parsed)}`
);
}
return parsed.percent;
}
function runTypeCoverage() {
const typeCoverageBin = path.join(ROOT, "node_modules", ".bin", "type-coverage");
if (!fs.existsSync(typeCoverageBin)) {
throw new Error(`[type-coverage] Binary not found at ${typeCoverageBin}`);
}
if (!fs.existsSync(TSCONFIG)) {
throw new Error(`[type-coverage] tsconfig not found at ${TSCONFIG}`);
}
let stdout;
try {
stdout = execFileSync(typeCoverageBin, ["--json-output", "-p", TSCONFIG], {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
cwd: ROOT,
});
} catch (err) {
// type-coverage exits non-zero when --at-least check fails, but we don't use that.
// If there is stdout, try to parse it anyway.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) throw err;
}
return parseTypeCoverageOutput(stdout.trim());
}
function main() {
console.log("[type-coverage] Running type-coverage (this may take ~30-60 s)…");
console.log(`[type-coverage] tsconfig: ${path.relative(ROOT, TSCONFIG)}`);
let pct;
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);
}
// 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.
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
main();
}

View File

@@ -0,0 +1,251 @@
#!/usr/bin/env node
// scripts/check/check-vuln-ratchet.mjs
// Catraca de vulnerabilidades de dependências via osv-scanner (Task 7.2 — Fase 7).
//
// Saída (stdout):
// vulnCount=N — total de vulnerabilidades encontradas (todos os severities)
// vulnCount=SKIP reason=binary-absent — osv-scanner não está no PATH
//
// Esta versão é ADVISORY (sai 0 sempre). O ratchet (direction:down) é gerenciado
// pelo motor quality-baseline.json no CI (Task 7.2 INT).
//
// Uso:
// node scripts/check/check-vuln-ratchet.mjs
// node scripts/check/check-vuln-ratchet.mjs --json # imprime JSON bruto do osv-scanner
// node scripts/check/check-vuln-ratchet.mjs --quiet # suprime logs de diagnóstico
import { execFileSync, spawnSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Conta vulnerabilidades no JSON emitido por `osv-scanner --format json`.
*
* Formato do osv-scanner v1+:
* {
* results: [
* {
* packages: [
* {
* package: { name, version, ecosystem },
* vulnerabilities: [ { id, aliases, affected, ... }, ... ],
* groups: [ { ids: [...] }, ... ]
* },
* ...
* ]
* },
* ...
* ]
* }
*
* Contagem: cada entrada em `vulnerabilities[]` de cada package conta como 1 vuln.
* Se `groups` estiver presente e tiver menos entradas que `vulnerabilities`, usamos
* `groups.length` para deduplificar (mesma vuln em múltiplos pacotes conta 1x por
* grupo). Caso contrário, contamos `vulnerabilities.length`.
*
* @param {object|null} osvJson - Objeto JSON parseado do osv-scanner
* @returns {{ vulnCount: number, bySeverity: Record<string, number> }}
*/
export function parseOsvJson(osvJson) {
if (!osvJson || !Array.isArray(osvJson.results)) {
return { vulnCount: 0, bySeverity: {} };
}
let vulnCount = 0;
const bySeverity = {};
for (const result of osvJson.results) {
if (!Array.isArray(result.packages)) continue;
for (const pkg of result.packages) {
if (!Array.isArray(pkg.vulnerabilities)) continue;
// Use groups for deduplication when available (same vuln in multiple paths)
const pkgCount = Array.isArray(pkg.groups) && pkg.groups.length > 0
? pkg.groups.length
: pkg.vulnerabilities.length;
vulnCount += pkgCount;
// Collect severity info from the vulnerability entries
for (const vuln of pkg.vulnerabilities) {
const severity = extractSeverity(vuln);
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
}
}
}
return { vulnCount, bySeverity };
}
/**
* Extrai a severidade de uma entrada de vulnerabilidade do osv-scanner.
* Tenta database_specific.severity, depois severity[0].type, depois "UNKNOWN".
*
* @param {object} vuln - Entrada de vulnerabilidade do osv-scanner
* @returns {string}
*/
export function extractSeverity(vuln) {
if (!vuln) return "UNKNOWN";
// osv-scanner v2 field: database_specific.severity (common in OSV schema)
const dbSeverity = vuln.database_specific?.severity;
if (typeof dbSeverity === "string" && dbSeverity.length > 0) {
return dbSeverity.toUpperCase();
}
// CVSS severity array: [{ type: "CVSS_V3", score: "CVSS:3.1/..." }, ...]
if (Array.isArray(vuln.severity) && vuln.severity.length > 0) {
const first = vuln.severity[0];
if (typeof first?.type === "string") {
return first.type;
}
}
return "UNKNOWN";
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
/**
* Detecta se o binário `osv-scanner` está disponível no PATH.
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
*
* @returns {string|null} Caminho absoluto para o binário, ou null se ausente.
*/
export function findOsvScanner() {
try {
const result = spawnSync("which", ["osv-scanner"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status === 0) {
return result.stdout.trim();
}
} catch {
// which não disponível — tentar command -v via sh
}
// Fallback: tentar executar diretamente para verificar ENOENT
try {
const result = spawnSync("osv-scanner", ["--version"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.error?.code === "ENOENT") return null;
if (result.status !== null) return "osv-scanner"; // found in PATH
} catch {
// noop
}
return null;
}
// ---------------------------------------------------------------------------
// Runner
// ---------------------------------------------------------------------------
/**
* Executa o osv-scanner sobre o lockfile/diretório.
* Usa execFileSync sem shell interpolation (Hard Rule #13).
*
* @param {string} osvBin - Caminho para o binário osv-scanner
* @returns {object} JSON parseado do output
*/
function runOsvScanner(osvBin) {
const args = [
"--format", "json",
"--lockfile", path.join(ROOT, "package-lock.json"),
];
if (!QUIET) {
process.stderr.write("[vuln-ratchet] Rodando osv-scanner --format json ...\n");
}
let stdout;
try {
stdout = execFileSync(osvBin, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 120_000, // 2 min
});
} catch (err) {
// osv-scanner sai com código != 0 quando encontra vulnerabilidades;
// o JSON ainda vai no stdout.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) {
process.stderr.write(`[vuln-ratchet] ERRO ao executar osv-scanner: ${err.message}\n`);
process.exit(2);
}
}
try {
return JSON.parse(stdout);
} catch (parseErr) {
process.stderr.write(`[vuln-ratchet] ERRO ao parsear JSON do osv-scanner: ${parseErr.message}\n`);
process.stderr.write(`[vuln-ratchet] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`);
process.exit(2);
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const osvBin = findOsvScanner();
if (!osvBin) {
// Skip gracioso: binário ausente — esperado em ambientes sem osv-scanner instalado.
console.log("vulnCount=SKIP reason=binary-absent");
if (!QUIET) {
process.stderr.write(
"[vuln-ratchet] SKIP — osv-scanner não encontrado no PATH.\n" +
"[vuln-ratchet] Instale via: https://google.github.io/osv-scanner/\n" +
"[vuln-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
);
}
process.exitCode = 0;
return;
}
const osvJson = runOsvScanner(osvBin);
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(osvJson, null, 2) + "\n");
return;
}
const { vulnCount, bySeverity } = parseOsvJson(osvJson);
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
console.log(`vulnCount=${vulnCount}`);
if (!QUIET) {
const severitySummary = Object.entries(bySeverity)
.map(([k, v]) => `${k}=${v}`)
.join(", ") || "nenhuma";
process.stderr.write(
`[vuln-ratchet] Total de vulnerabilidades: ${vulnCount} (${severitySummary})\n`
);
process.stderr.write(
"[vuln-ratchet] ADVISORY — esta versão não falha pela contagem (ratchet entra no CI).\n"
);
}
// Sai 0 sempre nesta versão (advisory)
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,304 @@
#!/usr/bin/env node
// scripts/check/check-workflows.mjs
// Lint + security audit of GitHub Actions workflow files.
// PLANO-QUALITY-GATES-FASE7.md, Task 19.
//
// Tools:
// actionlint — syntax / correctness / shellcheck of workflow YAML
// zizmor — 24+ security audits (unpinned actions, script injection,
// pull_request_target misuse, cache poisoning, …)
//
// Graceful-SKIP contract:
// If EITHER binary is absent from PATH, the script prints a SKIP notice and
// exits 0. This allows the gate to run in environments that have the tools
// installed (CI with setup steps, developer machines with actionlint/zizmor)
// while being inert elsewhere.
//
// Output (stdout, one line each):
// workflowFindings=<n> — total findings from both tools combined
// actionlintFindings=<n> — findings from actionlint alone
// zizmorFindings=<n> — findings from zizmor alone
//
// Exit codes:
// 0 — SKIP (binary absent) or all tools passed (0 findings each)
// 1 — one or more findings (gate failure; advisory mode = always 0; see --advisory)
//
// Usage:
// node scripts/check/check-workflows.mjs # advisory (exit 0 always)
// node scripts/check/check-workflows.mjs --strict # fail on any finding
// node scripts/check/check-workflows.mjs --quiet # suppress progress logs
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows");
const ZIZMOR_CONFIG = path.join(ROOT, ".zizmor.yml");
const STRICT = process.argv.includes("--strict");
const QUIET = process.argv.includes("--quiet");
// ---------------------------------------------------------------------------
// Utility: resolve binary from PATH (cross-platform)
// ---------------------------------------------------------------------------
/**
* Checks whether a binary exists in PATH by running `which`/`where`.
* Returns true if found, false otherwise.
*
* @param {string} name - Binary name (e.g. "actionlint")
* @returns {boolean}
*/
export function isBinaryAvailable(name) {
// Use `command -v` on Unix; `where` on Windows (via cmd).
// We shell through `sh -c` because execFileSync needs the actual path
// and we want cross-platform behaviour.
const result = spawnSync("sh", ["-c", `command -v ${name}`], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
return result.status === 0 && result.stdout.trim().length > 0;
}
// ---------------------------------------------------------------------------
// actionlint result parsing
// ---------------------------------------------------------------------------
/**
* Parses actionlint output (line-based, one finding per line) and counts
* findings. Each non-empty line = one finding.
*
* actionlint emits lines in the format:
* <file>:<line>:<col>: <message> [<rule>]
* or a summary line when all is well (zero findings = empty stdout).
*
* @param {string} stdout - Raw stdout from actionlint
* @returns {{ count: number, lines: string[] }}
*/
export function parseActionlintOutput(stdout) {
const lines = stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
return { count: lines.length, lines };
}
// ---------------------------------------------------------------------------
// zizmor result parsing
// ---------------------------------------------------------------------------
/**
* Parses zizmor JSON output and counts findings.
*
* zizmor --format json emits a JSON object:
* { diagnostics: Array<{ ...finding fields }> }
* or an array directly in older versions.
*
* If JSON parsing fails, falls back to line counting (graceful degradation).
*
* @param {string} stdout - Raw stdout from zizmor --format json (or text)
* @returns {{ count: number, diagnostics: unknown[] }}
*/
export function parseZizmorOutput(stdout) {
const trimmed = stdout.trim();
if (!trimmed) {
return { count: 0, diagnostics: [] };
}
try {
const parsed = JSON.parse(trimmed);
// zizmor ≥0.8 emits { diagnostics: [...] }
if (parsed && typeof parsed === "object" && Array.isArray(parsed.diagnostics)) {
return { count: parsed.diagnostics.length, diagnostics: parsed.diagnostics };
}
// Older versions may emit a bare array
if (Array.isArray(parsed)) {
return { count: parsed.length, diagnostics: parsed };
}
// Unexpected JSON shape — treat whole object as 0 findings if it has no
// obvious error marker; this is a best-effort parse.
return { count: 0, diagnostics: [] };
} catch {
// Not JSON (e.g. text output or error message) — count non-empty lines as
// a conservative fallback.
const lines = trimmed.split("\n").filter(Boolean);
return { count: lines.length, diagnostics: [] };
}
}
// ---------------------------------------------------------------------------
// Runner helpers
// ---------------------------------------------------------------------------
/**
* Collects all *.yml files from the workflows directory.
*
* @param {string} workflowsDir
* @returns {string[]} Absolute paths
*/
export function collectWorkflowFiles(workflowsDir) {
if (!fs.existsSync(workflowsDir)) {
return [];
}
return fs
.readdirSync(workflowsDir)
.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
.map((f) => path.join(workflowsDir, f));
}
/**
* Runs actionlint over the given workflow files.
* Returns parsed result. Never throws — returns count=0 on any exec error so
* that one broken binary does not abort the whole check.
*
* @param {string[]} files - Absolute paths to workflow YAMLs
* @returns {{ count: number, lines: string[], skipped: boolean }}
*/
export function runActionlint(files) {
if (files.length === 0) {
return { count: 0, lines: [], skipped: false };
}
try {
const stdout = execFileSync("actionlint", files, {
encoding: "utf8",
// actionlint exits non-zero when it finds issues; capture output anyway
// by catching the thrown error.
});
return { ...parseActionlintOutput(stdout), skipped: false };
} catch (err) {
// execFileSync throws when exit code != 0.
// stdout still contains the finding lines.
const stdout = (err && typeof err === "object" && "stdout" in err ? err.stdout : "") || "";
return { ...parseActionlintOutput(String(stdout)), skipped: false };
}
}
/**
* Runs zizmor over the workflows directory.
* Returns parsed result. Never throws.
*
* @param {string} workflowsDir - Path to .github/workflows
* @returns {{ count: number, diagnostics: unknown[], skipped: boolean }}
*/
export function runZizmor(workflowsDir) {
const args = ["--format", "json"];
if (fs.existsSync(ZIZMOR_CONFIG)) {
args.push("--config", ZIZMOR_CONFIG);
}
args.push(workflowsDir);
try {
const stdout = execFileSync("zizmor", args, {
encoding: "utf8",
maxBuffer: 4 * 1024 * 1024,
});
return { ...parseZizmorOutput(stdout), skipped: false };
} catch (err) {
const stdout = (err && typeof err === "object" && "stdout" in err ? err.stdout : "") || "";
return { ...parseZizmorOutput(String(stdout)), skipped: false };
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const hasActionlint = isBinaryAvailable("actionlint");
const hasZizmor = isBinaryAvailable("zizmor");
if (!hasActionlint && !hasZizmor) {
console.log(
"[check-workflows] SKIP — actionlint and zizmor not found in PATH.\n" +
" Install them to enable workflow linting and security audit:\n" +
" • actionlint: https://github.com/rhysd/actionlint\n" +
" • zizmor: https://github.com/woodruffw/zizmor\n" +
" This gate is advisory — the build is not blocked."
);
process.stdout.write("workflowFindings=SKIP\n");
process.exit(0);
}
const workflowFiles = collectWorkflowFiles(WORKFLOWS_DIR);
if (workflowFiles.length === 0) {
if (!QUIET) {
console.log(`[check-workflows] No workflow files found in ${WORKFLOWS_DIR} — nothing to check.`);
}
process.stdout.write("workflowFindings=0\nactionlintFindings=0\nzizmorFindings=0\n");
process.exit(0);
}
if (!QUIET) {
console.log(`[check-workflows] Found ${workflowFiles.length} workflow file(s) to check.`);
}
let actionlintCount = 0;
let zizmorCount = 0;
// ── actionlint ────────────────────────────────────────────────────────────
if (hasActionlint) {
if (!QUIET) {
process.stderr.write("[check-workflows] Running actionlint …\n");
}
const result = runActionlint(workflowFiles);
actionlintCount = result.count;
if (result.count > 0 && !QUIET) {
console.error(`[check-workflows] actionlint: ${result.count} finding(s):`);
result.lines.forEach((l) => console.error(` ${l}`));
} else if (!QUIET) {
console.log(`[check-workflows] actionlint: OK (0 findings)`);
}
} else {
if (!QUIET) {
console.log("[check-workflows] actionlint: SKIP (not in PATH)");
}
}
// ── zizmor ────────────────────────────────────────────────────────────────
if (hasZizmor) {
if (!QUIET) {
process.stderr.write("[check-workflows] Running zizmor …\n");
}
const result = runZizmor(WORKFLOWS_DIR);
zizmorCount = result.count;
if (result.count > 0 && !QUIET) {
console.error(`[check-workflows] zizmor: ${result.count} finding(s).`);
console.error(" Run: zizmor --format text .github/workflows/ for human-readable details.");
} else if (!QUIET) {
console.log(`[check-workflows] zizmor: OK (0 findings)`);
}
} else {
if (!QUIET) {
console.log("[check-workflows] zizmor: SKIP (not in PATH)");
}
}
const total = actionlintCount + zizmorCount;
process.stdout.write(`workflowFindings=${total}\n`);
process.stdout.write(`actionlintFindings=${actionlintCount}\n`);
process.stdout.write(`zizmorFindings=${zizmorCount}\n`);
if (STRICT && total > 0) {
console.error(
`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`
);
process.exit(1);
}
if (total > 0 && !QUIET) {
console.log(
`[check-workflows] ADVISORY — ${total} finding(s) detected. ` +
"Pass --strict to block the gate."
);
}
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
// scripts/check/lib/allowlist.mjs
// Shared helper for stale-allowlist enforcement (6A.3).
//
// Purpose: detect allowlist entries that no longer correspond to any live
// violation. When a developer fixes a violation, the entry in KNOWN_* must
// also be removed — otherwise the gate silently allows the violation to
// regress. This pattern is validated practice (ESLint --report-unused-disable-
// directives; Notion suppression hygiene).
/**
* Returns the subset of `allowlist` entries that do NOT appear in
* `liveViolations`. These are "stale" entries — the violation they once
* suppressed has been corrected, so the entry should be removed to prevent
* silent regression.
*
* @param {string[] | Set<string>} allowlist - The known-violations list/set.
* @param {string[] | Set<string>} liveViolations - Violations detected in the
* current run (strings as they appear in the allowlist).
* @param {string} gateName - Gate name used only in future error messages; not
* used internally by this function but kept for API consistency with
* assertNoStale.
* @returns {string[]} Stale entries (present in allowlist, absent in live).
*/
export function reportStaleEntries(allowlist, liveViolations, gateName) {
const liveSet = liveViolations instanceof Set ? liveViolations : new Set(liveViolations);
const stale = [];
for (const entry of allowlist) {
if (!liveSet.has(entry)) {
stale.push(entry);
}
}
return stale;
}
/**
* Calls reportStaleEntries; if any stale entries are found, logs them to
* stderr and sets process.exitCode = 1 so the gate fails without throwing
* (allowing multiple gates to report before the process exits).
*
* @param {string[] | Set<string>} allowlist
* @param {string[] | Set<string>} liveViolations
* @param {string} gateName - Shown in the error message to identify the gate.
* @returns {string[]} The same stale array returned by reportStaleEntries.
*/
export function assertNoStale(allowlist, liveViolations, gateName) {
const stale = reportStaleEntries(allowlist, liveViolations, gateName);
if (stale.length > 0) {
console.error(
`[${gateName}] ${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")
);
process.exitCode = 1;
}
return stale;
}

View File

@@ -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)`);

View File

@@ -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));
}

View 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();