feat(quality): Fase 6 — 8 new gates (Rule #11/#12, migrations, known-symbols, route-guard, complexity, docs-symbols, db-rules)

Deterministic gates, each freezing pre-existing violations in a documented allowlist (ratchet) so they pass now and block only NEW regressions:
- check-error-helper (Rule #12): 7 executors/handlers forwarding raw err.message frozen
- check-public-creds (Rule #11): 5 literal client_ids (Claude/Codex/Qwen/Kimi/Copilot) frozen
- check-migration-numbering: gaps 026/055 + dup 041 frozen (prevents the git-rm-deleted-migration incident)
- check-known-symbols: 93 executors conformance + 15 combo strategies + 18 translator pairs
- check-route-guard-membership (#15/#17): all 25 spawn-capable routes verified local-only (0 gaps)
- check-complexity: cyclomatic>15 / fn-length>80 ratchet (baseline 1739)
- check-docs-symbols: 30 stale doc /api refs frozen (docs hallucination)
- check-db-rules (#2/#5): 25 unexported db modules + 15 raw-SQL routes frozen
Wired into CI (lint / docs-sync-strict / quality-gate jobs). 115 TDD tests, all green. ESLint ratchet held at 3482.
This commit is contained in:
diegosouzapw
2026-06-09 09:57:43 -03:00
parent bb5ed0c0da
commit 8567a80778
20 changed files with 2738 additions and 0 deletions

View File

@@ -24,6 +24,12 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-latest
env:
# tsx gates below (known-symbols, route-guard-membership) import modules that
# open SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
@@ -41,6 +47,12 @@ jobs:
- run: npm run check:fetch-targets
- run: npm run check:deps
- run: npm run check:file-size
- run: npm run check:error-helper
- run: npm run check:migration-numbering
- run: npm run check:public-creds
- run: npm run check:db-rules
- run: npm run check:known-symbols
- run: npm run check:route-guard-membership
- run: npm run check:docs-sync
- run: npm run typecheck:core
# typecheck:noimplicit:core is a forward-looking gate (noImplicitAny).
@@ -76,6 +88,8 @@ jobs:
# para não pesar no caminho crítico do lint.
- name: Duplication ratchet
run: npm run check:duplication
- name: Complexity ratchet
run: npm run check:complexity
- name: Append summary
if: always()
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY"
@@ -109,6 +123,8 @@ jobs:
run: npm run check:openapi-security-tiers
- name: OpenAPI spec paths resolve to real routes (anti-hallucination)
run: npm run check:openapi-routes
- name: Doc /api refs resolve to real routes (anti-hallucination)
run: npm run check:docs-symbols
- name: i18n translation drift (warn)
run: node scripts/i18n/check-translation-drift.mjs --warn

4
complexity-baseline.json Normal file
View File

@@ -0,0 +1,4 @@
{
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
"count": 1739
}

View File

@@ -0,0 +1,64 @@
// eslint.complexity.config.mjs
// STANDALONE flat config for the complexity ratchet (scripts/check/check-complexity.mjs).
// Intentionally does NOT extend the project's main eslint.config.mjs — it enables ONLY two
// ESLint CORE rules so its violation count is isolated from the main lint's ratcheted
// warning budget (3482). No plugins, no extra dependency: just the TypeScript parser
// (typescript-eslint) so .ts/.tsx files can be parsed.
//
// complexity — cyclomatic complexity ceiling per function
// max-lines-per-function — function-length ceiling (skips blank lines + comments)
//
// Run via:
// npx eslint --no-config-lookup --config eslint.complexity.config.mjs --format json src open-sse
import tseslint from "typescript-eslint";
/** @type {import("eslint").Linter.Config[]} */
const complexityConfig = [
{
files: ["src/**/*.{ts,tsx}", "open-sse/**/*.{ts,tsx}"],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
ecmaFeatures: { jsx: true },
},
},
// Ignore ALL inline directive comments. Source files carry
// `// eslint-disable-next-line react-hooks/...` / `@next/next/...` directives that
// reference rules from plugins this standalone config deliberately does NOT load.
// Without this, ESLint emits an error-severity "Definition for rule ... was not
// found" for each such directive — polluting (and destabilizing) the violation
// count. noInlineConfig keeps the count to exactly our two rules.
linterOptions: {
noInlineConfig: true,
reportUnusedDisableDirectives: "off",
},
// ONLY these two core rules. Keep this list minimal so the reported violation
// count is exactly "functions over the complexity / length thresholds".
rules: {
complexity: ["error", 15],
"max-lines-per-function": [
"error",
{ max: 80, skipBlankLines: true, skipComments: true },
],
},
},
// Ignore everything that is not first-party src/open-sse production code so the count
// is not polluted by tests, type declarations, or build output.
{
ignores: [
"**/*.test.ts",
"**/*.test.tsx",
"**/__tests__/**",
"**/*.d.ts",
"node_modules/**",
".next/**",
".build/**",
"dist/**",
"coverage/**",
],
},
];
export default complexityConfig;

View File

@@ -119,6 +119,14 @@
"check:file-size": "node scripts/check/check-file-size.mjs",
"check:duplication": "node scripts/check/check-duplication.mjs",
"check:test-masking": "node scripts/check/check-test-masking.mjs",
"check:error-helper": "node scripts/check/check-error-helper.mjs",
"check:migration-numbering": "node scripts/check/check-migration-numbering.mjs",
"check:public-creds": "node scripts/check/check-public-creds.mjs",
"check:db-rules": "node scripts/check/check-db-rules.mjs",
"check:docs-symbols": "node scripts/check/check-docs-symbols.mjs",
"check:known-symbols": "node --import tsx scripts/check/check-known-symbols.ts",
"check:route-guard-membership": "node --import tsx scripts/check/check-route-guard-membership.ts",
"check:complexity": "node scripts/check/check-complexity.mjs",
"quality:collect": "node scripts/quality/collect-metrics.mjs",
"quality:ratchet": "node scripts/quality/check-quality-ratchet.mjs",
"quality:gate": "npm run quality:collect && npm run quality:ratchet -- --allow-missing",

View File

@@ -0,0 +1,87 @@
#!/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, "complexity-baseline.json")
);
const UPDATE = process.argv.includes("--update");
const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs");
const ESLINT_ARGS = [
"eslint",
"--no-config-lookup",
"--config",
CONFIG_PATH,
"--format",
"json",
"src",
"open-sse",
];
/** 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();

View File

@@ -0,0 +1,252 @@
#!/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.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
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 hoje. Congelados
// para a catraca ficar verde e bloquear QUALQUER módulo novo não re-exportado.
// CADA UM é dívida: ou é consumido por import direto de "@/lib/db/X" (legítimo,
// não precisa de re-export) ou deveria ser re-exportado. NÃO adicione novos aqui
// sem justificativa — esse é o ponto do gate (Hard Rule #2).
const KNOWN_UNEXPORTED = new Set([
"_rowTypes", // só tipos de linha (sem runtime API), consumido localmente pelos CRUDs F2
"cleanup", // rotina de manutenção, chamada por jobs/rotas via import direto
"cliToolState", // estado de CLI tools, import direto pelos consumidores
"comboForecast", // previsão de combo, import direto
"commandCodeAuth", // auth de command-code, import direto
"compression", // núcleo de compressão, import direto
"compressionScheduler", // scheduler, import direto
"detailedLogs", // logs detalhados, import direto
"discovery", // discovery de modelos, import direto
"domainState", // estado de domínio/circuit breaker, import direto
"encryption", // util de cripto at-rest, import direto
"healthCheck", // health check de DB, import direto
"jsonMigration", // migração JSON→SQLite (one-shot), import direto
"migrationRunner", // runner de migrations, import direto
"notion", // integração Notion, import direto
"obsidian", // integração Obsidian, import direto
"pluginMetrics", // métricas de plugin, import direto
"prompts", // prompts salvos, import direto
"providerStats", // stats de provider, import direto
"recovery", // recuperação de DB, import direto
"secrets", // secrets store, import direto
"serviceModels", // modelos de serviços embutidos, import direto
"stateReset", // reset de estado de resiliência, import direto
"stats", // agregações de stats, import direto
"tierConfig", // config de tier, import direto
]);
// (c) Ofensores de SQL cru PRÉ-EXISTENTES em rotas/handlers. Congelados para a
// catraca ficar verde e bloquear QUALQUER nova rota/handler com SQL inline.
// CADA UM é dívida da Hard Rule #5: mover para um módulo src/lib/db/. NÃO
// adicione novos aqui sem justificativa — crie/estenda um módulo db/ em vez disso.
// (Chaves = caminho relativo POSIX a partir da raiz do repo.)
const KNOWN_RAW_SQL = new Set([
"src/app/api/analytics/auto-routing/route.ts", // SELECT … FROM usage_logs
"src/app/api/cache/entries/route.ts", // semantic_cache COUNT/DELETE inline
"src/app/api/db-backups/exportAll/route.ts", // SELECT key_value/combos/connections/keys
"src/app/api/db-backups/import/route.ts", // SELECT sqlite_master + COUNTs
"src/app/api/gamification/federation/leaderboard/route.ts", // SELECT community_servers
"src/app/api/gamification/federation/score/route.ts", // SELECT community_servers
"src/app/api/logs/export/route.ts", // SELECT de proxy_logs
"src/app/api/oauth/cursor/auto-import/route.ts", // SELECT no itemTable do Cursor (DB externo)
"src/app/api/oauth/kiro/auto-import/route.ts", // SELECT no SQLite do Kiro (DB externo)
"src/app/api/provider-metrics/route.ts", // SELECT … FROM call_logs (agregação)
"src/app/api/search/stats/route.ts", // SELECT … FROM call_logs
"src/app/api/settings/export-json/route.ts", // SELECT * de usage_history/domain_*
"src/app/api/skills/[id]/route.ts", // UPDATE skills SET dinâmico
"src/app/api/usage/analytics/route.ts", // SELECT … FROM usage_history/daily_usage_summary
"src/app/api/v1/search/analytics/route.ts", // SELECT … FROM call_logs (request_type=search)
]);
// 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 congelados.
export function findMissingReexports(dbModules, reexported, allowlist = KNOWN_UNEXPORTED) {
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 = [];
// (a) re-export completeness
const dbModules = collectDbModules();
const reexported = extractReexportedModules(fs.readFileSync(LOCAL_DB, "utf8"));
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 KNOWN_UNEXPORTED com justificativa (import direto de "@/lib/db/${missing[0]}").`
);
}
// (b) localDb sem lógica
if (hasLogic(fs.readFileSync(LOCAL_DB, "utf8"))) {
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/
const rawSql = findRawSql(collectSqlScanFiles());
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.exit(1);
}
console.log(
`[check-db-rules] OK (${dbModules.length} módulos db/, ${reexported.size} re-exportados, ` +
`${KNOWN_UNEXPORTED.size} congelados; ${KNOWN_RAW_SQL.size} ofensores de SQL congelados)`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,237 @@
#!/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.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
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 — tabela de endpoints com várias rotas obsoletas:
"/api/acp/agents/[id]", // só existe /api/acp/agents (sem [id])
"/api/acp/agents/refresh", // sem rota /refresh
"/api/admin/circuit-breaker", // admin só tem /concurrency
"/api/admin/circuit-breaker/reset", // idem
"/api/admin/rate-limits", // idem
"/api/cache/clear", // cache usa DELETE em /api/cache, não /clear
"/api/cache/reasoning/clear", // /api/cache/reasoning existe; /clear não
"/api/guardrails", // sem dir de API guardrails (feature server-side, sem rota REST)
"/api/guardrails/[id]/disable",
"/api/guardrails/[id]/enable",
"/api/guardrails/logs",
"/api/guardrails/test",
"/api/plugins/[id]/disable", // rota real usa [name] + activate/deactivate
"/api/plugins/[id]/enable", // idem
"/api/shadow", // sem dir de API shadow (shadow routing não tem rota REST)
"/api/shadow/[id]",
"/api/shadow/[id]/results",
"/api/shadow/metrics",
"/api/skills/[id]/disable", // skills tem [id] e /executions (base), não estas sub-ações
"/api/skills/[id]/enable",
"/api/skills/[id]/execute",
"/api/skills/[id]/executions",
"/api/system-info", // sem rota /system-info
// docs/research/DISCOVERY_TOOL_DESIGN.md — design doc de feature NÃO implementada:
"/api/discovery/results",
"/api/discovery/results/:id",
"/api/discovery/scan",
"/api/discovery/verify/:id",
// docs/frameworks/AGENTBRIDGE.md — state POR-AGENTE; rota real é o /state GLOBAL
// (mesmo drift congelado em check-openapi-routes.mjs::KNOWN_STALE_SPEC):
"/api/tools/agent-bridge/agents/{id}/state",
// docs/reference/ENVIRONMENT.md — endpoint UPSTREAM do provedor Blackbox Web,
// citado na descrição de env var (não é rota do OmniRoute):
"/api/chat",
// docs/ops/TUNNELS_GUIDE.md — a doc afirma EXPLICITAMENTE que este endpoint NÃO
// existe ("There is no central /api/settings/tunnels endpoint"); menção pedagógica:
"/api/settings/tunnels",
]);
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).
const docFiles = walk(DOCS, (n) => /\.md$/.test(n)).filter(
(f) => !path.relative(ROOT, f).replace(/\\/g, "/").startsWith("docs/i18n/")
);
const docPathsByFile = docFiles.map((f) => ({
file: path.relative(ROOT, f).replace(/\\/g, "/"),
paths: extractDocApiPaths(fs.readFileSync(f, "utf8")),
}));
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.exit(1);
}
console.log(
`[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` +
`${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,288 @@
#!/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";
const cwd = process.cwd();
const SCAN_DIRS = [
path.join(cwd, "open-sse/executors"),
path.join(cwd, "open-sse/handlers"),
];
// 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([
// adapta-web: local makeErrorResponse() + `Adapta auth failed: ${msg}` where
// msg = err.message — raw auth/upstream error string in the JSON error body,
// no open-sse/utils/error import. Fix: sanitizeErrorMessage(msg) before forwarding.
"open-sse/executors/adapta-web.ts",
// deepseek-web: local errorResponse() shadow that puts `message` raw into the body,
// fed `DeepSeek error: ${msg}` where msg = err.message — bypasses the canonical
// sanitizer. Fix: route through buildErrorBody()/sanitizeErrorMessage().
"open-sse/executors/deepseek-web.ts",
// perplexity-web: `new Response({ error: { message: `Perplexity connection failed:
// ${err.message}` }})` (multi-line envelope) for TLS/connection failures — raw
// err.message in the client error body, no sanitizer import.
"open-sse/executors/perplexity-web.ts",
// qoder: `response: new Response({ error: { message: `Qoder fetch error:
// ${error.message}` }})` — raw error.message in the returned response body,
// no sanitizer import.
"open-sse/executors/qoder.ts",
// veoaifree-web: local errResp(msg) on nonce-fetch failure where msg = err.message —
// raw error string in the response body, no sanitizer import.
"open-sse/executors/veoaifree-web.ts",
// embeddings handler: `return { success: false, status: 502, error: `Embedding
// provider error: ${err.message}` }` — raw err.message in the result error field,
// no sanitizer import. (The saveCallLog `error: err.message` rows are internal and
// correctly NOT what is frozen here.)
"open-sse/handlers/embeddings.ts",
// search handler: `return { …, error: `Search provider …: ${err.message}` }` — raw
// err.message in the result error field, no sanitizer import.
"open-sse/handlers/search.ts",
]);
// 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 = [];
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"),
});
}
}
return files;
}
function main() {
const files = collectFiles();
const violations = findErrorHelperViolations(files, KNOWN_MISSING_ERROR_HELPER);
// Surface allowlist drift: entries that no longer match a real file (cleaned up or
// renamed) so the allowlist does not rot. This is a warning, not a failure.
const present = new Set(files.map((f) => f.path));
const stale = [...KNOWN_MISSING_ERROR_HELPER].filter((p) => !present.has(p));
if (stale.length) {
console.warn(
`[check-error-helper] WARN: ${stale.length} allowlist entr${
stale.length === 1 ? "y" : "ies"
} no longer match a file (remove from KNOWN_MISSING_ERROR_HELPER):\n` +
stale.map((p) => " - " + p).join("\n")
);
}
if (violations.length) {
console.error(
`[check-error-helper] ${violations.length} file(s) build an error response/result with a ` +
`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);
}
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();

View File

@@ -0,0 +1,292 @@
#!/usr/bin/env node
// scripts/check/check-known-symbols.ts
// Gate anti-alucinação: known-symbol allow-lists. Mata o padrão "símbolo inventado
// que silenciosamente vira no-op" em três superfícies de despacho por-string/por-chave:
//
// (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 (priority não tem branch; cai 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.
//
// Catraca: cada divergência pré-existente fica numa allowlist documentada e sai 0 hoje.
// Padrão herdado de scripts/check/check-provider-consistency.ts (gate .ts via
// `node --import tsx` que IMPORTA módulos reais + funções puras + main() guardado).
import { readFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, resolve as resolvePath } from "node:path";
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. Remover daqui se um branch dedicado for adicionado.
*/
export const IMPLICIT_DEFAULT_STRATEGIES: Record<string, string> = {
priority:
'Default sem branch: combo.ts não tem `strategy === "priority"`; cai no ordenamento padrão de resolveComboTargets (ordem de prioridade declarada). É o fallback de normalizeRoutingStrategy.',
};
/** 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-cli:claude",
"gemini-cli:openai",
"gemini:claude",
"gemini:openai",
"kiro:openai",
"openai-responses:openai",
"openai:antigravity",
"openai:claude",
"openai:cursor",
"openai:gemini",
"openai:gemini-cli",
"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();
}
// ───────────────────────────────────────────────────────────────────────────
// main() — importa módulos reais, lê fontes, roda as três 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 ──────────────────────────────────────────────────
const strategiesMod = await import("@/shared/constants/routingStrategies.ts");
const canonical = strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[];
const comboSource = readFileSync(resolvePath(REPO_ROOT, "open-sse/services/combo.ts"), "utf8");
const handled = extractHandledStrategies(comboSource);
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);
// ── Resultado ─────────────────────────────────────────────────────────────
if (failures.length) {
console.error(`[known-symbols] ${failures.length} sub-checagem(ns) falharam:\n\n${failures.join("\n\n")}`);
process.exit(1);
}
const newPairsNote = newPairs.length
? ` (${newPairs.length} par(es) novo(s) não-congelado(s): ${newPairs.join(", ")} — atualize KNOWN_TRANSLATOR_PAIRS 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}`
);
}
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);
});
}

View File

@@ -0,0 +1,145 @@
#!/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.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
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). No disco atual
// NÃO há arquivos físicos colidindo, mas congelamos os números reconhecidos para
// que, se um arquivo legado reaparecer com esse prefixo, o gate não exploda.
// ---------------------------------------------------------------------------
export const KNOWN_DUPLICATE_VERSIONS = new Set([
"041", // session_account_affinity → promovida para 050 (SUPERSEDED_DUPLICATE_MIGRATIONS)
]);
// ---------------------------------------------------------------------------
// 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();
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.exit(1);
}
console.log(
`[check-migration-numbering] OK (${filenames.length} migrations, ` +
`${KNOWN_GAPS.size} gap(s) conhecido(s), ${KNOWN_DUPLICATE_VERSIONS.size} duplicata(s) conhecida(s))`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,161 @@
#!/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";
const cwd = process.cwd();
// Arquivos que carregam configuração de credencial de upstream. O escopo é restrito
// de propósito: estes são os únicos pontos onde client_id/secret públicos vivem.
// Adicionar um novo arquivo de config de credencial? Inclua-o aqui.
const SCANNED_FILES = [
"open-sse/config/providerRegistry.ts",
"src/lib/oauth/constants/oauth.ts",
];
// 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".
//
// Tracking: estes 5 valores (9 call-sites) devem virar uma issue de segurança e
// migrar para resolvePublicCred() — Gemini/Antigravity já seguem o padrão correto.
export const KNOWN_LITERAL_CREDS = new Set([
// Claude — CLAUDE_OAUTH_CLIENT_ID (public, PKCE auth-code flow)
// providerRegistry.ts:659 + oauth.ts:37
"9d1c250a-e61b-44d9-88ed-5944d1962f5e",
// Codex (OpenAI) — CODEX_OAUTH_CLIENT_ID (public, PKCE)
// providerRegistry.ts:831 + oauth.ts:54
"app_EMoamEEZ73f0CkXaXp7hrann",
// Qwen — QWEN_OAUTH_CLIENT_ID (public, device-code + PKCE)
// providerRegistry.ts:925 + oauth.ts:101
"f0304373b74a44d2b584a3fb70ca9e56",
// Kimi Coding — KIMI_CODING_OAUTH_CLIENT_ID (public, device-code)
// providerRegistry.ts:1961 + oauth.ts:136
"17e5f671-d194-4dfb-9706-5516cb48c098",
// GitHub Copilot — GITHUB_OAUTH_CLIENT_ID (public, device-code)
// oauth.ts:238
"Iv1.b507a08c87ecfe98",
]);
/**
* 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 allMisses = [];
for (const rel of SCANNED_FILES) {
const abs = path.join(cwd, rel);
if (!fs.existsSync(abs)) {
console.error(`[check-public-creds] arquivo de escopo não encontrado: ${rel}`);
process.exit(1);
}
const src = fs.readFileSync(abs, "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);
}
console.log(
`[check-public-creds] OK (${SCANNED_FILES.length} arquivo(s), ` +
`${KNOWN_LITERAL_CREDS.size} literal(is) congelado(s))`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -0,0 +1,113 @@
#!/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 { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { isLocalOnlyPath } from "@/server/authz/routeGuard.ts";
// 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",
];
// 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(/^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));
}
/** 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 apiPaths = SPAWN_CAPABLE_ROUTE_ROOTS.flatMap(collectRouteFiles)
.map(routeFileToApiPath)
.sort();
const unclassified = findUnclassifiedSpawnRoutes(apiPaths, isLocalOnlyPath, KNOWN_UNCLASSIFIED);
if (unclassified.length) {
console.error(
`[route-guard-membership] CRITICAL — ${unclassified.length} spawn-capable route(s) NOT classified local-only by isLocalOnlyPath() (RCE-via-tunnel risk, Hard Rules #15/#17):\n` +
unclassified.map((p) => `${p}`).join("\n") +
`\n → add a matching prefix to LOCAL_ONLY_API_PREFIXES or a pattern to LOCAL_ONLY_API_PATTERNS in src/server/authz/routeGuard.ts (loopback enforcement must run before auth), or — only with written justification — freeze it in KNOWN_UNCLASSIFIED (scripts/check/check-route-guard-membership.ts).`
);
process.exit(1);
}
console.log(
`[route-guard-membership] OK — ${apiPaths.length} spawn-capable route(s) across ${SPAWN_CAPABLE_ROUTE_ROOTS.length} root(s) all classified local-only, ${Object.keys(KNOWN_UNCLASSIFIED).length} frozen exception(s)`
);
// 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();

View File

@@ -0,0 +1,46 @@
import { test } from "node:test";
import assert from "node:assert";
import { evaluateComplexity } from "../../scripts/check/check-complexity.mjs";
// The .mjs module has no .d.ts; type the pure comparator locally so the test file
// stays free of explicit `any` (ratchet 3482 — zero new warnings allowed).
type ComplexityVerdict = { regressed: boolean; improved: boolean };
const evaluate = evaluateComplexity as (current: number, baseline: number) => ComplexityVerdict;
const BASELINE = 1739;
test("equal to baseline passes", () => {
const r = evaluate(BASELINE, BASELINE);
assert.equal(r.regressed, false);
assert.equal(r.improved, false);
});
test("one more violation is a regression", () => {
const r = evaluate(BASELINE + 1, BASELINE);
assert.equal(r.regressed, true);
assert.equal(r.improved, false);
});
test("a large increase is a regression", () => {
const r = evaluate(BASELINE + 200, BASELINE);
assert.equal(r.regressed, true);
});
test("one fewer violation is an improvement (ratchet down)", () => {
const r = evaluate(BASELINE - 1, BASELINE);
assert.equal(r.regressed, false);
assert.equal(r.improved, true);
});
test("zero violations is an improvement and never regresses", () => {
const r = evaluate(0, BASELINE);
assert.equal(r.regressed, false);
assert.equal(r.improved, true);
});
test("exact-integer comparison — no epsilon tolerance", () => {
// Unlike the duplication gate (float %), complexity is an integer count: any increase
// at all must fail, with no slack.
assert.equal(evaluate(11, 10).regressed, true);
assert.equal(evaluate(10, 10).regressed, false);
});

View File

@@ -0,0 +1,198 @@
import { test } from "node:test";
import assert from "node:assert";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
collectDbModules,
extractReexportedModules,
findMissingReexports,
hasLogic,
extractStringLiterals,
findRawSql,
collectSqlScanFiles,
} from "../../scripts/check/check-db-rules.mjs";
const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), "../../..");
const LOCAL_DB = path.join(REPO_ROOT, "src/lib/localDb.ts");
// ---------- (a) re-export completeness ----------
test("findMissingReexports: flags a NEW db module that is not re-exported", () => {
const dbModules = ["providers", "brandNewModule"];
const reexported = new Set(["providers"]) as Set<string>;
const allowlist = new Set<string>();
const missing = findMissingReexports(dbModules, reexported, allowlist) as string[];
assert.deepEqual(missing, ["brandNewModule"]);
});
test("findMissingReexports: a re-exported module passes", () => {
const dbModules = ["providers"];
const reexported = new Set(["providers"]) as Set<string>;
const missing = findMissingReexports(dbModules, reexported, new Set<string>()) as string[];
assert.deepEqual(missing, []);
});
test("findMissingReexports: an allowlisted (frozen) module passes even if not re-exported", () => {
const dbModules = ["notion"];
const reexported = new Set<string>();
const allowlist = new Set(["notion"]) as Set<string>;
const missing = findMissingReexports(dbModules, reexported, allowlist) as string[];
assert.deepEqual(missing, []);
});
test("extractReexportedModules: parses ./db/X from export forms", () => {
const src = [
'export { getCombos } from "./db/combos";',
'export * from "./db/featureFlags";',
'export type { Webhook } from "./db/webhooks";',
'export { sumUsageTokensThisMonth } from "./db/usageSummary";',
// not a db module — must be ignored
'export { initPricingSync } from "./pricingSync";',
].join("\n");
const mods = extractReexportedModules(src) as Set<string>;
assert.equal(mods.has("combos"), true);
assert.equal(mods.has("featureFlags"), true);
assert.equal(mods.has("webhooks"), true);
assert.equal(mods.has("usageSummary"), true);
assert.equal(mods.has("pricingSync"), false);
});
test("collectDbModules: returns real modules and excludes core/localDb/index", () => {
const mods = collectDbModules() as string[];
assert.ok(mods.includes("providers"), "expected providers module");
assert.ok(mods.includes("combos"), "expected combos module");
assert.equal(mods.includes("core"), false, "core must be excluded");
assert.equal(mods.includes("localDb"), false, "localDb must be excluded");
assert.equal(mods.includes("index"), false, "index must be excluded");
});
// FREEZE GUARD: the live repo state must be green under the shipped allowlist.
test("live repo: no NEW unexported db modules beyond the frozen allowlist", async () => {
// Re-import the gate's frozen allowlist indirectly by running its default behavior:
// findMissingReexports with the gate default allowlist must be empty for the repo.
const dbModules = collectDbModules() as string[];
const reexported = extractReexportedModules(fs.readFileSync(LOCAL_DB, "utf8")) as Set<string>;
// Default allowlist (KNOWN_UNEXPORTED) is applied inside findMissingReexports.
const missing = findMissingReexports(dbModules, reexported) as string[];
assert.deepEqual(
missing,
[],
`Unexported db module(s) not in KNOWN_UNEXPORTED: ${missing.join(", ")}`
);
});
// ---------- (b) localDb has no logic ----------
test("hasLogic: false for a pure re-export layer", () => {
const src = [
"// re-export layer",
'export { a, b } from "./db/foo";',
'export * from "./db/bar";',
'export type { T } from "./db/baz";',
].join("\n");
assert.equal(hasLogic(src) as boolean, false);
});
test("hasLogic: true for a function declaration", () => {
const src = 'export { a } from "./db/foo";\nfunction doThing() { return 1; }';
assert.equal(hasLogic(src) as boolean, true);
});
test("hasLogic: true for an arrow-function const", () => {
const src = 'export { a } from "./db/foo";\nconst helper = (x) => x + 1;';
assert.equal(hasLogic(src) as boolean, true);
});
test("hasLogic: true for a class declaration", () => {
const src = 'export { a } from "./db/foo";\nclass Thing {}';
assert.equal(hasLogic(src) as boolean, true);
});
test("hasLogic: SQL/logic-looking text inside comments or strings does not trip", () => {
const src = [
"/* function notReal() {} */",
"// const fake = () => 1;",
'export const SOURCE = "./db/foo";', // string only, no function on rhs
'export { a } from "./db/foo";',
].join("\n");
// export const X = "string" is a value (not logic): the rhs is a string literal,
// so the arrow/call pattern must NOT match.
assert.equal(hasLogic(src) as boolean, false);
});
test("live repo: src/lib/localDb.ts contains no logic", () => {
const src = fs.readFileSync(LOCAL_DB, "utf8");
assert.equal(hasLogic(src) as boolean, false);
});
// ---------- (c) no raw SQL outside db/ ----------
test("extractStringLiterals: returns only string bodies, ignoring code", () => {
const code = 'import { x } from "y";\nconst q = `SELECT * FROM t`;\nobj.set(1);';
const literals = extractStringLiterals(code) as string;
assert.ok(literals.includes("SELECT * FROM t"), "captures the template body");
assert.ok(literals.includes("y"), "captures the import path string");
assert.equal(literals.includes("set"), false, "JS .set() call is not a string body");
});
test("findRawSql: flags a NEW route with raw SQL in a string literal", () => {
const tmp = path.join(REPO_ROOT, ".tmp-check-db-rules-raw-sql.route.ts");
fs.writeFileSync(
tmp,
'const rows = db.prepare(`SELECT id FROM users WHERE x = ?`).all();\n',
"utf8"
);
try {
const offenders = findRawSql([tmp], new Set<string>()) as string[];
assert.equal(offenders.length, 1, "raw SELECT...FROM should be flagged");
} finally {
fs.rmSync(tmp, { force: true });
}
});
test("findRawSql: does NOT flag SQL that only appears in a comment", () => {
const tmp = path.join(REPO_ROOT, ".tmp-check-db-rules-comment.route.ts");
fs.writeFileSync(tmp, "// SELECT id FROM users -- documentation only\nexport const x = 1;\n", "utf8");
try {
const offenders = findRawSql([tmp], new Set<string>()) as string[];
assert.deepEqual(offenders, []);
} finally {
fs.rmSync(tmp, { force: true });
}
});
test("findRawSql: does NOT flag JS .set()/import-from/new Set() false positives", () => {
const tmp = path.join(REPO_ROOT, ".tmp-check-db-rules-falsepos.route.ts");
fs.writeFileSync(
tmp,
[
'import { NextResponse } from "next/server";',
"const seen = new Set();",
"headers.set(key, value);",
"delete obj.field;",
].join("\n"),
"utf8"
);
try {
const offenders = findRawSql([tmp], new Set<string>()) as string[];
assert.deepEqual(offenders, []);
} finally {
fs.rmSync(tmp, { force: true });
}
});
test("findRawSql: an allowlisted (frozen) offender passes", () => {
const rel = "src/app/api/skills/[id]/route.ts";
const abs = path.join(REPO_ROOT, rel);
const allowlist = new Set([rel]) as Set<string>;
const offenders = findRawSql([abs], allowlist) as string[];
assert.deepEqual(offenders, []);
});
test("live repo: no NEW raw-SQL offenders beyond the frozen allowlist", () => {
// findRawSql uses the gate default allowlist (KNOWN_RAW_SQL) when none is passed.
const files = collectSqlScanFiles() as string[];
const offenders = findRawSql(files) as string[];
assert.deepEqual(offenders, [], `New raw-SQL offender(s): ${offenders.join(", ")}`);
});

View File

@@ -0,0 +1,169 @@
import { test } from "node:test";
import assert from "node:assert";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
resolveApiDocPathToRoute,
extractDocApiPaths,
findStaleDocApiRefs,
collectRouteFiles,
KNOWN_STALE_DOC_REFS,
} from "../../scripts/check/check-docs-symbols.mjs";
// Tipos explícitos (não `any`) para as exports do .mjs — mantém o test em 0 warnings de
// no-explicit-any (catraca 3482) usando `as <Type>`.
const resolve = resolveApiDocPathToRoute as (apiPath: string, routeFiles: Set<string>) => boolean;
const extract = extractDocApiPaths as (src: string) => string[];
const findStale = findStaleDocApiRefs as (
docPathsByFile: { file: string; paths: string[] }[],
routeFiles: Set<string>,
allowlist: Set<string>
) => string[];
const collect = collectRouteFiles as () => Set<string>;
const allowlist = KNOWN_STALE_DOC_REFS as Set<string>;
const here = path.dirname(fileURLToPath(import.meta.url));
const GATE = path.resolve(here, "../../scripts/check/check-docs-symbols.mjs");
const REPO = path.resolve(here, "../..");
// --- resolveApiDocPathToRoute --------------------------------------------------------
test("resolves a static doc path to a real route", () => {
const files = new Set(["src/app/api/usage/route.ts"]);
assert.equal(resolve("/api/usage", files), true);
});
test("resolves a {param} doc segment against a real [param] dynamic segment", () => {
const files = new Set(["src/app/api/providers/[id]/models/route.ts"]);
assert.equal(resolve("/api/providers/{providerId}/models", files), true);
});
test("resolves a :param (Express-style) doc segment too", () => {
const files = new Set(["src/app/api/shadow/[id]/route.ts"]);
assert.equal(resolve("/api/shadow/:id", files), true);
});
test("resolves a doc path that is a prefix of a deeper route (family reference)", () => {
const files = new Set(["src/app/api/auth/login/route.ts"]);
assert.equal(resolve("/api/auth", files), true);
});
test("resolves into a catch-all route segment", () => {
const files = new Set(["src/app/api/mcp/[...transport]/route.ts"]);
assert.equal(resolve("/api/mcp/sse/extra", files), true);
});
test("flags a hallucinated route with no matching file", () => {
const files = new Set(["src/app/api/usage/route.ts"]);
assert.equal(resolve("/api/shadow/metrics", files), false);
});
test("does NOT match when the doc path is deeper than the real route", () => {
const files = new Set(["src/app/api/acp/agents/route.ts"]);
assert.equal(resolve("/api/acp/agents/refresh", files), false);
});
test("static segment mismatch is not absorbed by an unrelated dynamic route", () => {
const files = new Set(["src/app/api/plugins/[name]/activate/route.ts"]);
// /api/plugins/{id}/enable: dynamic seg ok, but "enable" != "activate"
assert.equal(resolve("/api/plugins/{id}/enable", files), false);
});
// --- extractDocApiPaths --------------------------------------------------------------
test("extracts an /api path from a code fence", () => {
const md = "```\nGET /api/cache/stats\n```";
assert.deepEqual(extract(md), ["/api/cache/stats"]);
});
test("extracts an /api path from inline code and strips trailing prose punctuation", () => {
const md = "Call `/api/usage`, then check the result.";
assert.deepEqual(extract(md), ["/api/usage"]);
});
test("keeps balanced [param] / {param} segments intact", () => {
const md = "DELETE | `/api/shadow/[id]` | and `/api/tools/agent-bridge/agents/{id}/state`";
assert.deepEqual(extract(md), [
"/api/shadow/[id]",
"/api/tools/agent-bridge/agents/{id}/state",
]);
});
test("does NOT capture a source-file path tail (src/lib/api/..., @/app/api/...)", () => {
const md = "see `src/lib/api/requireManagementAuth.ts` and `@/app/api/oauth/route.ts`";
assert.deepEqual(extract(md), []);
});
test("ignores file references ending in .ts even when URL-shaped", () => {
// file-ref filter lives in findStaleDocApiRefs; extract still returns it, but the
// capture must not include the leading file-path context.
const md = "endpoint is /api/cache/route.ts in the tree";
assert.deepEqual(extract(md), ["/api/cache/route.ts"]);
});
test("drops a trailing markdown-emphasis underscore (table italics)", () => {
const md = "| _500 no POST /api/cli-tools/config_ | _Zod faltando_ |";
assert.deepEqual(extract(md), ["/api/cli-tools/config"]);
});
test("discards a segment with an unbalanced bracket (regex truncation in prose)", () => {
// greedy regex captured "/api/mcp/{status" — drop the dangling segment, keep prefix.
const md = "the `/api/mcp/{status` field (prose ran on)";
assert.deepEqual(extract(md), ["/api/mcp"]);
});
// --- findStaleDocApiRefs -------------------------------------------------------------
const routes = new Set([
"src/app/api/usage/route.ts",
"src/app/api/providers/[id]/models/route.ts",
]);
test("passes a doc path that resolves to a real route", () => {
const docs = [{ file: "docs/x.md", paths: ["/api/usage"] }];
assert.deepEqual(findStale(docs, routes, new Set()), []);
});
test("flags a hallucinated doc path as 'file → path'", () => {
const docs = [{ file: "docs/x.md", paths: ["/api/ghost"] }];
assert.deepEqual(findStale(docs, routes, new Set()), ["docs/x.md → /api/ghost"]);
});
test("allowlisted stale path is frozen (not flagged)", () => {
const docs = [{ file: "docs/x.md", paths: ["/api/ghost"] }];
assert.deepEqual(findStale(docs, routes, new Set(["/api/ghost"])), []);
});
test("IGNORE swallows the OpenAI-compat /api/v1 proxy surface", () => {
const docs = [{ file: "docs/x.md", paths: ["/api/v1/chat/completions"] }];
assert.deepEqual(findStale(docs, routes, new Set()), []);
});
test("IGNORE swallows obvious example/placeholder paths", () => {
const docs = [{ file: "docs/x.md", paths: ["/api/your-route/here", "/api/example/foo"] }];
assert.deepEqual(findStale(docs, routes, new Set()), []);
});
test("file-ref paths (.ts / /route) are skipped, not flagged", () => {
const docs = [{ file: "docs/x.md", paths: ["/api/cache/route.ts", "/api/oauth/route"] }];
assert.deepEqual(findStale(docs, routes, new Set()), []);
});
// --- live gate smoke -----------------------------------------------------------------
test("collectRouteFiles finds the real route tree (non-empty, all route.ts)", () => {
const files = collect();
assert.ok(files.size > 100, "expected the full route tree");
for (const f of files) assert.match(f, /route\.tsx?$/);
});
test("KNOWN_STALE_DOC_REFS is a frozen, documented allowlist (non-empty)", () => {
assert.ok(allowlist.size > 0);
for (const p of allowlist) assert.match(p, /^\/api\//);
});
test("the gate itself exits 0 on the current repo (baseline frozen)", () => {
const out = execFileSync("node", [GATE], { cwd: REPO, encoding: "utf8" });
assert.match(out, /\[check-docs-symbols\] OK/);
});

View File

@@ -0,0 +1,179 @@
// Tests for the Rule #12 error-sanitization gate (scripts/check/check-error-helper.mjs).
// Exercises the pure findErrorHelperViolations() against synthetic file shapes so the
// conservative heuristic (flag direct + indirect raw-error leaks, never internal sinks
// or helper-importing files) is locked down as a regression guard.
import test from "node:test";
import assert from "node:assert/strict";
// @ts-expect-error — .mjs gate module has no type declarations; runtime shape is known.
import { findErrorHelperViolations, KNOWN_MISSING_ERROR_HELPER } from "../../scripts/check/check-error-helper.mjs";
type FileEntry = { path: string; source: string };
type FindFn = (files: FileEntry[], allowlist: Set<string>) => string[];
const find = findErrorHelperViolations as FindFn;
const allowlist = KNOWN_MISSING_ERROR_HELPER as Set<string>;
const EMPTY = new Set<string>();
function run(source: string, path = "open-sse/executors/x.ts"): string[] {
return find([{ path, source } as FileEntry], EMPTY);
}
test("flags raw err.message assigned directly to an error: field", () => {
const src = `export function exec() {
try { doThing(); } catch (err) {
return { success: false, status: 502, error: err.message };
}
}`;
assert.deepEqual(run(src), ["open-sse/executors/x.ts"]);
});
test("flags raw err.message interpolated into a message: field", () => {
const src = `function build(err: Error) {
return new Response(JSON.stringify({ error: { message: \`boom: \${err.message}\` } }));
}`;
assert.deepEqual(run(src), ["open-sse/executors/x.ts"]);
});
test("flags err.stack placed into a message: field", () => {
const src = `function build(err: Error) {
return { error: { message: err.stack } };
}`;
assert.deepEqual(run(src), ["open-sse/executors/x.ts"]);
});
test("flags multi-line OpenAI error envelope inside new Response()", () => {
const src = `function build(err: unknown) {
return new Response(
JSON.stringify({
error: {
message: isTls
? \`tls failed: \${(err as Error).message}\`
: \`conn failed: \${err instanceof Error ? err.message : String(err)}\`,
type: "upstream_error",
},
}),
{ status: 502 }
);
}`;
assert.deepEqual(run(src), ["open-sse/executors/x.ts"]);
});
test("flags a tainted local variable passed into a response-builder call", () => {
const src = `function makeErrorResponse(s: number, m: string) { return new Response(m); }
function exec(err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { response: makeErrorResponse(401, \`auth failed: \${msg}\`) };
}`;
assert.deepEqual(run(src), ["open-sse/executors/x.ts"]);
});
test("flags errResp(msg) where msg is tainted", () => {
const src = `function errResp(message: string) { return new Response(JSON.stringify({ error: { message } })); }
function exec(err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to get nonce";
return { response: errResp(msg) };
}`;
assert.deepEqual(run(src), ["open-sse/executors/x.ts"]);
});
test("flags forwarded upstream body.error.message without sanitize", () => {
const src = `function build(body: { error: { message: string } }) {
return { success: false, error: body.error.message };
}`;
assert.deepEqual(run(src), ["open-sse/executors/x.ts"]);
});
// --- Negative cases: the gate must NOT flag these (conservative, no false positives) ---
test("does NOT flag a file that imports utils/error (relative)", () => {
const src = `import { sanitizeErrorMessage } from "../utils/error.ts";
function build(err: Error) { return { error: { message: err.message } }; }`;
assert.deepEqual(run(src), []);
});
test("does NOT flag a file that imports utils/error (workspace alias)", () => {
const src = `import { buildErrorBody } from "@omniroute/open-sse/utils/error";
function build(err: Error) { return new Response(JSON.stringify({ error: { message: \`x \${err.message}\` } })); }`;
assert.deepEqual(run(src), []);
});
test("does NOT flag raw err.message inside a saveCallLog audit row", () => {
const src = `function exec(err: Error) {
saveCallLog({
method: "POST",
status: 502,
error: err.message,
requestBody: rb,
}).catch(() => {});
return ok;
}`;
assert.deepEqual(run(src), []);
});
test("does NOT flag raw err.message inside a log call", () => {
const src = `function exec(err: Error) {
log?.error?.("X", \`refresh error: \${err.message}\`);
return ok;
}`;
assert.deepEqual(run(src), []);
});
test("does NOT flag err.message inside a thrown Error", () => {
const src = `function exec(err: Error) {
throw new Error(\`SPA send failed: \${err instanceof Error ? err.message : String(err)}\`);
}`;
assert.deepEqual(run(src), []);
});
test("does NOT flag err.message inside reject()", () => {
const src = `new Promise((_, reject) => {
onErr((err: Error) => reject(new Error(\`failed: \${err.message}\`)));
});`;
assert.deepEqual(run(src), []);
});
test("does NOT flag upstream-event read event.error.message", () => {
const src = `function parse(event: { error: { message: string } }) {
const content = typeof event.error === "string" ? event.error : event.error.message;
return { choices: [{ message: { content } }] };
}`;
assert.deepEqual(run(src), []);
});
test("does NOT flag a sanitized body.error.message line", () => {
const src = `function build(body: { error: { message: string } }) {
return { error: sanitizeErrorMessage(body.error.message) };
}`;
assert.deepEqual(run(src), []);
});
// --- Allowlist behavior ---
test("an allowlisted path is suppressed even when it would otherwise flag", () => {
const src = `function build(err: Error) { return { error: { message: err.message } }; }`;
const path = "open-sse/executors/legacy.ts";
assert.deepEqual(find([{ path, source: src } as FileEntry], EMPTY), [path]);
assert.deepEqual(find([{ path, source: src } as FileEntry], new Set([path])), []);
});
test("the shipped allowlist freezes exactly the known current violators", () => {
const frozen = [...allowlist].sort();
assert.deepEqual(frozen, [
"open-sse/executors/adapta-web.ts",
"open-sse/executors/deepseek-web.ts",
"open-sse/executors/perplexity-web.ts",
"open-sse/executors/qoder.ts",
"open-sse/executors/veoaifree-web.ts",
"open-sse/handlers/embeddings.ts",
"open-sse/handlers/search.ts",
]);
});
test("returns multiple violating paths and preserves input order", () => {
const files: FileEntry[] = [
{ path: "open-sse/executors/a.ts", source: `return { error: { message: err.message } };` },
{ path: "open-sse/executors/b.ts", source: `import { x } from "../utils/error.ts"; return { error: err.message };` },
{ path: "open-sse/executors/c.ts", source: `return { error: e.stack };` },
];
assert.deepEqual(find(files, EMPTY), ["open-sse/executors/a.ts", "open-sse/executors/c.ts"]);
});

View File

@@ -0,0 +1,179 @@
import { test } from "node:test";
import assert from "node:assert";
import {
extractHandledStrategies,
diffComboStrategies,
extractExecutorAliases,
findNonConformingExecutors,
findMissingTranslatorPairs,
findNewTranslatorPairs,
IMPLICIT_DEFAULT_STRATEGIES,
KNOWN_TRANSLATOR_PAIRS,
type ExecutorLike,
} from "../../scripts/check/check-known-symbols.ts";
// ───────────────────────────────────────────────────────────────────────────
// (2) COMBO STRATEGIES — extractHandledStrategies + diffComboStrategies
// ───────────────────────────────────────────────────────────────────────────
test("extractHandledStrategies pulls every `strategy === \"...\"` literal, deduped", () => {
const src = [
'if (strategy === "round-robin") {',
'} else if (strategy === "p2c") {',
'const x = strategy === "weighted" ? a : b;',
'} else if (strategy === "p2c") {', // dup → deduped by the Set
].join("\n");
const handled = extractHandledStrategies(src);
assert.deepEqual([...handled].sort(), ["p2c", "round-robin", "weighted"]);
});
test("extractHandledStrategies ignores non-matching comparisons", () => {
const src = 'if (mode === "fast") {}\nif (strategy == "loose") {}\nif (strategy === "auto") {}';
// `mode ===` and the loose `==` must not match; only the strict strategy compare.
assert.deepEqual([...extractHandledStrategies(src)], ["auto"]);
});
test("diffComboStrategies: no mismatch when dispatch + implicit defaults cover canonical exactly", () => {
const canonical = ["priority", "weighted", "auto"];
const handled = new Set(["weighted", "auto"]);
const implicit = { priority: "default no-branch" };
const result = diffComboStrategies(canonical, handled, implicit);
assert.deepEqual(result.canonicalNotHandled, []);
assert.deepEqual(result.handledNotCanonical, []);
});
test("diffComboStrategies flags a canonical strategy added without a dispatch branch", () => {
const canonical = ["priority", "weighted", "newfangled"];
const handled = new Set(["weighted"]);
const implicit = { priority: "default no-branch" };
const result = diffComboStrategies(canonical, handled, implicit);
assert.deepEqual(result.canonicalNotHandled, ["newfangled"]);
assert.deepEqual(result.handledNotCanonical, []);
});
test("diffComboStrategies flags an invented dispatch string not in the canonical set", () => {
const canonical = ["priority", "weighted"];
const handled = new Set(["weighted", "ghost-strategy"]);
const implicit = { priority: "default no-branch" };
const result = diffComboStrategies(canonical, handled, implicit);
assert.deepEqual(result.handledNotCanonical, ["ghost-strategy"]);
assert.deepEqual(result.canonicalNotHandled, []);
});
test("diffComboStrategies: an implicit-default string handled in dispatch is not flagged as invented", () => {
// If priority later gets an explicit branch, it appears in both handled AND implicit —
// it must NOT be reported as an invented (handledNotCanonical) string.
const canonical = ["priority", "weighted"];
const handled = new Set(["priority", "weighted"]);
const implicit = { priority: "default no-branch" };
const result = diffComboStrategies(canonical, handled, implicit);
assert.deepEqual(result.canonicalNotHandled, []);
assert.deepEqual(result.handledNotCanonical, []);
});
// ───────────────────────────────────────────────────────────────────────────
// (1) EXECUTOR CONFORMANCE — extractExecutorAliases + findNonConformingExecutors
// ───────────────────────────────────────────────────────────────────────────
test("extractExecutorAliases parses quoted and bare keys from the executors literal", () => {
const src = [
'import { Foo } from "./foo.ts";',
"const executors = {",
" antigravity: new Foo(),",
' "gemini-cli": new Foo(),',
" agy: new Foo(), // Alias",
' "amazon-q": new Foo("amazon-q"),',
"};",
"export function getExecutor() {}",
].join("\n");
assert.deepEqual(extractExecutorAliases(src), [
"antigravity",
"gemini-cli",
"agy",
"amazon-q",
]);
});
test("extractExecutorAliases throws when the executors map cannot be located", () => {
assert.throws(() => extractExecutorAliases("const other = { a: 1 };"), /could not find/);
});
test("findNonConformingExecutors returns [] when every alias resolves to a valid executor", () => {
const good = { execute: () => {}, getProvider: () => "x" } as ExecutorLike;
const resolve = (_alias: string) => good;
const isInstance = (_value: unknown) => true;
assert.deepEqual(findNonConformingExecutors(["a", "b"], resolve, isInstance), []);
});
test("findNonConformingExecutors flags an alias that does not resolve at all", () => {
const good = { execute: () => {}, getProvider: () => "x" } as ExecutorLike;
const resolve = (alias: string) => (alias === "ghost" ? null : good);
const isInstance = (_value: unknown) => true;
assert.deepEqual(findNonConformingExecutors(["a", "ghost", "b"], resolve, isInstance), ["ghost"]);
});
test("findNonConformingExecutors flags an alias resolving to a non-BaseExecutor instance", () => {
const stray = { execute: () => {}, getProvider: () => "x" } as ExecutorLike;
const resolve = (_alias: string) => stray;
// Simulate `instanceof BaseExecutor` returning false for the stray object.
const isInstance = (_value: unknown) => false;
assert.deepEqual(findNonConformingExecutors(["stray"], resolve, isInstance), ["stray"]);
});
test("findNonConformingExecutors flags an executor missing execute() or getProvider()", () => {
const noExecute = { getProvider: () => "x" } as ExecutorLike;
const noProvider = { execute: () => {} } as ExecutorLike;
const valid = { execute: () => {}, getProvider: () => "x" } as ExecutorLike;
const map: Record<string, ExecutorLike> = { ne: noExecute, np: noProvider, ok: valid };
const resolve = (alias: string) => map[alias];
const isInstance = (_value: unknown) => true;
assert.deepEqual(findNonConformingExecutors(["ne", "np", "ok"], resolve, isInstance), [
"ne",
"np",
]);
});
// ───────────────────────────────────────────────────────────────────────────
// (3) TRANSLATOR PAIRS — findMissingTranslatorPairs + findNewTranslatorPairs
// ───────────────────────────────────────────────────────────────────────────
test("findMissingTranslatorPairs returns [] when every frozen pair is still live", () => {
const frozen = ["openai:claude", "claude:openai"];
const live = new Set(["openai:claude", "claude:openai", "gemini:openai"]);
assert.deepEqual(findMissingTranslatorPairs(frozen, live), []);
});
test("findMissingTranslatorPairs flags a frozen pair that disappeared from the live registry", () => {
const frozen = ["openai:claude", "claude:openai"];
const live = new Set(["openai:claude"]);
assert.deepEqual(findMissingTranslatorPairs(frozen, live), ["claude:openai"]);
});
test("findNewTranslatorPairs reports live pairs absent from the frozen snapshot, sorted", () => {
const frozen = ["openai:claude"];
const live = new Set(["openai:claude", "z:y", "a:b"]);
assert.deepEqual(findNewTranslatorPairs(frozen, live), ["a:b", "z:y"]);
});
test("findNewTranslatorPairs returns [] when live is a subset of frozen", () => {
const frozen = ["openai:claude", "claude:openai"];
const live = new Set(["openai:claude"]);
assert.deepEqual(findNewTranslatorPairs(frozen, live), []);
});
// ───────────────────────────────────────────────────────────────────────────
// Allowlist / snapshot sanity (documented frozen sets stay well-formed)
// ───────────────────────────────────────────────────────────────────────────
test("IMPLICIT_DEFAULT_STRATEGIES documents `priority` with a justification", () => {
assert.ok(Object.prototype.hasOwnProperty.call(IMPLICIT_DEFAULT_STRATEGIES, "priority"));
assert.ok(IMPLICIT_DEFAULT_STRATEGIES.priority.length > 20);
});
test("KNOWN_TRANSLATOR_PAIRS is a non-empty, well-formed, deduped from:to snapshot", () => {
assert.ok(KNOWN_TRANSLATOR_PAIRS.length > 0);
assert.equal(new Set(KNOWN_TRANSLATOR_PAIRS).size, KNOWN_TRANSLATOR_PAIRS.length);
for (const pair of KNOWN_TRANSLATOR_PAIRS) {
assert.match(pair, /^[a-z0-9-]+:[a-z0-9-]+$/, `malformed translator pair: ${pair}`);
}
});

View File

@@ -0,0 +1,105 @@
import { test } from "node:test";
import assert from "node:assert";
import fs from "node:fs";
import path from "node:path";
import {
findMigrationAnomalies,
KNOWN_DUPLICATE_VERSIONS,
KNOWN_GAPS,
} from "../../scripts/check/check-migration-numbering.mjs";
type Anomalies = {
duplicates: Array<{ version: string; names: string[] }>;
gaps: string[];
badNames: string[];
};
const EMPTY = new Set<string>();
test("clean contiguous sequence has no anomalies", () => {
const files = ["001_a.sql", "002_b.sql", "003_c.sql"];
const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies;
assert.deepEqual(r.duplicates, []);
assert.deepEqual(r.gaps, []);
assert.deepEqual(r.badNames, []);
});
test("flags a filename without a zero-padded numeric prefix", () => {
const files = ["001_a.sql", "add_index.sql", "2_short.sql"];
const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies;
// "add_index.sql" has no numeric prefix; "2_short.sql" is not zero-padded (<3 digits).
assert.deepEqual(r.badNames.sort(), ["2_short.sql", "add_index.sql"]);
});
test("flags a real duplicate version prefix", () => {
const files = ["001_a.sql", "002_b.sql", "002_c.sql"];
const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies;
assert.equal(r.duplicates.length, 1);
assert.equal(r.duplicates[0].version, "002");
assert.deepEqual(r.duplicates[0].names, ["002_b.sql", "002_c.sql"]);
});
test("does NOT flag a duplicate that is in the knownDuplicates allowlist", () => {
const files = ["001_a.sql", "002_b.sql", "002_c.sql"];
const known = new Set<string>(["002"]);
const r = findMigrationAnomalies(files, known, EMPTY) as Anomalies;
assert.deepEqual(r.duplicates, []);
});
test("flags an unexplained sequence gap", () => {
const files = ["001_a.sql", "002_b.sql", "004_d.sql"];
const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies;
assert.deepEqual(r.gaps, ["003"]);
});
test("does NOT flag a gap that is in the knownGaps allowlist", () => {
const files = ["001_a.sql", "002_b.sql", "004_d.sql"];
const known = new Set<string>(["003"]);
const r = findMigrationAnomalies(files, EMPTY, known) as Anomalies;
assert.deepEqual(r.gaps, []);
});
test("gaps at the boundaries are not counted (only interior gaps)", () => {
// No phantom gap below min or above max.
const files = ["003_a.sql", "004_b.sql"];
const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies;
assert.deepEqual(r.gaps, []);
});
test("ignores non-.sql files entirely", () => {
const files = ["001_a.sql", "002_b.sql", "README.md", ".keep"];
const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies;
assert.deepEqual(r.badNames, []);
assert.deepEqual(r.gaps, []);
assert.deepEqual(r.duplicates, []);
});
test("a NEW gap is flagged even when a known gap is allowlisted", () => {
// Simulate the real frozen gaps plus a fresh hole that must NOT be tolerated.
const files = ["001_a.sql", "003_c.sql", "005_e.sql"];
const known = new Set<string>(["004"]); // 004 allowlisted, 002 is the new hole
const r = findMigrationAnomalies(files, EMPTY, known) as Anomalies;
assert.deepEqual(r.gaps, ["002"]);
});
// --- Real dataset: the frozen allowlists must keep the live dir green ---
test("the real migrations dir produces ZERO anomalies under the frozen allowlists", () => {
const dir = path.resolve(import.meta.dirname, "../../src/lib/db/migrations");
const filenames = fs.readdirSync(dir).filter((f) => f.endsWith(".sql"));
assert.ok(filenames.length > 0, "expected migration files to exist");
const r = findMigrationAnomalies(filenames, KNOWN_DUPLICATE_VERSIONS, KNOWN_GAPS) as Anomalies;
assert.deepEqual(r.badNames, [], `unexpected bad migration names: ${r.badNames.join(", ")}`);
assert.deepEqual(
r.duplicates,
[],
`unexpected duplicate versions: ${JSON.stringify(r.duplicates)}`
);
assert.deepEqual(r.gaps, [], `unexpected sequence gaps: ${r.gaps.join(", ")}`);
});
test("frozen allowlists match the documented audit (026 & 055 gaps, 041 dup)", () => {
assert.ok(KNOWN_GAPS.has("026"));
assert.ok(KNOWN_GAPS.has("055"));
assert.ok(KNOWN_DUPLICATE_VERSIONS.has("041"));
});

View File

@@ -0,0 +1,121 @@
import { test } from "node:test";
import assert from "node:assert";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { findLiteralCreds, KNOWN_LITERAL_CREDS } from "../../scripts/check/check-public-creds.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
test("flags a clientIdDefault assigned to a string literal", () => {
const src = `oauth: {\n clientIdDefault: "deadbeef-leaked-client-id",\n}`;
const v = findLiteralCreds(src, new Set(), "x.ts");
assert.equal(v.length, 1);
assert.match(v[0], /clientIdDefault/);
assert.match(v[0], /deadbeef-leaked-client-id/);
});
test("flags a clientId behind a process.env fallback (env || literal)", () => {
const src = `clientId: process.env.X_OAUTH_CLIENT_ID || "leaked-via-fallback",`;
const v = findLiteralCreds(src, new Set(), "x.ts");
assert.equal(v.length, 1);
assert.match(v[0], /leaked-via-fallback/);
});
test("flags clientSecret and apiKey literals too", () => {
const src = [
`clientSecret: "GOCSPX-secret-literal",`,
`apiKey: "AIzaSyLeakedFirebaseKey",`,
].join("\n");
const v = findLiteralCreds(src, new Set(), "x.ts");
assert.equal(v.length, 2);
});
test("does NOT flag resolvePublicCred() — the correct embedding pattern", () => {
const src = `clientIdDefault: resolvePublicCred("gemini_id"),`;
assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []);
});
test("does NOT flag resolvePublicCredMulti() with literal env-name args", () => {
const src = `clientId: resolvePublicCredMulti("gemini_id", ["GEMINI_OAUTH_CLIENT_ID", "ALT"]),`;
assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []);
});
test("does NOT flag empty-string fallback (process.env || \"\")", () => {
const src = `clientIdDefault: process.env.GITLAB_OAUTH_CLIENT_ID || "",`;
assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []);
});
test("does NOT flag an *Env key — it carries the env-var NAME, not the secret", () => {
const src = `clientIdEnv: "QWEN_OAUTH_CLIENT_ID",`;
assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []);
});
test("does NOT flag a member-access reference (CODEX_CONFIG.clientId)", () => {
const src = `clientId: CODEX_CONFIG.clientId,`;
assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []);
});
test("allowlist freezes a literal by VALUE", () => {
const src = `clientIdDefault: "frozen-value-123",`;
const allow = new Set(["frozen-value-123"]);
assert.deepEqual(findLiteralCreds(src, allow, "x.ts"), []);
});
test("allowlist freezes a literal by file:line:value key", () => {
const src = `\nclientIdDefault: "site-specific-123",`;
const allow = new Set(["x.ts:2:site-specific-123"]);
assert.deepEqual(findLiteralCreds(src, allow, "x.ts"), []);
});
test("a NEW literal is still flagged even with the real frozen allowlist", () => {
const src = `clientIdDefault: "brand-new-leaked-client-id",`;
const v = findLiteralCreds(src, KNOWN_LITERAL_CREDS, "x.ts");
assert.equal(v.length, 1);
});
test("real scanned files produce ZERO violations with the frozen allowlist (gate exits 0)", () => {
const scanned = [
"open-sse/config/providerRegistry.ts",
"src/lib/oauth/constants/oauth.ts",
];
for (const rel of scanned) {
const src = fs.readFileSync(path.join(repoRoot, rel), "utf8") as string;
const v = findLiteralCreds(src, KNOWN_LITERAL_CREDS, rel);
assert.deepEqual(v, [], `expected no live violations in ${rel}, got: ${v.join(", ")}`);
}
});
test("every frozen literal is actually present in a scanned file (no dead allowlist entries)", () => {
const scanned = [
"open-sse/config/providerRegistry.ts",
"src/lib/oauth/constants/oauth.ts",
];
const blob = scanned
.map((rel) => fs.readFileSync(path.join(repoRoot, rel), "utf8") as string)
.join("\n");
for (const entry of KNOWN_LITERAL_CREDS) {
// Plain value entries (no file:line: prefix) must appear verbatim in the source.
const value = entry.includes(":") && /:\d+:/.test(entry)
? entry.replace(/^.*?:\d+:/, "")
: entry;
assert.ok(blob.includes(value), `frozen literal not found in any scanned file: ${value}`);
}
});
test("with an empty allowlist the real files surface the known live violations", () => {
const reg = fs.readFileSync(
path.join(repoRoot, "open-sse/config/providerRegistry.ts"),
"utf8"
) as string;
const oauth = fs.readFileSync(
path.join(repoRoot, "src/lib/oauth/constants/oauth.ts"),
"utf8"
) as string;
const regViolations = findLiteralCreds(reg, new Set(), "providerRegistry.ts");
const oauthViolations = findLiteralCreds(oauth, new Set(), "oauth.ts");
// 4 in providerRegistry (Claude/Codex/Qwen/Kimi clientIdDefault),
// 5 in oauth.ts (the same four + GitHub).
assert.equal(regViolations.length, 4);
assert.equal(oauthViolations.length, 5);
});

View File

@@ -0,0 +1,74 @@
import { test } from "node:test";
import assert from "node:assert";
import {
routeFileToApiPath,
findUnclassifiedSpawnRoutes,
} from "../../scripts/check/check-route-guard-membership.ts";
// Synthetic isLocalOnlyPath: classifies anything under the three spawn-capable
// prefixes via startsWith. Mirrors the real predicate's prefix semantics without
// importing routeGuard.ts (keeps this test DB-free / pure).
const SYNTHETIC_PREFIXES = ["/api/mcp/", "/api/cli-tools/runtime/", "/api/services/"];
const isLocalOnly = (path: string): boolean =>
SYNTHETIC_PREFIXES.some((p) => path === p || path.startsWith(p));
test("routeFileToApiPath maps a Next App Router route.ts to its URL path", () => {
assert.equal(
routeFileToApiPath("src/app/api/services/9router/install/route.ts"),
"/api/services/9router/install"
);
});
test("routeFileToApiPath resolves dynamic [param] segments to a concrete placeholder", () => {
assert.equal(
routeFileToApiPath("src/app/api/services/[name]/logs/route.ts"),
"/api/services/_name_/logs"
);
assert.equal(
routeFileToApiPath("src/app/api/cli-tools/runtime/[toolId]/route.ts"),
"/api/cli-tools/runtime/_toolId_"
);
});
test("no unclassified routes when every spawn-capable route is local-only", () => {
const routes = [
"/api/mcp/tools",
"/api/services/9router/start",
"/api/cli-tools/runtime/_toolId_",
];
assert.deepEqual(findUnclassifiedSpawnRoutes(routes, isLocalOnly, {}), []);
});
test("flags a spawn-capable route that is NOT classified local-only (RCE-via-tunnel gap)", () => {
// Synthetic predicate that forgot to cover /api/services/ — the exact regression
// this gate guards against.
const leaky = (path: string): boolean => path.startsWith("/api/mcp/");
assert.deepEqual(
findUnclassifiedSpawnRoutes(
["/api/mcp/tools", "/api/services/cliproxy/install"],
leaky,
{}
),
["/api/services/cliproxy/install"]
);
});
test("allowlisted routes are not flagged (frozen pre-existing exceptions)", () => {
const leaky = (path: string): boolean => path.startsWith("/api/mcp/");
assert.deepEqual(
findUnclassifiedSpawnRoutes(
["/api/mcp/tools", "/api/services/legacy/route"],
leaky,
{ "/api/services/legacy/route": "frozen pre-existing exception" }
),
[]
);
});
test("flags multiple unclassified routes, preserves input order", () => {
const leaky = (): boolean => false;
assert.deepEqual(
findUnclassifiedSpawnRoutes(["/api/services/a", "/api/mcp/b", "/api/services/c"], leaky, {}),
["/api/services/a", "/api/mcp/b", "/api/services/c"]
);
});