chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
// check-build-scope.mjs — guards against worktrees / cruft leaking into the
// TypeScript build scope and poisoning `next build`.
//
// Root cause of the 2026-06-25 build OOM/GC-livelock incident: `tsconfig.json`
// uses `include: ["**/*.ts","**/*.tsx","**/*.js","**/*.jsx"]` (recursive glob),
// and 69 git worktrees under `.claude/worktrees/` were NOT in `exclude` — so the
// TS scope ballooned to 355,215 files (vs 4,547 real source files) and `next build`
// processed ~70x the codebase, OOMing even at a 64 GB heap. The CI built fine
// because its checkout is clean.
//
// This gate counts the .ts/.tsx/.js/.jsx files that tsconfig's include would match
// (respecting its top-level exclude dirs) and FAILS if the count exceeds a
// threshold — catching a leak BEFORE it detonates the build. Heap size is NOT the
// fix for an over-large scope; a clean scope is.
//
// Usage: node scripts/check/check-build-scope.mjs [--max N] [--json]
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const args = process.argv.slice(2);
const MAX = Number(args[args.indexOf("--max") + 1]) || 12000;
const JSON_OUT = args.includes("--json");
const EXT = new Set([".ts", ".tsx", ".js", ".jsx"]);
// Read tsconfig.json exclude (the source of truth for what's out of scope).
const tsconfigPath = path.join(ROOT, "tsconfig.json");
let exclude = [];
try {
exclude = JSON.parse(fs.readFileSync(tsconfigPath, "utf8")).exclude || [];
} catch {
console.error("[build-scope] could not read tsconfig.json exclude — aborting");
process.exit(2);
}
// Always skip VCS + the exclude dirs (normalise to bare top-level names).
const SKIP_DIRS = new Set([".git", ...exclude.map((e) => e.replace(/^\.\//, "").replace(/\/.*$/, ""))]);
let count = 0;
const byTop = {};
function walk(dir, top) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (e.isSymbolicLink()) continue; // don't follow symlinks (e.g. node_modules)
const full = path.join(dir, e.name);
if (e.isDirectory()) {
walk(full, top);
} else if (EXT.has(path.extname(e.name))) {
count++;
byTop[top] = (byTop[top] || 0) + 1;
}
}
}
for (const e of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!e.isDirectory()) {
if (EXT.has(path.extname(e.name))) {
count++;
byTop["(root)"] = (byTop["(root)"] || 0) + 1;
}
continue;
}
if (SKIP_DIRS.has(e.name)) continue;
walk(path.join(ROOT, e.name), e.name);
}
if (JSON_OUT) {
console.log(JSON.stringify({ count, max: MAX, byTop }, null, 2));
} else {
console.log(`[build-scope] ${count} .ts/.tsx/.js/.jsx files in tsconfig scope (max ${MAX})`);
const top = Object.entries(byTop)
.sort((a, b) => b[1] - a[1])
.slice(0, 8);
for (const [d, n] of top) console.log(` ${String(n).padStart(7)} ${d}`);
}
if (count > MAX) {
console.error(
`\n❌ [build-scope] scope of ${count} files exceeds ${MAX} — something is leaking into the\n` +
` tsconfig include scope (worktree, vendored copy, or build output). This poisons\n` +
` \`next build\` (OOM/GC-livelock). Add the offending dir to tsconfig.json "exclude"\n` +
` (and .dockerignore). Worktrees MUST live under .claude/worktrees/ (already excluded).\n` +
` Heap size does NOT fix this — a clean scope does. See incident 2026-06-25.`
);
process.exit(1);
}
console.log("✅ [build-scope] OK — no leak into the build scope.");
+285
View File
@@ -0,0 +1,285 @@
#!/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.
//
// Por default é ADVISORY: sempre sai 0 independente do resultado. Passe --ratchet
// para tornar BLOQUEANTE: lê metrics.bundleSize.value de
// config/quality/quality-baseline.json, compara o total MEDIDO e SAI 1 SE — E SOMENTE
// SE — o medido for MAIOR que o baseline (regressão real, direction:down).
//
// IMPORTANTE: o baseline (5601) é o valor GZIP do size-limit + @size-limit/file
// (instalado por 'npm ci' no CI). O modo FALLBACK-stat lê bytes CRUS (uma métrica
// DIFERENTE e maior) — comparar fallback-stat contra o baseline gzip seria um falso-
// positivo. Por isso o --ratchet SÓ bloqueia quando a medição veio do size-limit
// REAL (plugin presente); o fallback-stat e o no-build são SKIP gracioso (exit 0)
// mesmo com --ratchet — falta de plugin/build nunca bloqueia, só uma regressão
// medida na MESMA métrica do baseline bloqueia.
//
// 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)
// node scripts/check/check-bundle-size.mjs --ratchet (falha exit 1 numa regressão)
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");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const RATCHET = process.argv.includes("--ratchet");
/**
* 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 };
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia o total MEDIDO de bytes contra o baseline.
* Direction: down (o tamanho só pode CAIR — maior = regressão).
*
* @param {number} current - Total de bytes medido agora (gzip, via size-limit).
* @param {number} baseline - Total congelado em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateBundleSizeRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Lê metrics.bundleSize.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineBundleSizeValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.bundleSize;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
/**
* Aplica o ratchet (direction:down) sobre o total medido vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet + medição comparável (gzip via
* size-limit): exit 1 numa regressão real (medido > baseline). Baseline ausente →
* SKIP gracioso (exit 0). Define process.exitCode; não lança.
*
* @param {number} totalBytes - Total MEDIDO pelo size-limit (gzip).
*/
function applyRatchet(totalBytes) {
if (!RATCHET) {
process.exitCode = 0;
return;
}
const baselineValue = readBaselineBundleSizeValue(BASELINE_PATH);
if (baselineValue === null) {
console.log("[bundle-size] --ratchet: baseline ausente (metrics.bundleSize) — SKIP, sai 0.");
process.exitCode = 0;
return;
}
const { regressed } = evaluateBundleSizeRatchet(totalBytes, baselineValue);
if (regressed) {
console.error(
`[bundle-size] REGRESSÃO — ${totalBytes} bytes > baseline ${baselineValue}.\n` +
" → Reduza o tamanho dos entrypoints, ou re-baseline metrics.bundleSize em\n" +
" config/quality/quality-baseline.json se o crescimento for legítimo e justificado."
);
process.exitCode = 1;
return;
}
console.log(
`[bundle-size] --ratchet OK — ${totalBytes} bytes, baseline ${baselineValue} (sem regressão).`
);
process.exitCode = 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).
// SKIP sai 0 mesmo com --ratchet (build ausente nunca bloqueia).
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).
// SKIP sai 0 mesmo com --ratchet (erro de medição nunca bloqueia).
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}`);
// O ratchet só pode comparar a MESMA métrica que congelou o baseline (gzip via
// size-limit + @size-limit/file). O fallback-stat lê bytes CRUS — uma métrica
// diferente e maior — então com --ratchet ele faz SKIP gracioso (exit 0) em vez
// de um falso-positivo. Sem --ratchet, ambos os modos só reportam (advisory).
if (mode !== "size-limit") {
if (RATCHET) {
console.log(
`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — ` +
"--ratchet SKIP (medição não-comparável ao baseline gzip; instale @size-limit/file)."
);
process.exitCode = 0;
return;
}
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
return;
}
if (!RATCHET) {
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
}
applyRatchet(totalBytes);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// scripts/check/check-changelog-integrity.mjs
//
// Anti "CHANGELOG-eat" gate: no bullet line that exists in the BASE branch's
// CHANGELOG.md may disappear in the merge result. The chronic failure mode is
// git's merge auto-resolve silently dropping sibling bullets (or whole version
// sections) when two branches touch adjacent CHANGELOG lines — incident
// 2026-07-05: PR #6193's merge ate 212 lines (the entire [3.8.45] + [3.8.44]
// sections, 130 bullets), only recovered by hand from the pre-merge ref.
//
// On pull_request CI the checkout is refs/pull/N/merge — the auto-resolved
// merge result — so comparing it against origin/<base> catches the eat BEFORE
// the merge lands, in the PR that would cause it.
//
// Policy (Princípio Zero): this only ever ADDS work for the maintainer side —
// quality.yml runs it blocking for own-origin PRs and report-only for forks.
// The release captain's reconciliation rewrites the CHANGELOG legitimately,
// but that happens on the release PR (PR → main, ci.yml), which does not run
// this gate. Escape hatch for intentional removals (e.g. reverting a reverted
// feature's bullet): ALLOW_CHANGELOG_REMOVALS=1 turns failures into a report.
//
// Usage:
// node scripts/check/check-changelog-integrity.mjs
// env GITHUB_BASE_REF PR base branch (CI); local fallback: current release/*
// env CHANGELOG_BASE_REF explicit ref override (e.g. origin/release/v3.8.45)
// env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails)
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const CHANGELOG = "CHANGELOG.md";
/** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */
export function extractBullets(text) {
const bullets = new Set();
for (const raw of String(text || "").split("\n")) {
const line = raw.trim();
if (line.startsWith("- ") && line.length > 4) bullets.add(line);
}
return bullets;
}
/**
* Bullet lines present in the base CHANGELOG but absent from the head
* CHANGELOG — the "eaten" set. Pure so it has a unit test.
*/
export function findLostBullets(baseText, headText) {
const headBullets = extractBullets(headText);
const lost = [];
for (const b of extractBullets(baseText)) {
if (!headBullets.has(b)) lost.push(b);
}
return lost;
}
function git(args) {
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
}
function resolveBaseRef() {
if (process.env.CHANGELOG_BASE_REF) return process.env.CHANGELOG_BASE_REF;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
// Local fallback: the highest release/v* on origin (the active development base).
try {
const branches = git(["branch", "-r", "--list", "origin/release/v*", "--format=%(refname:short)"])
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
return branches[branches.length - 1] || null;
} catch {
return null;
}
}
function main() {
const baseRef = resolveBaseRef();
if (!baseRef) {
console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone).");
return 0;
}
let baseText;
try {
baseText = git(["show", `${baseRef}:${CHANGELOG}`]);
} catch {
console.log(`[changelog-integrity] SKIP — ${CHANGELOG} not readable at ${baseRef}.`);
return 0;
}
const headText = readFileSync(join(ROOT, CHANGELOG), "utf8");
const lost = findLostBullets(baseText, headText);
if (lost.length === 0) {
console.log(`[changelog-integrity] OK — no base bullets lost vs ${baseRef}.`);
return 0;
}
console.error(
`[changelog-integrity] ${lost.length} bullet(s) present in ${baseRef} are MISSING from this tree's ${CHANGELOG}:`
);
for (const b of lost.slice(0, 15)) console.error(`${b.slice(0, 160)}`);
if (lost.length > 15) console.error(` … and ${lost.length - 15} more`);
console.error(
"\nThis is the CHANGELOG-eat pattern (merge auto-resolve dropping sibling bullets)." +
"\nFix: restore the base CHANGELOG (`git checkout <base> -- CHANGELOG.md`), re-insert ONLY" +
"\nyour own bullet, and prove the net diff is additive. Intentional removals (rare):" +
"\nre-run with ALLOW_CHANGELOG_REMOVALS=1 and justify in the PR body."
);
if (process.env.ALLOW_CHANGELOG_REMOVALS === "1") {
console.error("[changelog-integrity] ALLOW_CHANGELOG_REMOVALS=1 — reporting only, not failing.");
return 0;
}
return 1;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exit(main());
}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
// scripts/check/check-circular-deps.mjs
// Gate: dpdm circular-deps cross-check (segunda opinião complementar ao check-cycles.mjs).
//
// check-cycles.mjs usa AST-TS próprio mas cobre apenas 5 sub-árvores + somente
// imports relativos. Este script usa dpdm (v4) que rastreia path-aliases via
// tsconfig.json e cobre entrypoints de alto risco.
//
// Advisory nesta versão: exit 0 sempre; imprime `circularDeps=N` para baseline.
// Direção da catraca: down (não pode subir). Adicionar ao quality-baseline.json
// como `{ value: N, direction: "down" }` após a primeira run verde no CI.
//
// Escopo limitado a 4 entrypoints principais para manter o tempo de análise
// abaixo de 60s. dpdm rastreia transitivamente todas as deps de cada entry.
// Cobrir mais entries aumenta o tempo sem proporcional ganho (as deps core se
// repetem via transitividade).
//
// Nota: dpdm pode reportar mais ciclos que check-cycles.mjs porque conta
// permutações de paths que passam pelo mesmo SCC, não apenas SCCs únicos.
// Isso é esperado — ferramentas diferentes, métricas complementares.
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, "../..");
// Entrypoints: cobrem o pipeline principal (chat, combo, MCP, DB).
// Mantido enxuto para que `dpdm -T` (transform) termine em < 60s.
const ENTRYPOINTS = [
"open-sse/handlers/chatCore.ts",
"open-sse/services/combo.ts",
"open-sse/mcp-server/index.ts",
"src/lib/db/core.ts",
];
const DPDM_BIN = resolve(projectRoot, "node_modules/.bin/dpdm");
const TSCONFIG = resolve(projectRoot, "tsconfig.json");
/**
* Parseia a saída JSON do dpdm e retorna a contagem de ciclos.
* Função exportada para ser testada isoladamente sem executar o dpdm.
*
* @param {string} jsonStr - string com o JSON de saída do dpdm (campo "circulars").
* @returns {{ count: number, circulars: string[][] }}
*/
export function parseDpdmOutput(jsonStr) {
let parsed;
try {
parsed = JSON.parse(jsonStr);
} catch {
throw new Error(`dpdm JSON parse failed: ${jsonStr.slice(0, 200)}`);
}
const circulars = Array.isArray(parsed.circulars) ? parsed.circulars : [];
return { count: circulars.length, circulars };
}
/**
* Executa o dpdm e retorna a string JSON bruta do arquivo de saída.
*
* @returns {string} conteúdo JSON do arquivo temporário.
*/
function runDpdm() {
if (!existsSync(DPDM_BIN)) {
throw new Error(`dpdm binary not found at ${DPDM_BIN}. Run: npm install`);
}
const tmpFile = path.join(os.tmpdir(), `dpdm-output-${process.pid}.json`);
try {
execFileSync(
"node",
[
DPDM_BIN,
"--circular",
"--no-warning",
"--no-tree",
"-T",
"--tsconfig",
TSCONFIG,
"-o",
tmpFile,
...ENTRYPOINTS,
],
{
cwd: projectRoot,
stdio: "inherit",
timeout: 120_000,
}
);
if (!existsSync(tmpFile)) {
throw new Error(`dpdm did not produce output file at ${tmpFile}`);
}
const raw = readFileSync(tmpFile, "utf8");
return raw;
} finally {
try {
fs.unlinkSync(tmpFile);
} catch {
// best-effort cleanup
}
}
}
function main() {
console.log("[circular-deps] Running dpdm cross-check...");
console.log(`[circular-deps] Entrypoints: ${ENTRYPOINTS.join(", ")}`);
let raw;
try {
raw = runDpdm();
} catch (err) {
console.error(`[circular-deps] ERROR running dpdm: ${err.message}`);
process.exit(1);
}
let result;
try {
result = parseDpdmOutput(raw);
} catch (err) {
console.error(`[circular-deps] ERROR parsing dpdm output: ${err.message}`);
process.exit(1);
}
// Advisory mode: always exit 0. Catraca pode ser adicionada no quality-baseline.json
// após baseline ser estabelecida.
console.log(`[circular-deps] circularDeps=${result.count}`);
console.log(
`[circular-deps] Advisory — add to quality-baseline.json: { "value": ${result.count}, "direction": "down" }`
);
process.exit(0);
}
// Permite que o módulo seja importado em testes sem executar main().
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* Validates that:
* 1. All t("key") calls in bin/cli/commands/ resolve to existing keys in en.json.
* 2. pt-BR.json has the same top-level shape as en.json (no missing top-level sections).
* 3. No raw string literals are passed to .description() in commands without going
* through t() — only warns, does not fail hard (many descriptions use || fallback).
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const COMMANDS_DIR = join(ROOT, "bin", "cli", "commands");
const LOCALES_DIR = join(ROOT, "bin", "cli", "locales");
// Paths that look like t() keys but are actually import paths — skip them.
const IGNORE_AS_KEY = new Set([".", ".."]);
const IMPORT_PATH_RE = /^(\.\.?\/|node:|\/)/;
function walk(dir) {
const results = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
results.push(...walk(full));
} else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
results.push(full);
}
}
return results;
}
function flattenKeys(obj, prefix = "") {
const keys = new Set();
for (const [k, v] of Object.entries(obj)) {
const full = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
for (const sub of flattenKeys(v, full)) keys.add(sub);
} else {
keys.add(full);
}
}
return keys;
}
function collectTKeys(files) {
const used = new Set();
const re = /\bt\(\s*["']([^"']+)["']/g;
for (const file of files) {
const src = readFileSync(file, "utf8");
let m;
re.lastIndex = 0;
while ((m = re.exec(src)) !== null) {
const key = m[1];
if (IGNORE_AS_KEY.has(key) || IMPORT_PATH_RE.test(key)) continue;
used.add(key);
}
}
return used;
}
function loadJson(file) {
return JSON.parse(readFileSync(file, "utf8"));
}
const files = walk(COMMANDS_DIR);
const usedKeys = collectTKeys(files);
const en = loadJson(join(LOCALES_DIR, "en.json"));
const ptBR = loadJson(join(LOCALES_DIR, "pt-BR.json"));
const enKeys = flattenKeys(en);
let errors = 0;
// Check 1: all used keys exist in en.json
const missingInEn = [...usedKeys].filter((k) => !enKeys.has(k));
if (missingInEn.length > 0) {
console.error("[cli-i18n] Keys used in commands but missing in en.json:");
for (const k of missingInEn) console.error(`${k}`);
errors += missingInEn.length;
} else {
console.log(`[cli-i18n] ✓ All ${usedKeys.size} t() keys found in en.json`);
}
// Check 2: pt-BR.json has the same top-level sections as en.json
const enTopLevel = Object.keys(en);
const ptTopLevel = new Set(Object.keys(ptBR));
const missingTopLevel = enTopLevel.filter((k) => !ptTopLevel.has(k));
if (missingTopLevel.length > 0) {
console.error("[cli-i18n] Top-level sections in en.json missing from pt-BR.json:");
for (const k of missingTopLevel) console.error(`${k}`);
errors += missingTopLevel.length;
} else {
console.log(`[cli-i18n] ✓ pt-BR.json has all ${enTopLevel.length} top-level sections`);
}
if (errors > 0) {
console.error(`[cli-i18n] FAIL — ${errors} error(s) found`);
process.exit(1);
} else {
console.log("[cli-i18n] PASS — CLI i18n is consistent");
}
+430
View File
@@ -0,0 +1,430 @@
#!/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
//
// RATCHET BLOQUEANTE (default): lê metrics.codeqlAlerts.value de
// config/quality/quality-baseline.json e SAI 1 SE — E SOMENTE SE — a contagem
// MEDIDA for MAIOR que o baseline (regressão real, mais alertas CodeQL abertos).
// Qualquer falha de MEDIÇÃO (gh ausente / sem auth / sem repo / erro de API) é um
// SKIP gracioso que SAI 0 — nunca bloqueia o build por falta de infraestrutura.
// Direction: down (a contagem só pode CAIR). Suporta --update para ratchetar.
//
// 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
// node scripts/check/check-codeql-ratchet.mjs --update # ratcheta o baseline (queda)
// node scripts/check/check-codeql-ratchet.mjs --advisory # nunca falha (modo coletor)
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const UPDATE = process.argv.includes("--update");
// --advisory: nunca falha pela contagem (modo coletor legado). Sem esta flag o
// gate é BLOQUEANTE: sai 1 numa regressão real (medida > baseline).
const ADVISORY = process.argv.includes("--advisory");
const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/quality-baseline.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 };
}
/**
* Avalia a contagem MEDIDA de alertas CodeQL contra o baseline.
* Direction: down (a contagem só pode CAIR — mais alertas = regressão).
*
* Exported for unit testing — espelha evaluateDeadCode em check-dead-code.mjs.
*
* @param {number} current - Contagem de alertas medida agora.
* @param {number} baseline - Contagem congelada em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateCodeqlRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
// ---------------------------------------------------------------------------
// 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) {
// A malformed (but HTTP-200) API response is a MEASUREMENT failure, not a
// regression. A blocking gate must never red on it — return the same
// {error,message} shape the caller already maps to a graceful SKIP (exit 0).
return { error: "parse-error", message: String(parseErr.message ?? parseErr) };
}
// 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;
}
// ---------------------------------------------------------------------------
// Baseline
// ---------------------------------------------------------------------------
/**
* Lê metrics.codeqlAlerts.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (modo coletor puro:
* sem baseline não há ratchet, só emissão da contagem).
*
* @returns {number|null}
*/
function readBaselineCodeqlValue() {
if (!fs.existsSync(BASELINE_PATH)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.codeqlAlerts;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Define process.exitCode = 1 numa regressão real (medida > baseline) salvo
* --advisory. Ratcheta o baseline com --update quando a contagem cai.
*
* Exported for unit testing (drives o efeito em process.exitCode).
*
* @param {number} alertCount - Contagem MEDIDA (medição bem-sucedida).
*/
export function applyRatchet(alertCount) {
const baselineValue = readBaselineCodeqlValue();
// Sem baseline → modo coletor puro (emite a contagem, não falha).
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[codeql-ratchet] baseline ausente (metrics.codeqlAlerts) — modo coletor, sem ratchet.\n"
);
}
process.exitCode = 0;
return;
}
const { regressed, improved } = evaluateCodeqlRatchet(alertCount, baselineValue);
if (UPDATE && improved) {
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
baselineJson.metrics.codeqlAlerts.value = alertCount;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[codeql-ratchet] baseline ratcheado: ${alertCount} (era ${baselineValue})`);
}
if (regressed && !ADVISORY) {
process.stderr.write(
`[codeql-ratchet] REGRESSÃO — ${alertCount} alertas CodeQL abertos > baseline ${baselineValue}\n` +
" → Corrija os novos alertas em Security → Code scanning, ou rode\n" +
" 'node scripts/check/check-codeql-ratchet.mjs --update' se a contagem caiu legitimamente.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
const verdict = regressed ? "ADVISORY — regressão ignorada (--advisory)" : "OK — sem regressão";
process.stderr.write(
`[codeql-ratchet] ${verdict}${alertCount} alertas (baseline ${baselineValue})\n`
);
}
process.exitCode = 0;
}
// ---------------------------------------------------------------------------
// 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`);
}
}
// Medição bem-sucedida → aplica o ratchet (bloqueante salvo --advisory).
// Qualquer falha de MEDIÇÃO acima já retornou com exit 0 (skip gracioso).
applyRatchet(alertCount);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
@@ -0,0 +1,155 @@
#!/usr/bin/env node
// scripts/check/check-cognitive-complexity.mjs
// Ratchet bloqueante para complexidade cognitiva (sonarjs/cognitive-complexity).
// Fase 7 INT: promovido de ADVISORY para RATCHET.
//
// Roda o ESLint sobre src+open-sse usando um config flat STANDALONE
// (eslint.sonarjs.config.mjs) que liga APENAS `sonarjs/cognitive-complexity` —
// mantendo a contagem ISOLADA do orçamento de warnings do lint principal.
//
// Lê o baseline de quality-baseline.json (metrics.cognitiveComplexity).
// Falha com exit 1 se a contagem SUBIR. Suporta --update.
//
// Saída canônica: cognitiveComplexity=N (parseable por collect-metrics.mjs)
//
// Uso:
// node scripts/check/check-cognitive-complexity.mjs
// node scripts/check/check-cognitive-complexity.mjs --quiet # só a linha canônica
// node scripts/check/check-cognitive-complexity.mjs --update # ratcheta baseline se melhorou
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const UPDATE = process.argv.includes("--update");
const CONFIG_PATH = path.join(ROOT, "eslint.sonarjs.config.mjs");
const ESLINT_BIN = path.join(ROOT, "node_modules", ".bin", "eslint");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/quality-baseline.json")
);
const ESLINT_ARGS = [
"--no-config-lookup",
"--config",
CONFIG_PATH,
"--format",
"json",
"src",
"open-sse",
];
/**
* Parses the ESLint JSON output (array of file results) and counts total
* `sonarjs/cognitive-complexity` violations.
*
* Exported so unit tests can call it directly with synthetic data.
*
* @param {Array<{messages: Array<{ruleId: string}>}>} report
* @returns {number}
*/
export function countCognitiveViolations(report) {
let count = 0;
for (const file of report) {
for (const msg of file.messages) {
if (msg.ruleId === "sonarjs/cognitive-complexity") {
count++;
}
}
}
return count;
}
/**
* Avalia a contagem atual de violações cognitivas contra o baseline.
* Direction: down (contagem só pode CAIR).
*
* Exported for unit testing.
*
* @param {number} current
* @param {number} baseline
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateCognitiveComplexity(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
function runEslint() {
let stdout;
try {
stdout = execFileSync(ESLINT_BIN, ESLINT_ARGS, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
} catch (err) {
// ESLint exits non-zero when there are lint errors; the JSON report is still
// in stdout. Re-throw only if there is no parseable output.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) throw err;
}
return JSON.parse(stdout);
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(
`[cognitive-complexity] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`
);
process.exit(2);
}
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const baselineMetric = baselineJson.metrics && baselineJson.metrics.cognitiveComplexity;
if (!baselineMetric || typeof baselineMetric.value !== "number") {
process.stderr.write(
"[cognitive-complexity] FAIL — metrics.cognitiveComplexity ausente em quality-baseline.json.\n"
);
process.exit(2);
}
const baselineValue = baselineMetric.value;
const report = runEslint();
const count = countCognitiveViolations(report);
// Canonical machine-readable output consumed by collect-metrics.mjs and shell scripts.
console.log(`cognitiveComplexity=${count}`);
if (!QUIET) {
console.log(
`[cognitive-complexity] ${count} function(s) exceed the cognitive-complexity threshold (15).`
);
}
const { regressed, improved } = evaluateCognitiveComplexity(count, baselineValue);
if (UPDATE && improved) {
baselineJson.metrics.cognitiveComplexity.value = count;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[cognitive-complexity] baseline ratcheado: ${count} (era ${baselineValue})`);
}
if (regressed) {
process.stderr.write(
`[cognitive-complexity] REGRESSÃO — ${count} violações > baseline ${baselineValue}\n` +
` → Quebre as funções complexas em helpers menores, ou rode\n` +
` 'node scripts/check/check-cognitive-complexity.mjs --update' se a contagem caiu legitimamente.\n`
);
process.exit(1);
}
if (!QUIET) {
console.log(`[cognitive-complexity] OK — ${count} violações (baseline ${baselineValue})`);
}
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
// scripts/check/check-complexity.mjs
// Catraca de complexidade de código. Roda o ESLint sobre src+open-sse usando um config
// flat STANDALONE (eslint.complexity.config.mjs) que liga APENAS duas regras CORE do
// ESLint — `complexity` (ciclomática) e `max-lines-per-function` (tamanho de função) —
// e compara a contagem total de violações contra um baseline congelado
// (complexity-baseline.json). Falha se a contagem SUBIR. Completa a dimensão
// "complexity" do snapshot de qualidade, ao lado de duplicação/tamanho-de-arquivo.
//
// O config dedicado evita poluir a contagem de warnings do lint principal (ratcheada
// em exatamente 3482): este gate roda isolado, com seu próprio par de regras. --update
// ratcheta (a contagem só pode CAIR).
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/complexity-baseline.json")
);
const UPDATE = process.argv.includes("--update");
const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs");
// Exported for the gate's own unit test (tests/unit/build/check-complexity.test.ts), which
// locks the scan scope to the one documented in eslint.complexity.config.mjs `files` and in
// complexity-baseline.json. The positional paths MUST match that scope (src+open-sse+electron+bin)
// — ESLint flat config only walks the directories passed here, so a `files` glob for bin/electron
// is inert unless the directory is also passed as a positional argument.
export const ESLINT_ARGS = [
"eslint",
"--no-config-lookup",
"--config",
CONFIG_PATH,
"--format",
"json",
"src",
"open-sse",
"electron",
"bin",
];
/** Avalia a contagem atual de violações contra o baseline. */
export function evaluateComplexity(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
function measureComplexityCount() {
let stdout;
try {
stdout = execFileSync("npx", ["--yes", ...ESLINT_ARGS], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
} catch (err) {
// ESLint sai com código !=0 quando há erros (e nossas regras são "error"); o relatório
// JSON ainda vai no stdout. Só relançamos se não houver stdout parseável.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) throw err;
}
const report = JSON.parse(stdout);
return report.reduce((sum, file) => sum + file.errorCount, 0);
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
console.error(`[complexity] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
process.exit(2);
}
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const current = measureComplexityCount();
const { regressed, improved } = evaluateComplexity(current, baseline.count);
if (UPDATE && improved) {
console.log(`[complexity] baseline ratcheado: ${current} (era ${baseline.count})`);
baseline.count = current;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
}
if (regressed) {
console.error(
`[complexity] REGRESSÃO — ${current} violações > baseline ${baseline.count}\n` +
` → quebre a função em helpers menores (reduza ramos/tamanho) ou rode\n` +
` 'node scripts/check/check-complexity.mjs --update' se a contagem caiu legitimamente.`
);
process.exit(1);
}
console.log(`[complexity] OK — ${current} violações (baseline ${baseline.count})`);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+83
View File
@@ -0,0 +1,83 @@
/**
* Compression budget gate (F2.4 / N4 ratchet).
*
* Runs the deterministic compression engines over BENCHMARK_CORPUS and fails if any engine's
* mean compressed-tokens-per-task RISES beyond the tolerance versus the frozen baseline — i.e. a
* change made compression worse. Falling cost (better compression) always passes.
*
* node --import tsx scripts/check/check-compression-budget.ts # check (CI)
* node --import tsx scripts/check/check-compression-budget.ts --update # refresh the baseline
*
* The benchmark is deterministic + API-free (chars/4 estimate over a fixed corpus), so the
* committed baseline is portable across local/CI.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
BENCHMARK_CORPUS,
DEFAULT_BENCHMARK_ENGINES,
benchmarkEngines,
runBenchmarkGate,
} from "../../open-sse/services/compression/harness/benchmark.ts";
import {
tokensPerTask,
type BudgetBaseline,
} from "../../open-sse/services/compression/harness/budgetGate.ts";
const BASELINE_PATH = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"compression-budget-baseline.json"
);
const TOLERANCE_PERCENT = 2;
async function main(): Promise<void> {
const update = process.argv.includes("--update");
const reports = await benchmarkEngines(BENCHMARK_CORPUS, DEFAULT_BENCHMARK_ENGINES);
if (update) {
const baselines: Record<string, BudgetBaseline> = {};
for (const [engine, report] of Object.entries(reports)) {
baselines[engine] = { tasks: tokensPerTask(report) };
}
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselines, null, 2) + "\n");
console.log(`Updated compression budget baseline (${Object.keys(baselines).length} engines).`);
return;
}
if (!fs.existsSync(BASELINE_PATH)) {
console.error("compression budget baseline missing — run with --update to generate it.");
process.exit(1);
}
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")) as Record<
string,
BudgetBaseline
>;
const results = runBenchmarkGate(reports, baselines, TOLERANCE_PERCENT);
const failed = results.filter((r) => !r.gate.passed);
if (failed.length === 0) {
console.log(`✓ compression budget gate: no regressions (tolerance ${TOLERANCE_PERCENT}%)`);
return;
}
console.error("✗ compression budget gate: tokens-per-task regressed (compression got worse):");
for (const { engine, gate } of failed) {
for (const reg of gate.regressions) {
console.error(
` ${engine}/${reg.task}: ${reg.baseline} -> ${reg.current} tokens (+${reg.deltaPercent}%)`
);
}
}
console.error(
"\nIf this is an intentional improvement/change, refresh: npm run check:compression-budget -- --update"
);
process.exit(1);
}
main().catch((err) => {
console.error("compression budget gate failed:", err instanceof Error ? err.message : err);
process.exit(1);
});
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const cwd = process.cwd();
const defaultRoots = [
"src/shared/components",
"src/lib/db",
"src/lib/compliance",
"open-sse/translator",
"open-sse/mcp-server",
];
const roots = process.argv.slice(2).length > 0 ? process.argv.slice(2) : defaultRoots;
const sourceExtensions = [".ts", ".tsx", ".js", ".mjs", ".jsx", ".mts", ".cts"];
function toPosix(filePath) {
return filePath.split(path.sep).join("/");
}
function listSourceFiles(rootDir) {
const absRoot = path.resolve(cwd, rootDir);
if (!fs.existsSync(absRoot)) {
return [];
}
const stack = [absRoot];
const files = [];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (sourceExtensions.includes(path.extname(entry.name))) {
files.push(path.resolve(fullPath));
}
}
}
return files;
}
function resolveRelativeImport(fromFile, specifier) {
const base = path.resolve(path.dirname(fromFile), specifier);
const ext = path.extname(base);
if (ext && fs.existsSync(base) && fs.statSync(base).isFile()) {
return path.resolve(base);
}
for (const extension of sourceExtensions) {
const candidate = `${base}${extension}`;
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return path.resolve(candidate);
}
}
for (const extension of sourceExtensions) {
const candidate = path.join(base, `index${extension}`);
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return path.resolve(candidate);
}
}
return null;
}
function extractImportSpecifiers(fileContents) {
const specs = [];
const regex = /\b(?:import|export)\s+(?:[^"'`]*?\sfrom\s*)?["'`]([^"'`]+)["'`]/g;
let match = regex.exec(fileContents);
while (match) {
specs.push(match[1]);
match = regex.exec(fileContents);
}
return specs;
}
function buildGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const filePath of files) {
const code = fs.readFileSync(filePath, "utf8");
const dependencies = new Set();
const importSpecifiers = extractImportSpecifiers(code);
for (const specifier of importSpecifiers) {
if (!specifier.startsWith(".")) continue;
const resolved = resolveRelativeImport(filePath, specifier);
if (!resolved) continue;
if (!fileSet.has(resolved)) continue;
dependencies.add(resolved);
}
graph.set(filePath, dependencies);
}
return graph;
}
function stronglyConnectedComponents(graph) {
const indexMap = new Map();
const lowLinkMap = new Map();
const onStack = new Set();
const stack = [];
const components = [];
let indexCounter = 0;
function strongConnect(node) {
indexMap.set(node, indexCounter);
lowLinkMap.set(node, indexCounter);
indexCounter += 1;
stack.push(node);
onStack.add(node);
for (const neighbor of graph.get(node) || []) {
if (!indexMap.has(neighbor)) {
strongConnect(neighbor);
lowLinkMap.set(node, Math.min(lowLinkMap.get(node), lowLinkMap.get(neighbor)));
} else if (onStack.has(neighbor)) {
lowLinkMap.set(node, Math.min(lowLinkMap.get(node), indexMap.get(neighbor)));
}
}
if (lowLinkMap.get(node) === indexMap.get(node)) {
const component = [];
while (stack.length > 0) {
const candidate = stack.pop();
onStack.delete(candidate);
component.push(candidate);
if (candidate === node) break;
}
components.push(component);
}
}
for (const node of graph.keys()) {
if (!indexMap.has(node)) {
strongConnect(node);
}
}
return components;
}
function isSelfCycle(component, graph) {
if (component.length !== 1) return false;
const [file] = component;
return (graph.get(file) || new Set()).has(file);
}
const files = roots.flatMap((root) => listSourceFiles(root));
const graph = buildGraph(files);
const components = stronglyConnectedComponents(graph);
const cycles = components.filter(
(component) => component.length > 1 || isSelfCycle(component, graph)
);
if (cycles.length === 0) {
console.log(
`[cycles] OK - no cycles detected across ${graph.size} files in: ${roots.join(", ")}`
);
process.exit(0);
}
console.error(`[cycles] FAIL - detected ${cycles.length} strongly connected component(s):`);
for (const component of cycles) {
const sorted = [...component].sort((a, b) => a.localeCompare(b));
console.error(`\n- SCC (${sorted.length} files)`);
for (const filePath of sorted) {
console.error(` - ${toPosix(path.relative(cwd, filePath))}`);
}
}
process.exit(1);
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env node
// scripts/check/check-db-rules.mjs
// Gate de convenções de banco (CLAUDE.md Hard Rules #2 e #5). Três verificações:
// (a) Todo módulo de domínio em src/lib/db/*.ts deve ser re-exportado por
// src/lib/localDb.ts (camada de compat). Um módulo db NOVO que não é
// re-exportado (e não está congelado) falha — força a decisão consciente
// de expor ou justificar (Hard Rule #2).
// (b) src/lib/localDb.ts é APENAS camada de re-export: nada de lógica
// (function/class/arrow de negócio). Mata o anti-padrão de "só uma
// funçãozinha aqui" que vira regra de negócio fora dos módulos db/.
// (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");
const LOCAL_DB = path.join(cwd, "src/lib/localDb.ts");
const API_DIR = path.join(cwd, "src/app/api");
const HANDLERS_DIR = path.join(cwd, "open-sse/handlers");
// (a) Módulos db/ que NÃO são re-exportados por localDb.ts por DESIGN (Hard Rule #2:
// "Never barrel-import from localDb.ts — import specific db/ modules instead").
// Cada entrada aqui foi auditada e é consumida via import direto de "@/lib/db/X"
// (estático ou dinâmico) pelos seus consumidores — exatamente o padrão correto.
// Re-exportar esses módulos via localDb.ts INCENTIVARIA o anti-padrão proibido.
// O gate ainda bloqueia QUALQUER módulo db/ NOVO que não seja re-exportado E não
// esteja nessa lista — mantendo a decisão consciente obrigatória (Hard Rule #2).
// Legenda de classificação:
// type-only = exporta apenas tipos (sem runtime API), não há o que re-exportar
// db-internal = importado apenas dentro de src/lib/db/ (coordenação interna)
// intentionally-internal = consumido por import direto fora de db/ (correto per Rule #2)
// DEAD? = zero importers encontrados na auditoria de 2026-06-11; não deletar
// sem investigação — pode ser reserva de schema ou F2 pendente
export const INTENTIONALLY_INTERNAL = new Set([
"_rowTypes", // type-only: 5 importers internos em db/ (AgentBridge/Inspector row types)
"accessTokens", // intentionally-internal: 4 rotas /api/cli/* (connect, whoami, tokens, tokens/[id]) + server/authz/accessTokenAuth.ts via import direto "@/lib/db/accessTokens" (Rule #2)
"apiKeyColumnFallbacks", // db-internal: importado só por db/apiKeys.ts (API_KEY_COLUMN_FALLBACKS — fallbacks de coluna split do apiKeys.ts)
"apiKeyUsageLimitFields", // db-internal: importado só por db/apiKeys.ts (helpers de campo de limite de uso split do apiKeys.ts; mig 101)
"caseMapping", // db-internal: importado só por db/core.ts (toSnakeCase/toCamelCase/objToSnake — column-mapping snake↔camel split do core.ts, #4947)
"cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs)
"cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings
"comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery
"domainState", // intentionally-internal: 5 callers (batchWriter, circuitBreaker, costRules, fallbackPolicy, lockoutPolicy)
"encryption", // intentionally-internal: 8+ callers (container, webhookDispatcher, cloudAgent/credentials, services/apiKey, 4+ routes, open-sse)
"healthCheck", // db-internal: importado por db/core.ts (runDbHealthCheck)
"jsonMigration", // intentionally-internal: src/app/api/settings/import-json/route.ts
"migrationRunner", // db-internal: importado por db/core.ts (runMigrations ao inicializar o DB)
"notion", // intentionally-internal: settings/notion API route + open-sse/mcp-server/tools/notionTools.ts
"obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts
"optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter
"pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts
"prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts
"providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421)
"providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts
"recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests
"schemaColumns", // db-internal: importado só por db/core.ts (ensureProviderConnections/UsageHistory/CallLogsColumns + hasColumn/hasTable/getTableColumns — schema-column reconciliation split do core.ts, #4948)
"secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização)
"serviceModels", // intentionally-internal: 3 callers (services/modelSync, services/bootstrap, /api/services/9router/models)
"stateReset", // db-internal: 3 callers dentro de src/lib/db/ (core, backup, apiKeys) para coordenação de reset
"stats", // intentionally-internal: src/app/api/settings/database/refresh-stats/route.ts
"tierConfig", // intentionally-internal: open-sse/services/tierResolver.ts (require() dinâmico)
"webSessionDedup", // db-internal: importado só por db/providers.ts (webSessionCredentialKey/parseProviderSpecificData — helpers puros de dedup de credencial web-session split do providers.ts, #3368 PR6)
]);
// Alias para retrocompatibilidade com os testes existentes que importam KNOWN_UNEXPORTED.
// O comportamento do gate é idêntico — só o nome e os comentários mudaram (#3499).
export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL;
// (c) Leituras de SQL contra bancos EXTERNOS, permitidas por design (#3500).
// Estas rotas NÃO consultam o DB do OmniRoute (getDbInstance) — elas abrem o
// SQLite de OUTRO aplicativo (Cursor / Kiro) para auto-importar credenciais.
// Por isso NÃO podem viver em src/lib/db/ (que é o domínio do DB do OmniRoute):
// são leituras read-only de um arquivo externo, com caminho/escopo próprios.
// Continuam no allowlist como exceção DOCUMENTADA — o gate ainda bloqueia
// QUALQUER novo SQL cru contra o DB do OmniRoute em rotas/handlers.
// Toda a dívida real da Hard Rule #5 (15 rotas internas) foi migrada para
// módulos src/lib/db/ nas slices do #3500; este set ficou só com as exceções.
const EXTERNAL_DB_ALLOWED = new Set([
"src/app/api/oauth/cursor/auto-import/route.ts", // read-only no itemTable do SQLite do Cursor (DB externo)
"src/app/api/oauth/kiro/auto-import/route.ts", // read-only no SQLite do Kiro (DB externo)
]);
// Alias de retrocompatibilidade (testes/consumidores que importam KNOWN_RAW_SQL).
// Comportamento do gate idêntico — só o nome e o enquadramento mudaram (#3500).
const KNOWN_RAW_SQL = EXTERNAL_DB_ALLOWED;
// Módulos sempre excluídos da checagem (a): não são domínio re-exportável.
const DB_MODULE_EXCLUDE = new Set(["core", "localDb", "index"]);
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 acc.push(p);
}
return acc;
}
// Lista os módulos de domínio em src/lib/db (top-level *.ts), excluindo
// core/localDb/index, *.d.ts e qualquer subdiretório (migrations/, adapters/, __tests__/).
export function collectDbModules(dbDir = DB_DIR) {
if (!fs.existsSync(dbDir)) return [];
return fs
.readdirSync(dbDir, { withFileTypes: true })
.filter((e) => e.isFile() && /\.ts$/.test(e.name) && !/\.d\.ts$/.test(e.name))
.map((e) => e.name.replace(/\.ts$/, ""))
.filter((name) => !DB_MODULE_EXCLUDE.has(name))
.sort();
}
// Extrai os nomes de módulo re-exportados de localDb.ts a partir de
// `... from "./db/X"` (cobre export {…}, export * e export type {…}).
export function extractReexportedModules(localDbSource) {
const re = /from\s+["']\.\/db\/([A-Za-z0-9_]+)["']/g;
const out = new Set();
let m;
while ((m = re.exec(localDbSource))) out.add(m[1]);
return out;
}
// (a) Módulos db/ que não são re-exportados e não estão na lista de
// intencionalmente-internos (INTENTIONALLY_INTERNAL). O gate falha para
// qualquer módulo NOVO que não seja re-exportado nem justificado.
export function findMissingReexports(dbModules, reexported, allowlist = INTENTIONALLY_INTERNAL) {
return dbModules.filter((mod) => !reexported.has(mod) && !allowlist.has(mod));
}
// (b) localDb.ts deve conter SOMENTE import/export + comentários (sem lógica).
// Remove comentários e strings, depois procura declarações de runtime.
export function hasLogic(localDbSource) {
const stripped = localDbSource
// comentários de bloco
.replace(/\/\*[\s\S]*?\*\//g, "")
// comentários de linha
.replace(/\/\/[^\n]*/g, "")
// template strings
.replace(/`(?:\\[\s\S]|[^\\`])*`/g, '""')
// strings simples/duplas (paths de import etc.)
.replace(/"(?:\\.|[^"\\])*"/g, '""')
.replace(/'(?:\\.|[^'\\])*'/g, '""');
// function/class declaradas, ou atribuição a função (const X = (…) =>, const X = function).
const logicPatterns = [
/(^|[^.\w])function\s+[A-Za-z_$]/, // function decl (não method .foo())
/(^|[^.\w])class\s+[A-Za-z_$]/, // class decl
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(/, // const X = (…) ... (arrow/call)
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s+)?function\b/, // const X = function
];
return logicPatterns.some((rx) => rx.test(stripped));
}
// SQL cru é sempre uma STRING passada a db.prepare()/exec(): casamos os padrões
// SÓ dentro de literais de string (não em código JS — `import … from`, `.set(`,
// `new Set(`, `delete x` etc. são falsos positivos se varrermos o código todo).
const SQL_PATTERNS = [
/\bSELECT\b[\s\S]*?\bFROM\b/i, // SELECT … FROM (multi-linha)
/\bINSERT\s+INTO\b/i,
/\bUPDATE\b[\s\S]*?\bSET\b/i, // UPDATE … SET (multi-linha)
/\bDELETE\s+FROM\b/i,
/\bCREATE\s+TABLE\b/i,
];
// Remove comentários (linha // … e blocos /* */) — SQL em comentário não conta.
function stripComments(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
}
// Extrai o conteúdo de todos os literais de string (template, aspas duplas, aspas
// simples) de um trecho de código já sem comentários. Retorna a concatenação dos
// corpos — é nesse corpo que SQL cru vive.
export function extractStringLiterals(code) {
const re = /`(?:\\[\s\S]|[^\\`])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
const out = [];
let m;
while ((m = re.exec(code))) {
// tira as aspas/crases delimitadoras
out.push(m[0].slice(1, -1));
}
return out.join("\n\n"); // separador que nenhum padrão SQL atravessa
}
// (c) Arquivos com SQL cru dentro de literais de string (linhas não-comentário),
// fora do allowlist.
export function findRawSql(files, allowlist = KNOWN_RAW_SQL) {
const offenders = [];
for (const file of files) {
const rel = path.relative(cwd, file).replace(/\\/g, "/");
if (allowlist.has(rel)) continue;
let src;
try {
src = fs.readFileSync(file, "utf8");
} catch {
continue;
}
const literals = extractStringLiterals(stripComments(src));
if (SQL_PATTERNS.some((rx) => rx.test(literals))) {
offenders.push(rel);
}
}
return offenders;
}
// Coleta os arquivos sujeitos à checagem (c): rotas de API + handlers de stream.
export function collectSqlScanFiles(apiDir = API_DIR, handlersDir = HANDLERS_DIR) {
const routes = walk(apiDir).filter((p) => /(^|\/)route\.tsx?$/.test(p.replace(/\\/g, "/")));
const handlers = fs.existsSync(handlersDir)
? fs
.readdirSync(handlersDir, { withFileTypes: true })
.filter((e) => e.isFile() && /\.tsx?$/.test(e.name))
.map((e) => path.join(handlersDir, e.name))
: [];
return [...routes, ...handlers];
}
function main() {
const failures = [];
const localDbSource = fs.readFileSync(LOCAL_DB, "utf8");
// (a) re-export completeness
const dbModules = collectDbModules();
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(
`[#2 re-export] ${missing.length} módulo(s) db/ não re-exportado(s) por src/lib/localDb.ts:\n` +
missing.map((m) => ` ✗ src/lib/db/${m}.ts`).join("\n") +
`\n → re-exporte de src/lib/localDb.ts (apenas a lista de re-export, nada de lógica)` +
` ou adicione a INTENTIONALLY_INTERNAL com justificativa (import direto de "@/lib/db/${missing[0]}").`
);
}
// (b) localDb sem lógica
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/.`
);
}
// (c) SQL cru fora de db/
// 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` +
rawSql.map((f) => `${f}`).join("\n") +
`\n → mova o SQL para um módulo src/lib/db/ (nunca SQL cru em rota/handler)` +
` ou congele em KNOWN_RAW_SQL com justificativa.`
);
}
if (failures.length) {
console.error(`[check-db-rules] FALHOU:\n\n` + failures.join("\n\n"));
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))`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env node
// scripts/check/check-dead-code.mjs
// Gate de dead-code via knip — unused exports, unused files.
// Fase 7 INT: promovido de ADVISORY para RATCHET bloqueante.
// Lê o baseline de quality-baseline.json (metrics.deadExports), compara e
// falha com exit 1 se a contagem SUBIR. Suporta --update para ratchetar o baseline.
//
// Saída (stdout):
// DEAD_EXPORTS=<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.
// Use --update para ratchetar o baseline quando a contagem cair legitimamente.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const KNIP_BIN = path.join(ROOT, "node_modules", ".bin", "knip");
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const UPDATE = process.argv.includes("--update");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/quality-baseline.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,
};
}
/**
* Avalia a contagem atual de dead-code total contra o baseline.
* Direction: down (contagem só pode CAIR).
*
* Exported for unit testing.
*
* @param {number} current
* @param {number} baseline
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateDeadCode(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
function runKnip() {
const args = [
"--reporter",
"json",
"--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() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(`[dead-code] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`);
process.exit(2);
}
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const baselineMetric = baselineJson.metrics && baselineJson.metrics.deadExports;
if (!baselineMetric || typeof baselineMetric.value !== "number") {
process.stderr.write(
"[dead-code] FAIL — metrics.deadExports ausente em quality-baseline.json.\n"
);
process.exit(2);
}
const baselineValue = baselineMetric.value;
const knipJson = runKnip();
if (PRINT_JSON) {
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}`);
const { regressed, improved } = evaluateDeadCode(deadTotal, baselineValue);
if (UPDATE && improved) {
baselineJson.metrics.deadExports.value = deadTotal;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[dead-code] baseline ratcheado: ${deadTotal} (era ${baselineValue})`);
}
if (regressed) {
process.stderr.write(
`[dead-code] REGRESSÃO — ${deadTotal} símbolos mortos > baseline ${baselineValue}\n` +
` → Remova exports/arquivos não utilizados ou rode\n` +
` 'node scripts/check/check-dead-code.mjs --update' se a contagem caiu legitimamente.\n`
);
process.exit(1);
}
console.log(`[dead-code] OK — ${deadTotal} símbolos mortos (baseline ${baselineValue})`);
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
// Detects hardcoded old versions / stale dates in docs that should follow the current release.
// Uses hardcoded regexes to avoid dynamic RegExp() (ReDoS concern flagged by semgrep).
// Exits 0 if clean, 1 (in --strict mode) if drift detected.
//
// Run: node scripts/check/check-deprecated-versions.mjs
// Strict: node scripts/check/check-deprecated-versions.mjs --strict
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const DOCS_DIR = path.join(ROOT, "docs");
const PKG_JSON = path.join(ROOT, "package.json");
const STRICT = process.argv.includes("--strict");
function currentVersion() {
try {
return JSON.parse(fs.readFileSync(PKG_JSON, "utf8")).version || "0.0.0";
} catch {
return "0.0.0";
}
}
// Hardcoded set of obviously-stale version patterns. Update when bumping major/minor.
// These match any version <= v3.6.x or any pre-3 major.
const STALE_VERSION_PATTERNS = [
/\bv?[12]\.\d+\.\d+\b/, // 1.x.x / 2.x.x
/\bv?3\.[0-6]\.\d+\b/, // 3.0.x..3.6.x
];
const SAFE_CONTEXTS =
/(historical|archive|legacy|previously|deprecated|since|introduced|was|originally|fix|fixed)/i;
// Dates older than 60 days are considered stale for "Last updated" / "Last consolidated".
const STALE_DATE_DAYS = 60;
const today = new Date();
const LAST_UPDATED_RE = /Last (?:updated|consolidated|generated)[^\d]{0,30}(\d{4}-\d{2}-\d{2})/i;
function isStaleDate(yyyy_mm_dd) {
const d = new Date(yyyy_mm_dd);
if (Number.isNaN(d.getTime())) return false;
const ageDays = (today - d) / (1000 * 60 * 60 * 24);
return ageDays > STALE_DATE_DAYS;
}
const IGNORED_DIRS = new Set(["archive", "i18n", "superpowers"]);
const IGNORED_BASENAMES = new Set(["CHANGELOG.md", "RFC-AUTO-ASSESSMENT-DRAFT.md"]);
function walkDocs(dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (IGNORED_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...walkDocs(full));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
if (IGNORED_BASENAMES.has(entry.name)) continue;
out.push(full);
}
}
return out;
}
function main() {
const cur = currentVersion();
console.log(`Version drift report (current: v${cur})`);
console.log("=".repeat(40));
const files = walkDocs(DOCS_DIR);
let drift = 0;
for (const file of files) {
const rel = path.relative(ROOT, file);
const txt = fs.readFileSync(file, "utf8");
const lines = txt.split("\n");
const issues = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length > 500) continue; // skip very long lines (likely code blocks / data)
// 1. Stale version refs
for (const re of STALE_VERSION_PATTERNS) {
const m = re.exec(line);
if (m && !SAFE_CONTEXTS.test(line)) {
issues.push({
line: i + 1,
type: "stale-version",
match: m[0],
text: line.trim().slice(0, 100),
});
break;
}
}
// 2. Stale "Last updated/consolidated/generated" dates
const dateMatch = LAST_UPDATED_RE.exec(line);
if (dateMatch && isStaleDate(dateMatch[1])) {
issues.push({
line: i + 1,
type: "stale-date",
match: dateMatch[1],
text: line.trim().slice(0, 100),
});
}
}
if (issues.length > 0) {
console.log(`\n ${rel}`);
for (const iss of issues.slice(0, 5)) {
console.log(` L${iss.line} [${iss.type}] ${iss.match}: ${iss.text}`);
}
if (issues.length > 5) console.log(` ... and ${issues.length - 5} more`);
drift += issues.length;
}
}
console.log();
if (drift > 0) {
console.warn(`${drift} potential drift(s) detected across ${files.length} doc files.`);
if (STRICT) process.exit(1);
} else {
console.log(`✓ No drift detected across ${files.length} doc files.`);
}
}
main();
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env node
// scripts/check/check-deps.mjs
// 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, "config/quality/dependency-allowlist.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) {
const seen = new Set();
const out = [];
for (const name of depNames) {
if (seen.has(name)) continue;
seen.add(name);
if (!allowlist.has(name)) out.push(name);
}
return out;
}
function depNamesFromManifest(root, rel) {
const full = path.join(root, rel);
if (!fs.existsSync(full)) return [];
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(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() {
if (!fs.existsSync(ALLOWLIST_PATH)) {
console.error(
`[check-deps] FAIL — ${path.basename(ALLOWLIST_PATH)} ausente. Gere com:\n` +
` node -e "require('./scripts/check/check-deps.mjs')" (ou veja o passo de bootstrap no PLANO)`
);
process.exit(1);
}
const allowlist = new Set(JSON.parse(fs.readFileSync(ALLOWLIST_PATH, "utf8")).allowed || []);
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);
}
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();
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env node
/**
* OmniRoute — internal documentation link checker.
*
* Scans every Markdown file under `docs/` (excluding mirrored translations,
* screenshots, exported diagrams, and superpowers plans) for relative or
* project-rooted internal links and verifies the referenced files exist on
* disk. External URLs (http/https/mailto), telephone links, and pure
* anchor-only links (#section) are ignored.
*
* Supported syntaxes:
* - Markdown links: [label](path "optional title")
* - Reference links: [label]: path "optional title"
* - HTML anchors: <a href="path">
* - HTML image refs: <img src="path">
*
* Usage:
* node scripts/check/check-doc-links.mjs # strict (CI gate)
* node scripts/check/check-doc-links.mjs --report # human report, exit 0
* node scripts/check/check-doc-links.mjs --json # JSON to stdout
*
* Exit codes:
* 0 ok (or --report mode)
* 1 one or more broken internal links detected
*/
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const DOCS_ROOT = path.join(REPO_ROOT, "docs");
const EXCLUDE_PREFIXES = [
path.join(DOCS_ROOT, "i18n") + path.sep,
path.join(DOCS_ROOT, "screenshots") + path.sep,
path.join(DOCS_ROOT, "superpowers") + path.sep,
path.join(DOCS_ROOT, "diagrams", "exported") + path.sep,
];
function parseArgs(argv) {
const opts = { report: false, json: false };
for (const arg of argv.slice(2)) {
if (arg === "--report") opts.report = true;
else if (arg === "--json") opts.json = true;
else if (arg === "--help" || arg === "-h") {
console.log(
[
"Usage: node scripts/check/check-doc-links.mjs [options]",
"",
" --report Print findings and exit 0 regardless",
" --json Emit JSON report to stdout",
].join("\n")
);
process.exit(0);
}
}
return opts;
}
function walkDocs(dir, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
const prefixed = full + path.sep;
if (EXCLUDE_PREFIXES.some((p) => prefixed.startsWith(p))) continue;
walkDocs(full, out);
} else if (entry.isFile() && full.endsWith(".md")) {
if (EXCLUDE_PREFIXES.some((p) => full.startsWith(p))) continue;
out.push(full);
}
}
}
function isExternal(target) {
return (
/^[a-z][a-z0-9+.-]*:/i.test(target) || // http:, https:, mailto:, tel:, data:, ftp: ...
target.startsWith("//")
);
}
function stripFragmentAndQuery(target) {
let value = target;
const hashAt = value.indexOf("#");
if (hashAt !== -1) value = value.slice(0, hashAt);
const queryAt = value.indexOf("?");
if (queryAt !== -1) value = value.slice(0, queryAt);
return value;
}
function extractLinks(content) {
const links = [];
// Strip fenced code blocks to avoid false positives.
const sanitized = content.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length));
const lines = sanitized.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNumber = i + 1;
// Markdown inline links: [text](target) — supports nested parens minimally.
const inlineRe = /(!?)\[(?:[^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)/g;
let match;
while ((match = inlineRe.exec(line)) !== null) {
const raw = match[2].replace(/\s+"[^"]*"$/, "").trim();
if (raw) links.push({ target: raw, line: lineNumber });
}
// Reference-style links: [label]: target ("optional title")
const refRe = /^\s{0,3}\[[^\]]+\]:\s+([^\s]+)(?:\s+"[^"]*")?\s*$/;
const refMatch = line.match(refRe);
if (refMatch) links.push({ target: refMatch[1], line: lineNumber });
// HTML href / src
const htmlRe = /\b(?:href|src)\s*=\s*"([^"]+)"/g;
while ((match = htmlRe.exec(line)) !== null) {
links.push({ target: match[1], line: lineNumber });
}
}
return links;
}
function resolveTarget(sourceFile, target) {
// /docs/foo.md, /foo, /reference/ENVIRONMENT.md → project-rooted.
if (target.startsWith("/")) {
return path.join(REPO_ROOT, target.replace(/^\/+/, ""));
}
// Otherwise resolve against the source file's directory.
return path.resolve(path.dirname(sourceFile), target);
}
function probeExists(absPath) {
if (fs.existsSync(absPath)) return true;
// Allow links omitting `.md` (some doc viewers do this).
if (!path.extname(absPath) && fs.existsSync(`${absPath}.md`)) return true;
// Allow directory links resolving to an index/README.
if (fs.existsSync(path.join(absPath, "README.md"))) return true;
if (fs.existsSync(path.join(absPath, "index.md"))) return true;
return false;
}
function main() {
const opts = parseArgs(process.argv);
if (!fs.existsSync(DOCS_ROOT)) {
console.error("[doc-links] FAIL — docs/ directory not found");
process.exit(1);
}
const files = [];
walkDocs(DOCS_ROOT, files);
/** @type {Array<{source:string, line:number, target:string, reason:string}>} */
const broken = [];
let checkedLinks = 0;
for (const file of files) {
const content = fs.readFileSync(file, "utf8");
const links = extractLinks(content);
for (const { target, line } of links) {
if (!target) continue;
if (target.startsWith("#")) continue; // anchor-only
if (isExternal(target)) continue;
const clean = stripFragmentAndQuery(target);
if (!clean) continue; // e.g. "?query" alone — ignore
checkedLinks++;
const abs = resolveTarget(file, clean);
if (!probeExists(abs)) {
broken.push({
source: path.relative(REPO_ROOT, file),
line,
target,
reason: "missing",
});
}
}
}
if (opts.json) {
process.stdout.write(
JSON.stringify(
{
ok: broken.length === 0,
scannedFiles: files.length,
checkedLinks,
broken,
},
null,
2
) + "\n"
);
process.exit(broken.length && !opts.report ? 1 : 0);
}
console.log(`[doc-links] scanned ${files.length} docs, checked ${checkedLinks} internal links`);
if (broken.length === 0) {
console.log("[doc-links] PASS — no broken internal links");
process.exit(0);
}
// Group by source for readability.
const bySource = new Map();
for (const entry of broken) {
if (!bySource.has(entry.source)) bySource.set(entry.source, []);
bySource.get(entry.source).push(entry);
}
console.log(`[doc-links] FAIL — ${broken.length} broken link(s) in ${bySource.size} file(s):`);
for (const [source, entries] of bySource) {
console.log(`\n ${source}`);
for (const entry of entries) {
console.log(` line ${entry.line}: ${entry.target}`);
}
}
process.exit(opts.report ? 0 : 1);
}
main();
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env node
// Validates that count-based assertions in docs match the actual code state.
//
// Two tiers of checks:
// • STRICT (always blocking — exit 1 on drift): high-confidence, slow-moving counts
// that historically caused the worst drift across README / AGENTS / docs.
// - provider count (source of truth: docs/reference/PROVIDER_REFERENCE.md total,
// which is auto-generated from src/shared/constants/providers.ts)
// - i18n locale count (source of truth: config/i18n.json `locales`)
// • SOFT (heuristic — only fails with --strict): file-count based assertions that can
// false-positive.
// - executors count in open-sse/executors/
// - routing strategies in src/shared/constants/routingStrategies.ts
// - OAuth providers in src/lib/oauth/providers/
// - A2A skills in src/lib/a2a/skills/
// - Cloud agents in src/lib/cloudAgent/agents/
//
// Exits 0 on success, 1 on STRICT drift (or any drift with --strict).
// Run: node scripts/check/check-docs-counts-sync.mjs
//
// NOTE: the provider check trusts PROVIDER_REFERENCE.md as the canonical total. If a
// provider is added to the code but the reference is not regenerated, this guard will
// not catch it — regenerate with `npm run gen:provider-reference` before relying on it.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const COMMON_NON_IMPL_BASENAMES = new Set([
"index.ts",
"index.mts",
"types.ts",
"base.ts",
"constants.ts",
]);
function countFiles(dir, suffix = ".ts") {
const abs = path.join(ROOT, dir);
if (!fs.existsSync(abs)) return 0;
return fs
.readdirSync(abs)
.filter(
(f) =>
f.endsWith(suffix) &&
!f.endsWith(".test.ts") &&
!f.startsWith("__") &&
!COMMON_NON_IMPL_BASENAMES.has(f)
).length;
}
function countRoutingStrategies() {
const file = path.join(ROOT, "src", "shared", "constants", "routingStrategies.ts");
if (!fs.existsSync(file)) return 0;
const txt = fs.readFileSync(file, "utf8");
const m = txt.match(/ROUTING_STRATEGY_VALUES\s*=\s*\[([^\]]*)\]/);
if (!m) return 0;
return (m[1].match(/"[^"]+"/g) || []).length;
}
// PURE: parse the canonical provider total out of the auto-generated catalog text.
export function parseProviderTotal(referenceText) {
if (!referenceText) return 0;
const m = referenceText.match(/Total providers:\s*\*\*(\d+)\*\*/);
return m ? Number(m[1]) : 0;
}
// STRICT: canonical provider total, read from the auto-generated catalog.
export function readProviderTotal() {
const abs = path.join(ROOT, "docs", "reference", "PROVIDER_REFERENCE.md");
if (!fs.existsSync(abs)) return 0;
return parseProviderTotal(fs.readFileSync(abs, "utf8"));
}
// STRICT: canonical i18n locale count, read from the shared config.
export function countLocales() {
const abs = path.join(ROOT, "config", "i18n.json");
if (!fs.existsSync(abs)) return 0;
try {
const cfg = JSON.parse(fs.readFileSync(abs, "utf8"));
return Array.isArray(cfg.locales) ? cfg.locales.length : 0;
} catch {
return 0;
}
}
// PURE: tally STRICT vs SOFT drift for a list of checks, given a content lookup.
// `getContent(file) -> string | null`. A check whose `actual` is 0 is skipped (the
// source count could not be determined). Returns { strict, soft, lines }.
export function tallyDrift(checks, getContent) {
let strict = 0;
let soft = 0;
const lines = [];
for (const c of checks) {
const tier = c.strict ? "STRICT" : "soft";
lines.push(`\n${c.label}: ${c.actual} (real) [${tier}]`);
if (!c.actual) {
lines.push(` ⚠ could not determine ${c.docKey} count from source — skipping`);
continue;
}
for (const f of c.files) {
const content = getContent(f);
const found = content != null && content.includes(String(c.actual));
if (found) {
lines.push(`${f} mentions "${c.actual}"`);
} else {
lines.push(` ${c.strict ? "✗" : "⚠"} ${f} does NOT mention "${c.actual}" for ${c.docKey}`);
if (c.strict) strict++;
else soft++;
}
}
}
return { strict, soft, lines };
}
export function buildChecks() {
return [
{
label: "Provider count",
actual: readProviderTotal(),
docKey: "providers",
strict: true,
files: ["README.md", "AGENTS.md", "CLAUDE.md"],
},
{
label: "i18n locales count",
actual: countLocales(),
docKey: "i18n locales",
strict: true,
files: ["docs/README.md", "docs/guides/I18N.md", "AGENTS.md"],
},
{
label: "Executors count",
actual: countFiles("open-sse/executors"),
docKey: "executors",
strict: false,
files: ["docs/architecture/ARCHITECTURE.md", "docs/architecture/CODEBASE_DOCUMENTATION.md"],
},
{
label: "Routing strategies count",
actual: countRoutingStrategies(),
docKey: "strategies",
strict: false,
files: ["docs/routing/AUTO-COMBO.md", "docs/architecture/RESILIENCE_GUIDE.md"],
},
{
label: "OAuth providers count",
actual: countFiles("src/lib/oauth/providers"),
docKey: "OAuth providers",
strict: false,
files: ["docs/architecture/ARCHITECTURE.md"],
},
{
label: "A2A skills count",
actual: countFiles("src/lib/a2a/skills"),
docKey: "A2A skills",
strict: false,
files: ["docs/frameworks/A2A-SERVER.md"],
},
{
label: "Cloud agents count",
actual: countFiles("src/lib/cloudAgent/agents"),
docKey: "cloud agents",
strict: false,
files: ["docs/frameworks/CLOUD_AGENT.md", "docs/frameworks/AGENT_PROTOCOLS_GUIDE.md"],
},
];
}
function main() {
const checks = buildChecks();
const getContent = (relPath) => {
const abs = path.join(ROOT, relPath);
return fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null;
};
console.log("Docs counts sync report");
console.log("=======================");
const { strict, soft, lines } = tallyDrift(checks, getContent);
for (const l of lines) console.log(l);
console.log();
if (strict > 0) {
console.error(
`${strict} STRICT drift(s) detected. ` +
`Update the docs above to the real counts, or regenerate auto-generated sources ` +
`(npm run gen:provider-reference).`
);
process.exit(1);
}
if (soft > 0) {
console.warn(`${soft} potential (soft) drift(s) detected. Review the docs above.`);
if (process.argv.includes("--strict")) process.exit(1);
} else {
console.log("✓ All checks pass.");
}
}
const invokedDirectly =
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) main();
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env node
// scripts/check/check-docs-symbols.mjs
// Gate anti-alucinação (docs → código): toda referência a uma rota `/api/...` dentro de
// docs/**/*.md deve resolver para um `route.ts` real em src/app/api/. Pega endpoint
// INVENTADO/obsoleto que a IA escreve em docs/PRs descrevendo uma rota que não existe —
// o padrão recorrente das PRs de docs (ex.: oyi77) que fabricam endpoints/APIs.
//
// Complementa os outros gates anti-alucinação:
// - check-fetch-targets.mjs : fetch("/api/...") na UI → route.ts (código → código)
// - check-openapi-routes.mjs : path da openapi.yaml → route.ts (spec → código)
// - este gate : /api/... na prosa/markdown → route.ts (docs → código)
//
// LOW-NOISE por design: escopo APENAS a paths de rota `/api/...` (sinal mais alto).
// 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");
const API = path.join(ROOT, "src/app/api");
// Padrões que NÃO são rotas internas do OmniRoute (ruído estrutural, não drift).
// Adicione aqui (com justificativa) em vez da allowlist quando uma categoria gera
// falsos positivos — a allowlist é só para endpoints stale REAIS.
const IGNORE = [
/^\/api\/v1\//, // superfície OpenAI-compat (proxy), não rota interna
/^\/api\/v1beta\//, // superfície Gemini-compat (proxy)
/^\/api\/v0\//, // APIs upstream de terceiros citadas em docs de pesquisa
/^\/api\/v2\//, // idem (deployments etc.)
/^\/api\/(organizations|map-image|graphql|gql)\b/, // APIs de provedores externos documentadas
/your-/i, // placeholder de exemplo
/example/i, // placeholder de exemplo
/\.{3}/, // placeholder "..."
/\{\}/, // placeholder de param vazio
/_(POST|GET|PUT|DELETE|PATCH)$/, // refs estilo trace de rede (gql_POST)
];
// Refs a ARQUIVOS-FONTE, não a URLs (ex.: src/app/api/.../route.ts citado em prosa).
// O gate só valida URLs de rota, não caminhos de arquivo.
function isFileRef(p) {
return /\.(ts|tsx|js|mjs|jsx)$/.test(p) || /\/route$/.test(p);
}
// Refs a `/api/...` que NÃO resolvem para rota real, congeladas para triagem
// (catraca: bloqueia QUALQUER nova ref inventada em docs). Estas são achados REAIS de
// drift/alucinação em docs pré-existentes — cada uma precisa de: criar a rota, corrigir
// o path na doc, ou remover a menção. NÃO adicione novas aqui sem justificativa — esse
// é o ponto do gate. Issues de tracking devem ser abertas para cada cluster.
export const KNOWN_STALE_DOC_REFS = new Set([
// docs/reference/API_REFERENCE.md — guardrails/shadow doc-fiction RESOLVED in #3496:
// GET /api/guardrails + POST /api/guardrails/test are now REAL routes (wrapping the
// existing guardrailRegistry); the fictional enable/disable/logs rows and the entire
// shadow table were removed from the doc (shadow A-B comparison is combo-config +
// /api/combos/metrics). No allowlist entries needed for these anymore.
// (DISCOVERY_TOOL_DESIGN.md saiu de docs/research/ para o repo isolado _tasks/research/
// — gitignored, fora do escopo deste gate. As 4 entradas /api/discovery/* viraram
// obsoletas e foram removidas para satisfazer o stale-enforcement da allowlist.)
]);
function walk(dir, filter, 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, filter, acc);
else if (filter(e.name)) acc.push(p);
}
return acc;
}
export function collectRouteFiles() {
return new Set(
walk(API, (n) => /^route\.tsx?$/.test(n)).map((p) => path.relative(ROOT, p).replace(/\\/g, "/"))
);
}
/** Normaliza um segmento dinâmico ({param} / [param] / [...param] / :param) para wildcard. */
function normSeg(seg) {
if (/^\[\[?\.{3}.+\]\]?$/.test(seg)) return ""; // catch-all [...x] / [[...x]]
if (/^\{[^}]+\}$/.test(seg) || /^\[[^\]]+\]$/.test(seg) || /^:[^/]+$/.test(seg)) return " ";
return seg;
}
// /api/providers/{id}/models → src/app/api/providers/[id]/models/route.ts
// Casa por contagem de segmentos OU por prefixo (uma doc pode citar só o prefixo de
// uma rota mais profunda, ex.: /api/auth descrevendo a família /api/auth/login). Qualquer
// segmento dinâmico ([..]/{..}/:..) casa com um segmento dinâmico real.
export function resolveApiDocPathToRoute(apiPath, routeFiles) {
const segs = apiPath
.replace(/^\//, "")
.replace(/[?#].*$/, "")
.split("/")
.map(normSeg);
for (const rf of routeFiles) {
const rsegs = rf
.replace(/^src\/app\//, "")
.replace(/\/route\.tsx?$/, "")
.split("/");
const rnorm = rsegs.map((rs) => {
if (/^\[\[?\.{3}.+\]\]?$/.test(rs)) return ""; // catch-all
if (/^\[[^\]]+\]$/.test(rs)) return " "; // [param]
return rs;
});
const catchAll = rnorm.includes("");
const effLen = catchAll ? rnorm.indexOf("") : rnorm.length;
if (!catchAll && segs.length > rnorm.length) continue; // doc mais profunda que a rota
if (catchAll && segs.length < effLen) continue;
const cmpLen = Math.min(segs.length, effLen || rnorm.length);
let match = true;
for (let i = 0; i < cmpLen; i++) {
const rs = rnorm[i];
if (rs === "") break; // catch-all absorve o resto
if (!(rs === segs[i] || rs === " " || segs[i] === " ")) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
/** Limpa o path capturado: remove pontuação/ênfase de prosa, fecha brackets pendentes. */
function cleanCapturedPath(raw) {
let p = raw.replace(/[.,:;_)>]+$/, "");
const ob = (p.match(/\[/g) || []).length;
const cb = (p.match(/\]/g) || []).length;
const oc = (p.match(/\{/g) || []).length;
const cc = (p.match(/\}/g) || []).length;
if (ob !== cb || oc !== cc) {
// segmento final truncado pelo regex (bracket aberto sem fechar na prosa) → descarta
p = p.replace(/\/[^/]*[[{][^/]*$/, "");
}
return p.replace(/\/$/, ""); // remove barra final (forma de prefixo)
}
// /api/... só conta como URL quando NÃO é a cauda de um caminho de arquivo-fonte
// (src/lib/api/, @/app/api/, app/api/). O grupo 2 é o path.
const API_PATH_RE = /(^|[^A-Za-z0-9_/])(\/api\/[A-Za-z0-9_\-/{}\[\].:]+)/g;
/** Extrai os paths /api/... distintos de um arquivo markdown (forma URL, não arquivo). */
export function extractDocApiPaths(src) {
const out = new Set();
let m;
API_PATH_RE.lastIndex = 0;
while ((m = API_PATH_RE.exec(src))) {
const p = cleanCapturedPath(m[2]);
if (p && p !== "/api") out.add(p);
}
return [...out];
}
/**
* Núcleo puro/testável.
* @param {{file: string, paths: string[]}[]} docPathsByFile
* @param {Set<string>} routeFiles conjunto de "src/app/api/.../route.ts"
* @param {Set<string>} allowlist paths stale congelados
* @returns {string[]} misses no formato "file → /api/path"
*/
export function findStaleDocApiRefs(docPathsByFile, routeFiles, allowlist) {
const misses = [];
for (const { file, paths } of docPathsByFile) {
for (const p of paths) {
if (IGNORE.some((rx) => rx.test(p))) continue;
if (isFileRef(p)) continue;
if (allowlist.has(p)) continue;
if (!resolveApiDocPathToRoute(p, routeFiles)) {
misses.push(`${file}${p}`);
}
}
}
return misses;
}
function main() {
const routeFiles = collectRouteFiles();
// docs/i18n/** são espelhos auto-gerados das docs canônicas — validar só o canônico
// evita 40× de ruído duplicado (e os mirrors herdam qualquer fix do canônico).
// docs/superpowers/** são planos internos de implementação (snapshots históricos
// de intenção — podem citar rotas planejadas/abandonadas), não claims sobre o
// código atual; fora do escopo do gate (drift surgiu no ciclo v3.8.18).
const docFiles = walk(DOCS, (n) => /\.md$/.test(n)).filter((f) => {
const rel = path.relative(ROOT, f).replace(/\\/g, "/");
return !rel.startsWith("docs/i18n/") && !rel.startsWith("docs/superpowers/");
});
const docPathsByFile = docFiles.map((f) => ({
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(
`[check-docs-symbols] ${misses.length} ref(s) /api em docs sem rota real:\n` +
misses.map((m) => " ✗ " + m).join("\n") +
`\n → crie o route.ts, corrija o path na doc, ou (se for upstream/placeholder)` +
` adicione um padrão a IGNORE com justificativa. NÃO adicione à allowlist sem` +
` confirmar que é drift pré-existente real.`
);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[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();
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const cwd = process.cwd();
const packageJsonPath = path.resolve(cwd, "package.json");
const openApiPath = path.resolve(cwd, "docs/openapi.yaml");
const changelogPath = path.resolve(cwd, "CHANGELOG.md");
const llmPath = path.resolve(cwd, "llm.txt");
const i18nDocsPath = path.resolve(cwd, "docs/i18n");
function readText(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${path.relative(cwd, filePath)}`);
}
return fs.readFileSync(filePath, "utf8");
}
function extractOpenApiVersion(content) {
const lines = content.split(/\r?\n/);
let inInfoBlock = false;
for (const line of lines) {
const trimmed = line.trim();
if (!inInfoBlock) {
if (trimmed === "info:") {
inInfoBlock = true;
}
continue;
}
if (line.length > 0 && !line.startsWith(" ")) {
break;
}
const match = line.match(/^\s{2}version:\s*["']?([^"'\s]+)["']?\s*$/);
if (match) {
return match[1];
}
}
return null;
}
function extractChangelogSections(content) {
const headings = [...content.matchAll(/^##\s+\[([^\]]+)\](?:\s+[-—–].*)?$/gm)];
return headings.map((match) => match[1]);
}
function stripTopHeading(content) {
return content.replace(/^# .+\r?\n+/, "");
}
function extractI18nMirrorBody(content) {
const separator = content.match(/^---\s*$/m);
if (!separator || separator.index === undefined) {
return null;
}
return content.slice(separator.index + separator[0].length).replace(/^\r?\n+/, "");
}
function normalizeMirrorBody(content) {
return content.replace(/\r\n/g, "\n").trim();
}
function isSemver(value) {
// Accept X.Y.Z and X.Y.Z-prerelease.N (e.g. 3.0.0-rc.1, 3.0.0-beta.2)
return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(value);
}
let hasFailure = false;
function fail(message) {
hasFailure = true;
console.error(`[docs-sync] FAIL - ${message}`);
}
function checkI18nMirrorFile(fileName, sourcePath) {
if (!fs.existsSync(i18nDocsPath)) {
fail("docs/i18n directory is missing");
return;
}
const sourceBody = normalizeMirrorBody(stripTopHeading(readText(sourcePath)));
const locales = fs
.readdirSync(i18nDocsPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
let checked = 0;
for (const locale of locales) {
const targetPath = path.join(i18nDocsPath, locale, fileName);
if (!fs.existsSync(targetPath)) {
fail(`docs/i18n/${locale}/${fileName} is missing`);
continue;
}
const body = extractI18nMirrorBody(readText(targetPath));
if (body === null) {
fail(`docs/i18n/${locale}/${fileName} is missing the i18n mirror separator`);
continue;
}
if (normalizeMirrorBody(body) !== sourceBody) {
fail(`docs/i18n/${locale}/${fileName} differs from root ${fileName}`);
continue;
}
checked += 1;
}
if (checked > 0) {
console.log(`[docs-sync] ${fileName} i18n mirrors match root content: ${checked} locales`);
}
}
/**
* Check i18n CHANGELOG mirrors by verifying that all version sections from the
* root CHANGELOG exist in each translation. Unlike strict mirror files (llm.txt),
* CHANGELOG translations have translated section headings (e.g. "Security" →
* "Segurança"), so byte-for-byte comparison is intentionally skipped.
*
* Validates:
* 1. File exists in each locale
* 2. Has the i18n mirror separator (---)
* 3. Contains all version sections (## [X.Y.Z]) from root, in the same order
* 4. Body is non-empty and within a reasonable size tolerance of the source
*/
function checkI18nChangelogFile(sourcePath) {
const fileName = "CHANGELOG.md";
if (!fs.existsSync(i18nDocsPath)) {
fail("docs/i18n directory is missing");
return;
}
const sourceContent = readText(sourcePath);
const sourceBody = normalizeMirrorBody(stripTopHeading(sourceContent));
const sourceVersions = extractChangelogSections(sourceContent);
const locales = fs
.readdirSync(i18nDocsPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
let checked = 0;
for (const locale of locales) {
const targetPath = path.join(i18nDocsPath, locale, fileName);
if (!fs.existsSync(targetPath)) {
fail(`docs/i18n/${locale}/${fileName} is missing`);
continue;
}
const targetContent = readText(targetPath);
const body = extractI18nMirrorBody(targetContent);
if (body === null) {
fail(`docs/i18n/${locale}/${fileName} is missing the i18n mirror separator`);
continue;
}
const normalizedBody = normalizeMirrorBody(body);
if (normalizedBody.length === 0) {
fail(`docs/i18n/${locale}/${fileName} has empty body after separator`);
continue;
}
// Verify all version sections from root exist in the translation
const targetVersions = extractChangelogSections(targetContent);
const missingVersions = sourceVersions.filter((v) => !targetVersions.includes(v));
if (missingVersions.length > 0) {
fail(
`docs/i18n/${locale}/${fileName} is missing version sections: ${missingVersions.slice(0, 3).join(", ")}${missingVersions.length > 3 ? ` (+${missingVersions.length - 3} more)` : ""}`
);
continue;
}
// Verify body line count is within 25% tolerance of source (translations
// should preserve structure — drastic line-count differences indicate
// stale or missing content)
const sourceLines = sourceBody.split("\n").length;
const targetLines = normalizedBody.split("\n").length;
const sizeDiff = Math.abs(targetLines - sourceLines) / sourceLines;
if (sizeDiff > 0.25) {
fail(
`docs/i18n/${locale}/${fileName} body line count differs by ${(sizeDiff * 100).toFixed(0)}% from root (expected within 25%)`
);
continue;
}
checked += 1;
}
if (checked > 0) {
console.log(
`[docs-sync] ${fileName} i18n translations validated: ${checked} locales (version sections + size check)`
);
}
}
try {
const packageJson = JSON.parse(readText(packageJsonPath));
const packageVersion = packageJson.version;
if (!isSemver(packageVersion)) {
fail(`package.json version is not valid semver: "${packageVersion}"`);
} else {
console.log(`[docs-sync] package.json version: ${packageVersion}`);
}
const openApiVersion = extractOpenApiVersion(readText(openApiPath));
if (!openApiVersion) {
fail("could not extract docs/openapi.yaml info.version");
} else if (openApiVersion !== packageVersion) {
fail(`OpenAPI version (${openApiVersion}) differs from package.json (${packageVersion})`);
} else {
console.log(`[docs-sync] openapi.yaml info.version matches: ${openApiVersion}`);
}
const changelogSections = extractChangelogSections(readText(changelogPath));
if (changelogSections.length === 0) {
fail("CHANGELOG.md has no version sections");
} else {
if (changelogSections[0] !== "Unreleased") {
fail('CHANGELOG.md first section must be "## [Unreleased]"');
} else {
console.log("[docs-sync] changelog has top Unreleased section");
}
const semverSections = changelogSections.filter((section) => isSemver(section));
if (semverSections.length === 0) {
fail("CHANGELOG.md has no semver release section");
} else if (semverSections[0] !== packageVersion) {
fail(
`Latest changelog release (${semverSections[0]}) differs from package.json (${packageVersion})`
);
} else {
console.log(
`[docs-sync] latest changelog release matches package version: ${packageVersion}`
);
}
}
// llm.txt mirrors must be exact copies (no translation)
checkI18nMirrorFile("llm.txt", llmPath);
// CHANGELOG.md mirrors are translations — check version sections and size, not exact content
checkI18nChangelogFile(changelogPath);
// Anti-regression: legacy duplicate docs that have been superseded must not return.
// Use docs/reference/* as the source of truth.
const supersededDocs = [{ legacy: "docs/CLI-TOOLS.md", current: "docs/reference/CLI-TOOLS.md" }];
for (const { legacy, current } of supersededDocs) {
const legacyAbs = path.resolve(cwd, legacy);
if (fs.existsSync(legacyAbs)) {
fail(
`legacy duplicate ${legacy} reappeared — use ${current} instead (single source of truth)`
);
}
}
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
if (hasFailure) {
process.exit(1);
}
console.log("[docs-sync] PASS - documentation version sync is consistent.");
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
// scripts/check/check-duplication.mjs
// Catraca de duplicação de código. Roda jscpd@4 (PINADO — o v5 é um rewrite Rust com
// CLI/JSON incompatíveis) sobre src+open-sse e compara a % atual contra um baseline
// congelado (duplication-baseline.json). Falha se a duplicação SUBIR. Ataca a assinatura
// nº1 de slop de IA (GitClear 2026: duplicação 4-8x na era IA) — no nosso caso, o
// copy-paste dos executors (48/50 sobrescrevem execute() inteiro). --update ratcheta.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/duplication-baseline.json")
);
const UPDATE = process.argv.includes("--update");
const EPS = 0.05; // tolerância de ruído de float (jscpd é determinístico; isto é margem)
// 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) {
return {
regressed: current > baseline + eps,
improved: current < baseline - eps,
};
}
function measureDuplicationPct() {
const out = fs.mkdtempSync(path.join(os.tmpdir(), "jscpd-"));
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;
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
console.error(`[duplication] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
process.exit(2);
}
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const current = measureDuplicationPct();
const { regressed, improved } = evaluateDuplication(current, baseline.percentage, EPS);
if (UPDATE && improved) {
baseline.percentage = current;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
console.log(`[duplication] baseline ratcheado: ${current}% (era ${baseline.percentage}%)`);
}
if (regressed) {
console.error(
`[duplication] REGRESSÃO — ${current}% > baseline ${baseline.percentage}% (+${EPS} tolerância)\n` +
` → extraia o trecho duplicado (helper compartilhado) ou ajuste duplication-baseline.json com justificativa.`
);
process.exit(1);
}
console.log(`[duplication] OK — ${current}% (baseline ${baseline.percentage}%)`);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env node
/**
* Strict environment variable contract checker.
*
* Enforces that every env var referenced in OmniRoute source code appears in
* both `.env.example` and `docs/reference/ENVIRONMENT.md`, and that the two files agree
* on the documented var set. Falls back to a small allowlist for variables
* that are intentionally documented but not literally referenced (legacy
* aliases, future-supported hooks) or vice versa.
*
* Usage:
* node scripts/check/check-env-doc-sync.mjs # strict (CI mode)
* node scripts/check/check-env-doc-sync.mjs --lenient # legacy report-only mode
*
* Strict mode exits non-zero if any of these are non-empty:
* - vars in code but missing from .env.example
* - vars in .env.example but missing from ENVIRONMENT.md
* - vars in ENVIRONMENT.md but missing from .env.example
*
* Programmatic API:
* Other Node tests can `import { runEnvDocSync } from "./check-env-doc-sync.mjs"`
* and pass `{ root, envExample, envDoc, codeVars, ignore, docOnlyAllowlist,
* envOnlyAllowlist }` to drive the checker against fixtures.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..");
// ─── Allowlists ────────────────────────────────────────────────────────────
// Env vars referenced in code that should NOT trigger documentation drift.
// These are usually system/process vars or harness-only knobs.
const IGNORE_FROM_CODE = new Set([
"NODE_ENV",
"PATH",
"HOME",
"USER",
"LOGNAME",
"XDG_CURRENT_DESKTOP",
"PWD",
"SHELL",
"TERM",
"TZ",
"LANG",
"LC_ALL",
"LC_MESSAGES",
"CI",
"GITHUB_ACTIONS",
"RUNNER_OS",
// Agent environment / system execution paths.
"PROJECT_ROOT",
"ARTIFACTS_DIR",
// OS / Node internals frequently surfaced by indirect dependencies.
"APPDATA",
"LOCALAPPDATA",
"XDG_CONFIG_HOME",
"USERPROFILE",
"PREFIX",
// X11 display server — set by the OS/session manager, not OmniRoute config.
"DISPLAY",
// POSIX session vars surfaced by cloudflaredTunnel.ts (env passthrough).
"LOGNAME",
"XDG_CURRENT_DESKTOP",
// Next.js / Node test runners — these are framework-managed.
"NEXT_DIST_DIR",
"NEXT_PHASE",
"NEXT_RUNTIME",
// Set/read by Next.js's own dev server (next-dev-server.js) when the turbopack
// bundler is active — framework-internal. The OmniRoute-facing knob is
// OMNIROUTE_USE_TURBOPACK (scripts/dev/run-next.mjs), which IS documented.
"TURBOPACK",
"NODE_TEST_CONTEXT",
"VITEST",
// Instruction snippet shown to users (Traffic Inspector HttpProxySnippetCard) — not OmniRoute config.
"NODE_TLS_REJECT_UNAUTHORIZED",
// Claude Code's own auth env var — read from the CLI environment to detect
// existing auth and written into the generated Claude Code settings (so the CLI
// points at OmniRoute). A downstream client-tool var, not an OmniRoute server
// input (src/shared/services/claudeCliConfig.ts, api/cli-tools/claude-settings).
"ANTHROPIC_AUTH_TOKEN",
// CI providers (set by the runner).
"GITHUB_BASE_REF",
"GITHUB_BASE_SHA",
// CI passes BASE_REF=${{ github.base_ref }} to the OpenAPI breaking-change gate
// (scripts/check/check-openapi-breaking.mjs) — a build/check signal, not OmniRoute runtime config.
"BASE_REF",
// 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",
// Gated combo live-smoke harness (scripts/test/_vpsClient.mjs) — override the VPS HTTP
// smoke target host/key. Test/CI-only signals with safe defaults
// ("http://192.168.0.15:20128" / null), never OmniRoute runtime config (#5151).
"COMBO_LIVE_BASE_URL",
"COMBO_LIVE_API_KEY",
// update-notifier opt-out for the CLI binary.
"OMNIROUTE_NO_UPDATE_NOTIFIER",
// Headless CLI execution flag for Electron.
"OMNIROUTE_HEADLESS",
// Platform / OS detection vars read by CLI environment helper (bin/cli/utils/environment.mjs).
// These are external signals set by the host OS or cloud provider — not OmniRoute config.
"CODESPACES",
"GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN",
"GITPOD_WORKSPACE_ID",
"NO_COLOR",
"REPL_ID",
"REPL_SLUG",
"WSL_DISTRO_NAME",
"WSL_INTEROP",
// X11/Wayland display server vars used by tray heuristic (isTraySupported).
"DISPLAY",
"WAYLAND_DISPLAY",
// Build-time override for OpenAPI spec path used by generate-api-commands.mjs.
"OPENAPI_SPEC",
// Aliases for documented vars handled via fallback ordering.
"API_KEY",
"APP_URL",
"PUBLIC_URL",
"ANTHROPIC_API_URL",
"OPENAI_API_URL",
"LOG_LEVEL",
// Internal QA helpers used only by scripts/ and Playwright.
"QA_BASE_URL",
"QA_LOCALES",
"QA_REPORT_SUFFIX",
"QA_ROUTES",
// Doctor diagnostic flags (no runtime behavior yet — placeholders).
"OMNIROUTE_DOCTOR_HOST",
"OMNIROUTE_DOCTOR_LIVENESS_URL",
"OMNIROUTE_PROVIDER_CATALOG_PATH",
"OMNIROUTE_PROVIDER_TEST_MODEL",
// Test-only opt-out: instructs bin/omniroute.mjs to skip auto-loading the
// repository .env so isolation tests get a deterministic environment.
"OMNIROUTE_CLI_SKIP_REPO_ENV",
// Eval-harness only: operator-supplied provider credentials JSON read by the
// opt-in `npm run eval:compression` CLI (scripts/compression-eval/index.ts).
// A dev/ops measurement tool, never OmniRoute runtime config.
"OMNIROUTE_EVAL_CREDENTIALS",
// Build-time only: set by `build:release` (git short SHA) and read by
// write-build-sha.mjs to stamp dist/BUILD_SHA — injected by the build, never
// configured by users in .env.
"OMNIROUTE_BUILD_SHA",
// Source typo / placeholder.
"OMNIROUT",
// Static config alias path (the canonical var is OMNIROUTE_PAYLOAD_RULES_PATH).
"PAYLOAD_RULES_PATH",
// Node.js module resolution path — OS/Node internal, not an OmniRoute config var.
// Referenced in resolveSpawnArgs (ninerouter) to pass bundled native modules to subprocess.
"NODE_PATH",
// NVIDIA diagnostic/test helpers used only by ad-hoc scripts.
"NVIDIA_BASE_URL",
"NVIDIA_MODEL",
// XDG standard data directory — set by OS/desktop session, not OmniRoute config.
// Read by setup-open-code.mjs to locate platform-specific OpenCode data dir.
"XDG_DATA_HOME",
// Test-only override: points setup-open-code.mjs at a fixture plugin dir without
// requiring the real bundled plugin to be built.
"OMNIROUTE_OPENCODE_PLUGIN_DIR",
]);
// Vars documented in ENVIRONMENT.md but intentionally absent from .env.example.
// Used for past-tense documentation (Audit / Dead vars section), legacy aliases
// with no runtime hook, and section anchors that look like vars to the regex.
const DOC_ONLY_ALLOWLIST = new Set([
// Audit history (Removed / Dead Variables section).
"CEREBRAS_API_KEY",
"COHERE_API_KEY",
"FIREWORKS_API_KEY",
"GROQ_API_KEY",
"MISTRAL_API_KEY",
"NEBIUS_API_KEY",
"PERPLEXITY_API_KEY",
"TOGETHER_API_KEY",
"XAI_API_KEY",
"QIANFAN_API_KEY",
"CURSOR_PROTOBUF_DEBUG",
"CLI_COMPAT_KIRO",
"CLI_KIMI_CODING_BIN",
"CLI_ROO_BIN",
"IFLOW_OAUTH_CLIENT_ID",
"IFLOW_OAUTH_CLIENT_SECRET",
// Source-code constants accidentally captured by the doc regex.
"CLI_COMPAT_OMITTED_PROVIDER_IDS",
// The stream-recovery tuning object in open-sse/config/constants.ts (`STREAM_RECOVERY.HOLDBACK_MS`
// etc.) — documented for reference; the real operator-facing env vars are STREAM_RECOVERY_ENABLED /
// STREAM_RECOVERY_MIDSTREAM_ENABLED (both in .env.example). The bare prefix is not an env var.
"STREAM_RECOVERY",
// Sample default values that look like SHOUTY_NAMES (not env vars).
"CHANGEME",
// Legacy aliases — present in docs as "would be aliases" but read-only
// through their canonical names today.
"OMNIROUTE_CRYPT_KEY",
"OMNIROUTE_API_KEY_BASE64",
// Future-supported hooks: documented but currently hardcoded constants.
"MAX_RETRY_INTERVAL_SEC",
"REQUEST_RETRY",
"SKILLS_EXECUTION_TIMEOUT_MS",
"SKILLS_SANDBOX_DOCKER_IMAGE",
// Source-code constants referenced in the docs narrative for the local
// endpoints / route-guard classification (PR-3 in #3932).
"LOCAL_ONLY_API_PREFIXES",
// SQL keyword mentioned in the new VACUUM scheduler docs (#4437).
// The check's regex picks up the bare word in description text.
"VACUUM",
]);
// Vars present in .env.example but intentionally absent from ENVIRONMENT.md.
// Empty today — kept for forward compatibility / explicit exemption.
const ENV_ONLY_ALLOWLIST = new Set([
// Documented in .env.example but not yet in docs/reference/ENVIRONMENT.md
"CODEX_REFRESH_SPACING_MS",
"DEBUG",
"HEAP_PRESSURE_THRESHOLD_MB",
"OMNIROUTE_TRACE",
"PII_TEST_BYPASS_MIN_WINDOW",
"PII_WINDOW_SIZE",
"TRAE_STREAM_TIMEOUT_MS",
"TRAE_TOKEN",
]);
// ─── Parsing helpers ───────────────────────────────────────────────────────
/**
* Extract VAR= entries from a `.env`-style file (handles commented examples).
*/
export function parseEnvExampleVars(text) {
const vars = new Set();
for (const line of String(text ?? "").split("\n")) {
const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)\s*=/);
if (m) vars.add(m[1]);
}
return vars;
}
/**
* Extract `VARNAME` tokens from a markdown doc — matches anything in backticks
* that looks like an env var (uppercase + digit + underscore).
*/
export function parseEnvDocVars(text) {
const vars = new Set();
for (const m of String(text ?? "").matchAll(/`([A-Z][A-Z0-9_]{2,})`/g)) {
vars.add(m[1]);
}
return vars;
}
/**
* Collect environment variable references in source code via grep against
* the `process.env` member access pattern.
*/
function scanCodeVars({ cwd } = {}) {
const repoRoot = cwd ?? REPO_ROOT;
const stdout = execSync(
"grep -rhoE 'process\\.env\\.[A-Z][A-Z0-9_]+' " +
"src/ open-sse/ bin/ scripts/ electron/main.js electron/preload.js 2>/dev/null || true",
{ cwd: repoRoot, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }
);
const vars = new Set();
for (const line of stdout.split("\n")) {
const m = line.match(/^process\.env\.([A-Z][A-Z0-9_]+)$/);
if (m) vars.add(m[1]);
}
return vars;
}
/**
* Diff helper.
*/
function diff(set, against) {
return [...set].filter((v) => !against.has(v)).sort((a, b) => a.localeCompare(b));
}
// ─── Programmatic entry point ──────────────────────────────────────────────
/**
* Run the contract checker. All inputs are overridable for tests.
*
* Returns `{ ok: boolean, summary, problems: { codeMissingEnv, envMissingDoc,
* docMissingEnv } }`.
*/
export function runEnvDocSync(options = {}) {
const ignore = options.ignore ?? IGNORE_FROM_CODE;
const docOnly = options.docOnlyAllowlist ?? DOC_ONLY_ALLOWLIST;
const envOnly = options.envOnlyAllowlist ?? ENV_ONLY_ALLOWLIST;
const envExampleText =
options.envExampleText ??
(options.envExamplePath
? fs.readFileSync(options.envExamplePath, "utf8")
: fs.readFileSync(path.join(REPO_ROOT, ".env.example"), "utf8"));
const envDocText =
options.envDocText ??
(options.envDocPath
? fs.readFileSync(options.envDocPath, "utf8")
: fs.readFileSync(path.join(REPO_ROOT, "docs", "reference", "ENVIRONMENT.md"), "utf8"));
const envVars = parseEnvExampleVars(envExampleText);
const docVars = parseEnvDocVars(envDocText);
const codeVars = new Set(
[...(options.codeVars ?? scanCodeVars({ cwd: options.root }))].filter((v) => !ignore.has(v))
);
const codeMissingEnv = diff(codeVars, envVars);
const envMissingDoc = diff(envVars, docVars).filter((v) => !envOnly.has(v));
const docMissingEnv = diff(docVars, envVars).filter((v) => !docOnly.has(v));
const ok =
codeMissingEnv.length === 0 && envMissingDoc.length === 0 && docMissingEnv.length === 0;
return {
ok,
summary: {
code: codeVars.size,
envExample: envVars.size,
doc: docVars.size,
},
problems: {
codeMissingEnv,
envMissingDoc,
docMissingEnv,
},
};
}
// ─── CLI ───────────────────────────────────────────────────────────────────
function printList(label, list, marker) {
if (list.length === 0) {
console.log(` ${marker || "✓"} ${label}: none`);
return;
}
console.log(`${label}: ${list.length}`);
for (const v of list.slice(0, 50)) console.log(` - ${v}`);
if (list.length > 50) console.log(` ... and ${list.length - 50} more`);
}
function main() {
const lenient = process.argv.includes("--lenient");
const result = runEnvDocSync();
console.log("Env var contract sync report");
console.log("============================");
console.log(`Code references: ${result.summary.code} unique vars`);
console.log(`In .env.example: ${result.summary.envExample} unique vars`);
console.log(`In docs/reference/ENVIRONMENT.md: ${result.summary.doc} unique vars`);
console.log();
printList("In code but missing from .env.example", result.problems.codeMissingEnv);
printList("In .env.example but missing from ENVIRONMENT.md", result.problems.envMissingDoc);
printList("In ENVIRONMENT.md but missing from .env.example", result.problems.docMissingEnv);
if (result.ok) {
console.log("\n✓ Env / docs contract is in sync.");
process.exit(0);
}
if (lenient) {
console.log("\n⚠ Drift detected (lenient mode — exit 0).");
process.exit(0);
}
console.log(
"\n✗ Env / docs contract is out of sync. Update .env.example, docs/reference/ENVIRONMENT.md,"
);
console.log(" or the allowlists in scripts/check/check-env-doc-sync.mjs and try again.");
process.exit(1);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env node
// scripts/check/check-error-helper.mjs
// Gate Hard Rule #12 (error sanitization): error responses/results built in
// open-sse/executors/ and open-sse/handlers/ MUST route through the helpers in
// open-sse/utils/error.ts (buildErrorBody / errorResponse / sanitizeErrorMessage /
// sanitizeUpstreamDetails / makeExecutorErrorResult / formatProviderError / …) so
// raw err.stack / err.message / upstream body.error.message never reach a client.
//
// The risk: a file that builds its own `new Response(JSON.stringify({ error: {
// message: err.message } }))` (or a result object with `error: <raw msg>`) and does
// NOT import the sanitizer leaks stack traces / absolute paths / upstream internals.
// CodeQL's js/stack-trace-exposure does not understand the custom sanitizer, so this
// static gate is the canonical enforcement. See docs/security/ERROR_SANITIZATION.md.
//
// Conservative by design: a file is flagged ONLY when it both (a) appears to forward
// a RAW error value into a response/result body AND (b) imports nothing from a
// utils/error path. Files that import the helper are trusted (the `body.error.message`
// they reference is the sanitized output of buildErrorBody, not raw upstream).
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([
// --- 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()
]);
// Import specifiers that count as "uses the error helper" (path ends in utils/error).
const ERROR_HELPER_IMPORT =
/\bfrom\s*["'](?:\.{1,2}\/)*(?:open-sse\/)?utils\/error(?:\.[tj]s)?["']|@omniroute\/open-sse\/utils\/error/;
// A caught-error identifier whose .message/.stack is RAW (not sanitized): the leading
// token must be exactly `err` / `error` / `e` (optionally `(err as Error)` cast), and
// NOT preceded by a member access — so `event.error.message` (an upstream-event read)
// does not match, only our own caught `err.message` / `error.stack` / `(err as …).msg`.
// The `(?<![.\w])` lookbehind is non-consuming so it works mid-template (e.g. `${err…`).
const RAW_ERR = String.raw`(?:\((?:err|error|e)\s+as\s+[^)]+\)|(?<![.\w])(?:err|error|e))\.(?:message|stack)\b`;
// Lines that are internal sinks (never reach the client) — excluded so the gate does
// not false-positive on logging, DB audit rows, thrown Errors, or rejected promises.
const INTERNAL_SINK =
/\b(?:log\??\.\w+\??\.?\(|console\.\w+\(|saveCallLog\s*\(|reqLogger\.|throw\s+new\s+\w*Error|reject\s*\(|\.error\??\.\(|finish\s*\()/;
// Internal-sink CALL openers — when a raw-error field sits inside one of these calls'
// argument object (e.g. `saveCallLog({ … error: err.message … })`), it is a DB audit
// row / log entry, not a client response. Matched against the line that opens the
// nearest still-unclosed call enclosing the flagged line.
const INTERNAL_SINK_CALL =
/\b(?:saveCallLog|log\??\.\w+|console\.\w+|reqLogger\.\w+)\s*\(\s*\{?\s*$/;
// A line that is constructing a client-facing response/result body.
const RESPONSE_LINE =
/new\s+Response\s*\(|\bresponse\s*:|\berrResp\s*\(|\bmakeErrorResponse\s*\(|\berrorResponse\s*\(/;
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 (/\.tsx?$/.test(e.name) && !/\.test\.tsx?$/.test(e.name)) acc.push(p);
}
return acc;
}
// A raw caught-error value assigned to / interpolated into a `message:`/`error:` field.
const RAW_ERR_FIELD = new RegExp(String.raw`\b(?:message|error)\s*:\s*` + RAW_ERR);
const RAW_ERR_FIELD_INTERP = new RegExp(
String.raw`\b(?:message|error)\s*:\s*[\`"'][^\n]*\$\{[^}]*` + RAW_ERR
);
// A raw caught-error value interpolated anywhere on a line that also builds a Response.
const RAW_ERR_INTERP = new RegExp(String.raw`\$\{[^}]*` + RAW_ERR);
// Upstream `body.error.message` forwarded into a field without a sanitize call.
const RAW_BODY_ERR = /\b(?:message|error)\s*:\s*[^,}\n]*\bbody\.error\.message\b/;
// A response-builder CALL that takes a message argument (client-facing). A tainted
// local variable (assigned from a raw error) passed here is a leak.
const RESPONSE_BUILDER_CALL =
/\b(?:errResp|makeErrorResponse|errorResponse)\s*\(|\bresponse\s*:\s*(?:errResp|makeErrorResponse|errorResponse|new\s+Response)\s*\(/;
// `const|let <id> = <expr containing a raw caught-error>` — a tainted local holding a
// raw, unsanitized error string. Captures the variable name for downstream tracking.
const TAINT_DECL = new RegExp(
String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*[^;\n]*` + RAW_ERR
);
/**
* Does this source forward a RAW error value into a CLIENT-FACING response/result body?
*
* Line-anchored + sink-aware so it does not false-positive on logging, DB audit rows
* (saveCallLog), thrown Errors, rejected promises, or parsed upstream-event reads.
*
* A line is a violation when, after skipping internal-sink lines, it either:
* - assigns/interpolates a raw caught-error into a `message:`/`error:` field, or
* - interpolates a raw caught-error AND is itself a Response/result-builder line, or
* - forwards upstream `body.error.message` into a field without sanitizing, or
* - passes a TAINTED local (a var assigned from a raw error, never sanitized) into a
* response-builder call (errResp / makeErrorResponse / errorResponse / new Response).
*/
function forwardsRawError(source) {
const lines = source.split("\n").map((l) => l.replace(/\/\/.*$/, ""));
// Pass 1: collect tainted local variables (raw error, no sanitize on the line).
const tainted = new Set();
for (const line of lines) {
if (INTERNAL_SINK.test(line)) continue;
const m = line.match(TAINT_DECL);
if (m && !/sanitize/i.test(line)) tainted.add(m[1]);
}
const taintedUse =
tainted.size > 0 ? new RegExp(String.raw`\b(?:${[...tainted].join("|")})\b`) : null;
// Pass 2: scan for leak lines.
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
if (INTERNAL_SINK.test(line)) continue; // log / audit / throw / reject
if (TAINT_DECL.test(line)) continue; // the assignment itself is not the leak
const directLeak =
RAW_ERR_FIELD.test(line) ||
RAW_ERR_FIELD_INTERP.test(line) ||
(RAW_ERR_INTERP.test(line) && RESPONSE_LINE.test(line)) ||
// Multi-line OpenAI error envelope: a raw-error interpolation that sits inside
// an enclosing `error: {` / `message:` field of a `new Response(` body.
(RAW_ERR_INTERP.test(line) && enclosedByErrorResponseBody(lines, i)) ||
(RAW_BODY_ERR.test(line) && !/sanitize/i.test(line));
const taintedLeak =
taintedUse !== null && RESPONSE_BUILDER_CALL.test(line) && taintedUse.test(line);
// The raw error reaches a client body unless it lives inside an internal-sink
// call's argument object (saveCallLog / log / console / reqLogger).
if ((directLeak || taintedLeak) && !enclosedByInternalSinkCall(lines, i)) return true;
}
return false;
}
/**
* Walk back from `idx`, tracking net brace/paren depth, to find the line that opens
* the call enclosing `idx`. Returns true if that opener is an internal-sink call.
* Bounded lookback (sink-call argument objects are small) keeps this cheap.
*/
function enclosedByInternalSinkCall(lines, idx) {
let depth = 0;
for (let j = idx; j >= 0 && idx - j < 80; j--) {
const l = lines[j].replace(/\/\/.*$/, "");
for (let k = l.length - 1; k >= 0; k--) {
const ch = l[k];
if (ch === ")" || ch === "}") depth++;
else if (ch === "(" || ch === "{") {
if (depth === 0) {
// Unbalanced opener at this position — the enclosing construct starts here.
return INTERNAL_SINK_CALL.test(l.slice(0, k + 1));
}
depth--;
}
}
}
return false;
}
// Field opener that is part of an OpenAI-style error envelope (`error: {` / `message:`).
const ERROR_FIELD_OPENER = /\b(?:error|message)\s*:\s*[`{]?\s*$/;
/**
* Walk back from `idx` to the nearest enclosing `{`/`(` opener; if it opens an error
* envelope field (`error: {` / `message:`) AND a `new Response(` / `response:` builder
* appears just above it, the raw error reaches a client error body. Conservative: only
* the canonical error-envelope shape qualifies (not `content:` / data fields).
*/
function enclosedByErrorResponseBody(lines, idx) {
let depth = 0;
for (let j = idx; j >= 0 && idx - j < 80; j--) {
const l = lines[j].replace(/\/\/.*$/, "");
for (let k = l.length - 1; k >= 0; k--) {
const ch = l[k];
if (ch === ")" || ch === "}") depth++;
else if (ch === "(" || ch === "{") {
if (depth === 0) {
if (!ERROR_FIELD_OPENER.test(l.slice(0, k + 1))) return false;
// Confirm a Response builder sits in the few lines above the envelope.
const window = lines.slice(Math.max(0, j - 8), j + 1).join("\n");
return /new\s+Response\s*\(|\bresponse\s*:/.test(window);
}
depth--;
}
}
}
return false;
}
export function findErrorHelperViolations(files, allowlist) {
const violations = [];
for (const { path: rel, source } of files) {
if (allowlist.has(rel)) continue;
if (ERROR_HELPER_IMPORT.test(source)) continue; // trusts the helper
if (forwardsRawError(source)) violations.push(rel);
}
return violations;
}
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({
path: path.relative(cwd, p).replace(/\\/g, "/"),
source: fs.readFileSync(p, "utf8"),
});
}
}
// 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);
if (violations.length) {
console.error(
`[check-error-helper] ${violations.length} file(s) build an error response/result with a ` +
`raw err.message/err.stack/body.error.message but do NOT import open-sse/utils/error:\n` +
violations.map((v) => " ✗ " + v).join("\n") +
`\n → route the message through buildErrorBody()/sanitizeErrorMessage()/` +
`makeExecutorErrorResult() (see docs/security/ERROR_SANITIZATION.md), or — if it is a ` +
`false positive — add it to KNOWN_MISSING_ERROR_HELPER with a justification.`
);
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)`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+903
View File
@@ -0,0 +1,903 @@
#!/usr/bin/env node
// Doc accuracy gate — catches fabricated API/endpoint/function/env-var claims in docs.
//
// Scans every `docs/{*,*/*}.md` and `AGENTS.md` for concrete code references and
// verifies each one against the source. Reports drift as warnings (soft-fail
// by default) and exits 1 with `--strict` so CI can block fabricated claims.
//
// What it checks:
// 1. /api/... endpoint paths → must match a route.ts file under src/app/api/
// 2. UPPER_SNAKE env var names → must have a process.env.X or env.X read
// 3. CLI commands `omniroute ...` → must exist in bin/cli/commands/ or bin/
// 4. BUILTIN_EVENTS hook names → must be exported from hooks.ts
// 5. `src/.../foo.ts` file refs → must exist (relative to repo root)
// 6. `open-sse/.../bar.ts` file refs → must exist
// 7. `bin/...` file refs → must exist
//
// Out of scope (covered by other scripts):
// - File-size / line-count claims → scripts/check/check-docs-counts-sync.mjs
// - Env var → doc table sync → scripts/check/check-env-doc-sync.mjs
// - Cross-doc link integrity → scripts/check/check-doc-links.mjs
// - openapi.yaml ↔ routes sync → scripts/check/check-openapi-coverage.mjs
//
// Exit codes:
// 0 no drift (or soft warnings only)
// 1 strict mode and any drift was found
//
// Usage:
// node scripts/check/check-fabricated-docs.mjs # soft report
// node scripts/check/check-fabricated-docs.mjs --strict # fail on any drift
// node scripts/check/check-fabricated-docs.mjs --json # machine-readable output
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const ARGS = new Set(process.argv.slice(2));
const STRICT = ARGS.has("--strict");
const JSON_OUT = ARGS.has("--json");
// ── Config ─────────────────────────────────────────────────────────────────
/** Paths to scan recursively. AGENTS.md is checked too. */
const SCAN_PATHS = ["docs", "AGENTS.md", "open-sse/AGENTS.md", "src/lib/db/AGENTS.md"];
/** Built-in event names that AGENTS.md / docs are allowed to mention. */
const KNOWN_HOOKS = new Set([
"onRequest",
"onResponse",
"onError",
"onModelSelect",
"onComboResolve",
"onRateLimit",
"onQuotaExhaust",
"onProviderError",
"onStreamStart",
"onStreamEnd",
"onInstall",
"onActivate",
"onDeactivate",
"onUninstall",
// Real callbacks wired in code that docs reference (verified present in src/):
// onChunk/onFirstChunk — streaming callbacks (src/shared/utils/streamTracker.ts,
// playground ChatTab.tsx); onServerStatus/onPortChanged/onUpdateStatus — Electron
// IPC callbacks (src/shared/hooks/useElectron.ts, HomePageClient.tsx);
// onEmpty — model-metadata registry callback (src/lib/modelMetadataRegistry.ts).
"onChunk",
"onFirstChunk",
"onServerStatus",
"onPortChanged",
"onUpdateStatus",
"onEmpty",
]);
// Common false-positives the heuristic would otherwise flag. Add to this
// list as the script matures — keep it small and well-justified.
const ENV_VAR_ALLOWLIST = new Set([
"PATH",
"HOME",
"USER",
"SHELL",
"PWD",
"LANG",
"NODE_ENV",
"NODE_PATH",
"NODE_OPTIONS",
"NODE_EXTRA_CA_CERTS", // Node runtime CA var: read by Node itself + passed to a spawned subprocess (cloudflaredTunnel.ts), not via process.env.X (AGENTBRIDGE.md)
"DEBUG",
"VERBOSE",
"LOG_LEVEL",
"PORT", // generic, not OmniRoute-specific
"DATA_DIR",
"REQUIRE_API_KEY",
"OMNIROUTE_BUILD_PROFILE", // build-time only
"OMNIROUTE_BUILD_SHA",
"OMNIROUTE_URL", // used by ad-hoc tooling, validated elsewhere
"OMNIROUTE_KEY", // ditto
"OPENCODE_API_KEY", // ditto
// ── External-tool / spawn-injected / ops env vars ────────────────────────
// Real environment variables, but they belong to an UPSTREAM CLI/tool, a
// docker-compose/electron-build pipeline, or are injected into a spawned
// subprocess — never read via `process.env.X` in OmniRoute's own source, so the
// code-read index can't see them. Documented (correctly) in the relevant guides.
"COPILOT_PROVIDER_BASE_URL", // GitHub Copilot CLI ≥v1.0.19's own env var (AGENTBRIDGE.md)
"OPENAI_BASE_URL", // env var OmniRoute passes to downstream CLIs (AGENT_PROTOCOLS_GUIDE.md)
"NINEROUTER_API_KEY", // injected into the 9router subprocess at spawn (EMBEDDED-SERVICES.md)
"CLAUDE_CODE_MAX_OUTPUT_TOKENS", // Claude Code CLI's own env var (CODEX-CLI-CONFIGURATION.md)
"CODEX_HOME", // Codex CLI's own config-home env var (CODEX-CLI-CONFIGURATION.md)
"OPENAI_API_BASE", // legacy OpenAI base-URL env var some downstream tools (e.g. Aider) read (CLI-INTEGRATIONS.md)
"PROMPTFOO_PROVIDER_KEY", // promptfoo's own provider-key env var, used by the red-team suite (GUARDRAILS.md)
"REDIS_PORT", // docker-compose host-port override (DOCKER_GUIDE.md)
"AUTO_UPDATE_HOST_REPO_DIR", // docker-compose self-update mount (DOCKER_GUIDE.md)
"LINUX_GPG_KEY", // electron AppImage signing key, CI/build only (ELECTRON_GUIDE.md)
"BRANCH_LOCK_TOKEN", // release branch-protection ops token (QUALITY_GATE_PLAYBOOK.md)
"NEXT_LOCALE", // next-intl locale cookie name (I18N.md)
]);
// Common pluralized / column-header all-caps that aren't env vars
const ENV_VAR_DENYLIST = new Set([
"API_DOCS",
"API_REFERENCE",
"API_GUIDE",
"PROVIDERS",
"FREE_TIERS",
"CHANGELOG",
"CONTRIBUTING",
"ARCHITECTURE",
"CODEBASE_DOCUMENTATION",
"REPOSITORY_MAP",
"AUTHZ_GUIDE",
"RESILIENCE_GUIDE",
"COMPRESSION_GUIDE",
"MCP_SERVER",
"MCP_AUDIT",
"MCP_TOOLS",
"MCP_SCOPES",
"BUILTIN_EVENTS",
"LIFECYCLE_HOOKS",
"OBSERVABILITY",
"TELEMETRY",
"TRACING",
"METRICS",
"WEB_COOKIE_PROVIDERS",
"WEB_SEARCH",
"WEB_FETCH",
"WEB_SOCKET",
"WEBSOCKET",
"WEBHOOKS",
"WEBHOOK_EVENTS",
"GUARDRAILS",
"PROVIDER_NODES",
"PROVIDER_NODES_VALIDATE",
"PROVIDER_HEALTH_AUTOPILOT",
"PROVIDER_HEALTH_MATRIX",
"PROVIDER_HEALTH_PROBE",
"PROVIDER_HEALTH_HISTORY",
"PROVIDER_QUOTA_WINDOWS",
"PROVIDER_STATS",
"PROVIDER_MODELS",
"PROVIDER_TYPE",
"PROVIDER_CREDENTIALS",
"PROVIDER_BULK",
"PROVIDER_VALIDATE",
"PROVIDER_TEST_BATCH",
"PROVIDER_TEST_ALL",
"PROVIDER_BULK_WEB_SESSION",
"PROVIDER_EXPIRATION",
"FREE_PROVIDERS",
"FREE_PROXIES",
"PROXY_POOLS",
"ONE_PROXY",
"ONE_PROXY_FETCH",
"ONE_PROXY_STATS",
"ONE_PROXY_ROTATE",
"PROXY_FALLBACK",
"PROXY_HEALTH",
"PROXY_STATS",
"PROXY_MARKETPLACE",
"PROVIDER_REGISTRY",
"PROVIDER_CATALOG",
"PROVIDER_COST",
"PROVIDER_LIMITS",
"PROVIDER_CONFIG",
"PROVIDER_CONNECTION",
"PROVIDER_CONNECTIONS",
"PROVIDER_REFRESH",
"PROVIDER_SYNC_MODELS",
"PROVIDER_TEST",
"PROVIDER_TESTS",
"PROVIDER_FETCH",
"PROVIDER_IMPORT",
"PROVIDER_EXPORT",
"PROVIDER_LIST",
"PROVIDER_ADD",
"PROVIDER_REMOVE",
"PROVIDER_CREATE",
"PROVIDER_UPDATE",
"PROVIDER_DELETE",
"PROVIDER_DISABLE",
"PROVIDER_ENABLE",
"PROVIDER_RESET",
"PROVIDER_RUN",
"PROVIDER_GET",
"PROVIDER_SET",
"PROVIDER_REVOKE",
"MODEL_REGISTRY",
"MODEL_COMBO_MAPPINGS",
"MODEL_ALIASES",
"COMBO_TARGETS",
"COMBO_HEALTH",
"COMBO_DEFAULTS",
"COMBO_FORECAST",
"COMBO_SCORING",
"COMBO_INSPECTOR",
"RATE_LIMITS",
"RATE_LIMIT_CONFIG",
"TASK_FACTORY",
"TASK_MANAGER",
"AGENT_BASE",
"AGENT_BUILDER",
"AGENT_SKILL",
"AGENT_SKILLS",
"AGENT_BRIDGE",
"MENU_ITEM",
"MENU_ITEMS",
"MENU_ICON",
"MENU_ICONS",
"FAVICON",
"FEATURE_FLAG",
"FEATURE_FLAGS",
"VERSION_MANAGER",
"VM_DEPLOY",
"VPS_DEPLOY",
"I18N_CONFIG",
"I18N_LOCALES",
"PROXY_GUIDE",
"OPENAPI_SPEC",
"OPENAPI_GUIDE",
"WAF_RULES",
"WAF_BYPASS",
"WAF_PROTECTION",
"SOCIAL_OAUTH",
"OAUTH_FLOWS",
"OAUTH_TOKENS",
"STORAGE_BACKEND",
"STORAGE_HEALTH",
"DATABASE_SETTINGS",
"TUNNELS",
"TUNNEL_CLOUDFLARED",
"TUNNEL_NGROK",
"TUNNEL_TAILSCALE",
"PRICING_CATALOG",
"PRICING_SYNC",
"PRICING_DEFAULTS",
"USAGE_ANALYTICS",
"USAGE_QUOTA",
"USAGE_BUDGET",
"QUOTA_SNAPSHOT",
"QUOTA_SNAPSHOTS",
"QUOTA_POOL",
"QUOTA_POOLS",
"QUOTA_PLAN",
"QUOTA_PLANS",
"QUOTA_MONITOR",
"QUOTA_MONITORS",
"DOMAIN_BUDGET",
"DOMAIN_BUDGETS",
"DOMAIN_COST",
"DOMAIN_COSTS",
"DOMAIN_FALLBACK",
"DOMAIN_FALLBACKS",
"DOMAIN_LOCKOUT",
"DOMAIN_LOCKOUTS",
"DOMAIN_CIRCUIT",
"DOMAIN_CIRCUITS",
"DOMAIN_RESET",
"DOMAIN_RESETS",
"PROVIDER_HEALTH_AUTOPILOT_ACTIONS",
"PROVIDER_HEALTH_AUTOPILOT_HISTORY",
"PROVIDER_HEALTH_AUTOPILOT_STATS",
"PROVIDER_HEALTH_AUTOPILOT_CONFIG",
"PROVIDER_HEALTH_AUTOPILOT_INTERVAL",
"PROVIDER_HEALTH_AUTOPILOT_TIMEOUT",
"PROVIDER_HEALTH_AUTOPILOT_THRESHOLD",
"PROVIDER_HEALTH_AUTOPILOT_COOLDOWN",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_TIMEOUT",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_THRESHOLD",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_COOLDOWN",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_MAX",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_MIN",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_BASE",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_FACTOR",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_JITTER",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_THRESHOLD",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_LIMIT",
"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
"ROUTER_BACKENDS", // typed router-backend registry constant documented in the ADR (ROUTER_BACKENDS.md); code lands with PR #5868 (#5798)
// ── Error / Node codes documented in prose (string-literal codes, not env vars) ──
"URL_GUARD_BLOCKED", // HTTP 422 guard-violation code (ARCHITECTURE.md)
"AUTHZ_NOT_INITIALIZED", // AuthzAssertionError code (AUTHZ_GUIDE.md)
"MODULE_NOT_FOUND", // Node runtime error code watched by service supervisor (ELECTRON_GUIDE.md)
"ERR_DLOPEN_FAILED", // Node native-module load error code (ELECTRON_GUIDE.md)
// ── Code-symbol / naming-convention examples documented in prose ─────────────
"UPPER_SNAKE", // the literal naming-convention token in the style guide (CODEBASE_DOCUMENTATION.md)
"DEFAULT_TIMEOUT", // example constant name in the UPPER_SNAKE convention row (AGENTS.md)
"SIDEBAR_DEFINITIONS", // code constant referenced in prose (MONITORING_SECTIONS.md)
"LOCAL_ONLY", // routeGuard classification label (AGENTBRIDGE.md)
"SPAWN_CAPABLE", // routeGuard classification label (AGENTBRIDGE.md)
"ZEROGRAVITY_SENSITIVE_WORDS", // cross-project constant named in a comparison (STEALTH_GUIDE.md)
]);
/** Endpoints that don't follow the standard route.ts pattern. */
const ENDPOINT_ALLOWLIST = new Set([
"/api/v1/models",
"/api/v1/chat/completions",
"/api/v1/embeddings",
"/api/v1/responses",
"/api/v1/images/generations",
"/api/v1/audio/transcriptions",
"/api/v1/audio/speech",
"/api/v1/videos/generations",
"/api/v1/music/generations",
"/api/v1/moderations",
"/api/v1/rerank",
"/api/v1/search",
"/api/v1/messages",
"/api/v1/agents/tasks",
"/api/v1/agents/tasks/{id}",
"/api/v1/agents/credentials",
"/api/v1/agents/health",
"/.well-known/agent.json",
"/v1/models",
"/v1/chat/completions",
"/v1/embeddings",
"/v1/responses",
"/v1/ws", // WebSocket bridge, not standard route.ts
"/a2a", // JSON-RPC 2.0 entry
"/api/mcp/stream", // Streamable HTTP MCP transport
"/api/mcp/sse", // SSE MCP transport
"/api/health",
// Upstream/external provider endpoints documented in provider guides — these are
// paths on the UPSTREAM service (Claude.ai web, Blackbox), not OmniRoute routes.
"/api/organizations/{orgId}/chat_conversations/{convId}/completion", // claude-web upstream
"/api/chat", // Blackbox Web upstream (validated-token target)
]);
/** Doc files to skip (auto-generated, vendored, or third-party). */
const SKIP_DOC_FILES = new Set([
"docs/reference/PROVIDER_REFERENCE.md", // auto-generated from providers.ts
"docs/openapi.yaml",
"docs/i18n", // translations — separate workflow
// Design / research / plan docs: by definition describe not-yet-built files and
// proposed (not-yet-shipped) endpoints (each carries a `Status: Design`/`Active
// research`/`Plano` header). Same rationale as the audit report above — these are
// forward-looking specs, not living API docs, so their forward references are
// expected, not fabrications.
"docs/research", // DISCOVERY_TOOL_DESIGN.md, UNLIMITED_LLM_ACCESS.md, …
"docs/superpowers/plans", // dated implementation plans (files described before they exist)
"docs/superpowers/specs", // dated research/spec reports (point-in-time findings, may cite proposed/not-yet-built endpoints, env vars, and files) — same rationale as the plans/research dirs above
// Release notes are historical, point-in-time records: they intentionally describe
// modules/paths as they were at that release (e.g. a module later moved or renamed).
// Rewriting them to today's layout would falsify history — out of scope for a
// living-docs accuracy gate.
"docs/releases",
// Forward-looking coverage plan: a `- [ ]` checklist of test targets and helper
// components to be created. Same rationale as the design/plan docs above.
"docs/ops/COVERAGE_PLAN.md",
]);
// ── File discovery ─────────────────────────────────────────────────────────
function walkMarkdown(dir, out = [], root = ROOT) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return out;
const stat = fs.statSync(abs);
if (stat.isFile()) {
if (abs.endsWith(".md") || abs.endsWith(".mdx")) out.push(abs);
return out;
}
for (const name of fs.readdirSync(abs)) {
if (name === "node_modules" || name.startsWith(".")) continue;
const childAbs = path.join(abs, name);
const s = fs.statSync(childAbs);
if (s.isDirectory()) walkMarkdown(path.relative(root, childAbs), out, root);
else if (childAbs.endsWith(".md") || childAbs.endsWith(".mdx")) out.push(childAbs);
}
return out;
}
function allScanFiles(root = ROOT) {
const files = [];
for (const p of SCAN_PATHS) walkMarkdown(p, files, root);
return files.filter((f) => {
const rel = path.relative(root, f);
for (const skip of SKIP_DOC_FILES) {
if (rel === skip || rel.startsWith(skip + path.sep)) return false;
}
return true;
});
}
// ── Codebase index ─────────────────────────────────────────────────────────
// Env var helper wrappers used across OmniRoute — envInt(NAME, 5), envBool(NAME),
// envStr(NAME), … — read the named var from the environment, so a string-literal
// argument is a genuine env-var read, equivalent to a direct process.env member read.
// (Comment avoids a literal `process.env.<NAME>` token so the sibling env-doc-sync
// grep does not mistake this example for a real env-var read.)
const ENV_HELPER_CALL =
/\benv(?:Int|Bool|Str|Num|Float|String|Flag|Raw|List|Json)?\(\s*["'`]([A-Z][A-Z0-9_]+)["'`]/g;
export function buildCodebaseIndex(root = ROOT) {
// Set of /api/... paths that have a route.ts handler.
const apiRoutes = new Set();
// Set of /api/... prefixes that are an ancestor of (or equal to) a real route.ts.
// A documented prefix like /api/cloud/ is valid even without a route.ts at that
// exact level, as long as some src/app/api/cloud/**/route.ts exists.
const apiPrefixes = new Set();
// Map of /api/... → methods implemented in route.ts
const apiMethods = new Map();
function dynToBrace(seg) {
// [id] → {id}, [...path] → {path} — match the doc convention for dynamic segments.
return seg.replace(/^\[\.\.\.(.+)\]$/, "{$1}").replace(/^\[(.+)\]$/, "{$1}");
}
function walkApiRoutes(dir) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return;
for (const name of fs.readdirSync(abs)) {
const child = path.join(abs, name);
const s = fs.statSync(child);
if (s.isDirectory()) walkApiRoutes(path.relative(root, child));
else if (name === "route.ts" || name === "route.mjs") {
// Build the route path from the directory hierarchy
const rel = path.relative(root, child).replace(/\\/g, "/");
const parts = rel.split("/");
// drop "src/app/api" and "route.ts"
parts.shift(); // src
parts.shift(); // app
parts.shift(); // api
parts.pop(); // route.ts
const routePath = "/api/" + parts.join("/");
apiRoutes.add(routePath);
apiRoutes.add(routePath + "/"); // trailing slash variant
// Register every ancestor prefix (and its {brace} dynamic-segment variant)
// so documented prefixes-with-subroutes resolve.
for (let i = 1; i <= parts.length; i++) {
const prefix = "/api/" + parts.slice(0, i).join("/");
const bracePrefix = "/api/" + parts.slice(0, i).map(dynToBrace).join("/");
apiPrefixes.add(prefix);
apiPrefixes.add(bracePrefix);
}
// Read the file to find exported HTTP methods
try {
const content = fs.readFileSync(child, "utf8");
const methods = new Set();
for (const m of ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]) {
const re = new RegExp(`export\\s+(?:async\\s+)?function\\s+${m}\\b`);
if (re.test(content)) methods.add(m);
const re2 = new RegExp(`export\\s+const\\s+${m}\\b`);
if (re2.test(content)) methods.add(m);
}
if (methods.size > 0) apiMethods.set(routePath, methods);
} catch {
/* ignore read errors */
}
}
}
}
walkApiRoutes("src/app/api");
// Set of env var names that are actually read in code, and the set of
// ALL_CAPS code identifiers (export const / enum / object-literal keys). A
// documented `UPPER_SNAKE` that resolves to a code identifier (e.g.
// LOCAL_ONLY_API_PREFIXES, HALF_OPEN) is NOT a fabricated env var.
const envVars = new Set();
const codeIdentifiers = new Set();
function walkForEnv(dir) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return;
const skipDirs = new Set(["node_modules", ".next", "dist", ".build", "coverage"]);
for (const name of fs.readdirSync(abs)) {
if (skipDirs.has(name)) continue;
const child = path.join(abs, name);
const s = fs.statSync(child);
if (s.isDirectory()) walkForEnv(path.relative(root, child));
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(name)) {
try {
const content = fs.readFileSync(child, "utf8");
// process.env.X
for (const m of content.matchAll(/process\.env\.([A-Z][A-Z0-9_]+)/g)) envVars.add(m[1]);
// process.env["X"] / process.env['X'] (bracket notation)
for (const m of content.matchAll(/process\.env\[\s*["'`]([A-Z][A-Z0-9_]+)["'`]\s*\]/g))
envVars.add(m[1]);
// env.X (destructured in some handlers)
for (const m of content.matchAll(/\benv\.([A-Z][A-Z0-9_]+)\b/g)) envVars.add(m[1]);
// env["X"] (bracket on a destructured env binding)
for (const m of content.matchAll(/\benv\[\s*["'`]([A-Z][A-Z0-9_]+)["'`]\s*\]/g))
envVars.add(m[1]);
// envInt("X", …) / envBool("X") / envStr("X") … helper wrappers
for (const m of content.matchAll(ENV_HELPER_CALL)) envVars.add(m[1]);
// import.meta.env.X (Vite-style, unlikely here but cheap)
for (const m of content.matchAll(/import\.meta\.env\.([A-Z][A-Z0-9_]+)/g))
envVars.add(m[1]);
// export const / const / let / var / enum NAME — JS identifiers, not env vars.
for (const m of content.matchAll(
/\b(?:export\s+)?(?:const|let|var|enum)\s+([A-Z][A-Z0-9_]{2,})\b/g
))
codeIdentifiers.add(m[1]);
// Object-literal / enum members on their own line: `HALF_OPEN: "HALF_OPEN"`.
for (const m of content.matchAll(/^[ \t]*([A-Z][A-Z0-9_]{2,})[ \t]*[:=]/gm))
codeIdentifiers.add(m[1]);
} catch {
/* ignore */
}
}
}
}
walkForEnv("src");
walkForEnv("open-sse");
walkForEnv("bin");
walkForEnv("scripts");
// Env vars that are only read by the test harness (e.g. RUN_CHAOS_INT) are still
// real env vars and must not be flagged as fabricated.
walkForEnv("tests");
// Env contract maintained by the sibling gate (check-env-doc-sync.mjs): a var
// listed in .env.example or docs/reference/ENVIRONMENT.md is, by definition, a
// documented OmniRoute env var (including external-CLI / docker / electron vars
// that are not read via process.env in our own source).
function readEnvContract() {
try {
const t = fs.readFileSync(path.join(root, ".env.example"), "utf8");
for (const line of t.split("\n")) {
const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)\s*=/);
if (m) envVars.add(m[1]);
}
} catch {
/* ignore */
}
try {
const t = fs.readFileSync(path.join(root, "docs", "reference", "ENVIRONMENT.md"), "utf8");
for (const m of t.matchAll(/`([A-Z][A-Z0-9_]{2,})`/g)) envVars.add(m[1]);
} catch {
/* ignore */
}
}
readEnvContract();
// Set of `omniroute <subcommand>` strings that exist in bin/
const cliCommands = new Set();
function walkCli(dir) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return;
for (const name of fs.readdirSync(abs)) {
const child = path.join(abs, name);
const s = fs.statSync(child);
if (s.isDirectory()) walkCli(path.relative(root, child));
else if (/\.(mjs|js|ts)$/.test(name)) {
try {
const content = fs.readFileSync(child, "utf8");
// Programmatic API: `command('foo', ...)`, `.command('bar')`, and
// arg-bearing forms `.command('connect <host>')` / `.command('chat [msg]')`
// — capture the leading subcommand token regardless of trailing args.
const m1 = content.matchAll(/\.command\(\s*['"`]([a-z][a-z0-9-]+)/g);
for (const m of m1) cliCommands.add(m[1]);
// Subcommand names: `${name}Cmd`, `name = "foo"`, etc.
const m2 = content.matchAll(/name:\s*['"`]([a-z][a-z0-9-]+)['"`]/g);
for (const m of m2) cliCommands.add(m[1]);
// `.name('foo')` (commander pattern)
const m3 = content.matchAll(/\.name\(\s*['"`]([a-z][a-z0-9-]+)['"`]\s*\)/g);
for (const m of m3) cliCommands.add(m[1]);
} catch {
/* ignore */
}
}
}
}
walkCli("bin");
return { apiRoutes, apiPrefixes, apiMethods, envVars, codeIdentifiers, cliCommands };
}
// ── Doc scanning ───────────────────────────────────────────────────────────
const COARSE_PATTERNS = {
apiPath: /(?<!\w)\/api\/[A-Za-z0-9_\-\/\[\]\{\}]+(?!\w)/g,
// Catches ALL_CAPS env var names of length >= 3
envVar: /\b([A-Z][A-Z0-9_]{2,})\b/g,
// omniroute <verb> <sub> ... — only on the same line, captures first 2 tokens
cliCmd: /\bomniroute\s+([a-z][a-z0-9-]+)(?:\s+([a-z][a-z0-9-]+))?/g,
// Built-in event names like onRequest, onFoo
hookName: /\b(on[A-Z][a-zA-Z]+)\b/g,
// File references like src/lib/foo.ts, open-sse/handlers/bar.ts, bin/cli/baz.mjs
fileRef:
/\b((?:src|open-sse|bin|scripts|tests|electron)\/[A-Za-z0-9_\-\/\.]+\.(?:ts|tsx|mjs|js|cjs|sh|sql))\b/g,
};
function stripCodeBlocksAndFences(text) {
// Remove fenced code blocks (``` ... ```) but KEEP inline backticks so
// we can still detect `BACKTICKED_LIKE_THIS` env-var/hook/CLI claims.
return text.replace(/```[\s\S]*?```/g, "");
}
function lineOf(text, idx) {
let line = 1;
for (let i = 0; i < idx && i < text.length; i++) if (text[i] === "\n") line++;
return line;
}
export function scanDocFile(absPath, index, root = ROOT) {
const rel = path.relative(root, absPath);
const text = fs.readFileSync(absPath, "utf8");
const textNoCode = stripCodeBlocksAndFences(text);
const findings = [];
// 1) API endpoints
for (const m of textNoCode.matchAll(COARSE_PATTERNS.apiPath)) {
// Normalize wildcard segments to {brace} form so [id] / [...path] / {id} all
// compare equally against the indexed routes/prefixes.
const normalized = m[0].replace(/\[\.\.\.(.+?)\]/g, "{$1}").replace(/\[(.+?)\]/g, "{$1}");
const candidate = normalized.replace(/\/$/, "");
const stripped = m[0].replace(/[\[\]\{\}]/g, "").replace(/\/$/, ""); // legacy lookup form
if (
ENDPOINT_ALLOWLIST.has(candidate) ||
ENDPOINT_ALLOWLIST.has(candidate + "/") ||
ENDPOINT_ALLOWLIST.has(stripped) ||
ENDPOINT_ALLOWLIST.has(stripped + "/")
)
continue;
if (
index.apiRoutes.has(stripped) ||
index.apiRoutes.has(stripped + "/") ||
index.apiRoutes.has(candidate) ||
index.apiRoutes.has(candidate + "/")
)
continue;
// Prefix-with-subroutes: a documented prefix like /api/cloud/ or /api/services/{name}/
// is valid when some src/app/api/<prefix>/**/route.ts exists. Accept when the
// documented path is (or is an ancestor of) a real route prefix, or vice-versa.
let isPrefix = false;
for (const pre of index.apiPrefixes) {
if (pre === candidate || pre.startsWith(candidate + "/") || candidate.startsWith(pre + "/")) {
isPrefix = true;
break;
}
}
if (isPrefix) continue;
// Allow docs that describe intended-but-not-yet-shipped routes by skipping lines that say "planned" / "TBD" / "future"
const ln = lineOf(text, m.index);
const lineText = text.split("\n")[ln - 1] || "";
if (/\b(planned|tbd|future|coming|proposed|not yet|will be)\b/i.test(lineText)) continue;
findings.push({
kind: "api-path",
value: m[0],
line: ln,
msg: `endpoint ${m[0]} not found in src/app/api/`,
});
}
// 2) Env vars — only flag names wrapped in backticks AND containing an
// underscore. The maintainer's actual fabricated env vars (PR #3456)
// were always in `BACKTICKS` inside tables; bare all-caps tokens
// inside markdown link display text are doc references, not env vars.
// Example of TRUE positive: | `ACP_MAX_CONCURRENT_SESSIONS` | 5 | ... |
// Example of false positive: | [STEALTH_GUIDE](security/...) |
for (const m of textNoCode.matchAll(/`([A-Z][A-Z0-9_]{4,})`/g)) {
const name = m[1];
if (ENV_VAR_ALLOWLIST.has(name)) continue;
if (!/_/.test(name)) continue; // real env vars have an underscore
if (index.envVars.has(name)) continue;
// Documented ALL_CAPS that resolves to a JS identifier (export const / enum /
// object-literal key) is a code symbol, not a fabricated env var.
// E.g. LOCAL_ONLY_API_PREFIXES, HALF_OPEN, A2A_SKILL_HANDLERS, MCP_SCOPE_PRESETS.
if (index.codeIdentifiers.has(name)) continue;
if (/^X-[A-Z]/.test(name)) continue;
if (ENV_VAR_DENYLIST.has(name)) continue;
const ln = lineOf(text, m.index);
// Use the line as seen in the stripped text (where m.index is valid) for context
// checks — fenced-code removal shifts offsets, so text.split()[ln-1] can be wrong.
const lineStart = textNoCode.lastIndexOf("\n", m.index) + 1;
let lineEnd = textNoCode.indexOf("\n", m.index);
if (lineEnd === -1) lineEnd = textNoCode.length;
const lineText = textNoCode.slice(lineStart, lineEnd);
if (/example|placeholder|todo|tbd|\.\.\./i.test(lineText)) continue;
// A doc that explicitly states a var does NOT exist / is not implemented is
// documenting its absence, not fabricating it. Examples:
// "`MEMORY_RRF_VECTOR_WEIGHT` … do not exist"
// "a `ZED_CONFIG_PATH` environment variable override is not yet implemented"
if (
/\b(?:do(?:es)?\s+not\s+exist|no\s+such|not\s+a\s+real|isn't\s+a\s+real|never\s+(?:read|exists?)|not\s+(?:yet\s+)?(?:implemented|supported))\b/i.test(
lineText
)
)
continue;
findings.push({
kind: "env-var",
value: name,
line: ln,
msg: `env var \`${name}\` is never read via process.env / env / import.meta.env`,
});
}
// 3) CLI commands: `omniroute foo bar` — only flag when the line is in
// a code-like context (inside backticks or a shell block). Bare prose
// like "we use omniroute and..." is not a command claim.
for (const m of textNoCode.matchAll(COARSE_PATTERNS.cliCmd)) {
const sub = m[1];
if (index.cliCommands.has(sub)) continue;
if (["help", "--help", "-h", "version", "--version", "doctor", "setup", "chat"].includes(sub))
continue;
const ln = lineOf(text, m.index);
const lineText = text.split("\n")[ln - 1] || "";
// Only flag when on a line that looks like a shell command (starts with $, or
// inside a shell block, or wrapped in `code`)
const isShellLike = /^[ \t]*\$\s|^```sh|^```bash|^```shell|`omniroute/.test(lineText);
if (!isShellLike) continue;
if (/example|placeholder|tbd/i.test(lineText)) continue;
findings.push({
kind: "cli-cmd",
value: `omniroute ${sub}`,
line: ln,
msg: `omniroute subcommand '${sub}' not registered in bin/`,
});
}
// 4) Hook names — only flag when wrapped in backticks/code, since bare
// "onFoo" prose is common English.
for (const m of textNoCode.matchAll(/`?(on[A-Z][a-zA-Z]+)`?/g)) {
const name = m[1];
if (KNOWN_HOOKS.has(name)) continue;
// Require backticks to reduce noise (text mentions are usually casual)
if (!m[0].startsWith("`")) continue;
const ln = lineOf(text, m.index);
const lineText = text.split("\n")[ln - 1] || "";
if (/example|placeholder|tbd/i.test(lineText)) continue;
findings.push({
kind: "hook",
value: name,
line: ln,
msg: `hook ${name} not in BUILTIN_EVENTS (hooks.ts) — is this a real hook?`,
});
}
// 5) File references
for (const m of textNoCode.matchAll(COARSE_PATTERNS.fileRef)) {
const ref = m[1].replace(/\\/g, "/");
const abs = path.join(root, ref);
if (fs.existsSync(abs)) continue;
// Allow README/AGENTS to mention example files explicitly in a non-verified way
if (/\{\{|\.\.\./.test(ref)) continue; // templated / placeholder
// Tutorial placeholders in the "how to add a …" scenarios are intentional
// stand-ins, not real files: src/app/api/your-route/route.ts,
// src/lib/db/yourModule.ts, src/lib/guardrails/myGuardrail.ts, etc.
if (/(?:^|\/)(?:your-|your[A-Z]|my[A-Z])/.test(ref)) continue;
// Skip matches that are only the tail of a longer path, not a repo-root ref:
// the fileRef regex anchors on the `src`/`open-sse`/… token, so a leading `/`
// means the real reference is `<something>/src/...` — either a relative example
// path (`./src/index.ts`, a PII-pattern sample) or a workspace-package path
// (`@omniroute/opencode-provider/src/index.ts`). Neither resolves from repo root.
if (m.index > 0 && textNoCode[m.index - 1] === "/") continue;
const ln = lineOf(text, m.index);
findings.push({
kind: "file-ref",
value: ref,
line: ln,
msg: `file ${ref} does not exist`,
});
}
return { rel, findings };
}
// ── Main ───────────────────────────────────────────────────────────────────
export function runFabricatedDocsCheck(opts = {}) {
// `root` lets tests run the full pipeline against a fixture tree (with its own
// docs/, src/, .env.example) instead of the live repo.
const root = opts.root ?? ROOT;
const index = opts.index ?? buildCodebaseIndex(root);
const files = allScanFiles(root);
const allFindings = [];
for (const f of files) {
const result = scanDocFile(f, index, root);
if (result.findings.length > 0) {
allFindings.push(result);
}
}
const totalFindings = allFindings.reduce((acc, r) => acc + r.findings.length, 0);
return { totalFindings, files: allFindings, fileCount: files.length, index };
}
export function formatHumanReport(result) {
const { totalFindings, files, fileCount, index } = result;
const lines = [];
lines.push("Doc accuracy gate — fabricated-claim detection");
lines.push("================================================");
lines.push(`Scanned ${fileCount} markdown file(s)`);
lines.push(
`Codebase: ${index.apiRoutes.size} api routes · ${index.envVars.size} env vars · ${index.cliCommands.size} cli commands`
);
lines.push("");
if (totalFindings === 0) {
lines.push("✓ No fabricated API/env/CLI/hook/file references found.");
return lines.join("\n");
}
// Dedupe identical findings across files (report once, with file list)
const deduped = new Map();
for (const r of files) {
for (const f of r.findings) {
const key = `${f.kind}::${f.value}::${f.msg}`;
if (!deduped.has(key)) deduped.set(key, { ...f, files: new Set() });
deduped.get(key).files.add(r.rel);
}
}
const groups = { "api-path": [], "env-var": [], "cli-cmd": [], hook: [], "file-ref": [] };
for (const f of deduped.values()) groups[f.kind].push(f);
const KIND_LABELS = {
"api-path": "API endpoint paths not in src/app/api/",
"env-var": "Env vars never read in code",
"cli-cmd": "omniroute subcommands not registered",
hook: "Hook names not in BUILTIN_EVENTS",
"file-ref": "File references that don't exist",
};
for (const [kind, items] of Object.entries(groups)) {
if (items.length === 0) continue;
lines.push(`\n## ${KIND_LABELS[kind]} (${items.length})`);
for (const f of items.slice(0, 20)) {
const fileList = [...f.files]
.slice(0, 3)
.map((r) => `${r}:${f.line}`)
.join(", ");
const more = f.files.size > 3 ? ` (+${f.files.size - 3} more)` : "";
lines.push(`${f.value.padEnd(40)} ${f.msg}`);
lines.push(` ${fileList}${more}`);
}
if (items.length > 20) lines.push(` ... and ${items.length - 20} more`);
}
return lines.join("\n");
}
// CLI entry — only run when invoked directly (not when imported for tests).
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
main();
}
function main() {
const result = runFabricatedDocsCheck();
const { totalFindings, files } = result;
if (JSON_OUT) {
console.log(
JSON.stringify(
{
totalFindings,
files: files.length,
results: files,
},
null,
2
)
);
if (STRICT && totalFindings > 0) process.exit(1);
process.exit(0);
}
console.log(formatHumanReport(result));
console.log();
if (totalFindings === 0) {
// Clean run — pass regardless of mode.
process.exit(0);
}
if (STRICT) {
console.error(`${totalFindings} claim(s) drift from source. Failing (--strict).`);
process.exit(1);
} else {
console.warn(`${totalFindings} claim(s) drift from source. Re-run with --strict to fail.`);
process.exit(0);
}
}
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env node
// 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 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.
const IGNORE = [
/^\/api\/v1\//, // superfície OpenAI-compat
];
// 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([
// 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 })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, acc);
else if (/\.(ts|tsx)$/.test(e.name)) acc.push(p);
}
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)
.filter((p) => /route\.tsx?$/.test(p))
.map((p) => path.relative(cwd, p).replace(/\\/g, "/"))
);
}
// ─── 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;
}
/**
* 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(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 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)) {
liveMissesStatic.add(apiPath); // record as live so stale-check works
continue;
}
if (!resolveApiPathToRoute(apiPath, routeFiles)) {
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);
}
}
}
// 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)`);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
// scripts/check/check-file-size.mjs
// Catraca de tamanho de arquivo (mata o god-component). Modelado no
// check-t11-any-budget.mjs: um baseline congelado por arquivo (file-size-baseline.json).
// - arquivo congelado: só pode ENCOLHER (nunca crescer);
// - arquivo NOVO (fora do baseline): não pode passar do CAP.
// Assim o próximo arquivo de 12.760 linhas é impossível, e os 91 atuais só melhoram.
// --update ratcheta o baseline para baixo (encolhimentos + remove quem caiu < cap).
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
function getArg(name, fallback) {
const i = process.argv.indexOf(name);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const BASELINE_PATH = path.resolve(
getArg("--baseline", path.join(ROOT, "config/quality/file-size-baseline.json"))
);
const UPDATE = process.argv.includes("--update");
const SCAN_DIRS = ["src", "open-sse", "electron", "bin"];
// Test files live under tests/ plus co-located *.test.ts(x) inside the source dirs.
const TEST_SCAN_DIRS = ["tests", ...SCAN_DIRS];
// 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.
* @returns {{violations: string[], improvements: [string, number][]}}
*/
export function evaluateFileSizes(currentLocByFile, frozen, cap) {
const violations = [];
const improvements = [];
for (const [file, loc] of Object.entries(currentLocByFile)) {
if (file in frozen) {
if (loc > frozen[file])
violations.push(`${file}: ${loc} > congelado ${frozen[file]} (não pode crescer)`);
else if (loc < frozen[file]) improvements.push([file, loc]);
} else if (loc > cap) {
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
}
}
return { violations, improvements };
}
function countLines(file) {
return fs.readFileSync(file, "utf8").split("\n").length;
}
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()) {
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;
}
function collectLoc() {
const out = {};
for (const d of SCAN_DIRS)
for (const f of walk(path.join(ROOT, d)))
out[path.relative(ROOT, f).replace(/\\/g, "/")] = countLines(f);
return out;
}
// Walk for TEST files: collects *.test.ts / *.test.tsx (the inverse of walk(),
// which deliberately excludes them). Skips .d.ts and the same SKIP_DIRS.
function walkTests(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 (!SKIP_DIRS.has(e.name)) walkTests(p, acc);
} else if (/\.test\.tsx?$/.test(e.name) && !/\.d\.ts$/.test(e.name)) {
acc.push(p);
}
}
return acc;
}
function collectTestLoc() {
const out = {};
for (const d of TEST_SCAN_DIRS)
for (const f of walkTests(path.join(ROOT, d)))
out[path.relative(ROOT, f).replace(/\\/g, "/")] = countLines(f);
return out;
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
console.error(`[file-size] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
process.exit(2);
}
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const cap = baseline.cap;
const frozen = baseline.frozen || {};
const current = collectLoc();
const { violations, improvements } = evaluateFileSizes(current, frozen, cap);
// Test-file gate (Layer 1 anti-reinflation): same shrink-only + new-≤cap semantics,
// reusing evaluateFileSizes against the testFrozen baseline + testCap.
const testCap = baseline.testCap;
const testFrozen = baseline.testFrozen || {};
const currentTests = collectTestLoc();
const { violations: testViolations, improvements: testImprovements } =
typeof testCap === "number"
? evaluateFileSizes(currentTests, testFrozen, testCap)
: { violations: [], improvements: [] };
if (UPDATE) {
let changed = false;
if (violations.length === 0 && improvements.length) {
for (const [file, loc] of improvements) {
if (loc <= cap)
delete frozen[file]; // caiu para dentro do cap → sai do baseline
else frozen[file] = loc; // continua grande mas encolheu → trava no novo valor
}
baseline.frozen = Object.fromEntries(Object.entries(frozen).sort());
changed = true;
console.log(`[file-size] baseline ratcheado: ${improvements.length} arquivo(s) encolheram`);
}
if (typeof testCap === "number" && testViolations.length === 0 && testImprovements.length) {
for (const [file, loc] of testImprovements) {
if (loc <= testCap)
delete testFrozen[file]; // caiu para dentro do testCap → sai do baseline
else testFrozen[file] = loc; // continua grande mas encolheu → trava no novo valor
}
baseline.testFrozen = Object.fromEntries(Object.entries(testFrozen).sort());
changed = true;
console.log(
`[test-file-size] baseline ratcheado: ${testImprovements.length} arquivo(s) de teste encolheram`
);
}
if (changed) fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
}
let failed = false;
if (violations.length) {
console.error(
`[file-size] ${violations.length} violação(ões):\n` +
violations.map((v) => " ✗ " + v).join("\n") +
`\n → modularize/extraia (DRY) para encolher, ou (último caso) ajuste file-size-baseline.json com justificativa.`
);
failed = true;
} else {
console.log(
`[file-size] OK — ${Object.keys(frozen).length} arquivos congelados, cap ${cap} para novos (${Object.keys(current).length} arquivos verificados)`
);
}
if (typeof testCap === "number") {
if (testViolations.length) {
console.error(
`[test-file-size] ${testViolations.length} test file violation(s) (testCap ${testCap}):\n` +
testViolations.map((v) => " ✗ " + v).join("\n") +
`\n → split the test file (extract helpers/sub-suites) to shrink it, or (last resort) adjust testFrozen in file-size-baseline.json with justification.`
);
failed = true;
} else {
console.log(
`[test-file-size] OK — ${Object.keys(testFrozen).length} test files congelados, testCap ${testCap} para novos (${Object.keys(currentTests).length} test files verificados)`
);
}
}
if (failed) process.exit(1);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+707
View File
@@ -0,0 +1,707 @@
#!/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 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
// instância de BaseExecutor que expõe execute() + getProvider(). Um alias que
// não resolve para um executor válido é um símbolo morto (roteia para fallback
// silencioso em vez de falhar).
//
// (2) COMBO STRATEGIES — a cadeia de despacho `strategy === "..."` em
// open-sse/services/combo.ts DEVE tratar exatamente o conjunto canônico de
// ROUTING_STRATEGY_VALUES (src/shared/constants/routingStrategies.ts), exceto
// as estratégias-default implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES
// (estratégias canônicas sem NENHUMA referência `strategy === "..."`; caem no
// ordenamento padrão). Adicionar um valor canônico sem fiá-lo no despacho, ou
// fiar uma string de estratégia que não é canônica (inventada), falha aqui.
//
// (3) TRANSLATOR PAIRS — os pares from:to registrados em runtime no registry de
// tradutores (após bootstrap) são congelados em KNOWN_TRANSLATOR_PAIRS. Catraca:
// 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).
//
// Stale-enforcement (6A.3): a ÚNICA allowlist de SUPRESSÃO deste gate é
// IMPLICIT_DEFAULT_STRATEGIES — cada entrada suprime uma violação `canonicalNotHandled`
// (estratégia canônica sem branch de despacho). Uma entrada que não suprime mais
// nenhuma violação real (porque a estratégia ganhou um branch `strategy === "..."`)
// é obsoleta → o gate falha com instrução de remoção, fechando o furo de regressão
// silenciosa. As demais listas (KNOWN_TRANSLATOR_PAIRS, KNOWN_MCP_TOOL_NAMES) NÃO são
// allowlists de supressão e sim snapshots-catraca (falham na REMOÇÃO, não na presença):
// uma entrada nelas exige que o par/tool continue VIVO no registry — o oposto de
// supressão — então a semântica de stale-enforcement não se aplica a elas.
import { readFileSync, readdirSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, resolve as resolvePath, basename, extname } from "node:path";
import { assertNoStale } from "./lib/allowlist.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolvePath(HERE, "..", "..");
// ───────────────────────────────────────────────────────────────────────────
// (2) COMBO STRATEGIES — fonte canônica + defaults implícitos
// ───────────────────────────────────────────────────────────────────────────
/**
* Estratégias canônicas que NÃO têm um branch `strategy === "..."` na cadeia de
* despacho porque são o comportamento padrão (sem reordenamento explícito). Cada
* uma documentada. Adicionar aqui se uma estratégia canônica não tiver NENHUMA
* referência `strategy === "..."` em combo.ts (do contrário extractHandledStrategies
* já a considera tratada e a entrada vira obsoleta — stale-enforcement abaixo falha).
*
* Atualmente vazio: a entrada `priority` foi removida porque combo.ts passou a
* referenciar `strategy === "priority"` (pre-screen de latência em resolveComboTargets),
* o que torna `priority` já-tratada por extractHandledStrategies — a supressão não
* suprimia mais nenhuma violação `canonicalNotHandled` (era stale). Se o pre-screen
* for removido no futuro, `priority` reaparecerá como `canonicalNotHandled` e o gate
* pedirá para refiá-la no despacho OU redocumentá-la aqui.
*/
export const IMPLICIT_DEFAULT_STRATEGIES: Record<string, string> = {};
/** Extrai todas as strings literais de `strategy === "..."` da fonte do combo. */
export function extractHandledStrategies(comboSource: string): Set<string> {
const handled = new Set<string>();
const re = /strategy\s*===\s*"([a-z0-9-]+)"/g;
let match: RegExpExecArray | null;
while ((match = re.exec(comboSource)) !== null) {
handled.add(match[1]);
}
return handled;
}
export type StrategyMismatch = {
canonicalNotHandled: string[];
handledNotCanonical: string[];
};
/**
* Compara o conjunto canônico (ROUTING_STRATEGY_VALUES) com o conjunto efetivamente
* tratado (branches do despacho defaults implícitos).
* - canonicalNotHandled: estratégia canônica adicionada sem fiação no despacho.
* - handledNotCanonical: branch de despacho para uma string não-canônica (inventada).
*/
export function diffComboStrategies(
canonical: readonly string[],
handled: Set<string>,
implicitDefaults: Record<string, string>
): StrategyMismatch {
const canonicalSet = new Set(canonical);
const effectivelyHandled = new Set<string>(handled);
for (const id of Object.keys(implicitDefaults)) effectivelyHandled.add(id);
const canonicalNotHandled = [...canonicalSet].filter((s) => !effectivelyHandled.has(s));
// Strings tratadas que não são canônicas NEM defaults implícitos = inventadas.
const handledNotCanonical = [...handled].filter(
(s) => !canonicalSet.has(s) && !(s in implicitDefaults)
);
return { canonicalNotHandled, handledNotCanonical };
}
// ───────────────────────────────────────────────────────────────────────────
// (1) EXECUTOR CONFORMANCE — parse do mapa + validação de conformidade
// ───────────────────────────────────────────────────────────────────────────
/**
* Extrai as chaves (aliases) do objeto literal `const executors = { ... }` da fonte
* de open-sse/executors/index.ts. O mapa não é exportado, então enumeramos pela fonte
* (determinístico — é um literal simples). Cada chave é validada em runtime via
* getExecutor() na função main().
*/
export function extractExecutorAliases(indexSource: string): string[] {
const start = indexSource.indexOf("const executors = {");
if (start < 0) throw new Error("could not find `const executors = {` in executors/index.ts");
const end = indexSource.indexOf("\n};", start);
if (end < 0) throw new Error("could not find end of executors map (`\\n};`)");
const block = indexSource.slice(start, end);
const keyRe = /^\s*(?:"([^"]+)"|([A-Za-z0-9_$-]+))\s*:/gm;
const keys: string[] = [];
let match: RegExpExecArray | null;
while ((match = keyRe.exec(block)) !== null) {
keys.push(match[1] ?? match[2]);
}
return keys;
}
/** Superfície pública mínima que todo executor registrado deve expor. */
export type ExecutorLike = {
execute?: unknown;
getProvider?: unknown;
};
/**
* Dada a lista de aliases e um resolvedor (getExecutor), retorna os aliases que NÃO
* resolvem para um BaseExecutor válido (não é instância, ou falta execute/getProvider).
* isInstance é injetado para manter a função pura/testável com inputs sintéticos.
*/
export function findNonConformingExecutors(
aliases: string[],
resolve: (alias: string) => ExecutorLike | null | undefined,
isInstance: (value: unknown) => boolean
): string[] {
return aliases.filter((alias) => {
const ex = resolve(alias);
if (!ex || !isInstance(ex)) return true;
return typeof ex.execute !== "function" || typeof ex.getProvider !== "function";
});
}
// ───────────────────────────────────────────────────────────────────────────
// (3) TRANSLATOR PAIRS — snapshot congelado (catraca: pares não somem)
// ───────────────────────────────────────────────────────────────────────────
/**
* Pares from:to congelados, registrados no registry de tradutores após bootstrap.
* Snapshot real medido em 2026-06-09 (18 pares). Catraca: se um par some, falha.
* Adicionar um par NÃO falha aqui (apenas reportado) — só remoções são regressões.
* Para regravar após adicionar/remover legitimamente um adapter, atualize esta lista.
*/
export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [
"antigravity:claude",
"antigravity:openai",
"claude:gemini",
"claude:openai",
"cursor:openai",
"gemini:claude",
"gemini:openai",
"kiro:openai",
"openai-responses:openai",
"openai:antigravity",
"openai:claude",
"openai:cursor",
"openai:gemini",
"openai:kiro",
"openai:openai-responses",
];
/**
* Pares frozen que sumiram do registry vivo (regressão). frozen = snapshot;
* live = pares observados em runtime. Retorna os que estão no frozen mas não no live.
*/
export function findMissingTranslatorPairs(frozen: readonly string[], live: Set<string>): string[] {
return frozen.filter((pair) => !live.has(pair));
}
/** Pares vivos que ainda não estão no snapshot frozen (informativo, não falha). */
export function findNewTranslatorPairs(frozen: readonly string[], live: Set<string>): string[] {
const frozenSet = new Set(frozen);
return [...live].filter((pair) => !frozenSet.has(pair)).sort();
}
// ───────────────────────────────────────────────────────────────────────────
// (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",
// #4227: file agents/cursor.ts ↔ registry key "cursor-cloud" (distinct from the
// OAuth chat provider `cursor`).
cursor: "cursor-cloud",
};
// ───────────────────────────────────────────────────────────────────────────
// main() — importa módulos reais, lê fontes, roda as seis sub-checagens
// ───────────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const failures: string[] = [];
// ── (1) Executor conformance ──────────────────────────────────────────────
const executorsMod = await import("@omniroute/open-sse/executors/index.ts");
const getExecutor = executorsMod.getExecutor as (alias: string) => ExecutorLike;
const BaseExecutor = executorsMod.BaseExecutor as new (...args: never[]) => unknown;
const indexSource = readFileSync(resolvePath(REPO_ROOT, "open-sse/executors/index.ts"), "utf8");
const aliases = extractExecutorAliases(indexSource);
if (aliases.length === 0) {
failures.push(
"[executor] parse do mapa `executors` não encontrou nenhum alias (regex quebrada?)"
);
}
const isExecutorInstance = (value: unknown) => value instanceof BaseExecutor;
const badExecutors = findNonConformingExecutors(aliases, getExecutor, isExecutorInstance);
if (badExecutors.length) {
failures.push(
`[executor] ${badExecutors.length} alias(es) registrado(s) não resolvem para um BaseExecutor válido (instância + execute() + getProvider()):\n` +
badExecutors.map((a) => `${a}`).join("\n") +
`\n → verifique a entrada em open-sse/executors/index.ts (classe importada/exportada e estende BaseExecutor).`
);
}
// ── (2) Combo strategies ──────────────────────────────────────────────────
// Canonical = user-facing ROUTING_STRATEGY_VALUES INTERNAL_ROUTING_STRATEGY_VALUES
// (system-only strategies like "quota-share" are registered but hidden from the UI;
// they still must have a real dispatch branch in combo.ts — enforced below).
const strategiesMod = await import("@/shared/constants/routingStrategies.ts");
const canonical = [
...(strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]),
...(strategiesMod.INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]),
];
// The combo dispatch was decomposed (Block J): the `strategy === "..."` branches
// now live across combo.ts + its strategy-ordering leaves, so scan all of them.
const comboDispatchFiles = [
"open-sse/services/combo.ts",
"open-sse/services/combo/applyStrategyOrdering.ts",
"open-sse/services/combo/resolveAutoStrategy.ts",
];
const comboSource = comboDispatchFiles
.map((rel) => readFileSync(resolvePath(REPO_ROOT, rel), "utf8"))
.join("\n");
const handled = extractHandledStrategies(comboSource);
// Stale-enforcement (6A.3): IMPLICIT_DEFAULT_STRATEGIES is a suppression allowlist —
// each entry exists ONLY to suppress a `canonicalNotHandled` violation (a canonical
// strategy with no `strategy === "..."` dispatch reference). The live violations it
// suppresses are the canonical strategies NOT already in `handled` (computed with an
// EMPTY implicit-defaults map). An entry whose key IS already in `handled` suppresses
// nothing → it is stale and the gate must fail asking for its removal.
const liveImplicitNeeded = diffComboStrategies(canonical, handled, {}).canonicalNotHandled;
assertNoStale(
Object.keys(IMPLICIT_DEFAULT_STRATEGIES),
liveImplicitNeeded,
"known-symbols:combo"
);
const { canonicalNotHandled, handledNotCanonical } = diffComboStrategies(
canonical,
handled,
IMPLICIT_DEFAULT_STRATEGIES
);
if (canonicalNotHandled.length) {
failures.push(
`[combo] ${canonicalNotHandled.length} estratégia(s) canônica(s) sem branch de despacho em combo.ts:\n` +
canonicalNotHandled.map((s) => `${s}`).join("\n") +
`\n → fie no despacho (\`strategy === "${canonicalNotHandled[0]}"\`) ou documente em IMPLICIT_DEFAULT_STRATEGIES.`
);
}
if (handledNotCanonical.length) {
failures.push(
`[combo] ${handledNotCanonical.length} string(s) de estratégia tratada(s) no despacho mas ausente(s) de ROUTING_STRATEGY_VALUES (inventada/órfã):\n` +
handledNotCanonical.map((s) => `${s}`).join("\n") +
`\n → registre em src/shared/constants/routingStrategies.ts ou remova o branch morto.`
);
}
// ── (3) Translator pairs ──────────────────────────────────────────────────
await import("@omniroute/open-sse/translator/bootstrap.ts").then((m) =>
(m.bootstrapTranslatorRegistry as () => void)()
);
const formatsMod = await import("@omniroute/open-sse/translator/formats.ts");
const registryMod = await import("@omniroute/open-sse/translator/registry.ts");
const FORMATS = formatsMod.FORMATS as Record<string, string>;
const getRequestTranslator = registryMod.getRequestTranslator as (
from: string,
to: string
) => unknown;
const getResponseTranslator = registryMod.getResponseTranslator as (
from: string,
to: string
) => unknown;
const formatIds = Object.values(FORMATS);
const livePairs = new Set<string>();
for (const from of formatIds) {
for (const to of formatIds) {
if (from === to) continue;
if (getRequestTranslator(from, to) || getResponseTranslator(from, to)) {
livePairs.add(`${from}:${to}`);
}
}
}
const missingPairs = findMissingTranslatorPairs(KNOWN_TRANSLATOR_PAIRS, livePairs);
if (missingPairs.length) {
failures.push(
`[translator] ${missingPairs.length} par(es) from:to congelado(s) sumiram do registry vivo (regressão):\n` +
missingPairs.map((p) => `${p}`).join("\n") +
`\n → restaure o adapter em open-sse/translator/ ou, se a remoção foi intencional, atualize KNOWN_TRANSLATOR_PAIRS.`
);
}
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")}`
);
process.exit(1);
}
// assertNoStale (combo) seta process.exitCode=1 sem lançar — não imprima o OK
// enganoso; a mensagem de stale já foi logada no stderr pelo helper.
if (process.exitCode === 1) return;
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}; ` +
`${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)`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
main().catch((err) => {
console.error(
`[known-symbols] erro fatal: ${err instanceof Error ? err.message : String(err)}`
);
process.exit(1);
});
}
+255
View File
@@ -0,0 +1,255 @@
#!/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, "config/quality/.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
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
// scripts/check/check-lockfile.mjs
// Gate de política de lockfile (CLAUDE.md — extensão Hard Rule #1).
//
// Objetivo: detectar supply-chain poisoning no package-lock.json antes que código
// malicioso entre no repo. Verifica:
// --validate-https → toda URL "resolved" deve usar HTTPS (bloqueia http://)
// --validate-integrity → todo pacote deve ter hash de integridade sha512
// --allowed-hosts npm → apenas registry.npmjs.org é host permitido
//
// Complementa check-deps (Fase 2 / allowlist de nomes): aquele garante que só
// nomes aprovados entram; este garante que os pacotes instalados vieram do registry
// legítimo com integridade verificável.
//
// Referência: PLANO-QUALITY-GATES-FASE7.md, Task 7.7.
// Tool: lockfile-lint v5 (node_modules/.bin/lockfile-lint).
import { execFileSync } from "node:child_process";
import path from "node:path";
import fs from "node:fs";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
/**
* Returns the canonical lockfile-lint configuration used by this gate.
* Exporting this object makes the policy auditable and unit-testable without
* spawning a child process.
*
* @returns {{
* lockfilePath: string,
* type: string,
* validateHttps: boolean,
* validateIntegrity: boolean,
* allowedHosts: string[],
* }}
*/
export function getLockfileLintConfig() {
return {
lockfilePath: path.join(ROOT, "package-lock.json"),
type: "npm",
validateHttps: true,
validateIntegrity: true,
// Only the official npm registry is permitted.
// registry.npmjs.org resolves to the "npm" shorthand in lockfile-lint.
// If the project ever adopts a scoped/private registry, add its hostname here
// and document the justification.
allowedHosts: ["npm"],
};
}
/**
* Builds the argv array to pass to the lockfile-lint binary, derived from
* the config returned by getLockfileLintConfig().
*
* @param {ReturnType<typeof getLockfileLintConfig>} cfg
* @returns {string[]}
*/
export function buildLockfileLintArgs(cfg) {
const args = [
"--path", cfg.lockfilePath,
"--type", cfg.type,
];
if (cfg.validateHttps) args.push("--validate-https");
if (cfg.validateIntegrity) args.push("--validate-integrity");
if (cfg.allowedHosts.length) {
args.push("--allowed-hosts", ...cfg.allowedHosts);
}
return args;
}
function main() {
const cfg = getLockfileLintConfig();
if (!fs.existsSync(cfg.lockfilePath)) {
console.error(
`[check-lockfile] FAIL — lockfile not found: ${cfg.lockfilePath}\n` +
" → Run `npm install` to generate package-lock.json"
);
process.exit(1);
}
const bin = path.join(ROOT, "node_modules", ".bin", "lockfile-lint");
if (!fs.existsSync(bin)) {
console.error(
`[check-lockfile] FAIL — lockfile-lint binary not found at:\n ${bin}\n` +
" → Run `npm install` to install dev dependencies"
);
process.exit(1);
}
const args = buildLockfileLintArgs(cfg);
try {
const output = execFileSync(bin, args, { encoding: "utf8" });
// lockfile-lint outputs a green ✔ message on success
console.log("[check-lockfile] OK —", output.trim());
} catch (err) {
const stdout = err.stdout ?? "";
const stderr = err.stderr ?? "";
console.error("[check-lockfile] FAIL — lockfile-lint found policy violations:");
if (stdout) console.error(stdout);
if (stderr) console.error(stderr);
console.error(
"\n Possible causes:\n" +
" • A package was resolved from a non-HTTPS URL (http:// poisoning attempt)\n" +
" • A package is missing its integrity hash (tampered or legacy entry)\n" +
" • A package was resolved from a host other than registry.npmjs.org\n" +
" If a scoped/private registry is intentionally used, add its hostname\n" +
" to getLockfileLintConfig().allowedHosts in scripts/check/check-lockfile.mjs"
);
process.exit(1);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
// scripts/check/check-migration-numbering.mjs
// Gate de numeração de migrations: protege src/lib/db/migrations/ contra regressões
// de numeração. WHY: um incidente destrutivo aconteceu DUAS VEZES — um `git rm` de
// uma migration duplicada durante um merge apagou uma migration REAL da release
// (094/095 em #3365/#3371). Este gate cria uma ligação de CI entre o disco e as
// anomalias já reconhecidas em migrationRunner.ts, falhando em QUALQUER:
// - nome de arquivo sem prefixo numérico zero-padded (NNN_*.sql);
// - prefixo de versão DUPLICADO no disco (exceto duplicatas já reconhecidas);
// - NOVO gap inexplicado na sequência (gaps conhecidos 026/055 são congelados).
// 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");
// Convenção de nome: NNN_descricao.sql (prefixo numérico zero-padded de >= 3 dígitos).
// Mesmo regex usado pelo runner de produção (migrationRunner.ts ~linha 282).
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). 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" 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)
]);
// ---------------------------------------------------------------------------
// ALLOWLIST 2 — gaps de sequência CONHECIDOS.
// Fonte: auditoria do disco (src/lib/db/migrations/) — a sequência pula 026 e 055.
// Estes números nunca tiveram arquivo físico (slots legados que viraram outros
// números via RENAMED_MIGRATION_COMPATIBILITY em migrationRunner.ts). Congelados
// para que o gate bloqueie apenas NOVOS buracos inexplicados na sequência.
// ---------------------------------------------------------------------------
export const KNOWN_GAPS = new Set(["026", "055"]);
function pad3(n) {
return String(n).padStart(3, "0");
}
/**
* Função pura — detecta anomalias de numeração de migrations.
*
* @param {string[]} filenames nomes de arquivo (basename) em src/lib/db/migrations/
* @param {Set<string>} knownDuplicates versões com duplicata reconhecida (ex.: "041")
* @param {Set<string>} knownGaps gaps de sequência reconhecidos (ex.: "026")
* @returns {{ duplicates: Array<{version:string,names:string[]}>, gaps: string[], badNames: string[] }}
*/
export function findMigrationAnomalies(filenames, knownDuplicates, knownGaps) {
const dups = knownDuplicates || new Set();
const gapsAllow = knownGaps || new Set();
const badNames = [];
const byVersion = new Map();
for (const filename of filenames) {
if (!filename.endsWith(".sql")) continue;
const match = filename.match(MIGRATION_NAME_RE);
if (!match) {
badNames.push(filename);
continue;
}
const version = match[1];
if (!byVersion.has(version)) byVersion.set(version, []);
byVersion.get(version).push(filename);
}
// Duplicatas: dois arquivos físicos com o mesmo prefixo, exceto os reconhecidos.
const duplicates = [];
for (const [version, names] of byVersion.entries()) {
if (names.length <= 1) continue;
if (dups.has(version)) continue;
duplicates.push({ version, names: [...names].sort() });
}
duplicates.sort((a, b) => a.version.localeCompare(b.version));
// Gaps: buracos na sequência min..max que não estão na allowlist.
const versions = [...byVersion.keys()].map((v) => parseInt(v, 10)).sort((a, b) => a - b);
const gaps = [];
if (versions.length > 0) {
const min = versions[0];
const max = versions[versions.length - 1];
const present = new Set(versions);
for (let n = min + 1; n < max; n++) {
if (present.has(n)) continue;
const padded = pad3(n);
if (gapsAllow.has(padded)) continue;
gaps.push(padded);
}
}
return { duplicates, gaps, badNames };
}
function listMigrationFilenames() {
if (!fs.existsSync(MIGRATIONS_DIR)) return [];
return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql"));
}
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,
KNOWN_GAPS
);
const problems = [];
for (const b of badNames) {
problems.push(` ✗ nome inválido (esperado NNN_descricao.sql): ${b}`);
}
for (const d of duplicates) {
problems.push(` ✗ prefixo de versão duplicado ${d.version}: [${d.names.join(", ")}]`);
}
for (const g of gaps) {
problems.push(` ✗ gap inexplicado na sequência: faltando ${g}`);
}
if (problems.length > 0) {
console.error(
`[check-migration-numbering] ${problems.length} anomalia(s) de numeração:\n` +
problems.join("\n") +
`\n → renomeie o arquivo colidente, preencha o gap, ou — se for legítimo — ` +
`adicione o número às allowlists KNOWN_DUPLICATE_VERSIONS / KNOWN_GAPS com ` +
`justificativa rastreável a src/lib/db/migrationRunner.ts.`
);
process.exitCode = 1;
}
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();
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env node
// scripts/check/check-mutation-ratchet.mjs
// Catraca de mutationScore (Quality Gate v2 / Fase 9 T5 — Onda 2, Task 3).
//
// Mirrors check-bundle-size.mjs: ADVISORY by default (always exit 0), BLOCKING only
// with --ratchet — and even then exits 1 SE — E SOMENTE SE — um módulo medido REGREDIU
// vs o baseline (direction: UP, o score só pode subir). Skip gracioso (exit 0) quando
// não há mutation.json (ex.: o nightly não rodou) ou não há baseline para o módulo —
// falta de dados NUNCA bloqueia, só uma regressão medida bloqueia.
//
// Score por módulo = COVERED score = detected / (detected + survived), onde
// detected = Killed + Timeout. NoCoverage é EXCLUÍDO do denominador (é uma lacuna de
// cobertura, não um sinal de qualidade-de-teste) — mesmo denominador que a radiografia
// (scripts/quality/mutation-radiography.mjs).
//
// O nightly divide o `mutate` em batches paralelos (um reports/mutation/mutation.json
// por job). Este script roda DENTRO de cada job sobre o report daquele batch e compara
// só os módulos presentes nele. Aceita vários paths para uso local/agregado.
//
// Uso:
// node scripts/check/check-mutation-ratchet.mjs (advisory; report default)
// node scripts/check/check-mutation-ratchet.mjs reports/mutation/mutation.json
// node scripts/check/check-mutation-ratchet.mjs <a.json> <b.json> ... --ratchet
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = process.cwd();
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const DEFAULT_REPORT = path.join(ROOT, "reports/mutation/mutation.json");
const BASELINE_PREFIX = "mutationScore.";
const RATCHET = process.argv.includes("--ratchet");
// Anti-flake tolerance (percentage points). The tap-runner runs at concurrency 4 and a
// mutant whose tests sit near the `timeoutMS` boundary can flip Killed/Timeout/Survived
// between runs, jittering a module's covered score by a fraction of a point. Only a drop
// LARGER than this counts as a regression — same idea as the coverage ratchet's `eps`. It
// does NOT lower the baseline; it absorbs run-to-run noise so the blocking gate stays
// deterministic. Real test-quality regressions (the headers.ts/telemetry-scale drops that
// motivated the tap.testFiles coverage fix) are far larger than EPS and still fire.
export const MUTATION_RATCHET_EPS = 1.0;
const DETECTED = new Set(["Killed", "Timeout"]);
const SURVIVED = new Set(["Survived"]);
/**
* Avalia o score MEDIDO de um módulo contra o baseline. Direction: UP (o score só
* pode subir — menor = regressão), com tolerância anti-flake `eps`: só conta como
* regressão uma queda MAIOR que `eps` pontos.
* @param {number} current
* @param {number} baseline
* @param {number} [eps] tolerância anti-flake em pontos percentuais
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateMutationRatchet(current, baseline, eps = MUTATION_RATCHET_EPS) {
return {
regressed: current < baseline - eps,
improved: current > baseline,
};
}
/**
* Covered mutation score de um arquivo: detected/(detected+survived)*100.
* NoCoverage/Ignored/RuntimeError/CompileError ficam fora do denominador.
* @param {{mutants?: Array<{status: string}>}} fileData
* @returns {number|null} score em %, ou null se não houver mutantes cobertos
*/
export function mutationScoreForFile(fileData) {
let detected = 0;
let survived = 0;
for (const m of fileData?.mutants || []) {
if (DETECTED.has(m.status)) detected += 1;
else if (SURVIVED.has(m.status)) survived += 1;
}
const denom = detected + survived;
return denom === 0 ? null : (detected / denom) * 100;
}
/**
* Score por arquivo a partir de um ou mais reports (batches). Arquivos sem mutante
* coberto (score null) são omitidos.
*
* ⭐ Sibling batches que fatiam o MESMO arquivo (auth.ts split em a1:1-1109 + a2:1110-2218
* por mutation range; accountFallback em b1/b2) carregam fatias DISJUNTAS dos mutantes
* daquele arquivo. O score verdadeiro do arquivo precisa de TODAS as fatias juntas, então
* unimos `files[<arquivo>].mutants` entre os reports ANTES de pontuar — não sobrescrever
* (senão a última fatia venceria e reportaria só metade do arquivo).
* @param {object|object[]} reportOrReports parsed mutation.json (ou array)
* @returns {Record<string, number>}
*/
export function measureMutationScores(reportOrReports) {
const reports = Array.isArray(reportOrReports) ? reportOrReports : [reportOrReports];
const mutantsByFile = {};
for (const report of reports) {
for (const [file, data] of Object.entries(report?.files || {})) {
(mutantsByFile[file] ||= []).push(...(data?.mutants || []));
}
}
const out = {};
for (const [file, mutants] of Object.entries(mutantsByFile)) {
const score = mutationScoreForFile({ mutants });
if (score !== null) out[file] = score;
}
return out;
}
/**
* Lê metrics["mutationScore.<path>"].value do quality-baseline.json.
* Retorna {} se o arquivo ou as chaves estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
* @param {string} baselinePath
* @returns {Record<string, number>}
*/
export function readBaselineMutationScores(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return {};
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return {};
}
const metrics = baselineJson?.metrics || {};
const out = {};
for (const [key, val] of Object.entries(metrics)) {
if (key.startsWith(BASELINE_PREFIX) && val && typeof val.value === "number") {
out[key.slice(BASELINE_PREFIX.length)] = val.value;
}
}
return out;
}
function loadReport(p) {
return JSON.parse(fs.readFileSync(p, "utf8"));
}
function main(argv) {
const paths = argv.slice(2).filter((a) => !a.startsWith("--"));
const reportPaths = paths.length > 0 ? paths : [DEFAULT_REPORT];
const existing = reportPaths.filter((p) => fs.existsSync(p));
if (existing.length === 0) {
process.stdout.write("mutationScore=SKIP reason=no-report\n");
process.exit(0);
}
const measured = measureMutationScores(existing.map(loadReport));
const baseline = readBaselineMutationScores();
const regressions = [];
const modules = Object.keys(measured).sort();
for (const mod of modules) {
const current = measured[mod];
if (!(mod in baseline)) {
process.stdout.write(`mutationScore.${mod}=${current.toFixed(2)} (no baseline — advisory)\n`);
continue;
}
const { regressed } = evaluateMutationRatchet(current, baseline[mod]);
const tag = regressed ? "REGRESSED" : "ok";
process.stdout.write(
`mutationScore.${mod}=${current.toFixed(2)} baseline=${baseline[mod].toFixed(2)} ${tag}\n`
);
if (regressed) regressions.push({ mod, current, baseline: baseline[mod] });
}
if (regressions.length > 0 && RATCHET) {
process.stderr.write(
`\nMutation ratchet FAILED — ${regressions.length} module(s) dropped below baseline:\n`
);
for (const r of regressions) {
process.stderr.write(` ${r.mod}: ${r.current.toFixed(2)} < ${r.baseline.toFixed(2)}\n`);
}
process.exit(1);
}
process.exit(0);
}
if (
import.meta.url === `file://${process.argv[1]}` ||
process.argv[1] === fileURLToPath(import.meta.url)
) {
main(process.argv);
}
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// check-mutation-test-coverage — guards against tap.testFiles drift.
//
// WHY: Stryker (nightly-mutation) only runs the test files listed in
// stryker.conf.json `tap.testFiles` against each mutant. When a NEW unit test
// that covers a mutated module is added (or an existing one is split/renamed)
// but NOT added to tap.testFiles, that test's kills stop counting. The mutants
// it would kill then go COVERED-but-unkilled = SURVIVED on a cold run, so the
// module's COVERED mutation score collapses and the blocking mutationScore
// ratchet (nightly-mutation.yml) false-fails — but only on cold-cache nights,
// because the warm incremental run reuses the older (passing) verdicts. The
// pass/fail then tracks GitHub cache state, not code quality. Root cause:
// tap.testFiles is a hand-maintained list with no drift guard. This is it.
//
// INVARIANT: every UNIT test (tests/unit/**) that imports a mutated module
// (stryker.conf.json `mutate`) MUST be in `tap.testFiles`. Integration/e2e
// tests are intentionally excluded (the tap-runner runs node:test units only).
//
// MODE: advisory by default (prints drift, exit 0). `--strict` exits 1 on drift
// so CI can block. Skip-graceful (exit 0) if stryker.conf.json is absent.
//
// USAGE:
// node scripts/check/check-mutation-test-coverage.mjs # advisory
// node scripts/check/check-mutation-test-coverage.mjs --strict # blocking
import fs from "node:fs";
import { execFileSync } from "node:child_process";
/** 3-segment path suffix without extension (unique enough; matches relative + alias imports). */
export function moduleFragment(modulePath) {
return modulePath.replace(/\.ts$/, "").split("/").slice(-3).join("/");
}
/**
* True if `content` imports the module identified by `fragment`, in any form:
* static `... from "…fragment…"`, dynamic `import("…fragment…")` (incl. split
* across lines), or `require("…fragment…")`. The fragment must appear INSIDE the
* import string literal — a bare mention in a comment does not match.
*/
export function testImportsModule(content, fragment) {
const esc = fragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(
"(?:from\\s+|\\bimport\\s*\\(\\s*|\\brequire\\s*\\(\\s*)[\"'][^\"']*" + esc
);
return re.test(content);
}
/**
* @param {{mutate: string[], tapTestFiles: string[], unitTests: {path:string, content:string}[]}} input
* @returns {Record<string,string[]>} module -> covering unit tests NOT in tap.testFiles (drift)
*/
export function findCoverageDrift({ mutate, tapTestFiles, unitTests }) {
const tap = new Set(tapTestFiles);
const drift = {};
for (const mod of mutate) {
if (mod.startsWith("_") || !mod.endsWith(".ts")) continue; // skip comment/non-ts entries
const frag = moduleFragment(mod);
const missing = unitTests
.filter((t) => testImportsModule(t.content, frag) && !tap.has(t.path))
.map((t) => t.path)
.sort();
if (missing.length > 0) drift[mod] = missing;
}
return drift;
}
function listUnitTests() {
// Static argv — no shell, no interpolation.
const out = execFileSync("git", ["ls-files", "tests/unit"], { encoding: "utf8" });
return out
.split("\n")
.filter((f) => /\.test\.ts$/.test(f))
// Exclude tests/unit/build/: these test the build TOOLING (scripts/), not the
// mutated runtime modules. They legitimately embed module paths as fixture
// strings (e.g. this gate's own test), which would otherwise false-match.
.filter((f) => !f.startsWith("tests/unit/build/"))
.map((path) => ({ path, content: fs.readFileSync(path, "utf8") }));
}
function main() {
const STRICT = process.argv.includes("--strict");
let conf;
try {
conf = JSON.parse(fs.readFileSync("stryker.conf.json", "utf8"));
} catch {
console.warn("[mutation-test-coverage] stryker.conf.json not found — skipping (advisory).");
process.exit(0);
}
const mutate = (conf.mutate || []).filter((m) => typeof m === "string");
const tapTestFiles = conf.tap?.testFiles || [];
const unitTests = listUnitTests();
const drift = findCoverageDrift({ mutate, tapTestFiles, unitTests });
const modules = Object.keys(drift);
const total = modules.reduce((n, m) => n + drift[m].length, 0);
console.log("Mutation test-coverage gate — tap.testFiles drift detection");
console.log("===========================================================");
console.log(
`Scanned ${unitTests.length} unit test file(s) against ${mutate.filter((m) => m.endsWith(".ts")).length} mutated module(s).`
);
if (total === 0) {
console.log("✓ No drift — every covering unit test is listed in tap.testFiles.");
process.exit(0);
}
for (const m of modules) {
console.log(`\n ${m}`);
for (const t of drift[m]) console.log(` + ${t}`);
}
const msg = `${total} covering unit test(s) across ${modules.length} module(s) are missing from stryker.conf.json tap.testFiles.`;
if (STRICT) {
console.error(`\n${msg} Add them so their mutant kills count (--strict).`);
process.exit(1);
}
console.warn(`\n${msg} Re-run with --strict to fail. Add them to tap.testFiles.`);
process.exit(0);
}
if (import.meta.url === `file://${process.argv[1]}`) main();
+497
View File
@@ -0,0 +1,497 @@
#!/usr/bin/env node
// scripts/check/check-openapi-breaking.mjs
// Catraca de breaking-change na API pública (Fase 8 B.4 — backlog opcional).
//
// Diffa a spec do PR (docs/openapi.yaml na working tree = HEAD) contra
// a MESMA spec no branch base, via `oasdiff breaking`. Pega regressões de contrato:
// endpoint removido, parâmetro novo obrigatório, campo de resposta removido, enum
// estreitado, etc. — mudanças que quebram clientes existentes.
//
// Complementa os gates anti-alucinação existentes:
// • check-openapi-routes.mjs — toda `path` na spec resolve a uma rota real.
// • check-openapi-coverage.mjs — % de rotas reais documentadas (ratchet).
// Nenhum dos dois compara DUAS versões da spec; este sim.
//
// Saída (stdout, KEY=VALUE para o coletor de métricas collect-metrics.mjs):
// openapiBreaking=N — número de breaking changes
// openapiBreaking=SKIP reason=binary-absent — oasdiff não está no PATH
// openapiBreaking=SKIP reason=base-unresolved — a spec base não pôde ser lida
// (arquivo não existia no base, ou
// clone shallow sem o ref base)
//
// Por default é ADVISORY (sai 0 SEMPRE, mesmo com N>0). Passe --ratchet para
// tornar BLOQUEANTE: lê metrics.openapiBreaking.value de
// config/quality/quality-baseline.json, compara a contagem MEDIDA e SAI 1 SE — E
// SOMENTE SE — a medida for MAIOR que o baseline (regressão real, direction:down).
// Qualquer SKIP gracioso (oasdiff ausente do PATH, spec base não resolvível em
// clone shallow, JSON inválido) SAI 0 MESMO com --ratchet — uma falha de MEDIÇÃO
// nunca bloqueia, só uma regressão MEDIDA bloqueia (mesma trajetória de todo gate
// neste repo: report → ratchet → block).
//
// Base ref:
// • CI passa BASE_REF=${{ github.base_ref }} (ex.: "release/vX.Y.Z").
// • Local: default derivado da versão do package.json (releaseBranchForVersion),
// ex.: package 3.8.29 → "origin/release/v3.8.29" — nunca fica stale entre ciclos.
// A spec base é extraída com `git show <BASE_REF>:docs/openapi.yaml`.
//
// Uso:
// node scripts/check/check-openapi-breaking.mjs
// BASE_REF=origin/release/vX.Y.Z node scripts/check/check-openapi-breaking.mjs
// node scripts/check/check-openapi-breaking.mjs --json # imprime JSON bruto do oasdiff
// node scripts/check/check-openapi-breaking.mjs --quiet # suprime logs de diagnóstico
// node scripts/check/check-openapi-breaking.mjs --ratchet # falha (exit 1) numa regressão
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const RATCHET = process.argv.includes("--ratchet");
const SPEC_REL = "docs/openapi.yaml";
const SPEC_PATH = path.join(ROOT, "docs", "openapi.yaml");
/**
* Deriva o branch base de release a partir de uma versão semver
* (ex.: "3.8.29" → "origin/release/v3.8.29"). Mantém o default sincronizado com
* o ciclo de release SEM hard-code: o version-bump atualiza package.json a cada
* ciclo, então o default nunca fica stale (era "origin/release/v3.8.27" fixo).
* Ignora sufixos de prerelease/build (ex.: "3.8.29-dev.2" → v3.8.29).
*
* @param {string|null|undefined} version
* @returns {string|null} branch base (sem `origin/` ausente) ou null se não-semver
*/
export function releaseBranchForVersion(version) {
const m = String(version ?? "")
.trim()
.match(/^(\d+)\.(\d+)\.(\d+)/);
return m ? `origin/release/v${m[1]}.${m[2]}.${m[3]}` : null;
}
function readPackageVersion() {
try {
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
} catch {
return null;
}
}
// CI sempre passa BASE_REF=${{ github.base_ref }} e vence; este default só vale
// para runs locais. Derivado da versão para não re-driftar a cada release.
const DEFAULT_BASE_REF = releaseBranchForVersion(readPackageVersion()) || "origin/release/v3.8.29";
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Conta breaking changes no JSON emitido por `oasdiff breaking --format json`.
*
* O oasdiff emite um array de objetos (ou array vazio quando não há breaking
* change). Cada objeto tem a forma:
* [
* {
* id: string, // ex.: "api-path-removed-without-deprecation"
* text: string, // descrição legível
* level: number, // 3 = ERR, 2 = WARN, 1 = INFO
* operation: string, // ex.: "GET"
* path: string, // ex.: "/api/bar"
* section: string,
* source?: string,
* baseSource?: { file, line, column },
* fingerprint: string
* },
* ...
* ]
*
* @param {Array|null} oasdiffJson - Array de breaking changes do oasdiff (ou null).
* @returns {{ count: number, byId: Record<string, number>, byPath: Record<string, number>, items: Array }}
*/
export function parseOasdiffBreaking(oasdiffJson) {
// null, undefined ou array vazio = nenhum breaking change.
if (
oasdiffJson === null ||
oasdiffJson === undefined ||
(Array.isArray(oasdiffJson) && oasdiffJson.length === 0)
) {
return { count: 0, byId: {}, byPath: {}, items: [] };
}
// Defensivo: qualquer coisa que não seja array é tratada como "sem dados".
if (!Array.isArray(oasdiffJson)) {
return { count: 0, byId: {}, byPath: {}, items: [] };
}
let count = 0;
const byId = {};
const byPath = {};
const items = [];
for (const change of oasdiffJson) {
if (!change || typeof change !== "object") continue;
count++;
items.push(change);
const id = change.id ?? change.ID ?? "unknown";
byId[id] = (byId[id] ?? 0) + 1;
const p = change.path ?? change.Path ?? "unknown";
byPath[p] = (byPath[p] ?? 0) + 1;
}
return { count, byId, byPath, items };
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia a contagem MEDIDA de breaking changes contra o baseline.
* Direction: down (a contagem só pode CAIR — mais breaking changes = regressão).
*
* Uma medição ausente (current null/undefined) OU um baseline ausente
* (baseline null/undefined) → { regressed:false, skipped:true }: sem uma das
* duas pontas não há ratchet possível, então o caller trata como SKIP gracioso
* (exit 0 mesmo com --ratchet). Uma falha de MEDIÇÃO nunca bloqueia.
*
* @param {object} args
* @param {number|null} args.current - Breaking changes medidos agora (null = sem medição).
* @param {number|null} args.baseline - Contagem congelada em quality-baseline.json (null = sem baseline).
* @returns {{ regressed: boolean, skipped: boolean }}
*/
export function evaluateOpenapiRatchet({ current, baseline }) {
if (current === null || current === undefined || baseline === null || baseline === undefined) {
return { regressed: false, skipped: true };
}
return { regressed: current > baseline, skipped: false };
}
/**
* Lê metrics.openapiBreaking.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes/inválidos (sem
* baseline não há ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineOpenapiValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.openapiBreaking;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
/**
* Detecta se o binário `oasdiff` 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 findOasdiff() {
try {
const result = spawnSync("which", ["oasdiff"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim();
}
} catch {
// which não disponível
}
// Fallback: tentar executar diretamente para distinguir ENOENT de "existe".
try {
const result = spawnSync("oasdiff", ["--version"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.error?.code === "ENOENT") return null;
if (result.status !== null) return "oasdiff"; // encontrado no PATH
} catch {
// noop
}
return null;
}
// ---------------------------------------------------------------------------
// Base spec resolution
// ---------------------------------------------------------------------------
/**
* Extrai a spec base via `git show <BASE_REF>:<SPEC_REL>` para um arquivo temp.
* Retorna o caminho do temp (chamador é responsável por limpar), ou null se a
* spec não pôde ser resolvida (ref ausente em clone shallow, ou arquivo não
* existia naquele ref). NUNCA lança — falha → null → SKIP gracioso.
*
* @param {string} baseRef
* @returns {string|null} caminho do arquivo temp com a spec base, ou null.
*/
export function resolveBaseSpec(baseRef) {
let stdout;
try {
stdout = execFileSync("git", ["show", `${baseRef}:${SPEC_REL}`], {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 30_000,
stdio: ["ignore", "pipe", "ignore"], // descarta stderr ruidoso do git
});
} catch {
// ref desconhecido (shallow clone), arquivo inexistente no base, etc.
return null;
}
if (!stdout || !stdout.trim()) return null;
const tmpFile = path.join(
os.tmpdir(),
`oasdiff-base-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.yaml`
);
try {
fs.writeFileSync(tmpFile, stdout, "utf8");
} catch {
return null;
}
return tmpFile;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const baseRef = (process.env.BASE_REF || "").trim() || DEFAULT_BASE_REF;
// 1) HEAD spec precisa existir (working tree).
if (!fs.existsSync(SPEC_PATH)) {
console.log("openapiBreaking=SKIP reason=head-spec-absent");
if (!QUIET) {
process.stderr.write(`[openapi-breaking] SKIP — spec não encontrada: ${SPEC_PATH}\n`);
}
process.exitCode = 0;
return;
}
// 2) Binário oasdiff precisa estar no PATH.
const oasdiffBin = findOasdiff();
if (!oasdiffBin) {
console.log("openapiBreaking=SKIP reason=binary-absent");
if (!QUIET) {
process.stderr.write(
"[openapi-breaking] SKIP — oasdiff não encontrado no PATH.\n" +
"[openapi-breaking] Instale via: https://github.com/oasdiff/oasdiff\n" +
"[openapi-breaking] ADVISORY — este gate sai 0 (promove a bloqueante depois).\n"
);
}
process.exitCode = 0;
return;
}
// 3) Resolver a spec base (git show → temp). SKIP se não der.
const baseTmp = resolveBaseSpec(baseRef);
if (!baseTmp) {
console.log(`openapiBreaking=SKIP reason=base-unresolved ref=${baseRef}`);
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] SKIP — não consegui ler ${SPEC_REL} em '${baseRef}'.\n` +
"[openapi-breaking] Causas: clone shallow sem o ref base, arquivo novo (não existia no base),\n" +
"[openapi-breaking] ou ref inválido. Em CI use fetch-depth: 0 ou git fetch do base ref.\n"
);
}
process.exitCode = 0;
return;
}
try {
// 4) Rodar `oasdiff breaking --format json <baseTmp> <headSpec>`.
// oasdiff sai 0 por padrão mesmo com breaking changes (só com --fail-on
// é que sai 1). Capturamos stdout independentemente do exit code.
const args = ["breaking", "--format", "json", baseTmp, SPEC_PATH];
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] Rodando: oasdiff breaking --format json <base:${baseRef}> ${SPEC_REL} ...\n`
);
}
let stdout = "";
try {
stdout = execFileSync(oasdiffBin, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 90_000,
});
} catch (err) {
// oasdiff PODE sair !=0 (ex.: com --fail-on em versões futuras, ou erro real).
// Capturamos stdout de qualquer jeito: se ele tem JSON parseável, é o resultado.
stdout = err.stdout ? String(err.stdout) : "";
const stderr = err.stderr ? String(err.stderr) : "";
if (!stdout.trim()) {
// Sem stdout = erro real do oasdiff (spec inválida, etc.). Advisory → SKIP.
console.log("openapiBreaking=SKIP reason=oasdiff-error");
if (!QUIET) {
process.stderr.write(`[openapi-breaking] SKIP — oasdiff falhou: ${err.message}\n`);
if (stderr) {
process.stderr.write(`[openapi-breaking] stderr: ${stderr.slice(0, 500)}\n`);
}
}
process.exitCode = 0;
return;
}
}
const trimmed = stdout.trim();
let parsed = [];
if (trimmed && trimmed !== "null") {
try {
parsed = JSON.parse(trimmed);
} catch (parseErr) {
// JSON inesperado — advisory, não derruba o build.
console.log("openapiBreaking=SKIP reason=parse-error");
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] SKIP — JSON do oasdiff não parseável: ${parseErr.message}\n` +
`[openapi-breaking] stdout (primeiros 500): ${trimmed.slice(0, 500)}\n`
);
}
process.exitCode = 0;
return;
}
}
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(parsed, null, 2) + "\n");
return;
}
const { count, byId, byPath, items } = parseOasdiffBreaking(parsed);
// Emitir KEY=VALUE para o coletor de métricas.
console.log(`openapiBreaking=${count}`);
if (!QUIET) {
if (count > 0) {
const topIds = Object.entries(byId)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([id, n]) => `${id}(${n})`)
.join(", ");
process.stderr.write(
`[openapi-breaking] ⚠️ ${count} breaking change(s) vs '${baseRef}' (top: ${topIds})\n`
);
for (const it of items.slice(0, 20)) {
const op = it.operation ?? it.Operation ?? "?";
const p = it.path ?? it.Path ?? "?";
const txt = it.text ?? it.Text ?? it.id ?? "";
process.stderr.write(`[openapi-breaking] ✗ ${op} ${p}${txt}\n`);
}
if (items.length > 20) {
process.stderr.write(`[openapi-breaking] … +${items.length - 20} more\n`);
}
// Pista de mitigação: por path.
const topPaths = Object.entries(byPath)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([p, n]) => `${p}(${n})`)
.join(", ");
process.stderr.write(`[openapi-breaking] affected paths: ${topPaths}\n`);
if (RATCHET) {
process.stderr.write(
"[openapi-breaking] Se a quebra é intencional (major bump), documente no PR e\n" +
"[openapi-breaking] re-baseline metrics.openapiBreaking em config/quality/quality-baseline.json\n" +
"[openapi-breaking] com justificativa + issue de tracking; senão, ajuste a spec.\n"
);
} else {
process.stderr.write(
"[openapi-breaking] ADVISORY — passe --ratchet para BLOQUEAR uma regressão. Se a quebra é\n" +
"[openapi-breaking] intencional (major bump), documente no PR; senão, ajuste a spec.\n"
);
}
} else {
process.stderr.write(
`[openapi-breaking] OK — nenhuma breaking change na spec vs '${baseRef}'.\n`
);
}
}
// Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet).
applyRatchet(count);
} finally {
// Limpa o arquivo temp da spec base.
try {
fs.unlinkSync(baseTmp);
} catch {
// best-effort
}
}
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet: exit 1 numa regressão real
* (medida > baseline). Baseline ausente → SKIP gracioso (exit 0).
*
* @param {number} count - Contagem MEDIDA de breaking changes (medição bem-sucedida).
*/
function applyRatchet(count) {
if (!RATCHET) {
process.exitCode = 0;
return;
}
const baselineValue = readBaselineOpenapiValue(BASELINE_PATH);
const { regressed, skipped } = evaluateOpenapiRatchet({
current: count,
baseline: baselineValue,
});
if (skipped) {
if (!QUIET) {
process.stderr.write(
"[openapi-breaking] baseline ausente (metrics.openapiBreaking) — SKIP gracioso, sai 0.\n"
);
}
process.exitCode = 0;
return;
}
if (regressed) {
process.stderr.write(
`[openapi-breaking] REGRESSÃO — ${count} breaking change(s) > baseline ${baselineValue}\n` +
" → Ajuste a spec para não quebrar clientes existentes. Se a quebra é intencional\n" +
" (major bump), re-baseline metrics.openapiBreaking em\n" +
" config/quality/quality-baseline.json com justificativa + issue de tracking.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] OK — sem regressão (${count} breaking change(s), baseline ${baselineValue}).\n`
);
}
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Validates that openapi.yaml documents ≥ 99% of implemented routes.
* Routes marked x-internal: true in openapi.yaml count as "covered" because
* they are acknowledged as existing — just not part of the public API surface.
*
* Fails if coverage < 99%.
*/
import fs from "node:fs";
import path from "node:path";
import * as yaml from "js-yaml";
const ROOT = process.cwd();
const API_ROOT = path.join(ROOT, "src", "app", "api");
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
// Floor recorded on 2026-05-26 for release/v3.8.4: 137/365 routes documented.
// The original ≥99% target tracks the OpenAPI audit follow-up (#2701);
// until the backlog (services, free-proxies, relay-tokens, key-groups,
// middleware/hooks, etc.) is documented, the gate enforces "no regressions"
// instead of the absolute target. Raise this back to 99 once the backlog clears.
const THRESHOLD = 36;
function collectRoutePaths(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const paths = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
paths.push(...collectRoutePaths(fullPath));
continue;
}
if (entry.isFile() && entry.name === "route.ts") {
const apiPath = path
.dirname(fullPath)
.replace(API_ROOT, "")
.replace(/\[([^\]]+)\]/g, "{$1}");
paths.push(`/api${apiPath}`);
}
}
return paths;
}
function normalizePath(p) {
return p.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}").replace(/\[([^\]]+)\]/g, "{$1}");
}
if (!fs.existsSync(API_ROOT)) {
console.error(`[openapi-coverage] FAIL — API root not found: ${API_ROOT}`);
process.exit(1);
}
if (!fs.existsSync(OPENAPI_PATH)) {
console.error(`[openapi-coverage] FAIL — openapi.yaml not found: ${OPENAPI_PATH}`);
process.exit(1);
}
const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath).sort();
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const documentedPaths = new Set(Object.keys(raw.paths || {}));
let covered = 0;
const missing = [];
for (const p of implementedPaths) {
if (documentedPaths.has(p)) {
covered++;
} else {
missing.push(p);
}
}
const total = implementedPaths.length;
const coverage = (covered / total) * 100;
if (coverage >= THRESHOLD) {
console.log(
`[openapi-coverage] PASS — ${coverage.toFixed(1)}% (${covered}/${total} routes documented)`
);
process.exit(0);
} else {
console.error(`[openapi-coverage] FAIL — coverage ${coverage.toFixed(1)}% < ${THRESHOLD}%`);
console.error(`Missing routes (${missing.length}):`);
missing.forEach((p) => console.error(` - ${p}`));
process.exit(1);
}
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
// scripts/check/check-openapi-routes.mjs
// Gate anti-alucinação (docs): toda `path` documentada em docs/openapi.yaml
// 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 * as 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", "openapi.yaml");
// Entradas da spec sem rota real, congeladas para triagem (catraca: bloqueia NOVAS).
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.
]);
/** Normaliza qualquer {param} para {} para casar independente do nome do parâmetro. */
export function normalizeParams(p) {
return p.replace(/\{[^}]+\}/g, "{}");
}
/** Paths da spec que não casam com nenhuma rota implementada (param-insensitive). */
export function findSpecPathsWithoutRoute(specPaths, implPaths) {
const impl = new Set(implPaths.map(normalizeParams));
return specPaths.filter((p) => !impl.has(normalizeParams(p)));
}
function collectRoutePaths(dir) {
const paths = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
paths.push(...collectRoutePaths(full));
} else if (entry.isFile() && entry.name === "route.ts") {
const apiPath = path
.dirname(full)
.replace(API_ROOT, "")
.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}")
.replace(/\[([^\]]+)\]/g, "{$1}");
paths.push(`/api${apiPath}`);
}
}
return paths;
}
function main() {
if (!fs.existsSync(OPENAPI_PATH)) {
console.error(`[openapi-routes] FAIL — openapi.yaml não encontrado: ${OPENAPI_PATH}`);
process.exit(1);
}
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const specPaths = Object.keys(raw.paths || {}).filter((p) => p.startsWith("/api"));
const implPaths = collectRoutePaths(API_ROOT);
// 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.exitCode = 1;
}
if (!process.exitCode) {
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();
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* Cross-references openapi.yaml x-loopback-only / x-always-protected annotations
* against the compile-time constants in src/server/authz/routeGuard.ts.
*
* Fails if any YAML annotation disagrees with the routeGuard.ts constants.
*/
import fs from "node:fs";
import path from "node:path";
import * as yaml from "js-yaml";
const ROOT = process.cwd();
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts");
function parseStringArray(match) {
if (!match) return [];
// Strip line comments before splitting — array entries in routeGuard.ts often
// carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens.
return match[1]
.replace(/\/\/[^\n]*/g, "")
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
}
const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8");
const LOCAL_ONLY_PREFIXES = parseStringArray(
guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s)
);
const ALWAYS_PROTECTED_PATHS = parseStringArray(
guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s)
);
if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) {
console.error("[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants");
process.exit(1);
}
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const paths = raw.paths || {};
const errors = [];
for (const [pathStr, methods] of Object.entries(paths)) {
if (!methods || typeof methods !== "object") continue;
for (const [method, spec] of Object.entries(methods)) {
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
if (spec["x-loopback-only"] === true) {
const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => {
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return pathStr === norm || pathStr.startsWith(norm + "/");
});
if (!matchesPrefix) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` +
`LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]`
);
}
}
if (spec["x-always-protected"] === true) {
const matchesPath = ALWAYS_PROTECTED_PATHS.some(
(p) => pathStr === p || pathStr.startsWith(`${p}/`)
);
if (!matchesPath) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT in ` +
`ALWAYS_PROTECTED_API_PATHS [${ALWAYS_PROTECTED_PATHS.join(", ")}]`
);
}
}
}
}
// Reverse pass: every YAML path that falls under a LOCAL_ONLY prefix should
// carry `x-loopback-only: true` on every method, otherwise external API
// consumers have no signal that the route is loopback-restricted. Closes the
// "new spawn-capable route added without annotation" regression class.
//
// Currently reported as warnings (non-fatal) because the v3.8.4 release ships
// with a known annotation gap on /api/services/* and /api/cli-tools/runtime/*
// that will be patched in a follow-up doc-only PR. Promote to errors once the
// backlog is cleared.
const reverseWarnings = [];
for (const [pathStr, methods] of Object.entries(paths)) {
if (!methods || typeof methods !== "object") continue;
const fallsUnderLocalOnly = LOCAL_ONLY_PREFIXES.some((prefix) => {
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return pathStr === norm || pathStr.startsWith(norm + "/");
});
if (!fallsUnderLocalOnly) continue;
for (const [method, spec] of Object.entries(methods)) {
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
if (spec["x-loopback-only"] !== true) {
reverseWarnings.push(
`${method.toUpperCase()} ${pathStr}: falls under LOCAL_ONLY_API_PREFIXES ` +
`but is missing x-loopback-only: true annotation`
);
}
}
}
if (reverseWarnings.length > 0) {
console.warn(
`[openapi-security-tiers] WARN — ${reverseWarnings.length} LOCAL_ONLY paths missing x-loopback-only annotation (non-fatal, follow-up doc PR):`
);
reverseWarnings.forEach((w) => console.warn(` - ${w}`));
}
if (errors.length === 0) {
console.log("[openapi-security-tiers] PASS — all security tier annotations match routeGuard.ts");
process.exit(0);
} else {
console.error(`[openapi-security-tiers] FAIL — ${errors.length} annotation mismatches:`);
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}
+258
View File
@@ -0,0 +1,258 @@
#!/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,
"",
"> ️ Editing the PR body to add the evidence does NOT re-run this gate — `ci.yml` " +
"does not listen to the `edited` event. Add the `## Evidence` block, then **push a " +
"commit** (or re-run this job) to re-validate. For releases, put the Evidence block in " +
"the body BEFORE the first push (see the generate-release skill, Phase 0)."
);
}
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);
}
}
+136
View File
@@ -0,0 +1,136 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
const SOURCE_ROOTS = ["src/", "open-sse/", "electron/", "bin/"];
const TEST_PATTERNS = [/^tests\//, /(?:^|\/)__tests__\//, /\.(?:test|spec)\.[cm]?[jt]sx?$/];
// Test files for specific source types (e.g., Python validation scripts for i18n)
const TEST_FILE_PATTERNS = {
"src/i18n/messages/": [
/\/scripts\/validate_translation\.py$/,
/\/scripts\/check_translations\.py$/,
],
};
// Exclude directories that don't require tests (i18n has Python validation, docs, config)
const EXCLUDED_PATTERNS = [
/\/i18n\/messages\//, // i18n files have their own Python test scripts
/\.md$/, // Documentation
/\.yaml$/, // Config files
/\.yml$/, // Config files
/(?:^|\/)package(?:-lock)?\.json$/, // Dependency manifests/lockfiles (e.g. Dependabot bumps) — not testable code
];
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 runGit(args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
function isSourceFile(filePath) {
// Exclude patterns that don't require tests
if (EXCLUDED_PATTERNS.some((pattern) => pattern.test(filePath))) {
return false;
}
return SOURCE_ROOTS.some((root) => filePath.startsWith(root));
}
function isTestFile(filePath) {
// Check standard test patterns
if (TEST_PATTERNS.some((pattern) => pattern.test(filePath))) {
return true;
}
// Check custom test file patterns for specific directories
for (const [dir, patterns] of Object.entries(TEST_FILE_PATTERNS)) {
if (filePath.startsWith(dir) && patterns.some((pattern) => pattern.test(filePath))) {
return true;
}
}
return false;
}
function buildReport(lines) {
return `${lines.join("\n")}\n`;
}
const summaryFile = getArg("--summary-file", "");
const baseRef = process.env.GITHUB_BASE_REF;
if (!baseRef) {
const report = buildReport([
"## PR Test Policy",
"",
"Skipped: not running in a pull request context.",
]);
if (summaryFile) {
mkdirSync(path.dirname(summaryFile), { recursive: true });
writeFileSync(summaryFile, report);
}
process.stdout.write(report);
process.exit(0);
}
const baseTarget = process.env.GITHUB_BASE_SHA || `origin/${baseRef}`;
const changedFiles = runGit(["diff", "--name-only", "--diff-filter=ACMR", `${baseTarget}...HEAD`])
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const changedSourceFiles = changedFiles.filter(isSourceFile);
const changedTestFiles = changedFiles.filter(isTestFile);
const hasRequiredTests = changedSourceFiles.length === 0 || changedTestFiles.length > 0;
const reportLines = [
"## PR Test Policy",
"",
`Base ref: \`${baseRef}\``,
`Changed production files: ${changedSourceFiles.length}`,
`Changed automated test files: ${changedTestFiles.length}`,
"",
];
if (changedSourceFiles.length > 0) {
reportLines.push("### Production files in scope", "");
for (const filePath of changedSourceFiles.slice(0, 20)) {
reportLines.push(`- \`${filePath}\``);
}
reportLines.push("");
}
if (changedTestFiles.length > 0) {
reportLines.push("### Tests in this PR", "");
for (const filePath of changedTestFiles.slice(0, 20)) {
reportLines.push(`- \`${filePath}\``);
}
reportLines.push("");
}
if (hasRequiredTests) {
reportLines.push("Result: PASS");
} else {
reportLines.push(
"Result: FAIL",
"",
"This PR changes production code under `src/`, `open-sse/`, `electron/`, or `bin/` but does not add or update automated tests."
);
}
const report = buildReport(reportLines);
if (summaryFile) {
mkdirSync(path.dirname(summaryFile), { recursive: true });
writeFileSync(summaryFile, report);
}
process.stdout.write(report);
if (!hasRequiredTests) {
process.exit(1);
}
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
import { readFile, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
const providerDir = join(repoRoot, "public", "providers");
const MAX_RASTER_BYTES = 128 * 1024;
const MAX_RASTER_DIMENSION = 256;
const RASTER_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
function extensionOf(fileName) {
const dot = fileName.lastIndexOf(".");
return dot >= 0 ? fileName.slice(dot).toLowerCase() : "";
}
function readPngDimensions(buffer) {
if (buffer.length < 24 || buffer.toString("ascii", 1, 4) !== "PNG") return null;
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
function readJpegDimensions(buffer) {
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) return null;
const marker = buffer[offset + 1];
const length = buffer.readUInt16BE(offset + 2);
if (marker >= 0xc0 && marker <= 0xc3) {
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
}
offset += 2 + length;
}
return null;
}
async function readDimensions(filePath, extension) {
const buffer = await readFile(filePath);
if (buffer.length >= 4 && buffer.toString("ascii", 1, 4) === "PNG") {
return readPngDimensions(buffer);
}
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8) {
return readJpegDimensions(buffer);
}
return null;
}
const failures = [];
const files = await readdir(providerDir);
for (const fileName of files) {
const extension = extensionOf(fileName);
if (!RASTER_EXTENSIONS.has(extension)) continue;
const filePath = join(providerDir, fileName);
const info = await stat(filePath);
const dimensions = await readDimensions(filePath, extension);
if (!dimensions) {
if (info.size > 4 * 1024) {
failures.push(`${fileName}: could not read image dimensions`);
}
continue;
}
const width = dimensions.width || 0;
const height = dimensions.height || 0;
if (info.size > MAX_RASTER_BYTES) {
failures.push(
`${fileName}: ${(info.size / 1024).toFixed(1)} KiB exceeds ${MAX_RASTER_BYTES / 1024} KiB`
);
}
if (width > MAX_RASTER_DIMENSION || height > MAX_RASTER_DIMENSION) {
failures.push(
`${fileName}: ${width}x${height} exceeds ${MAX_RASTER_DIMENSION}px max dimension`
);
}
}
if (failures.length > 0) {
console.error("Provider asset budget failed:");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("Provider asset budget passed.");
@@ -0,0 +1,52 @@
#!/usr/bin/env node
// scripts/check/check-provider-consistency.ts
// Gate anti-alucinação nº1: toda entrada em REGISTRY (open-sse/config/providerRegistry.ts)
// deve corresponder a um provider canônico em src/shared/constants/providers.ts.
// 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.
export const KNOWN_REGISTRY_ONLY: Record<string, string> = {};
/** Ids do REGISTRY que não são providers canônicos e não estão na allowlist. */
export function findOrphanRegistryIds(
registryIds: string[],
isKnownProvider: (id: string) => boolean,
allowlist: Record<string, string>
): string[] {
return registryIds.filter((id) => !isKnownProvider(id) && !(id in allowlist));
}
function main(): void {
const canonical = new Set(Object.keys(AI_PROVIDERS));
const isKnown = (id: string) => canonical.has(id) || Boolean(getProviderById(id));
// 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.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)`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
// scripts/check/check-public-creds.mjs
// Gate de segurança — CLAUDE.md Hard Rule #11.
//
// Credenciais públicas de upstream (OAuth client_id/client_secret de CLIs públicas
// + Firebase web keys) DEVEM ser embutidas via `resolvePublicCred()` /
// `resolvePublicCredMulti()` (de open-sse/utils/publicCreds.ts), NUNCA como string
// literal no código. Ver docs/security/PUBLIC_CREDS.md.
//
// Literais embutidos (a) disparam scanners de secret/CodeQL a cada release, gerando
// ruído, e (b) acoplam o valor ao texto-fonte em vez de ao decodificador central —
// se o upstream rotacionar o client_id público, há N cópias para atualizar e o
// override por `process.env` deixa de ser a única fonte de verdade.
//
// Este gate varre os arquivos que carregam configuração de credencial e bloqueia
// QUALQUER atribuição NOVA de uma chave de credencial a uma string literal não-vazia.
// Os literais pré-existentes (auditados abaixo) ficam congelados em
// KNOWN_LITERAL_CREDS para a catraca sair 0 hoje e bloquear regressões.
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();
// 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)
// - clientId / clientSecret: forma dos *_CONFIG em oauth.ts
// - apiKey / apiKeyDefault: chaves de API embutidas (mesmo princípio)
const CRED_KEY_RE =
/(?:^|[\s{,([])(clientIdDefault|clientSecretDefault|clientId|clientSecret|apiKeyDefault|apiKey)\s*:/;
// Chaves de ambiente (clientIdEnv, clientSecretEnv, …) terminam em "Env" e carregam
// o NOME da variável de ambiente, não a credencial — nunca devem ser flagadas.
const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/;
// Literais pré-existentes auditados (DISCOVERY 2026-06-09). Cada um é uma credencial
// pública de upstream embutida ANTES deste gate existir. Ficam congelados aqui para
// a catraca sair 0 agora e bloquear QUALQUER literal NOVO. CADA UM é dívida de
// segurança Rule #11 a ser migrada para resolvePublicCred() — NÃO adicione novos
// sem justificativa; esse é o ponto do gate.
//
// A allowlist casa por VALOR do literal (o mesmo client_id público aparece nos dois
// arquivos, então congelar por valor cobre ambas as cópias). Para congelar um valor
// só num arquivo:linha específico, use a chave "arquivo:linha:valor".
//
// 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.
//
// 6A.8: Expanded scope to open-sse/** + src/lib/oauth/**. Newly discovered FPs:
//
// open-sse/services/usage/minimax.ts L213: `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.
// The MiniMax family was extracted from services/usage.ts into services/usage/minimax.ts
// (god-file decomposition), so the FP moved with the getMiniMaxUsage signature.
export const KNOWN_LITERAL_CREDS = new Set([
"open-sse/services/usage/minimax.ts:213:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature)
"open-sse/services/usage/minimax.ts:213:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature)
]);
/**
* Encontra atribuições de uma chave de credencial a uma string literal não-vazia.
*
* Pura: recebe o texto-fonte e a allowlist, devolve a lista de violações. Não toca
* em I/O. Cada violação é "L<linha>: <key> = \"<literal>\"".
*
* Regras de detecção (linha a linha):
* 1. A linha precisa atribuir uma das CRED_KEY (clientIdDefault, clientId, …)
* e não ser uma chave *Env (que carrega só o nome da env-var).
* 2. Se o RHS chama resolvePublicCred()/resolvePublicCredMulti(), está CORRETO
* (o literal ali é a CHAVE do default embutido, não a credencial) → ignora.
* 3. Caso contrário, qualquer string literal NÃO-VAZIA no RHS é uma violação
* — cobre tanto `key: "literal"` quanto `key: process.env.X || "literal"`.
* 4. Literais vazios ("" / '') são fallback legítimo de process.env → ignorados.
* 5. Literais presentes na allowlist (por valor OU por chave "arquivo:linha:valor")
* ficam congelados → ignorados.
*
* @param {string} source conteúdo do arquivo
* @param {Set<string>} allowlist valores de literal (ou chaves arquivo:linha:valor) congelados
* @param {string} [relFile] caminho relativo do arquivo (para chaves arquivo:linha:valor)
* @returns {string[]} violações legíveis
*/
export function findLiteralCreds(source, allowlist, relFile = "") {
const violations = [];
const lines = String(source).split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const keyMatch = CRED_KEY_RE.exec(line);
if (!keyMatch) continue;
if (ENV_KEY_RE.test(line)) continue;
const key = keyMatch[1];
// RHS = tudo após o primeiro ":" da chave de credencial.
const colonIdx = line.indexOf(":", keyMatch.index);
const rhs = colonIdx >= 0 ? line.slice(colonIdx + 1) : line;
// Forma correta: embutido via decodificador central. Não inspeciona literais.
if (/resolvePublicCred(?:Multi)?\s*\(/.test(rhs)) continue;
// Extrai todo literal de string do RHS (aspas simples, duplas ou crase).
const litRe = /(["'`])((?:\\.|(?!\1).)*)\1/g;
let lit;
while ((lit = litRe.exec(rhs))) {
const value = lit[2];
if (!value) continue; // "" / '' — fallback de env, legítimo
const lineNo = i + 1;
const fileLineKey = relFile ? `${relFile}:${lineNo}:${value}` : "";
if (allowlist.has(value)) continue;
if (fileLineKey && allowlist.has(fileLineKey)) continue;
violations.push(`L${lineNo}: ${key} = ${JSON.stringify(value)}`);
}
}
return violations;
}
function main() {
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
}
}
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}`);
}
}
if (allMisses.length) {
console.error(
`[check-public-creds] ${allMisses.length} credencial(is) pública(s) como string literal ` +
`(viola CLAUDE.md Hard Rule #11):\n` +
allMisses.map((m) => " ✗ " + m).join("\n") +
`\n → embuta via resolvePublicCred()/resolvePublicCredMulti() ` +
`(open-sse/utils/publicCreds.ts). Ver docs/security/PUBLIC_CREDS.md.\n` +
` → se for um literal pré-existente já auditado, congele em KNOWN_LITERAL_CREDS ` +
`com justificativa (e abra tracking de migração).`
);
process.exit(1);
}
if (process.exitCode === 1) return; // stale entries already logged
console.log(
`[check-public-creds] OK (${scannedFiles.length} arquivo(s) em ${SCAN_ROOTS.length} raiz(es), ` +
`${KNOWN_LITERAL_CREDS.size} literal(is) congelado(s))`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
@@ -0,0 +1,261 @@
#!/usr/bin/env node
// scripts/check/check-route-guard-membership.ts
// Quality gate: route-guard membership (CLAUDE.md Hard Rules #15 + #17).
//
// WHY: routes that spawn child processes (`npm install`, `node`, MITM/Playwright,
// worker_threads) MUST be classified loopback-only by `isLocalOnlyPath()` in
// src/server/authz/routeGuard.ts. Loopback enforcement runs unconditionally
// BEFORE any auth check — so a leaked JWT over a tunnel cannot reach a spawn.
// A single spawn-capable `route.ts` that `isLocalOnlyPath()` does NOT match is an
// RCE-via-tunnel hole (the GHSA-fhh6-4qxv-rpqj surface the LOCAL_ONLY tier closes).
//
// This gate enumerates every `route.ts` under the spawn-capable prefixes and
// asserts each resolved URL path is classified local-only by the REAL predicate.
//
// Ratchet: any pre-existing unclassified route is frozen in KNOWN_UNCLASSIFIED
// 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 { 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/.
export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray<string> = [
"src/app/api/services",
"src/app/api/mcp",
"src/app/api/cli-tools/runtime",
"src/app/api/local", // T-12: 1-click local service launchers (Redis today) — every child here spawns podman/docker (Hard Rules #15 + #17)
];
// Frozen pre-existing exceptions: spawn-capable routes NOT yet classified
// local-only. Each entry is a documented security debt — the route is reachable
// past the loopback gate. Empty today (every spawn-capable route is classified).
// Adding an entry here REQUIRES a justification + a follow-up to classify it in
// LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS (src/server/authz/routeGuard.ts).
export const KNOWN_UNCLASSIFIED: Record<string, string> = {};
/**
* Map a Next.js App Router `route.ts` file path to the URL path the route
* serves, in the exact shape `isLocalOnlyPath()` expects (a plain `/api/...`
* path). Dynamic `[param]` segments become a concrete `_param_` placeholder —
* `isLocalOnlyPath` matches prefixes via `startsWith`, so any non-empty segment
* satisfies the classification (e.g. `/api/services/_name_/logs` still starts
* with `/api/services/`).
*/
export function routeFileToApiPath(routeFile: string): string {
return routeFile
.replace(/\\/g, "/")
.replace(/^src\/app/, "")
.replace(/\/route\.ts$/, "")
.replace(/\[([^\]]+)\]/g, "_$1_");
}
/**
* Pure matching core: given resolved URL paths, a classifier predicate, and an
* allowlist, return the paths that are NEITHER classified local-only NOR
* allowlisted (input order preserved). These are the RCE-via-tunnel holes.
*/
export function findUnclassifiedSpawnRoutes(
apiPaths: string[],
isLocalOnly: (path: string) => boolean,
allowlist: Record<string, string>
): string[] {
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[];
try {
entries = readdirSync(dir);
} catch {
return []; // dir does not exist — nothing to enumerate
}
const out: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...collectRouteFiles(full));
} else if (entry === "route.ts") {
out.push(full);
}
}
return out;
}
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) 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, or freeze in KNOWN_UNCLASSIFIED with justification.`
);
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} 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
// keep the process alive. The gate's work is done — exit cleanly.
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const API_ROOT = path.join(ROOT, "src", "app", "api");
const FILE_NAME = "route.ts";
const REQUEST_JSON_REGEX = /request\.json\s*\(/;
const VALIDATE_BODY_REGEX = /\bvalidateBody\s*\(/;
const SAFE_PARSE_REGEX = /\.safeParse\s*\(/;
/**
* Walk directory recursively and collect route files.
* @param {string} dir
* @returns {string[]}
*/
function collectRouteFiles(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectRouteFiles(fullPath));
continue;
}
if (entry.isFile() && entry.name === FILE_NAME) {
files.push(fullPath);
}
}
return files;
}
if (!fs.existsSync(API_ROOT)) {
console.error(`[t06:route-validation] FAIL - API root not found: ${API_ROOT}`);
process.exit(1);
}
const routeFiles = collectRouteFiles(API_ROOT).sort();
const missingValidation = [];
for (const fullPath of routeFiles) {
const source = fs.readFileSync(fullPath, "utf8");
if (!REQUEST_JSON_REGEX.test(source)) continue;
// Accept either validateBody() or .safeParse() as validation
if (!VALIDATE_BODY_REGEX.test(source) && !SAFE_PARSE_REGEX.test(source)) {
missingValidation.push(path.relative(ROOT, fullPath));
}
}
if (missingValidation.length > 0) {
console.error(
"[t06:route-validation] FAIL - routes with request.json() without validateBody() or .safeParse():"
);
for (const file of missingValidation) {
console.error(` - ${file}`);
}
process.exit(1);
}
console.log(
`[t06:route-validation] PASS - ${routeFiles.length} route files scanned, all request.json() usages are validated.`
);
+375
View File
@@ -0,0 +1,375 @@
#!/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
//
// Por default é ADVISORY (sai 0 sempre). Passe --ratchet para tornar BLOQUEANTE:
// lê metrics.secretFindings.value de config/quality/quality-baseline.json, compara
// a contagem MEDIDA e SAI 1 SE — E SOMENTE SE — a medida for MAIOR que o baseline
// (regressão real, direction:down). Qualquer SKIP gracioso (binário ausente, nenhum
// dir de fonte) SAI 0 mesmo com --ratchet — falta de infraestrutura nunca bloqueia,
// só uma regressão medida bloqueia.
//
// 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
// node scripts/check/check-secrets.mjs --ratchet # falha (exit 1) numa regressão
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 RATCHET = process.argv.includes("--ratchet");
const GITLEAKS_CONFIG = path.join(ROOT, ".gitleaks.toml");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
// Source directories to scan for secrets. We deliberately scope to the
// production/source trees instead of scanning the whole working dir:
// • `gitleaks dir .` (and `detect --no-git --source .`) WALKS the entire tree
// and READS every file — including a real `node_modules/` (90k+ files) when
// present (CI runs `npm ci`). gitleaks has no traversal-exclude flag: the
// `.gitleaks.toml [allowlist].paths` list filters FINDINGS *after* each file
// is read, so it does NOT speed up the walk. The full walk blows past the
// timeout in CI (confirmed: ETIMEDOUT) → the gate silently never produces a
// value. Scoping the scan to the source dirs keeps it fast (~6s) while still
// covering every place an embedded secret would actually be a risk (the same
// dirs Hard Rule #8 governs: src/open-sse/electron/bin, plus scripts/).
// • We also drop git-history mode (scanning 4500+ commits is slow and grows
// unbounded); the current working tree is what ships.
const SECRET_SCAN_DIRS = ["src", "open-sse", "bin", "electron", "scripts"];
// ---------------------------------------------------------------------------
// 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 };
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia a contagem MEDIDA de secrets contra o baseline.
* Direction: down (a contagem só pode CAIR — mais secrets = regressão).
*
* @param {number} current - Contagem de findings medida agora.
* @param {number} baseline - Contagem congelada em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateSecretsRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Lê metrics.secretFindings.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineSecretsValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.secretFindings;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// 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] SKIP gracioso — sai 0 mesmo com --ratchet (binário ausente nunca bloqueia).\n"
);
}
process.exitCode = 0;
return;
}
// Resolver os diretórios de fonte que realmente existem (robusto se um sumir).
const scanDirs = SECRET_SCAN_DIRS.filter((d) => fs.existsSync(path.join(ROOT, d)));
if (scanDirs.length === 0) {
// Nenhum dir de fonte encontrado — nada a escanear (advisory, sai 0).
console.log("secretFindings=0");
if (!QUIET) {
process.stderr.write("[check-secrets] Nenhum diretório de fonte encontrado para escanear.\n");
}
process.exitCode = 0;
return;
}
if (!QUIET) {
process.stderr.write(
`[check-secrets] Rodando gitleaks dir <dir> --report-format json para: ${scanDirs.join(", ")} ...\n`
);
}
// `gitleaks dir` aceita UM ÚNICO path posicional (uso: `gitleaks dir [flags]
// [path]`). Passar múltiplos paths faz o gitleaks ignorar os extras e cair para
// escanear o CWD inteiro (`.`) — o que re-traz node_modules/docs/tests e o
// timeout original. Por isso escaneamos CADA diretório de fonte em uma invocação
// separada e concatenamos os findings.
const gitleaksJson = [];
for (const dir of scanDirs) {
const args = [
"dir",
dir,
"--report-format",
"json",
"--report-path",
"-", // output para stdout
"--no-banner",
];
if (fs.existsSync(GITLEAKS_CONFIG)) {
args.push("--config", GITLEAKS_CONFIG);
}
let stdout = "";
try {
stdout = execFileSync(gitleaksBin, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 90_000, // 90s por dir — o scan escopado completa em ~10s; folga ampla
});
} 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 neste dir e saiu com exit 1
} else if (!stdout.trim()) {
process.stderr.write(
`[check-secrets] ERRO ao executar gitleaks em '${dir}': ${err.message}\n`
);
if (stderr) process.stderr.write(`[check-secrets] stderr: ${stderr.slice(0, 500)}\n`);
process.exit(2);
}
}
if (!stdout.trim() || stdout.trim() === "null") {
continue; // sem findings neste dir
}
let parsed;
try {
parsed = JSON.parse(stdout.trim());
} catch (parseErr) {
process.stderr.write(
`[check-secrets] ERRO ao parsear JSON do gitleaks em '${dir}': ${parseErr.message}\n`
);
process.stderr.write(
`[check-secrets] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`
);
process.exit(2);
}
if (Array.isArray(parsed)) {
gitleaksJson.push(...parsed);
}
}
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");
}
}
// Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet).
applyRatchet(findingCount);
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet: exit 1 numa regressão real
* (medida > baseline). Baseline ausente → SKIP gracioso (exit 0).
*
* @param {number} findingCount - Contagem MEDIDA (medição bem-sucedida).
*/
function applyRatchet(findingCount) {
if (!RATCHET) {
if (!QUIET) {
process.stderr.write(
"[check-secrets] ADVISORY — não falha pela contagem (passe --ratchet para bloquear regressão).\n"
);
}
process.exitCode = 0;
return;
}
const baselineValue = readBaselineSecretsValue(BASELINE_PATH);
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[check-secrets] baseline ausente (metrics.secretFindings) — SKIP gracioso, sai 0.\n"
);
}
process.exitCode = 0;
return;
}
const { regressed } = evaluateSecretsRatchet(findingCount, baselineValue);
if (regressed) {
process.stderr.write(
`[check-secrets] REGRESSÃO — ${findingCount} secret findings > baseline ${baselineValue}\n` +
" → Remova o novo secret (ou allowliste em .gitleaks.toml se for falso-positivo legítimo),\n" +
" depois re-baseline metrics.secretFindings em config/quality/quality-baseline.json.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
process.stderr.write(
`[check-secrets] OK — sem regressão (${findingCount} findings, baseline ${baselineValue}).\n`
);
}
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import {
getNodeRuntimeSupport,
getNodeRuntimeWarning,
} from "../../src/shared/utils/nodeRuntimeSupport.ts";
const support = getNodeRuntimeSupport();
if (!support.nodeCompatible) {
console.error(`Unsupported or insecure Node.js runtime detected: ${support.nodeVersion}`);
console.error(getNodeRuntimeWarning() || "Unsupported Node.js runtime.");
console.error(`Supported secure runtimes: ${support.supportedDisplay}`);
console.error(`Recommended version: ${support.recommendedVersion}`);
process.exit(1);
}
console.log(
`Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
);
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const cwd = process.cwd();
/**
* T11 Phase-A budget:
* keep explicit `any` at zero in files already hardened.
*/
const budget = [
{ file: "src/app/api/settings/proxy/route.ts", maxAny: 0 },
{ file: "src/app/api/settings/proxy/test/route.ts", maxAny: 0 },
{ file: "src/shared/components/OAuthModal.tsx", maxAny: 0 },
{ file: "open-sse/translator/index.ts", maxAny: 0 },
{ file: "open-sse/translator/registry.ts", maxAny: 0 },
// Freeze legacy hot spots to avoid any-regression while strict migration continues.
{ file: "src/lib/db/apiKeys.ts", maxAny: 0 },
{ file: "src/lib/db/cliToolState.ts", maxAny: 0 },
{ file: "src/lib/db/encryption.ts", maxAny: 0 },
{ file: "src/lib/db/prompts.ts", maxAny: 0 },
{ file: "src/lib/db/providers.ts", maxAny: 0 },
{ file: "src/lib/db/settings.ts", maxAny: 0 },
{ file: "open-sse/config/providerRegistry.ts", maxAny: 0 },
{ file: "open-sse/config/providerModels.ts", maxAny: 0 },
{ file: "open-sse/mcp-server/audit.ts", maxAny: 0 },
// 3 `(toolDef: any)` in the dynamic memory/skill/compression tool-registration
// loops (#3077) — heterogeneous tool defs accessed via existing `@ts-ignore`
// dynamic-zod paths; pragmatic dynamic dispatch, not a type-safety regression.
{ file: "open-sse/mcp-server/server.ts", maxAny: 3 },
{ file: "open-sse/mcp-server/tools/advancedTools.ts", maxAny: 0 },
{ file: "open-sse/services/signatureCache.ts", maxAny: 0 },
{ file: "open-sse/services/comboMetrics.ts", maxAny: 0 },
{ file: "open-sse/services/sessionManager.ts", maxAny: 0 },
{ file: "open-sse/services/provider.ts", maxAny: 0 },
{ file: "open-sse/services/contextManager.ts", maxAny: 0 },
{ file: "open-sse/services/comboConfig.ts", maxAny: 0 },
{ file: "open-sse/services/accountSelector.ts", maxAny: 0 },
{ file: "open-sse/services/wildcardRouter.ts", maxAny: 0 },
{ file: "open-sse/services/rateLimitSemaphore.ts", maxAny: 0 },
{ file: "open-sse/services/roleNormalizer.ts", maxAny: 0 },
{ file: "open-sse/services/usage.ts", maxAny: 0 },
{ file: "open-sse/services/rateLimitManager.ts", maxAny: 0 },
{ file: "open-sse/services/tokenRefresh.ts", maxAny: 0 },
{ file: "open-sse/services/backgroundTaskDetector.ts", maxAny: 0 },
{ file: "open-sse/services/accountFallback.ts", maxAny: 0 },
{ file: "open-sse/handlers/responseSanitizer.ts", maxAny: 0 },
{ file: "open-sse/handlers/responseTranslator.ts", maxAny: 0 },
{ file: "open-sse/utils/stream.ts", maxAny: 0 },
{ file: "open-sse/translator/request/openai-responses.ts", maxAny: 0 },
// 2 FALSE POSITIVES: #4389 compares the Anthropic `tool_choice` value against the
// STRING literal "any" (`tb.tool_choice === "any"` and `.type === "any"`) to detect
// forced tool use. The checker strips comments but not strings, and there are zero
// actual TypeScript `any` types in this file. Budget set to the matched count.
{ file: "open-sse/executors/base.ts", maxAny: 2 },
{ file: "open-sse/executors/kiro.ts", maxAny: 0 },
// 3 FALSE POSITIVES: the word "any" appears in #3104's tool-commit / output-
// constraint prompt STRINGS ("not any other tool", "any text", "any of these
// sequences"). The checker strips comments but not strings, and there are zero
// actual TypeScript `any` types in this file. Budget set to the matched count.
{ file: "open-sse/executors/cursor.ts", maxAny: 3 },
{ file: "open-sse/executors/qoder.ts", maxAny: 0 },
{ file: "open-sse/utils/comfyuiClient.ts", maxAny: 0 },
{ file: "open-sse/utils/tlsClient.ts", maxAny: 0 },
{ file: "open-sse/utils/proxyFetch.ts", maxAny: 0 },
{ file: "open-sse/utils/error.ts", maxAny: 0 },
{ file: "open-sse/translator/request/openai-to-gemini.ts", maxAny: 0 },
{ file: "open-sse/translator/request/antigravity-to-openai.ts", maxAny: 0 },
{ file: "open-sse/translator/request/claude-to-openai.ts", maxAny: 0 },
{ file: "open-sse/handlers/audioTranscription.ts", maxAny: 0 },
{ file: "open-sse/handlers/sseParser.ts", maxAny: 0 },
{ file: "open-sse/handlers/chatCore.ts", maxAny: 0 },
{ file: "open-sse/config/codexInstructions.ts", maxAny: 0 },
{ file: "open-sse/config/imageRegistry.ts", maxAny: 0 },
{ file: "open-sse/config/registryUtils.ts", maxAny: 0 },
{ file: "open-sse/executors/antigravity.ts", maxAny: 0 },
{ file: "open-sse/executors/default.ts", maxAny: 0 },
{ file: "open-sse/handlers/audioSpeech.ts", maxAny: 0 },
{ file: "open-sse/handlers/embeddings.ts", maxAny: 0 },
{ file: "open-sse/handlers/imageGeneration.ts", maxAny: 3 },
{ file: "open-sse/handlers/moderations.ts", maxAny: 0 },
{ file: "open-sse/handlers/rerank.ts", maxAny: 0 },
{ file: "open-sse/handlers/responsesHandler.ts", maxAny: 0 },
{ file: "open-sse/mcp-server/__tests__/advancedTools.test.ts", maxAny: 0 },
{ file: "open-sse/mcp-server/__tests__/essentialTools.test.ts", maxAny: 0 },
{ file: "open-sse/services/combo.ts", maxAny: 0 },
{ file: "open-sse/services/thinkingBudget.ts", maxAny: 0 },
{ file: "open-sse/translator/helpers/geminiHelper.ts", maxAny: 0 },
{ file: "open-sse/translator/helpers/openaiHelper.ts", maxAny: 0 },
{ file: "open-sse/translator/helpers/responsesApiHelper.ts", maxAny: 0 },
{ file: "open-sse/translator/request/claude-to-gemini.ts", maxAny: 0 },
{ file: "open-sse/translator/request/gemini-to-openai.ts", maxAny: 0 },
{ file: "open-sse/translator/request/openai-to-claude.ts", maxAny: 1 }, // 1 = string literal "any" (Claude tool_choice value, not a TS type) — #1072
{ file: "open-sse/translator/request/openai-to-cursor.ts", maxAny: 0 },
{ file: "open-sse/translator/request/openai-to-kiro.ts", maxAny: 0 },
{ file: "open-sse/translator/response/claude-to-openai.ts", maxAny: 0 },
{ file: "open-sse/translator/response/gemini-to-openai.ts", maxAny: 0 },
{ file: "open-sse/translator/response/kiro-to-openai.ts", maxAny: 0 },
{ file: "open-sse/translator/response/openai-responses.ts", maxAny: 0 },
{ file: "open-sse/translator/response/openai-to-antigravity.ts", maxAny: 0 },
{ file: "open-sse/utils/bypassHandler.ts", maxAny: 0 },
{ file: "open-sse/utils/logger.ts", maxAny: 0 },
{ file: "open-sse/utils/networkProxy.ts", maxAny: 0 },
{ file: "open-sse/utils/ollamaTransform.ts", maxAny: 0 },
{ file: "open-sse/utils/proxyDispatcher.ts", maxAny: 0 },
{ file: "open-sse/utils/requestLogger.ts", maxAny: 0 },
{ file: "open-sse/utils/streamHandler.ts", maxAny: 0 },
{ file: "open-sse/utils/usageTracking.ts", maxAny: 0 },
];
const anyRegex = /\bany\b/g;
let hasFailure = false;
for (const item of budget) {
const absolutePath = path.resolve(cwd, item.file);
if (!fs.existsSync(absolutePath)) {
console.error(`[t11:any-budget] FAIL - file not found: ${item.file}`);
hasFailure = true;
continue;
}
const content = fs.readFileSync(absolutePath, "utf8");
// Remove block and line comments to avoid false positives with the word "any" in comments
let cleanContent = content.replace(/\/\*[\s\S]*?\*\//g, "");
cleanContent = cleanContent.replace(/\/\/.*$/gm, "");
const matches = cleanContent.match(anyRegex);
const count = matches ? matches.length : 0;
const status = count <= item.maxAny ? "OK" : "FAIL";
if (status === "FAIL") {
hasFailure = true;
}
console.log(
`[t11:any-budget] ${status} - ${item.file} (explicit any: ${count}, budget: ${item.maxAny})`
);
}
if (hasFailure) {
process.exit(1);
}
console.log("[t11:any-budget] PASS - explicit any budget respected.");
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env node
// scripts/check/check-test-discovery.mjs
// Gate 6A.1 — test discovery: todo arquivo *.test.ts|tsx / *.spec.ts|tsx do repo deve
// ser COLETADO por pelo menos um runner que efetivamente RODA via npm script ou CI.
//
// WHY: a auditoria 2026-06-09 encontrou ≈135 testes em subdiretórios de tests/unit/
// que nenhum runner coleta (o glob `tests/unit/*.test.ts` é top-level-only), incluindo
// tests/unit/authz/routeGuard.test.ts (Hard Rules #15/#17) — cujos asserts JÁ FALHAM,
// apodrecidos sem ninguém ver. Teste que não roda é o falso verde definitivo: todo o
// investimento anti test-masking protege asserts que nem executam.
//
// Modelo: COLLECTORS declara explicitamente o glob de cada runner REAL + as fontes
// (package.json / ci.yml / vitest configs) onde o padrão deve aparecer textualmente
// (drift-check: mudou o glob na fonte sem atualizar aqui → o gate falha pedindo sync).
// "Coletado" = casado pelo glob de um runner executado por script npm ou job de CI.
// Includes de config que NENHUM script executa (ex.: vitest.config.ts sem filtro) NÃO
// contam — config morta não roda teste.
//
// Catraca: órfãos pré-existentes ficam congelados em test-discovery-baseline.json
// (dívida visível, decrescente). Órfão NOVO → fail. Entrada do baseline que deixou de
// ser órfã (religada/deletada) → fail pedindo remoção (stale-allowlist enforcement).
// --update regrava o baseline com o estado atual (use só para REMOVER religados;
// adições novas devem ser corrigidas, não congeladas — esse é o ponto do gate).
//
// Limitações documentadas (v1):
// - `exclude` de arquivo individual em vitest configs não é modelado (1 caso hoje:
// providerDiversity.test.ts — coletado pelo include, deliberadamente excluído).
// - @omniroute/* ficam fora do walk (têm CI próprio: opencode-*-ci.yml).
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/test-discovery-baseline.json")
);
const UPDATE = process.argv.includes("--update");
// Raízes varridas em busca de arquivos de teste.
const WALK_ROOTS = ["tests", "src", "open-sse", "electron", "bin"];
const WALK_EXCLUDE = new Set(["node_modules", ".next", "dist", "coverage", ".git"]);
const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|mjs)$/;
// Runners REAIS e seus globs. `sources`: arquivos onde `anchor` (default: o próprio
// glob) deve aparecer textualmente — se o runner mudar, este gate exige o sync.
export const COLLECTORS = [
// Node native runner — test:unit / test:unit:fast / shards / test:coverage. O CI
// (test-unit ×8 + quality.yml fast-unit) agora chama o npm script test:unit:ci:shard
// (fonte única, plano mestre testes+CI QW-d) — o wiring é ancorado pelo NOME do script
// nos workflows (entrada dedicada abaixo), e os globs vivem SÓ no package.json.
{ glob: "tests/unit/*.test.ts", sources: ["package.json"] },
// Node native runner — subdiretórios religados pela 6A.1c (2026-06-09). Braces
// explícitos para NÃO incluir tests/unit/autoCombo/** (testes vitest — importam
// "vitest" e explodem no node runner) NEM tests/unit/dashboard/** (invocação própria
// abaixo). Subdir novo: adicione aqui E nos scripts (o drift-check + o gate de
// órfãos forçam a manutenção em sincronia).
{
glob: "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts",
sources: ["package.json"],
},
// Node native runner — tests/unit/dashboard/** roda numa invocação separada com o hook
// COMPLETO do tsx (--import tsx): o grafo dos componentes de dashboard puxa
// @lobehub/icons, cujo build es/ faz require() interno de arquivos com sintaxe ESM —
// sem o patch CJS do tsx isso estoura "Unexpected token 'export'" (visto no Node
// 24.18 do CI; no 24.16 local vira um crawl de ~60s/arquivo). O resto da suíte roda
// sob tsx/esm (~-50% de bootstrap por processo). Plano mestre testes+CI, QW-b.
{ glob: "tests/unit/dashboard/**/*.test.ts", sources: ["package.json"] },
// Quarentena de flakes de concorrência (plano melhorias v3.8.46, P0.3): arquivos
// sensíveis a contenção de CPU/timing (classe glm-3580 / quota-division /
// provider-health-autopilot) rodam num passo dedicado --test-concurrency=1 ao FIM
// de cada runner. Fora dos globs paralelos acima por diretório próprio.
{ glob: "tests/unit/serial/**/*.test.ts", sources: ["package.json"] },
// Órfãos religados (plano mestre QW-c): arquivos .test.mjs (top-level + db/ + feature-triage/) — fora do glob
// *.test.ts histórico, nunca rodava em job nenhum (53 casos recuperados).
{ glob: "tests/unit/**/*.test.mjs", sources: ["package.json"] },
// Wiring CI→npm script (fonte única): os jobs de unit do ci.yml e o fast-unit do
// quality.yml DEVEM invocar o script canônico — se renomearem/inlinarem, este gate
// exige o sync (substitui as âncoras textuais de glob que existiam nos workflows).
{
glob: "tests/unit/*.test.ts",
sources: ["package.json", ".github/workflows/ci.yml", ".github/workflows/quality.yml"],
anchors: {
".github/workflows/ci.yml": "test:unit:ci:shard",
".github/workflows/quality.yml": "test:unit:ci:shard",
},
},
// Node native runner — test:integration (top-level only; tests/integration/services/ NÃO roda)
{ glob: "tests/integration/*.test.ts", sources: ["package.json"] },
// Node native runner — test:combo:matrix / test:integration (combo strategy decision matrix, 17 strategies)
{ glob: "tests/integration/combo-matrix/*.test.ts", sources: ["package.json"] },
// Node native runner — test:combo:live (gated real-upstream smoke; RUN_COMBO_LIVE=1 + VPS creds)
{ glob: "tests/integration/combo-live/*.live.test.ts", sources: ["package.json"] },
// Node native runner — test:system
{ glob: "tests/e2e/system-failover.test.ts", sources: ["package.json"] },
// vitest.mcp.config.ts — test:vitest
{ glob: "open-sse/mcp-server/__tests__/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "open-sse/services/autoCombo/__tests__/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "open-sse/services/combo/__tests__/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
// Single-file include: the rest of open-sse/services/__tests__/ are frozen orphans
// (empty/dormant stubs); only this one is wired to run under test:vitest.
{
glob: "open-sse/services/__tests__/antigravity-quota-family.test.ts",
sources: ["vitest.mcp.config.ts"],
},
{ glob: "tests/unit/autoCombo/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "tests/unit/encryption.spec.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/shared/components/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/shared/hooks/__tests__/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/app/(dashboard)/**/__tests__/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
// vitest.config.ts via test:vitest:ui (roda com path-filter `tests/unit/ui`, então o
// conjunto EFETIVO é a interseção do include `tests/unit/**/*.test.tsx` com o filtro)
{
glob: "tests/unit/ui/**/*.test.tsx",
sources: ["package.json", "vitest.config.ts"],
anchors: { "package.json": "tests/unit/ui", "vitest.config.ts": "tests/unit/**/*.test.tsx" },
},
// Playwright — test:e2e (o script passa tests/e2e/*.spec.ts; testMatch **/*.spec.ts)
{ glob: "tests/e2e/*.spec.ts", sources: ["package.json"] },
// Runners custom — test:ecosystem / test:protocols:e2e (spawnam vitest com o arquivo)
{ glob: "tests/e2e/ecosystem.test.ts", sources: ["scripts/dev/run-ecosystem-tests.mjs"] },
{
glob: "tests/e2e/protocol-clients.test.ts",
sources: ["scripts/dev/run-protocol-clients-tests.mjs"],
},
];
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/** Converte um glob em RegExp ancorada. Suporta `*`, `**` (com ou sem barra) e `{a,b}`. */
export function globToRegExp(glob) {
let re = "";
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === "*") {
if (glob[i + 1] === "*") {
if (glob[i + 2] === "/") {
re += "(?:.*/)?"; // "**/" — zero ou mais diretórios
i += 2;
} else {
re += ".*"; // "**" solto
i += 1;
}
} else {
re += "[^/]*"; // "*" não atravessa "/"
}
} else if (c === "{") {
const end = glob.indexOf("}", i);
const alts = glob
.slice(i + 1, end)
.split(",")
.map(escapeRe);
re += "(?:" + alts.join("|") + ")";
i = end;
} else {
re += escapeRe(c);
}
}
return new RegExp("^" + re + "$");
}
/** Arquivos de teste não casados por NENHUM glob de collector (ordem preservada). */
export function findOrphans(files, globs) {
const regexes = globs.map(globToRegExp);
return files.filter((f) => !regexes.some((re) => re.test(f)));
}
/**
* Compara os órfãos atuais com o baseline congelado.
* - newOrphans: órfão atual fora do baseline → teste novo que NÃO RODA (fail).
* - stale: entrada do baseline que não é mais órfã (religada/deletada) → remova (fail).
*/
export function evaluateAgainstBaseline(orphans, baselineList) {
const baseSet = new Set(baselineList);
const orphanSet = new Set(orphans);
return {
newOrphans: orphans.filter((o) => !baseSet.has(o)),
stale: baselineList.filter((b) => !orphanSet.has(b)),
};
}
/**
* Drift-check: cada glob declarado (ou seu anchor por fonte) deve aparecer textualmente
* em TODAS as suas fontes. Retorna mensagens de drift.
*/
export function findCollectorDrift(collectors, contents) {
const drift = [];
for (const c of collectors) {
for (const source of c.sources) {
const anchor = c.anchors?.[source] ?? c.glob;
const body = contents[source];
if (body === undefined || !body.includes(anchor)) {
drift.push(
`glob "${c.glob}" (anchor "${anchor}") não encontrado em ${source} — o runner mudou? Sincronize COLLECTORS em check-test-discovery.mjs`
);
}
}
}
return drift;
}
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (WALK_EXCLUDE.has(e.name)) continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, acc);
else if (TEST_FILE_RE.test(e.name)) acc.push(p);
}
return acc;
}
function collectTestFiles() {
const out = [];
for (const root of WALK_ROOTS) {
for (const f of walk(path.join(ROOT, root))) {
out.push(path.relative(ROOT, f).replace(/\\/g, "/"));
}
}
return out.sort();
}
function main() {
// 1) drift dos collectors vs fontes reais
const contents = {};
for (const c of COLLECTORS) {
for (const s of c.sources) {
if (contents[s] === undefined) {
const p = path.join(ROOT, s);
contents[s] = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : undefined;
}
}
}
const drift = findCollectorDrift(COLLECTORS, contents);
// 2) órfãos vs baseline
const files = collectTestFiles();
const orphans = findOrphans(
files,
COLLECTORS.map((c) => c.glob)
);
if (!fs.existsSync(BASELINE_PATH) && !UPDATE) {
console.error(
`[test-discovery] FAIL — ${path.basename(BASELINE_PATH)} ausente. Bootstrap:\n` +
` node scripts/check/check-test-discovery.mjs --update (gera o baseline com os órfãos atuais)`
);
process.exit(2);
}
const baseline = fs.existsSync(BASELINE_PATH)
? JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"))
: {
_comment:
"Catraca de test-discovery (check-test-discovery.mjs). Cada entrada e um arquivo de teste que NENHUM runner coleta (ele nunca roda) — divida congelada na auditoria 6A.1 (2026-06-09). So pode DIMINUIR: religue o teste (ajustando o glob do runner ou movendo o arquivo) e remova a entrada via --update. NAO adicione novos orfaos — corrija o runner.",
orphans: [],
};
const { newOrphans, stale } = evaluateAgainstBaseline(orphans, baseline.orphans || []);
if (UPDATE && drift.length === 0) {
baseline.orphans = orphans;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
console.log(
`[test-discovery] baseline regravado: ${orphans.length} órfão(s) (${stale.length} removido(s), ${newOrphans.length} adicionado(s) — adições devem ser corrigidas, não congeladas)`
);
return;
}
const problems = [];
for (const d of drift) problems.push(` ✗ [drift] ${d}`);
for (const o of newOrphans) {
problems.push(
` ✗ [órfão NOVO] ${o} — nenhum runner coleta este arquivo (ele NUNCA roda). Mova-o para um path coletado ou ajuste o runner.`
);
}
for (const s of stale) {
problems.push(
` ✗ [stale] ${s} — não é mais órfão (religado/removido). Remova do baseline: node scripts/check/check-test-discovery.mjs --update`
);
}
if (problems.length) {
console.error(`[test-discovery] ${problems.length} problema(s):\n` + problems.join("\n"));
process.exit(1);
}
console.log(
`[test-discovery] OK — ${files.length} arquivos de teste, ${COLLECTORS.length} collectors, ${(baseline.orphans || []).length} órfão(s) congelado(s) (dívida rastreada, só decresce)`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+483
View File
@@ -0,0 +1,483 @@
#!/usr/bin/env node
// scripts/check/check-test-masking.mjs
// Gate anti test-masking (a preocupação nº1 do CLAUDE.md: "subagente não pode
// enfraquecer/remover asserts pra ficar verde"). Para cada arquivo de teste MODIFICADO
// 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 { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const TEST_RE = /\.(test|spec)\.(ts|tsx)$/;
// Production TypeScript sources (excludes test files, handled separately via TEST_RE).
const PROD_SRC_RE = /\.(ts|tsx|mts|cts)$/;
/** Conta chamadas de assert.*( / assert( / expect( . */
export function countAssertions(src) {
const a = (src.match(/\bassert\b\s*[.(]/g) || []).length;
const e = (src.match(/\bexpect\s*\(/g) || []).length;
return a + e;
}
/** Conta tautologias assert.ok(true). */
export function countTautologies(src) {
return (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length;
}
/**
* (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;
}
// ─── (6348) Subcheck 4: inline-reimplemented prod conditions (REPORT-ONLY) ───
// A test that copies a conditional expression out of production code (instead of
// importing and exercising the symbol that owns it) is the wrong-shape-contract-test
// class (#6216): the assertion re-encodes the branch locally, so it stays green even
// when the real prod condition drifts. This subcheck is a HEURISTIC, textual gate
// mirroring the count* helpers above — it never parses an AST. It is REPORT-ONLY for
// now (warns, does not fail the gate).
/** Collapse all runs of whitespace to a single space and trim. */
function normalizeWhitespace(s) {
return (s || "").replace(/\s+/g, " ").trim();
}
/**
* Count "significant" tokens in a normalized condition. Single-char identifiers
* (`x`, `i`) and single-digit numeric literals (`0`, `1`) are treated as noise and
* NOT counted; operators and multi-char identifiers/numbers ARE. This is what makes
* `x > 0` trivial (1 significant token) while `status >= 500` is meaningful (3):
* - `status >= 500` → status, >=, 500 → 3
* - `x === LIMIT && y` → ===, LIMIT, && → 3
* - `x > 0` → > → 1
*/
export function countSignificantTokens(cond) {
const tokens =
(cond || "").match(
/===|!==|==|!=|>=|<=|&&|\|\||[<>+\-*/%!]|[A-Za-z_$][\w$]*|\d+(?:\.\d+)?/g
) || [];
let count = 0;
for (const tk of tokens) {
if (/^[A-Za-z_$]/.test(tk)) {
if (tk.length >= 2) count++; // multi-char identifier
} else if (/^\d/.test(tk)) {
if (tk.length >= 2) count++; // multi-digit number
} else {
count++; // operator
}
}
return count;
}
/** A condition is "meaningful" when it carries ≥3 significant tokens. */
function isSignificantCondition(cond) {
return countSignificantTokens(cond) >= 3;
}
/**
* Extract meaningful (≥3-token) conditional expressions from a production source,
* paired with the nearest enclosing declared symbol that "owns" them. Covers
* `if (...)` (via paren balancing) and comparison-bearing ternaries (`a === b ? … : …`).
* Returns [{ condition (whitespace-normalized), owner }].
*/
export function extractProdConditions(src) {
const results = [];
if (!src) return results;
// Declarations (function / const / let / var) with their positions, so each
// condition can be attributed to the symbol whose body it lives in.
const decls = [];
const declRe =
/(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g;
let dm;
while ((dm = declRe.exec(src))) {
decls.push({ index: dm.index, name: dm[1] || dm[2] });
}
const ownerAt = (idx) => {
let owner = "";
for (const d of decls) {
if (d.index <= idx) owner = d.name;
else break;
}
return owner;
};
const seen = new Set();
const pushCond = (raw, owner) => {
const norm = normalizeWhitespace(raw);
if (!norm || seen.has(norm) || !isSignificantCondition(norm)) return;
seen.add(norm);
results.push({ condition: norm, owner });
};
// if (...) — balance parentheses to capture the full condition.
const ifRe = /\bif\s*\(/g;
let m;
while ((m = ifRe.exec(src))) {
let depth = 1;
let i = m.index + m[0].length;
for (; i < src.length && depth > 0; i++) {
const ch = src[i];
if (ch === "(") depth++;
else if (ch === ")") depth--;
}
pushCond(src.slice(m.index + m[0].length, i - 1), ownerAt(m.index));
}
// Comparison-bearing ternaries: `<lhs> <cmp> <rhs> ? … : …` (best-effort, low-noise).
const ternRe =
/([A-Za-z_$][\w$).\]]*\s*(?:===|!==|==|!=|>=|<=|>|<)\s*[^?;{}\n]+?)\s*\?/g;
let t;
while ((t = ternRe.exec(src))) {
pushCond(t[1], ownerAt(t.index));
}
return results;
}
/**
* Collect the identifiers/module specifiers a test file imports, so we can tell
* whether it exercises a prod symbol through the real import (clean) or merely
* re-implements one of its conditions locally (masked). Returns a Set of names:
* imported bindings, module paths, and module basenames.
*/
export function extractImports(src) {
const names = new Set();
if (!src) return names;
const addModule = (mod) => {
names.add(mod);
const base = mod.split("/").pop().replace(/\.\w+$/, "");
if (base) names.add(base);
};
let m;
const importRe = /import\s+(?:type\s+)?([^;]*?)\s+from\s+['"]([^'"]+)['"]/g;
while ((m = importRe.exec(src))) {
addModule(m[2]);
for (const id of m[1].match(/[A-Za-z_$][\w$]*/g) || []) {
if (id !== "as" && id !== "type") names.add(id);
}
}
const dynRe = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
while ((m = dynRe.exec(src))) addModule(m[1]);
const reqRe = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
while ((m = reqRe.exec(src))) addModule(m[1]);
return names;
}
/**
* PURE core of subcheck 4. Given the sources of the prod files changed in a PR,
* one test file's source, and the set of names that test imports: return the prod
* conditions the test re-implements textually WITHOUT importing the symbol that owns
* them. Whitespace is squashed on both sides so spacing differences never mask a hit.
* Returns [{ condition, owner }].
*/
export function findReimplementedConditions(prodSources, testSource, testImports) {
const flags = [];
if (!testSource) return flags;
const imports =
testImports instanceof Set ? testImports : new Set(testImports || []);
const squash = (s) => (s || "").replace(/\s+/g, "");
const testSq = squash(testSource);
const seen = new Set();
for (const prod of prodSources || []) {
for (const { condition, owner } of extractProdConditions(prod)) {
if (owner && imports.has(owner)) continue; // exercised through the real import
if (seen.has(condition)) continue;
if (testSq.includes(squash(condition))) {
seen.add(condition);
flags.push({ condition, owner: owner || null });
}
}
}
return flags;
}
/**
* (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).
*
* `deletionAllowlist` (`_deletedWithReplacement` no test-masking-allowlist.json)
* isenta uma deleção SOMENTE quando o substituto declarado existe no HEAD e é
* ele próprio um arquivo de teste — o caso "reescrito em outro path sem rename
* detectável" (conteúdo novo demais para o -M do git). Qualquer entrada cujo
* substituto não exista ou não seja teste continua flagada.
*/
export function evaluateDeletedFiles(
deletedPaths,
deletionAllowlist = {},
fileExists = fs.existsSync
) {
const flags = [];
for (const f of deletedPaths) {
if (!TEST_RE.test(f)) continue;
const entry = deletionAllowlist[f];
if (entry && typeof entry.replacement === "string") {
if (TEST_RE.test(entry.replacement) && fileExists(entry.replacement)) continue;
flags.push(
`${f}: deleção allowlistada mas o substituto declarado (${entry.replacement}) não existe ou não é arquivo de teste`
);
continue;
}
flags.push(
`${f}: arquivo de teste deletado — revisão humana obrigatória (mascaramento alto-sinal)`
);
}
return flags;
}
/**
* Parse `git diff --name-status -M --diff-filter=DR` output, separating TRUE
* test-file deletions ("D\tpath") from RENAMES ("R<score>\told\tnew").
*
* A rename whose destination is still a test file is a *relocation* (the test
* was substituted at a new path, not removed) — per this file's subcheck-1
* contract it must NOT be treated as a deletion; the assert-reduction check
* still runs across the rename to catch gutting-via-rename. A rename that lands
* OUTSIDE test scope (test → non-test) removes the test and is treated as a
* deletion. Returns test-file paths only.
*/
export function partitionDeletedRenamed(nameStatusOutput) {
const deletedTests = [];
const renames = [];
for (const line of (nameStatusOutput || "").split("\n")) {
if (!line.trim()) continue;
const parts = line.split("\t").map((s) => s.trim());
const status = parts[0] || "";
if (status.startsWith("D")) {
if (TEST_RE.test(parts[1] || "")) deletedTests.push(parts[1]);
} else if (status.startsWith("R")) {
const from = parts[1] || "";
const to = parts[2] || "";
if (TEST_RE.test(from)) renames.push({ from, to });
}
}
return { deletedTests, renames };
}
/**
* 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, assertReductionAllowlist = new Set()) {
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;
// The net-assert-REDUCTION signal can be allowlisted per file when the reduction is a
// verified-legitimate refactor/field-removal (config/quality/test-masking-allowlist.json).
// The tautology / skip / deletion signals below are NEVER allowlisted.
if (f.headAsserts < f.baseAsserts && !assertReductionAllowlist.has(f.file))
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;
}
function git(args) {
try {
return execFileSync("git", args, { encoding: "utf8" });
} catch {
return "";
}
}
function resolveBase() {
if (process.env.GITHUB_BASE_SHA) return process.env.GITHUB_BASE_SHA;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
return null;
}
function main() {
const base = resolveBase();
if (!base) {
console.log("[test-masking] sem base ref (não é PR) — pulando.");
return;
}
// (6A.10 subcheck 1) Arquivos de teste deletados/renomeados via MDR filter.
// Renames test→test são RELOCAÇÕES (substituição) e passam pela verificação de
// redução de asserts abaixo (gutting-via-rename ainda flaga); só deleções reais
// e renames test→não-teste contam como remoção de teste.
const { deletedTests, renames } = partitionDeletedRenamed(
git(["diff", "--name-status", "-M", "--diff-filter=DR", `${base}...HEAD`])
);
const relocatedOutOfTest = [];
const renamePerFile = [];
for (const { from, to } of renames) {
if (!TEST_RE.test(to)) {
// test → non-test: the test was removed from coverage.
relocatedOutOfTest.push(from);
continue;
}
// test → test: compare the original (base) against the relocated (head) file so
// a clean relocation passes but a rename that drops asserts/adds tautologies fires.
const baseSrc = git(["show", `${base}:${from}`]);
const headSrc = fs.existsSync(to) ? fs.readFileSync(to, "utf8") : "";
renamePerFile.push({
file: to,
baseAsserts: countAssertions(baseSrc),
headAsserts: countAssertions(headSrc),
baseTaut: countTautologies(baseSrc),
headTaut: countTautologies(headSrc),
baseSkips: countSkips(baseSrc),
headSkips: countSkips(headSrc),
baseExtTaut: countExtendedTautologies(baseSrc),
headExtTaut: countExtendedTautologies(headSrc),
});
}
// 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())
.filter((f) => TEST_RE.test(f) && fs.existsSync(f));
const perFile = [...renamePerFile];
for (const file of changed) {
const baseSrc = git(["show", `${base}:${file}`]);
const headSrc = fs.readFileSync(file, "utf8");
perFile.push({
file,
baseAsserts: countAssertions(baseSrc),
headAsserts: countAssertions(headSrc),
baseTaut: countTautologies(baseSrc),
headTaut: countTautologies(headSrc),
baseSkips: countSkips(baseSrc),
headSkips: countSkips(headSrc),
baseExtTaut: countExtendedTautologies(baseSrc),
headExtTaut: countExtendedTautologies(headSrc),
});
}
// Per-file allowlist for verified-legitimate net-assert reductions (refactor/field-removal).
// Only exempts the reduction signal; tautology/skip/deletion signals still fire.
let assertReductionAllowlist = new Set();
let deletionAllowlist = {};
let reimplementedAllowlist = new Set();
try {
const raw = JSON.parse(fs.readFileSync("config/quality/test-masking-allowlist.json", "utf8"));
assertReductionAllowlist = new Set(Object.keys(raw).filter((k) => !k.startsWith("_")));
deletionAllowlist = raw._deletedWithReplacement || {};
reimplementedAllowlist = new Set(raw._reimplementedConditions || []);
} catch {
// no allowlist file — treat as empty
}
// (6348 subcheck 4, REPORT-ONLY) Tests that inline-reimplement a prod condition
// instead of importing the symbol that owns it. Prod files changed in this PR
// (added/copied/modified TS sources) are the reference corpus; each changed test
// file is scanned against them. Warns only — it never fails the gate for now.
const prodChanged = git(["diff", "--name-only", "--diff-filter=ACM", `${base}...HEAD`])
.split("\n")
.map((s) => s.trim())
.filter((f) => PROD_SRC_RE.test(f) && !TEST_RE.test(f) && fs.existsSync(f));
const prodSources = prodChanged.map((f) => {
try {
return fs.readFileSync(f, "utf8");
} catch {
return "";
}
});
const changedTests = git(["diff", "--name-only", "--diff-filter=ACM", `${base}...HEAD`])
.split("\n")
.map((s) => s.trim())
.filter((f) => TEST_RE.test(f) && fs.existsSync(f));
const reimplementedFlags = [];
if (prodSources.length) {
for (const tf of changedTests) {
if (reimplementedAllowlist.has(tf)) continue;
const src = fs.readFileSync(tf, "utf8");
for (const hit of findReimplementedConditions(prodSources, src, extractImports(src))) {
reimplementedFlags.push(
`${tf}: re-implementa a condição \`${hit.condition}\`` +
(hit.owner ? ` (dona: ${hit.owner})` : "") +
" — asserte através do import real em vez de copiar a condição"
);
}
}
}
if (reimplementedFlags.length) {
console.warn(
`[test-masking] (report-only) ${reimplementedFlags.length} teste(s) re-implementam ` +
`condição de produção em vez de importar o símbolo dono (classe #6216):\n` +
reimplementedFlags.map((f) => " ⚠ " + f).join("\n") +
`\n → importe o símbolo/função dono e asserte através dele (evita contrato duplicado ` +
`que diverge silenciosamente). Report-only por enquanto — não falha o gate.`
);
}
const deletedFlags = evaluateDeletedFiles(
[...deletedTests, ...relocatedOutOfTest],
deletionAllowlist
);
const maskingFlags = evaluateMasking(perFile, assertReductionAllowlist);
const allFlags = [...deletedFlags, ...maskingFlags];
if (allFlags.length) {
console.error(
`[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} modificado(s), ${renames.length} renomeado(s) (relocação), ` +
`${deletedTests.length} deletado(s) — sem enfraquecimento`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+59
View File
@@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
// Dirs collected ONLY by vitest (vitest.mcp.config.ts include globs for .ts tests).
// Keep in sync with vitest.mcp.config.ts. A test here MUST import from "vitest".
const VITEST_ONLY_DIRS = [
"tests/unit/autoCombo",
"open-sse/services/autoCombo",
"open-sse/mcp-server",
];
function walk(dir, root, out = []) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return out;
for (const name of fs.readdirSync(abs)) {
const rel = path.join(dir, name);
const s = fs.statSync(path.join(root, rel));
if (s.isDirectory()) {
if (name === "node_modules" || name === ".git") continue;
walk(rel, root, out);
} else if (/\.test\.(ts|tsx|js|mjs)$/.test(name)) {
out.push(rel);
}
}
return out;
}
function isVitestOnly(relFile) {
const norm = relFile.replace(/\\/g, "/");
return VITEST_ONLY_DIRS.some(
(d) => norm.startsWith(d + "/") && (norm.includes("/__tests__/") || d.startsWith("tests/unit/"))
);
}
export function findRunnerMismatches(root) {
const files = VITEST_ONLY_DIRS.flatMap((d) => walk(d, root));
const bad = [];
for (const f of files) {
if (!isVitestOnly(f)) continue;
const txt = fs.readFileSync(path.join(root, f), "utf8");
const importsNodeTest = /from\s+["']node:test["']/.test(txt);
const importsVitest = /from\s+["']vitest["']/.test(txt);
if (importsNodeTest && !importsVitest) {
bad.push({ file: f, reason: "vitest-only dir but imports node:test (use the vitest API)" });
}
}
return bad;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const root = process.cwd();
const bad = findRunnerMismatches(root);
if (bad.length) {
console.error(`[test-runner-api] FAIL — ${bad.length} test(s) use the wrong runner API:`);
for (const b of bad) console.error(` ${b.file}: ${b.reason}`);
process.exit(1);
}
console.log("[test-runner-api] OK — vitest-only dirs use the vitest API.");
}
+99
View File
@@ -0,0 +1,99 @@
#!/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", // legacy root location (still forbidden if a stale run writes it)
"config/quality/quality-metrics.json", // current generated location (collect-metrics.mjs)
]);
/**
* 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();
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env node
// scripts/check/check-type-coverage.mjs
// Type-coverage ratchet (Task 6 of Fase 7).
// Fase 7 INT: promovido de ADVISORY para RATCHET bloqueante.
//
// Measures the % of typed symbols across the codebase using the `type-coverage`
// tool and prints `typeCoveragePct=<N>`. Lê o baseline de quality-baseline.json
// (metrics.typeCoveragePct) e falha com exit 1 se a % CAIR além do eps.
//
// tsconfig used: open-sse/tsconfig.json
// - Rationale: the only tsconfig that covers the full open-sse workspace
// (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).
// Eps: 0.05 (float noise tolerance — type-coverage may vary by ~0.01% between runs).
//
// Run:
// node scripts/check/check-type-coverage.mjs
// node scripts/check/check-type-coverage.mjs --update # ratchet baseline up
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");
const UPDATE = process.argv.includes("--update");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/quality-baseline.json")
);
// Small epsilon to absorb float noise between runs (type-coverage can vary ~0.01%).
const DEFAULT_EPS = 0.05;
/**
* Parse the JSON output produced by `type-coverage --json-output`.
* 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;
}
/**
* Avalia a % de type-coverage atual contra o baseline.
* Direction: up (% só pode SUBIR; queda além de eps é regressão).
*
* Exported for unit testing.
*
* @param {number} current
* @param {number} baseline
* @param {number} [eps=0] - tolerance for float noise
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateTypeCoverage(current, baseline, eps = 0) {
const regressed = current < baseline - eps;
const improved = current > baseline + eps;
return { regressed, improved };
}
function runTypeCoverage() {
const typeCoverageBin = path.join(ROOT, "node_modules", ".bin", "type-coverage");
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() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(`[type-coverage] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`);
process.exit(2);
}
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const baselineMetric = baselineJson.metrics && baselineJson.metrics.typeCoveragePct;
if (!baselineMetric || typeof baselineMetric.value !== "number") {
process.stderr.write(
"[type-coverage] FAIL — metrics.typeCoveragePct ausente em quality-baseline.json.\n"
);
process.exit(2);
}
const baselineValue = baselineMetric.value;
const eps = typeof baselineMetric.eps === "number" ? baselineMetric.eps : DEFAULT_EPS;
console.log("[type-coverage] Running type-coverage (this may take ~30-60 s)…");
console.log(`[type-coverage] tsconfig: ${path.relative(ROOT, TSCONFIG)}`);
let pct;
try {
pct = runTypeCoverage();
} catch (err) {
process.stderr.write(`[type-coverage] FAIL — ${err.message}\n`);
process.exit(2);
}
// Canonical output line consumed by collect-metrics.mjs and shell scripts.
console.log(`typeCoveragePct=${pct}`);
const { regressed, improved } = evaluateTypeCoverage(pct, baselineValue, eps);
if (UPDATE && improved) {
baselineJson.metrics.typeCoveragePct.value = pct;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[type-coverage] baseline ratcheado: ${pct} (era ${baselineValue})`);
}
if (regressed) {
process.stderr.write(
`[type-coverage] REGRESSÃO — ${pct}% < baseline ${baselineValue}% (eps=${eps})\n` +
` → Adicione anotações de tipo ou rode\n` +
` 'node scripts/check/check-type-coverage.mjs --update' se a % subiu legitimamente.\n`
);
process.exit(1);
}
console.log(
`[type-coverage] OK — ${pct}% symbols typed (baseline ${baselineValue}%, eps=${eps})`
);
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
main();
}
+375
View File
@@ -0,0 +1,375 @@
#!/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
//
// Por default é ADVISORY (sai 0 sempre). Passe --ratchet para tornar BLOQUEANTE:
// lê metrics.vulnCount.value de config/quality/quality-baseline.json, compara a
// contagem MEDIDA e SAI 1 SE — E SOMENTE SE — a medida for MAIOR que o baseline
// (regressão real, direction:down). Qualquer SKIP gracioso (binário ausente,
// osv.dev/rede inacessível, erro de parse) SAI 0 mesmo com --ratchet — uma falha
// de MEDIÇÃO nunca bloqueia, só uma regressão medida bloqueia.
//
// NB (variância de CVE): osv mede contra um banco de CVEs que cresce de forma
// contínua. Um PR que NÃO toca dependências pode subitamente medir vulnCount >
// baseline porque um novo CVE foi divulgado nas deps existentes — isso é o
// comportamento ESPERADO de um gate de CVE bloqueante, não uma regressão de
// produto. O remédio é (a) bumpar a dep afetada (preferível) ou, se não houver
// fix, (b) re-baseline metrics.vulnCount com justificativa + issue de tracking.
// Ver docs/security/SUPPLY_CHAIN.md → "Variância de CVE".
//
// 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
// node scripts/check/check-vuln-ratchet.mjs --ratchet # falha (exit 1) numa regressão
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 RATCHET = process.argv.includes("--ratchet");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.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";
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia a contagem MEDIDA de vulnerabilidades contra o baseline.
* Direction: down (a contagem só pode CAIR — mais vulns = regressão).
*
* @param {number} current - Contagem de vulnerabilidades medida agora.
* @param {number} baseline - Contagem congelada em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateVulnRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Lê metrics.vulnCount.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineVulnValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.vulnCount;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// 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).
*
* Em uma falha de MEDIÇÃO (osv-scanner não produziu JSON — rede/osv.dev
* inacessível, timeout, JSON inválido) retorna { skip: true, reason } em vez de
* abortar o processo: o caller traduz isso num SKIP gracioso (exit 0 mesmo com
* --ratchet). Uma falha de medição NUNCA bloqueia; só uma regressão MEDIDA bloqueia.
*
* @param {string} osvBin - Caminho para o binário osv-scanner
* @returns {{ json: object } | { skip: true, reason: string }}
*/
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`);
return { skip: true, reason: "osv-error" };
}
}
try {
return { json: 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`);
return { skip: true, reason: "parse-error" };
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const osvBin = findOsvScanner();
if (!osvBin) {
// Skip gracioso: binário ausente — esperado em ambientes sem osv-scanner instalado.
// SKIP sai 0 MESMO com --ratchet (binário ausente nunca bloqueia).
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] SKIP gracioso — sai 0 mesmo com --ratchet (binário ausente nunca bloqueia).\n"
);
}
process.exitCode = 0;
return;
}
const osvResult = runOsvScanner(osvBin);
// Falha de MEDIÇÃO (rede/osv.dev inacessível, timeout, JSON inválido) → SKIP
// gracioso, sai 0 mesmo com --ratchet (uma falha de medição nunca bloqueia).
if (osvResult.skip) {
console.log(`vulnCount=SKIP reason=${osvResult.reason}`);
if (!QUIET) {
process.stderr.write(
`[vuln-ratchet] SKIP — osv-scanner não produziu uma medição (${osvResult.reason}).\n` +
"[vuln-ratchet] SKIP gracioso — sai 0 mesmo com --ratchet (falha de medição nunca bloqueia).\n"
);
}
process.exitCode = 0;
return;
}
const osvJson = osvResult.json;
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`
);
}
// Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet).
applyRatchet(vulnCount);
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet: exit 1 numa regressão real
* (medida > baseline). Baseline ausente → SKIP gracioso (exit 0).
*
* @param {number} vulnCount - Contagem MEDIDA (medição bem-sucedida).
*/
function applyRatchet(vulnCount) {
if (!RATCHET) {
if (!QUIET) {
process.stderr.write(
"[vuln-ratchet] ADVISORY — não falha pela contagem (passe --ratchet para bloquear regressão).\n"
);
}
process.exitCode = 0;
return;
}
const baselineValue = readBaselineVulnValue(BASELINE_PATH);
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[vuln-ratchet] baseline ausente (metrics.vulnCount) — SKIP gracioso, sai 0.\n"
);
}
process.exitCode = 0;
return;
}
const { regressed } = evaluateVulnRatchet(vulnCount, baselineValue);
if (regressed) {
process.stderr.write(
`[vuln-ratchet] REGRESSÃO — ${vulnCount} vulnerabilidades > baseline ${baselineValue}\n` +
" → Bumpe a(s) dep(s) afetada(s) (preferível). Se não houver fix, re-baseline\n" +
" metrics.vulnCount em config/quality/quality-baseline.json com justificativa\n" +
" + issue de tracking. Ver docs/security/SUPPLY_CHAIN.md → 'Variância de CVE'.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
process.stderr.write(
`[vuln-ratchet] OK — sem regressão (${vulnCount} vulns, baseline ${baselineValue}).\n`
);
}
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+398
View File
@@ -0,0 +1,398 @@
#!/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 / no ratchet regression
// 1 — gate failure: --strict + any finding, OR --ratchet + zizmorFindings
// regression (measured > baseline)
//
// Ratchet mode (--ratchet): reads metrics.zizmorFindings.value from
// config/quality/quality-baseline.json and exits 1 IF — AND ONLY IF — the MEASURED
// zizmor count is GREATER than the baseline (real regression, direction:down).
// ONLY zizmorFindings is ratcheted; actionlint findings are REPORTED but NOT
// ratcheted (use the separate --strict all-or-nothing flag for those). Any graceful
// SKIP (binary absent, no workflows) exits 0 even with --ratchet — missing infra
// never blocks, only a measured regression does.
//
// 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 --ratchet # fail on zizmor regression
// 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 BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const STRICT = process.argv.includes("--strict");
const RATCHET = process.argv.includes("--ratchet");
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: [] };
}
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down, zizmorFindings only) — exported for tests
// ---------------------------------------------------------------------------
/**
* Evaluates the MEASURED zizmor finding count against the baseline.
* Direction: down (the count may only DROP — more findings = regression).
*
* @param {number} current - Measured zizmor finding count.
* @param {number} baseline - Frozen count in quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateZizmorRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Reads metrics.zizmorFindings.value from quality-baseline.json.
* Returns null when the file or metric is missing (no baseline → no ratchet
* possible; the caller treats this as a graceful SKIP, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineZizmorValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.zizmorFindings;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// 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" +
" Graceful SKIP — exits 0 even with --ratchet (missing binaries never block)."
);
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);
}
// ── ratchet (zizmorFindings only, direction:down) ──────────────────────────
// We can only ratchet zizmor when zizmor actually RAN (binary present). If
// zizmor is absent we have no comparable measurement → graceful SKIP (exit 0):
// a missing binary must never block, only a measured regression does.
if (RATCHET) {
if (!hasZizmor) {
if (!QUIET) {
process.stderr.write(
"[check-workflows] --ratchet: zizmor absent — SKIP (no measurement, never blocks).\n"
);
}
process.exit(0);
}
const baselineValue = readBaselineZizmorValue(BASELINE_PATH);
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[check-workflows] --ratchet: baseline absent (metrics.zizmorFindings) — SKIP, exit 0.\n"
);
}
process.exit(0);
}
const { regressed } = evaluateZizmorRatchet(zizmorCount, baselineValue);
if (regressed) {
console.error(
`\n[check-workflows] REGRESSION — ${zizmorCount} zizmor finding(s) > baseline ${baselineValue}.\n` +
" → Fix the new workflow finding(s), or re-baseline metrics.zizmorFindings in\n" +
" config/quality/quality-baseline.json if the rise is a legitimate, justified drift.\n" +
" (actionlint findings are reported, not ratcheted — use --strict for those.)"
);
process.exit(1);
}
if (!QUIET) {
process.stderr.write(
`[check-workflows] --ratchet OK — ${zizmorCount} zizmor finding(s), baseline ${baselineValue} (no regression).\n`
);
}
process.exit(0);
}
if (total > 0 && !QUIET) {
console.log(
`[check-workflows] ADVISORY — ${total} finding(s) detected. ` +
"Pass --strict to block on any finding, or --ratchet to block on a zizmor regression."
);
}
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
@@ -0,0 +1,58 @@
{
"lite": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"caveman": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"aggressive": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"ultra": {
"tasks": {
"prose": 92,
"tool-output": 116,
"json": 117
}
},
"rtk": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"session-dedup": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"headroom": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"ccr": {
"tasks": {
"prose": 129,
"tool-output": 56,
"json": 14
}
}
}
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// scripts/check/lib/allowlist.mjs
// Shared helper for stale-allowlist enforcement (6A.3).
//
// Purpose: detect allowlist entries that no longer correspond to any live
// violation. When a developer fixes a violation, the entry in KNOWN_* must
// also be removed — otherwise the gate silently allows the violation to
// regress. This pattern is validated practice (ESLint --report-unused-disable-
// directives; Notion suppression hygiene).
/**
* Returns the subset of `allowlist` entries that do NOT appear in
* `liveViolations`. These are "stale" entries — the violation they once
* suppressed has been corrected, so the entry should be removed to prevent
* silent regression.
*
* @param {string[] | Set<string>} allowlist - The known-violations list/set.
* @param {string[] | Set<string>} liveViolations - Violations detected in the
* current run (strings as they appear in the allowlist).
* @param {string} gateName - Gate name used only in future error messages; not
* used internally by this function but kept for API consistency with
* assertNoStale.
* @returns {string[]} Stale entries (present in allowlist, absent in live).
*/
export function reportStaleEntries(allowlist, liveViolations, gateName) {
const liveSet = liveViolations instanceof Set ? liveViolations : new Set(liveViolations);
const stale = [];
for (const entry of allowlist) {
if (!liveSet.has(entry)) {
stale.push(entry);
}
}
return stale;
}
/**
* Calls reportStaleEntries; if any stale entries are found, logs them to
* stderr and sets process.exitCode = 1 so the gate fails without throwing
* (allowing multiple gates to report before the process exits).
*
* @param {string[] | Set<string>} allowlist
* @param {string[] | Set<string>} liveViolations
* @param {string} gateName - Shown in the error message to identify the gate.
* @returns {string[]} The same stale array returned by reportStaleEntries.
*/
export function assertNoStale(allowlist, liveViolations, gateName) {
const stale = reportStaleEntries(allowlist, liveViolations, gateName);
if (stale.length > 0) {
console.error(
`[${gateName}] ${stale.length} entrada(s) obsoleta(s) na allowlist ` +
`— a violação foi corrigida; REMOVA a entrada para travar a correção:\n` +
stale.map((e) => `${e}`).join("\n")
);
process.exitCode = 1;
}
return stale;
}
+110
View File
@@ -0,0 +1,110 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
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 formatPercent(value) {
return `${Number(value ?? 0).toFixed(2)}%`;
}
function parseThreshold(name, fallbackValue) {
const rawValue = getArg(name, fallbackValue);
const value = Number(rawValue);
if (!Number.isFinite(value)) {
console.error(`Invalid coverage threshold for ${name}: ${rawValue}`);
process.exit(1);
}
return value;
}
const inputPath = getArg("--input", "coverage/coverage-summary.json");
const outputPath = getArg("--output", "");
const hasGlobalThreshold = process.argv.includes("--threshold");
const defaultThreshold = parseThreshold("--threshold", "75");
if (!existsSync(inputPath)) {
console.error(`Coverage summary file not found: ${inputPath}`);
process.exit(1);
}
const summary = JSON.parse(readFileSync(inputPath, "utf8"));
const cwd = process.cwd();
const metrics = [
["lines", "Lines"],
["statements", "Statements"],
["functions", "Functions"],
["branches", "Branches"],
];
const thresholds = Object.fromEntries(
metrics.map(([metric]) => {
const fallback = metric === "branches" && !hasGlobalThreshold ? "70" : String(defaultThreshold);
return [metric, parseThreshold(`--${metric}`, fallback)];
})
);
const total = summary.total ?? {};
const gatePassed = metrics.every(([metric]) => (total[metric]?.pct ?? 0) >= thresholds[metric]);
const files = Object.entries(summary)
.filter(([name]) => name !== "total" && /\.(?:[cm]?[jt]sx?)$/.test(name))
.map(([name, stats]) => {
const relativeName = path.relative(cwd, name);
const totalLines = stats.lines?.total ?? 0;
const coveredLines = stats.lines?.covered ?? 0;
return {
name: relativeName,
lines: stats.lines?.pct ?? 0,
branches: stats.branches?.pct ?? 0,
functions: stats.functions?.pct ?? 0,
missingLines: Math.max(totalLines - coveredLines, 0),
};
})
.sort((left, right) => {
if (left.lines !== right.lines) return left.lines - right.lines;
if (left.branches !== right.branches) return left.branches - right.branches;
return right.missingLines - left.missingLines;
})
.slice(0, 15);
const report = [
"# Coverage Report",
"",
`Gate: ${gatePassed ? "PASS" : "FAIL"} at configured metric minimums.`,
"",
"## Totals",
"",
"| Metric | Covered | Total | Percent | Threshold | Status |",
"| --- | ---: | ---: | ---: | ---: | --- |",
...metrics.map(([metric, label]) => {
const covered = total[metric]?.covered ?? 0;
const totalCount = total[metric]?.total ?? 0;
const pct = total[metric]?.pct ?? 0;
const threshold = thresholds[metric];
const status = pct >= threshold ? "PASS" : "FAIL";
return `| ${label} | ${covered} | ${totalCount} | ${formatPercent(pct)} | ${threshold}% | ${status} |`;
}),
"",
"## Lowest Coverage Files",
"",
"| File | Lines | Branches | Functions | Missing Lines |",
"| --- | ---: | ---: | ---: | ---: |",
...files.map(
(entry) =>
`| \`${entry.name}\` | ${formatPercent(entry.lines)} | ${formatPercent(entry.branches)} | ${formatPercent(entry.functions)} | ${entry.missingLines} |`
),
];
const reportContent = `${report.join("\n")}\n`;
if (outputPath) {
writeFileSync(outputPath, reportContent);
} else {
process.stdout.write(reportContent);
}