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